initial commit
This commit is contained in:
84
Hashline/HashlineEncoder.cs
Normal file
84
Hashline/HashlineEncoder.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
namespace AnchorCli.Hashline;
|
||||
|
||||
/// <summary>
|
||||
/// Encodes file lines with Hashline anchors in the format: {lineNumber}:{hash}|{content}
|
||||
///
|
||||
/// Hash algorithm: Adler-8 with a position seed.
|
||||
/// raw = ((sum_of_bytes % 251) ^ (count_of_bytes % 251)) & 0xFF
|
||||
/// final = raw ^ (lineNumber & 0xFF)
|
||||
///
|
||||
/// The position seed ensures duplicate lines produce different tags,
|
||||
/// preventing ambiguous anchor resolution on large files.
|
||||
/// </summary>
|
||||
internal static class HashlineEncoder
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the 2-char hex Hashline tag for a single line.
|
||||
/// </summary>
|
||||
/// <param name="line">Raw line content (without newline).</param>
|
||||
/// <param name="lineNumber">1-indexed line number within the file.</param>
|
||||
public static string ComputeHash(ReadOnlySpan<char> line, int lineNumber)
|
||||
{
|
||||
int sum = 0;
|
||||
int count = line.Length;
|
||||
|
||||
foreach (char c in line)
|
||||
{
|
||||
// Use the lower byte of each character — sufficient for source code
|
||||
sum += c & 0xFF;
|
||||
}
|
||||
|
||||
int raw = ((sum % 251) ^ (count % 251)) & 0xFF;
|
||||
int final = raw ^ (lineNumber & 0xFF);
|
||||
|
||||
return final.ToString("x2");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes all lines with Hashline prefixes.
|
||||
/// </summary>
|
||||
/// <param name="lines">All lines of the file (without trailing newlines).</param>
|
||||
/// <returns>A single string with each line prefixed as "{n}:{hash}|{content}\n".</returns>
|
||||
public static string Encode(string[] lines) => Encode(lines, startLine: 1, endLine: lines.Length);
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a window of lines with Hashline prefixes.
|
||||
/// Line numbers in the output reflect their true position in the file.
|
||||
/// </summary>
|
||||
/// <param name="lines">All lines of the file.</param>
|
||||
/// <param name="startLine">1-indexed first line to include (inclusive).</param>
|
||||
/// <param name="endLine">1-indexed last line to include (inclusive). Clamped to file length.</param>
|
||||
public static string Encode(string[] lines, int startLine, int endLine)
|
||||
{
|
||||
if (lines.Length == 0) return string.Empty;
|
||||
|
||||
startLine = Math.Max(1, startLine);
|
||||
endLine = Math.Min(lines.Length, endLine <= 0 ? lines.Length : endLine);
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
|
||||
for (int i = startLine; i <= endLine; i++)
|
||||
{
|
||||
string line = lines[i - 1];
|
||||
string hash = ComputeHash(line.AsSpan(), i);
|
||||
sb.Append(i).Append(':').Append(hash).Append('|').AppendLine(line);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a short file-level fingerprint: XOR of all per-line hashes (as bytes).
|
||||
/// Useful for cheap full-file staleness checks.
|
||||
/// </summary>
|
||||
public static string FileFingerprint(string[] lines)
|
||||
{
|
||||
int fp = 0;
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
string hash = ComputeHash(lines[i].AsSpan(), i + 1);
|
||||
fp ^= Convert.ToInt32(hash, 16);
|
||||
}
|
||||
return fp.ToString("x2");
|
||||
}
|
||||
}
|
||||
113
Hashline/HashlineValidator.cs
Normal file
113
Hashline/HashlineValidator.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
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 <= end.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user