diff --git a/packages/opencode/src/tool/hashline.ts b/packages/opencode/src/tool/hashline.ts index 2a69e4b56b2..834bc5fd251 100644 --- a/packages/opencode/src/tool/hashline.ts +++ b/packages/opencode/src/tool/hashline.ts @@ -35,9 +35,9 @@ export function normalizeLine(line: string): string { return result.trim() } -export function hashLine(line: string): string { +export function hashLine(line: string, lineNumber: number): string { const normalized = normalizeLine(line) - const hash = Bun.hash.xxHash32(normalized, 0) + const hash = Bun.hash.xxHash32(normalized, lineNumber) const codePoint = (hash % 20992) + 0x4e00 return String.fromCharCode(codePoint) } @@ -78,7 +78,7 @@ export function applyHashlineEdits(content: string, edits: HashlineEdit[]): stri const lineMap = new Map() lines.forEach((line, i) => { - lineMap.set(i + 1, hashLine(line)) + lineMap.set(i + 1, hashLine(line, i + 1)) }) const mismatches: { line: number; ref: string; error: string }[] = [] @@ -145,7 +145,7 @@ export function applyHashlineEdits(content: string, edits: HashlineEdit[]): stri for (const [start, end] of displayRanges) { for (let i = start; i <= end; i++) { const markers = mismatchSet.has(i) ? "→" : " " - lineDisplays.push(`${markers}${i}${hashLine(lines[i - 1])}${lines[i - 1]}`) + lineDisplays.push(`${markers}${i}${hashLine(lines[i - 1], i)}${lines[i - 1]}`) } if (end < lines.length) { lineDisplays.push("...") @@ -181,10 +181,10 @@ export function applyHashlineEdits(content: string, edits: HashlineEdit[]): stri `Invalid line ${line}: must be between 1 and ${resultLines.length}` ) } - const currentHash = hashLine(resultLines[line - 1]) + const currentHash = hashLine(resultLines[line - 1], line) if (currentHash !== edit.anchor.hashChar) { const currentLinesWithMarkers = resultLines - .map((l, i) => `${i + 1}${hashLine(l)}${l}`) + .map((l, i) => `${i + 1}${hashLine(l, i + 1)}${l}`) .join("\n") throw new HashlineMismatchError( [{ line: edit.anchor.line, ref: `${edit.anchor.line}${edit.anchor.hashChar}`, error: "hash mismatch after editing" }], @@ -217,10 +217,10 @@ export function applyHashlineEdits(content: string, edits: HashlineEdit[]): stri `Invalid line ${line}: must be between 1 and ${resultLines.length}` ) } - const currentHash = hashLine(resultLines[line - 1]) + const currentHash = hashLine(resultLines[line - 1], line) if (currentHash !== edit.anchor.hashChar) { const currentLinesWithMarkers = resultLines - .map((l, i) => `${i + 1}${hashLine(l)}${l}`) + .map((l, i) => `${i + 1}${hashLine(l, i + 1)}${l}`) .join("\n") throw new HashlineMismatchError( [{ line: edit.anchor.line, ref: `${edit.anchor.line}${edit.anchor.hashChar}`, error: "hash mismatch after editing" }], @@ -236,10 +236,10 @@ export function applyHashlineEdits(content: string, edits: HashlineEdit[]): stri `Invalid line ${line}: must be between 1 and ${resultLines.length}` ) } - const currentHash = hashLine(resultLines[line - 1]) + const currentHash = hashLine(resultLines[line - 1], line) if (currentHash !== edit.anchor.hashChar) { const currentLinesWithMarkers = resultLines - .map((l, i) => `${i + 1}${hashLine(l)}${l}`) + .map((l, i) => `${i + 1}${hashLine(l, i + 1)}${l}`) .join("\n") throw new HashlineMismatchError( [{ line: edit.anchor.line, ref: `${edit.anchor.line}${edit.anchor.hashChar}`, error: "hash mismatch after editing" }], diff --git a/packages/opencode/src/tool/hashline_read.ts b/packages/opencode/src/tool/hashline_read.ts index ed11654a6d8..07cef691015 100644 --- a/packages/opencode/src/tool/hashline_read.ts +++ b/packages/opencode/src/tool/hashline_read.ts @@ -86,7 +86,7 @@ export const HashlineReadTool = Tool.define("hashline_read", { for (let i = start; i < Math.min(lines.length, start + limit); i++) { const line = lines[i].length > MAX_LINE_LENGTH ? lines[i].substring(0, MAX_LINE_LENGTH) + "..." : lines[i] const lineNum = i + 1 - const hashChar = hashLine(line) + const hashChar = hashLine(line, lineNum) const outputLine = `${lineNum}${hashChar}${line}` const size = Buffer.byteLength(outputLine, "utf-8") + (raw.length > 0 ? 1 : 0) if (bytes + size > MAX_BYTES) { diff --git a/packages/opencode/test/tool/hashline.test.ts b/packages/opencode/test/tool/hashline.test.ts index 1fe927db4b2..0e1503ea1c1 100644 --- a/packages/opencode/test/tool/hashline.test.ts +++ b/packages/opencode/test/tool/hashline.test.ts @@ -24,7 +24,7 @@ describe("normalizeLine", () => { describe("hashLine", () => { test("returns single char with charCodeAt(0) in [0x4E00, 0x9FFF]", () => { - const result = hashLine("test line") + const result = hashLine("test line", 1) expect(result).toHaveLength(1) const code = result.charCodeAt(0) expect(code).toBeGreaterThanOrEqual(0x4E00) @@ -33,10 +33,17 @@ describe("hashLine", () => { test("stable (same input → same output)", () => { const input = "stable test" - const result1 = hashLine(input) - const result2 = hashLine(input) + const result1 = hashLine(input, 1) + const result2 = hashLine(input, 1) expect(result1).toBe(result2) }) + + test("blank lines at different positions get different anchors", () => { + const blankLine = "" + const result1 = hashLine(blankLine, 5) + const result5 = hashLine(blankLine, 10) + expect(result1).not.toBe(result5) + }) }) describe("parseAnchor", () => { @@ -68,7 +75,7 @@ describe("applyHashlineEdits", () => { const edits = [ { op: "set_line" as const, - anchor: { line: 2, hashChar: hashLine("line2") }, + anchor: { line: 2, hashChar: hashLine("line2", 2) }, new_text: "replaced", }, ] @@ -81,7 +88,7 @@ describe("applyHashlineEdits", () => { const edits = [ { op: "set_line" as const, - anchor: { line: 2, hashChar: hashLine("line2") }, + anchor: { line: 2, hashChar: hashLine("line2", 2) }, new_text: "", }, ] @@ -94,8 +101,8 @@ describe("applyHashlineEdits", () => { const edits = [ { op: "replace_lines" as const, - start_anchor: { line: 2, hashChar: hashLine("line2") }, - end_anchor: { line: 3, hashChar: hashLine("line3") }, + start_anchor: { line: 2, hashChar: hashLine("line2", 2) }, + end_anchor: { line: 3, hashChar: hashLine("line3", 3) }, new_text: "new2\nnew3", }, ] @@ -108,8 +115,8 @@ describe("applyHashlineEdits", () => { const edits = [ { op: "replace_lines" as const, - start_anchor: { line: 2, hashChar: hashLine("line2") }, - end_anchor: { line: 3, hashChar: hashLine("line3") }, + start_anchor: { line: 2, hashChar: hashLine("line2", 2) }, + end_anchor: { line: 3, hashChar: hashLine("line3", 3) }, new_text: "", }, ] @@ -122,7 +129,7 @@ describe("applyHashlineEdits", () => { const edits = [ { op: "insert_after" as const, - anchor: { line: 2, hashChar: hashLine("line2") }, + anchor: { line: 2, hashChar: hashLine("line2", 2) }, text: "inserted", }, ] @@ -135,12 +142,12 @@ describe("applyHashlineEdits", () => { const edits = [ { op: "set_line" as const, - anchor: { line: 4, hashChar: hashLine("line4") }, + anchor: { line: 4, hashChar: hashLine("line4", 4) }, new_text: "replaced4", }, { op: "set_line" as const, - anchor: { line: 2, hashChar: hashLine("line2") }, + anchor: { line: 2, hashChar: hashLine("line2", 2) }, new_text: "replaced2", }, ] @@ -164,37 +171,26 @@ describe("applyHashlineEdits", () => { test("relocates line when hash found at different line", () => { const content = "line1\ntarget\nline3\nline4\nline5" + // Anchor says line 1, but we give it the hash that actually appears at line 2 + // This simulates a hash that was read when the content was at line 1, but is now at line 2 const edits = [ { op: "set_line" as const, - anchor: { line: 1, hashChar: hashLine("target") }, // Hash says line 1, but target is at line 2 + anchor: { line: 1, hashChar: hashLine("target", 2) }, // Hash from line 2, but anchor says line 1 new_text: "replaced", }, ] const result = applyHashlineEdits(content, edits) + // Should relocate to line 2 where the hash is found 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") }, + anchor: { line: 2, hashChar: hashLine("line2", 2) }, new_text: "line2", // Same content }, ] @@ -214,7 +210,7 @@ describe("applyHashlineEdits", () => { const edits2 = [ { op: "set_line" as const, - anchor: { line: 3, hashChar: hashLine("line3") }, + anchor: { line: 3, hashChar: hashLine("line3", 3) }, new_text: "also replaced", }, ] @@ -229,7 +225,7 @@ describe("applyHashlineEdits", () => { const edits = [ { op: "set_line" as const, - anchor: { line: 2, hashChar: hashLine("line2") }, + anchor: { line: 2, hashChar: hashLine("line2", 2) }, new_text: "replaced", }, ] @@ -242,17 +238,17 @@ describe("applyHashlineEdits", () => { const edits = [ { op: "set_line" as const, - anchor: { line: 1, hashChar: hashLine("a") }, + anchor: { line: 1, hashChar: hashLine("a", 1) }, new_text: "X", }, { op: "set_line" as const, - anchor: { line: 3, hashChar: hashLine("c") }, + anchor: { line: 3, hashChar: hashLine("c", 3) }, new_text: "Y", }, { op: "set_line" as const, - anchor: { line: 5, hashChar: hashLine("e") }, + anchor: { line: 5, hashChar: hashLine("e", 5) }, new_text: "Z", }, ] @@ -265,12 +261,12 @@ describe("applyHashlineEdits", () => { const edits = [ { op: "set_line" as const, - anchor: { line: 1, hashChar: hashLine("line1") }, + anchor: { line: 1, hashChar: hashLine("line1", 1) }, new_text: "modified", }, { op: "set_line" as const, - anchor: { line: 2, hashChar: hashLine("line2") }, // Line 2 becomes line 2, but anchor validation happens + anchor: { line: 2, hashChar: hashLine("line2", 2) }, // Line 2 becomes line 2, but anchor validation happens new_text: "should-fail", }, ] @@ -283,12 +279,12 @@ describe("applyHashlineEdits", () => { const edits = [ { op: "set_line" as const, - anchor: { line: 2, hashChar: hashLine("line2") }, + anchor: { line: 2, hashChar: hashLine("line2", 2) }, new_text: "", // Delete line 2 }, { op: "set_line" as const, - anchor: { line: 4, hashChar: hashLine("line4") }, + anchor: { line: 4, hashChar: hashLine("line4", 4) }, new_text: "replaced4", // Should target original line 4, not shifted line 3 }, ] @@ -303,13 +299,13 @@ describe("applyHashlineEdits", () => { const edits = [ { op: "replace_lines" as const, - start_anchor: { line: 2, hashChar: hashLine("line2") }, - end_anchor: { line: 3, hashChar: hashLine("line3") }, + start_anchor: { line: 2, hashChar: hashLine("line2", 2) }, + end_anchor: { line: 3, hashChar: hashLine("line3", 3) }, new_text: "", // Delete lines 2-3 }, { op: "set_line" as const, - anchor: { line: 5, hashChar: hashLine("line5") }, + anchor: { line: 5, hashChar: hashLine("line5", 5) }, new_text: "replaced5", // Should target original line 5, not shifted line 3 }, ] @@ -324,12 +320,12 @@ describe("applyHashlineEdits", () => { const edits = [ { op: "set_line" as const, - anchor: { line: 2, hashChar: hashLine("b") }, + anchor: { line: 2, hashChar: hashLine("b", 2) }, new_text: "", // Delete line 2 }, { op: "set_line" as const, - anchor: { line: 4, hashChar: hashLine("d") }, + anchor: { line: 4, hashChar: hashLine("d", 4) }, new_text: "", // Delete line 4 (originally) }, ] @@ -345,12 +341,12 @@ describe("applyHashlineEdits", () => { const edits = [ { op: "insert_after" as const, - anchor: { line: 1, hashChar: hashLine("a") }, + anchor: { line: 1, hashChar: hashLine("a", 1) }, text: "X", // Insert X after line 1, shifts b,c,d down }, { op: "set_line" as const, - anchor: { line: 3, hashChar: hashLine("c") }, + anchor: { line: 3, hashChar: hashLine("c", 3) }, new_text: "C", // Should still find line c and replace it }, ] @@ -360,4 +356,19 @@ describe("applyHashlineEdits", () => { // After insert_after: a, X, b, C, d expect(result).toBe("a\nX\nb\nC\nd") }) -}) \ No newline at end of file + + test("file with 10 blank lines, insert_after on one blank line succeeds", () => { + const content = "\n\n\n\n\n\n\n\n\n\n" + const edits = [ + { + op: "insert_after" as const, + anchor: { line: 5, hashChar: hashLine("", 5) }, + text: "inserted", + }, + ] + const result = applyHashlineEdits(content, edits) + expect(result).toContain("inserted") + const lines = result.split("\n") + expect(lines.length).toBe(12) // 10 blank + 1 inserted + 1 from final empty string + }) +}) diff --git a/packages/opencode/test/tool/hashline_edit.test.ts b/packages/opencode/test/tool/hashline_edit.test.ts index f9b1288c76f..01d8407ab91 100644 --- a/packages/opencode/test/tool/hashline_edit.test.ts +++ b/packages/opencode/test/tool/hashline_edit.test.ts @@ -5,6 +5,7 @@ import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import { FileTime } from "../../src/file/time" import { Flag } from "../../src/flag/flag" +import { hashLine } from "../../src/tool/hashline" const ctx = { sessionID: "test", @@ -29,10 +30,11 @@ describe("tool.hashline_edit", () => { fn: async () => { const tool = await HashlineEditTool.init() FileTime.hashlineRead(ctx.sessionID, path.join(tmp.path, "test.txt")) + const anchor = `2${hashLine("line2", 2)}` const result = await tool.execute( { filePath: path.join(tmp.path, "test.txt"), - edits: [{ op: "set_line", anchor: "2咲", new_text: "new line 2" }], + edits: [{ op: "set_line", anchor, new_text: "new line 2" }], }, ctx ) @@ -54,10 +56,11 @@ describe("tool.hashline_edit", () => { fn: async () => { const tool = await HashlineEditTool.init() FileTime.hashlineRead(ctx.sessionID, path.join(tmp.path, "test.txt")) + const anchor = `2${hashLine("line2", 2)}` const result = await tool.execute( { filePath: path.join(tmp.path, "test.txt"), - edits: [{ op: "set_line", anchor: "2咲", new_text: "" }], + edits: [{ op: "set_line", anchor, new_text: "" }], }, ctx ) @@ -85,8 +88,8 @@ describe("tool.hashline_edit", () => { edits: [ { op: "replace_lines", - start_anchor: "2咲", - end_anchor: "4扟", + start_anchor: `2${hashLine("line2", 2)}`, + end_anchor: `4${hashLine("line4", 4)}`, new_text: "replaced lines", }, ], @@ -117,8 +120,8 @@ describe("tool.hashline_edit", () => { edits: [ { op: "replace_lines", - start_anchor: "2咲", - end_anchor: "3徃", + start_anchor: `2${hashLine("line2", 2)}`, + end_anchor: `3${hashLine("line3", 3)}`, new_text: "", }, ], @@ -149,7 +152,7 @@ describe("tool.hashline_edit", () => { edits: [ { op: "insert_after", - anchor: "2咲", + anchor: `2${hashLine("line2", 2)}`, text: "inserted line", }, ], @@ -200,10 +203,11 @@ describe("tool.hashline_edit", () => { fn: async () => { const tool = await HashlineEditTool.init() FileTime.hashlineRead(ctx.sessionID, path.join(tmp.path, "test.txt")) + const anchor = `2${hashLine("line2", 2)}` const result = await tool.execute( { filePath: path.join(tmp.path, "test.txt"), - edits: [{ op: "set_line", anchor: "2咲", new_text: "line2" }], + edits: [{ op: "set_line", anchor, new_text: "line2" }], }, ctx ).catch((e) => e) @@ -229,8 +233,8 @@ describe("tool.hashline_edit", () => { { filePath: path.join(tmp.path, "test.txt"), edits: [ - { op: "set_line", anchor: "2咲", new_text: "updated line 2" }, - { op: "set_line", anchor: "3徃", new_text: "updated line 3" }, + { op: "set_line", anchor: `2${hashLine("line2", 2)}`, new_text: "updated line 2" }, + { op: "set_line", anchor: `3${hashLine("line3", 3)}`, new_text: "updated line 3" }, ], }, ctx @@ -257,7 +261,7 @@ describe("tool.hashline_edit", () => { { filePath: path.join(tmp.path, "test.txt"), edits: [ - { op: "set_line", anchor: "2咲", new_text: "this should apply" }, + { op: "set_line", anchor: `2${hashLine("line2", 2)}`, new_text: "this should apply" }, { op: "set_line", anchor: "3戌", new_text: "this should fail" }, ], }, @@ -297,7 +301,7 @@ describe("tool.hashline_edit", () => { const result = await tool.execute( { filePath: path.join(tmp.path, "test.txt"), - edits: [{ op: "set_line", anchor: "2咲", new_text: "new line 2" }], + edits: [{ op: "set_line", anchor: `2${hashLine("line2", 2)}`, new_text: "new line 2" }], }, ctx ).catch((e) => e) @@ -328,7 +332,7 @@ describe("tool.hashline_edit", () => { const result1 = await tool.execute( { filePath: filepath1, - edits: [{ op: "set_line", anchor: "2咲", new_text: "modified" }], + edits: [{ op: "set_line", anchor: `2${hashLine("line2", 2)}`, new_text: "modified" }], }, ctx ) @@ -339,7 +343,7 @@ describe("tool.hashline_edit", () => { const result2 = await tool.execute( { filePath: filepath2, - edits: [{ op: "set_line", anchor: "2咲", new_text: "modified" }], + edits: [{ op: "set_line", anchor: `2${hashLine("line2", 2)}`, new_text: "modified" }], }, ctx ).catch((e) => e) @@ -371,7 +375,7 @@ describe("tool.hashline_edit", () => { const result = await tool.execute( { filePath: filepath, - edits: [{ op: "set_line", anchor: "2咲", new_text: "new" }], + edits: [{ op: "set_line", anchor: `2${hashLine("line2", 2)}`, new_text: "new" }], }, ctx ).catch((e) => e) @@ -401,7 +405,7 @@ describe("tool.hashline_edit", () => { const result = await tool.execute( { filePath: filepath, - edits: [{ op: "set_line", anchor: "2咲", new_text: "modified" }], + edits: [{ op: "set_line", anchor: `2${hashLine("line2", 2)}`, new_text: "modified" }], }, ctxB ).catch((e) => e) @@ -551,7 +555,7 @@ describe("tool.hashline_edit", () => { const result = await tool.execute( { filePath: filepath, - edits: [{ op: "set_line", anchor: "1赙", new_text: "modified" }], + edits: [{ op: "set_line", anchor: `1${hashLine("new line1", 1)}`, new_text: "modified" }], }, ctx ) @@ -581,7 +585,7 @@ describe("tool.hashline_edit", () => { const result1 = await tool.execute( { filePath: filepath, - edits: [{ op: "set_line", anchor: "2咲", new_text: "edited line 2" }], + edits: [{ op: "set_line", anchor: `2${hashLine("line2", 2)}`, new_text: "edited line 2" }], }, ctx ) @@ -592,7 +596,7 @@ describe("tool.hashline_edit", () => { const result2 = await tool.execute( { filePath: filepath, - edits: [{ op: "set_line", anchor: "3徃", new_text: "edited line 3" }], + edits: [{ op: "set_line", anchor: `3${hashLine("line3", 3)}`, new_text: "edited line 3" }], }, ctx ) @@ -604,4 +608,4 @@ describe("tool.hashline_edit", () => { }, }) }) -}) \ No newline at end of file +})