1
0
Files
AnchorCli/Hashline/HashlineValidator.cs

121 lines
4.4 KiB
C#

namespace AnchorCli.Hashline;
/// <summary>
/// Validates and resolves Hashline anchors of the form "{lineNumber}:{hash}" against
/// the current state of a file's lines.
///
/// An anchor is valid if and only if BOTH conditions hold:
/// 1. The referenced line number exists in the file.
/// 2. The hash at that line number matches what the encoder would produce today.
///
/// This dual-check makes stale edits fail loudly rather than silently corrupting a
/// different line that happens to share the same hash.
/// </summary>
internal static class HashlineValidator
{
/// <summary>
/// Parses a "{lineNumber}:{hash}" anchor string.
/// </summary>
/// <returns>True and the parsed values if the format is valid; false otherwise.</returns>
public static bool TryParseAnchor(string anchor, out int lineNumber, out string hash)
{
lineNumber = 0;
hash = string.Empty;
int colonIndex = anchor.IndexOf(':');
if (colonIndex <= 0 || colonIndex == anchor.Length - 1)
return false;
ReadOnlySpan<char> numPart = anchor.AsSpan(0, colonIndex);
ReadOnlySpan<char> hashPart = anchor.AsSpan(colonIndex + 1);
if (!int.TryParse(numPart, out lineNumber) || lineNumber < 1)
return false;
hash = hashPart.ToString();
return hash.Length == 2;
}
/// <summary>
/// Resolves an anchor to a 0-based index into <paramref name="lines"/>,
/// verifying both the line number and the content hash.
/// </summary>
/// <param name="anchor">Anchor string, e.g. "5:a3".</param>
/// <param name="lines">Current file lines (without newlines).</param>
/// <param name="zeroBasedIndex">Resolved 0-based index on success.</param>
/// <param name="error">Human-readable error message on failure.</param>
/// <returns>True if the anchor is valid and current; false otherwise.</returns>
public static bool TryResolve(
string anchor,
string[] lines,
out int zeroBasedIndex,
out string error)
{
zeroBasedIndex = -1;
error = string.Empty;
if (!TryParseAnchor(anchor, out int lineNumber, out string expectedHash))
{
error = $"Anchor '{anchor}' is not in 'lineNumber:hash' format (e.g. '5:a3').";
return false;
}
if (lineNumber < 1 || lineNumber > lines.Length)
{
error = $"Anchor '{anchor}': line {lineNumber} is out of range " +
$"(file has {lines.Length} line(s)).";
return false;
}
string actualHash = HashlineEncoder.ComputeHash(lines[lineNumber - 1].AsSpan(), lineNumber);
if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase))
{
error = $"Anchor '{anchor}': hash mismatch at line {lineNumber} " +
$"(expected '{expectedHash}', got '{actualHash}'). " +
$"The file has changed — re-read before editing.";
return false;
}
zeroBasedIndex = lineNumber - 1;
return true;
}
/// <summary>
/// Validates both a start and end anchor, and ensures start &lt;= end.
/// </summary>
/// <param name="startAnchor">The starting anchor string.</param>
/// <param name="endAnchor">The ending anchor string.</param>
/// <param name="lines">Current file lines (without newlines).</param>
/// <param name="startIndex">Resolved 0-based start index on success.</param>
/// <param name="endIndex">Resolved 0-based end index on success.</param>
/// <param name="error">Human-readable error message on failure.</param>
/// <returns>True if the range is valid; false otherwise.</returns>
public static bool TryResolveRange(
string startAnchor,
string endAnchor,
string[] lines,
out int startIndex,
out int endIndex,
out string error)
{
startIndex = -1;
endIndex = -1;
error = string.Empty;
if (!TryResolve(startAnchor, lines, out startIndex, out error))
return false;
if (!TryResolve(endAnchor, lines, out endIndex, out error))
return false;
if (startIndex > endIndex)
{
error = $"Range error: start anchor '{startAnchor}' (line {startIndex + 1}) " +
$"is after end anchor '{endAnchor}' (line {endIndex + 1}).";
return false;
}
return true;
}
}