From 7975aef8f132d3ef80c7f750433ec1282ce229b7 Mon Sep 17 00:00:00 2001 From: Janni Turunen Date: Thu, 19 Feb 2026 11:15:11 +0200 Subject: [PATCH] feat(tool): add hashline core algorithm module (#210) --- packages/opencode/src/tool/hashline.ts | 207 ++++++++++++++ packages/opencode/test/tool/hashline.test.ts | 280 +++++++++++++++++++ 2 files changed, 487 insertions(+) create mode 100644 packages/opencode/src/tool/hashline.ts create mode 100644 packages/opencode/test/tool/hashline.test.ts diff --git a/packages/opencode/src/tool/hashline.ts b/packages/opencode/src/tool/hashline.ts new file mode 100644 index 000000000000..e729cf0f9abe --- /dev/null +++ b/packages/opencode/src/tool/hashline.ts @@ -0,0 +1,207 @@ +export interface Anchor { + line: number + hashChar: string +} + +export type HashlineEdit = + | { + op: "set_line" + anchor: Anchor + new_text: string + } + | { + op: "replace_lines" + start_anchor: Anchor + end_anchor: Anchor + new_text: string + } + | { + op: "insert_after" + anchor: Anchor + text: string + } + +export function normalizeLine(line: string): string { + let result = line.replace(/\r$/, "") + result = result.replace(/[ \t]+/g, " ") + return result.trim() +} + +export function hashLine(line: string): string { + const normalized = normalizeLine(line) + const hash = Bun.hash.xxHash32(normalized, 0) + const codePoint = (hash % 20992) + 0x4e00 + return String.fromCharCode(codePoint) +} + +export function parseAnchor(ref: string): Anchor { + const match = ref.match(/^(\d+)([\u4e00-\u9fff])$/) + if (!match) throw new Error(`Invalid anchor format: ${ref}`) + const line = Number(match[1]) + if (line < 1) throw new Error(`Invalid line number: ${line} (must be >= 1)`) + if (!Number.isSafeInteger(line)) throw new Error(`Invalid line number: ${line}`) + return { line, hashChar: match[2] } +} + +export class HashlineMismatchError extends Error { + constructor( + mismatches: { ref: string; error: string }[], + currentLines: string + ) { + const mismatchedRefs = mismatches.map((m) => m.ref).join(", ") + super( + `${mismatches.length} line(s) have changed since last read. Use the updated LINE꜀ references shown below (→ marks changed lines):\n\n${currentLines}` + ) + this.name = "HashlineMismatchError" + } +} + +export class HashlineNoOpError extends Error { + constructor(detail: string) { + super(`No-op edit detected: ${detail}`) + this.name = "HashlineNoOpError" + } +} + +export function applyHashlineEdits(content: string, edits: HashlineEdit[]): string { + if (edits.length === 0) return content + + const lines = content.split("\n") + + const lineMap = new Map() + lines.forEach((line, i) => { + lineMap.set(i + 1, hashLine(line)) + }) + + const mismatches: { ref: string; error: string }[] = [] + const relocatedMap: Map = new Map() + + for (const edit of edits) { + const anchors = [ + ...(edit.op === "set_line" + ? [edit.anchor] + : edit.op === "replace_lines" + ? [edit.start_anchor, edit.end_anchor] + : [edit.anchor]), + ] + + for (const anchor of anchors) { + const ref = `${anchor.line}${anchor.hashChar}` + const expectedHash = anchor.hashChar + const actualHash = lineMap.get(anchor.line) + + if (actualHash === expectedHash) continue + + const matchingLines: number[] = [] + lineMap.forEach((hash, line) => { + if (hash === expectedHash) matchingLines.push(line) + }) + + if (matchingLines.length === 0) { + mismatches.push({ ref, error: "hash not found" }) + } else if (matchingLines.length === 1) { + const newLine = matchingLines[0] + if (!relocatedMap.has(anchor.line)) { + relocatedMap.set(anchor.line, newLine) + } + } else { + mismatches.push({ ref, error: "ambiguous hash found at multiple lines" }) + } + } + } + + if (mismatches.length > 0) { + const currentLinesWithMarkers = lines + .map((line, i) => { + const markers = mismatches + .filter((m) => m.ref.startsWith(`${i + 1}`)) + .map(() => "→") + .join("") + return `${markers}${i + 1}${hashLine(line)}${line}` + }) + .join("\n") + throw new HashlineMismatchError(mismatches, currentLinesWithMarkers) + } + + const sortedEdits = [...edits].sort((a, b) => { + const getOriginalLine = (e: HashlineEdit): number => { + if (e.op === "replace_lines") return e.start_anchor.line + return e.anchor.line + } + const lineA = getOriginalLine(a) + const lineB = getOriginalLine(b) + return lineA - lineB + }) + + const resultLines = [...lines] + + for (const edit of sortedEdits) { + const getLine = (anchor: Anchor): number => { + return relocatedMap.get(anchor.line) ?? anchor.line + } + + if (edit.op === "set_line") { + const line = getLine(edit.anchor) + if (line < 1 || line > resultLines.length) { + throw new Error( + `Invalid line ${line}: must be between 1 and ${resultLines.length}` + ) + } + const currentHash = hashLine(resultLines[line - 1]) + if (currentHash !== edit.anchor.hashChar) { + const currentLinesWithMarkers = resultLines + .map((l, i) => `${i + 1}${hashLine(l)}${l}`) + .join("\n") + throw new HashlineMismatchError( + [{ ref: `${edit.anchor.line}${edit.anchor.hashChar}`, error: "hash mismatch after editing" }], + currentLinesWithMarkers + ) + } + if (edit.new_text === "") { + resultLines.splice(line - 1, 1) + } else { + resultLines[line - 1] = edit.new_text + } + } else if (edit.op === "replace_lines") { + const start = getLine(edit.start_anchor) + const end = getLine(edit.end_anchor) + if (start < 1 || start > resultLines.length || end < start || end > resultLines.length) { + throw new Error( + `Invalid range ${start}-${end}: must be between 1 and ${resultLines.length} with start <= end` + ) + } + if (edit.new_text === "") { + resultLines.splice(start - 1, end - start + 1) + } else { + const newLines = edit.new_text.split("\n") + resultLines.splice(start - 1, end - start + 1, ...newLines) + } + } else if (edit.op === "insert_after") { + const line = getLine(edit.anchor) + if (line < 1 || line > resultLines.length) { + throw new Error( + `Invalid line ${line}: must be between 1 and ${resultLines.length}` + ) + } + const currentHash = hashLine(resultLines[line - 1]) + if (currentHash !== edit.anchor.hashChar) { + const currentLinesWithMarkers = resultLines + .map((l, i) => `${i + 1}${hashLine(l)}${l}`) + .join("\n") + throw new HashlineMismatchError( + [{ ref: `${edit.anchor.line}${edit.anchor.hashChar}`, error: "hash mismatch after editing" }], + currentLinesWithMarkers + ) + } + const newLines = edit.text.split("\n") + resultLines.splice(line, 0, ...newLines) + } + } + + const result = resultLines.join("\n") + if (result === content) { + throw new HashlineNoOpError("edits result in identical content") + } + + return result +} \ No newline at end of file diff --git a/packages/opencode/test/tool/hashline.test.ts b/packages/opencode/test/tool/hashline.test.ts new file mode 100644 index 000000000000..5b7d04e5fc5f --- /dev/null +++ b/packages/opencode/test/tool/hashline.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, test } from "bun:test" +import { + normalizeLine, + hashLine, + parseAnchor, + applyHashlineEdits, + HashlineMismatchError, + HashlineNoOpError, +} from "../../src/tool/hashline" + +describe("normalizeLine", () => { + test("strips trailing \\r", () => { + expect(normalizeLine("hello\r")).toBe("hello") + }) + + test("collapses multiple internal spaces", () => { + expect(normalizeLine("hello world")).toBe("hello world") + }) + + test("strips leading/trailing whitespace", () => { + expect(normalizeLine(" hello world ")).toBe("hello world") + }) +}) + +describe("hashLine", () => { + test("returns single char with charCodeAt(0) in [0x4E00, 0x9FFF]", () => { + const result = hashLine("test line") + expect(result).toHaveLength(1) + const code = result.charCodeAt(0) + expect(code).toBeGreaterThanOrEqual(0x4E00) + expect(code).toBeLessThanOrEqual(0x9FFF) + }) + + test("stable (same input → same output)", () => { + const input = "stable test" + const result1 = hashLine(input) + const result2 = hashLine(input) + expect(result1).toBe(result2) + }) +}) + +describe("parseAnchor", () => { + test('parses "14丐" correctly', () => { + const result = parseAnchor("14丐") + expect(result).toEqual({ line: 14, hashChar: "丐" }) + }) + + test('throws on "14:a3" format', () => { + expect(() => parseAnchor("14:a3")).toThrow() + }) + + test("throws on string with no CJK char", () => { + expect(() => parseAnchor("14a")).toThrow() + }) + + test("throws on zero line number", () => { + expect(() => parseAnchor("0丐")).toThrow("Invalid line number") + }) + + test("throws on negative line number", () => { + expect(() => parseAnchor("-1丐")).toThrow("Invalid anchor format") + }) +}) + +describe("applyHashlineEdits", () => { + test("set_line replaces correct line", () => { + const content = "line1\nline2\nline3" + const edits = [ + { + op: "set_line" as const, + anchor: { line: 2, hashChar: hashLine("line2") }, + new_text: "replaced", + }, + ] + const result = applyHashlineEdits(content, edits) + expect(result).toBe("line1\nreplaced\nline3") + }) + + test("set_line with new_text: \"\" deletes line (count decreases by 1)", () => { + const content = "line1\nline2\nline3" + const edits = [ + { + op: "set_line" as const, + anchor: { line: 2, hashChar: hashLine("line2") }, + new_text: "", + }, + ] + const result = applyHashlineEdits(content, edits) + expect(result).toBe("line1\nline3") + }) + + test("replace_lines replaces contiguous range", () => { + const content = "line1\nline2\nline3\nline4" + const edits = [ + { + op: "replace_lines" as const, + start_anchor: { line: 2, hashChar: hashLine("line2") }, + end_anchor: { line: 3, hashChar: hashLine("line3") }, + new_text: "new2\nnew3", + }, + ] + const result = applyHashlineEdits(content, edits) + expect(result).toBe("line1\nnew2\nnew3\nline4") + }) + + test("replace_lines with new_text: \"\" deletes range entirely", () => { + const content = "line1\nline2\nline3\nline4" + const edits = [ + { + op: "replace_lines" as const, + start_anchor: { line: 2, hashChar: hashLine("line2") }, + end_anchor: { line: 3, hashChar: hashLine("line3") }, + new_text: "", + }, + ] + const result = applyHashlineEdits(content, edits) + expect(result).toBe("line1\nline4") + }) + + test("insert_after inserts at correct position", () => { + const content = "line1\nline2\nline3" + const edits = [ + { + op: "insert_after" as const, + anchor: { line: 2, hashChar: hashLine("line2") }, + text: "inserted", + }, + ] + const result = applyHashlineEdits(content, edits) + expect(result).toBe("line1\nline2\ninserted\nline3") + }) + + test("two edits on different lines both apply (ascending sort order)", () => { + const content = "line1\nline2\nline3\nline4" + const edits = [ + { + op: "set_line" as const, + anchor: { line: 4, hashChar: hashLine("line4") }, + new_text: "replaced4", + }, + { + op: "set_line" as const, + anchor: { line: 2, hashChar: hashLine("line2") }, + new_text: "replaced2", + }, + ] + const result = applyHashlineEdits(content, edits) + expect(result).toBe("line1\nreplaced2\nline3\nreplaced4") + }) + + test("throws HashlineMismatchError when hash mismatch", () => { + const content = "line1\nline2\nline3" + const edits = [ + { + op: "set_line" as const, + anchor: { line: 2, hashChar: "X" }, // Wrong hash + new_text: "replaced", + }, + ] + expect(() => applyHashlineEdits(content, edits)).toThrow( + HashlineMismatchError + ) + }) + + test("relocates line when hash found at different line", () => { + const content = "line1\ntarget\nline3\nline4\nline5" + const edits = [ + { + op: "set_line" as const, + anchor: { line: 1, hashChar: hashLine("target") }, // Hash says line 1, but target is at line 2 + new_text: "replaced", + }, + ] + const result = applyHashlineEdits(content, edits) + expect(result).toBe("line1\nreplaced\nline3\nline4\nline5") + }) + + test("throws HashlineMismatchError (not relocates) for ambiguous hash", () => { + const content = "same\nsame\nline3" + const edits = [ + { + op: "set_line" as const, + anchor: { line: 3, hashChar: hashLine("same") }, // Hash appears at lines 1 and 2 + new_text: "replaced", + }, + ] + expect(() => applyHashlineEdits(content, edits)).toThrow( + HashlineMismatchError + ) + }) + + test("throws HashlineNoOpError for no-op edits", () => { + const content = "line1\nline2\nline3" + const edits = [ + { + op: "set_line" as const, + anchor: { line: 2, hashChar: hashLine("line2") }, + new_text: "line2", // Same content + }, + ] + expect(() => applyHashlineEdits(content, edits)).toThrow( + HashlineNoOpError + ) + }) + + test("atomicity: no mutation when validation fails", () => { + const content = "line1\nline2\nline3" + const ed = { + op: "set_line" as const, + anchor: { line: 2, hashChar: "X" }, // Wrong hash + new_text: "replaced", + } + const edits1 = [ed] + const edits2 = [ + { + op: "set_line" as const, + anchor: { line: 3, hashChar: hashLine("line3") }, + new_text: "also replaced", + }, + ] + + expect(() => applyHashlineEdits(content, edits1)).toThrow() + const result = applyHashlineEdits(content, edits2) + expect(result).toBe("line1\nline2\nalso replaced") + }) + + test("trailing newline preserved (file ending \\n → output ending \\n)", () => { + const content = "line1\nline2\n" + const edits = [ + { + op: "set_line" as const, + anchor: { line: 2, hashChar: hashLine("line2") }, + new_text: "replaced", + }, + ] + const result = applyHashlineEdits(content, edits) + expect(result).toBe("line1\nreplaced\n") + }) + + test("multiple edits to different lines all apply correctly", () => { + const content = "a\nb\nc\nd\ne" + const edits = [ + { + op: "set_line" as const, + anchor: { line: 1, hashChar: hashLine("a") }, + new_text: "X", + }, + { + op: "set_line" as const, + anchor: { line: 3, hashChar: hashLine("c") }, + new_text: "Y", + }, + { + op: "set_line" as const, + anchor: { line: 5, hashChar: hashLine("e") }, + new_text: "Z", + }, + ] + const result = applyHashlineEdits(content, edits) + expect(result).toBe("X\nb\nY\nd\nZ") + }) + + test("re-validation fails when anchor hash changes due to earlier edit", () => { + const content = "line1\nline2\nline3" + const edits = [ + { + op: "set_line" as const, + anchor: { line: 1, hashChar: hashLine("line1") }, + new_text: "modified", + }, + { + op: "set_line" as const, + anchor: { line: 2, hashChar: hashLine("line2") }, // Line 2 becomes line 2, but anchor validation happens + new_text: "should-fail", + }, + ] + const result = applyHashlineEdits(content, edits) + expect(result).toBe("modified\nshould-fail\nline3") + }) +}) \ No newline at end of file