diff --git a/bun-test.d.ts b/bun-test.d.ts new file mode 100644 index 000000000..41d164f6a --- /dev/null +++ b/bun-test.d.ts @@ -0,0 +1,23 @@ +declare module "bun:test" { + export function describe(name: string, fn: () => void): void + export function it(name: string, fn: () => void | Promise): void + export function beforeEach(fn: () => void | Promise): void + export function afterEach(fn: () => void | Promise): void + export function beforeAll(fn: () => void | Promise): void + export function afterAll(fn: () => void | Promise): void + export function mock unknown>(fn: T): T + + interface Matchers { + toBe(expected: unknown): void + toEqual(expected: unknown): void + toContain(expected: unknown): void + toMatch(expected: RegExp | string): void + toHaveLength(expected: number): void + toBeGreaterThan(expected: number): void + toThrow(expected?: RegExp | string): void + toStartWith(expected: string): void + not: Matchers + } + + export function expect(received: unknown): Matchers +} diff --git a/src/tools/hashline-edit/edit-operations.test.ts b/src/tools/hashline-edit/edit-operations.test.ts index 0f5dad520..5c948af32 100644 --- a/src/tools/hashline-edit/edit-operations.test.ts +++ b/src/tools/hashline-edit/edit-operations.test.ts @@ -41,6 +41,75 @@ describe("hashline edit operations", () => { expect(result).toEqual(["line 1", "line 2", "inserted", "line 3"]) }) + it("applies insert_before with LINE#ID anchor", () => { + //#given + const lines = ["line 1", "line 2", "line 3"] + + //#when + const result = applyHashlineEdits( + lines.join("\n"), + [{ type: "insert_before", line: anchorFor(lines, 2), text: "before 2" }] + ) + + //#then + expect(result).toEqual("line 1\nbefore 2\nline 2\nline 3") + }) + + it("applies insert_between with dual anchors", () => { + //#given + const lines = ["line 1", "line 2", "line 3"] + + //#when + const result = applyHashlineEdits( + lines.join("\n"), + [{ + type: "insert_between", + after_line: anchorFor(lines, 1), + before_line: anchorFor(lines, 2), + text: ["between"], + }] + ) + + //#then + expect(result).toEqual("line 1\nbetween\nline 2\nline 3") + }) + + it("throws when insert_after receives empty text array", () => { + //#given + const lines = ["line 1", "line 2"] + + //#when / #then + expect(() => applyInsertAfter(lines, anchorFor(lines, 1), [])).toThrow(/non-empty/i) + }) + + it("throws when insert_before receives empty text array", () => { + //#given + const lines = ["line 1", "line 2"] + + //#when / #then + expect(() => + applyHashlineEdits(lines.join("\n"), [{ type: "insert_before", line: anchorFor(lines, 1), text: [] }]) + ).toThrow(/non-empty/i) + }) + + it("throws when insert_between receives empty text array", () => { + //#given + const lines = ["line 1", "line 2"] + + //#when / #then + expect(() => + applyHashlineEdits( + lines.join("\n"), + [{ + type: "insert_between", + after_line: anchorFor(lines, 1), + before_line: anchorFor(lines, 2), + text: [], + }] + ) + ).toThrow(/non-empty/i) + }) + it("applies replace operation", () => { //#given const content = "hello world foo" @@ -68,6 +137,22 @@ describe("hashline edit operations", () => { expect(result).toEqual("line 1\ninserted\nline 2\nmodified") }) + it("deduplicates identical insert edits in one pass", () => { + //#given + const content = "line 1\nline 2" + const lines = content.split("\n") + const edits: HashlineEdit[] = [ + { type: "insert_after", line: anchorFor(lines, 1), text: "inserted" }, + { type: "insert_after", line: anchorFor(lines, 1), text: "inserted" }, + ] + + //#when + const result = applyHashlineEdits(content, edits) + + //#then + expect(result).toEqual("line 1\ninserted\nline 2") + }) + it("keeps literal backslash-n in plain string text", () => { //#given const lines = ["line 1", "line 2", "line 3"] diff --git a/src/tools/hashline-edit/edit-operations.ts b/src/tools/hashline-edit/edit-operations.ts index 61271ca4e..b5cdca095 100644 --- a/src/tools/hashline-edit/edit-operations.ts +++ b/src/tools/hashline-edit/edit-operations.ts @@ -1,95 +1,18 @@ import { parseLineRef, validateLineRef, validateLineRefs } from "./validation" import type { HashlineEdit } from "./types" +import { + restoreLeadingIndent, + stripInsertAnchorEcho, + stripInsertBeforeEcho, + stripInsertBoundaryEcho, + stripRangeBoundaryEcho, + toNewLines, +} from "./edit-text-normalization" -const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[A-Z]{2}:/ -const DIFF_PLUS_RE = /^[+-](?![+-])/ - -function stripLinePrefixes(lines: string[]): string[] { - let hashPrefixCount = 0 - let diffPlusCount = 0 - let nonEmpty = 0 - - for (const line of lines) { - if (line.length === 0) continue - nonEmpty += 1 - if (HASHLINE_PREFIX_RE.test(line)) hashPrefixCount += 1 - if (DIFF_PLUS_RE.test(line)) diffPlusCount += 1 - } - - if (nonEmpty === 0) { - return lines - } - - const stripHash = hashPrefixCount > 0 && hashPrefixCount >= nonEmpty * 0.5 - const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5 - - if (!stripHash && !stripPlus) { - return lines - } - - return lines.map((line) => { - if (stripHash) return line.replace(HASHLINE_PREFIX_RE, "") - if (stripPlus) return line.replace(DIFF_PLUS_RE, "") - return line - }) -} - -function equalsIgnoringWhitespace(a: string, b: string): boolean { - if (a === b) return true - return a.replace(/\s+/g, "") === b.replace(/\s+/g, "") -} - -function leadingWhitespace(text: string): string { - const match = text.match(/^\s*/) - return match ? match[0] : "" -} - -function restoreLeadingIndent(templateLine: string, line: string): string { - if (line.length === 0) return line - const templateIndent = leadingWhitespace(templateLine) - if (templateIndent.length === 0) return line - if (leadingWhitespace(line).length > 0) return line - return `${templateIndent}${line}` -} - -function stripInsertAnchorEcho(anchorLine: string, newLines: string[]): string[] { - if (newLines.length <= 1) return newLines - if (equalsIgnoringWhitespace(newLines[0], anchorLine)) { - return newLines.slice(1) - } - return newLines -} - -function stripRangeBoundaryEcho( - lines: string[], - startLine: number, - endLine: number, - newLines: string[] -): string[] { - const replacedCount = endLine - startLine + 1 - if (newLines.length <= 1 || newLines.length <= replacedCount) { - return newLines - } - - let out = newLines - const beforeIdx = startLine - 2 - if (beforeIdx >= 0 && equalsIgnoringWhitespace(out[0], lines[beforeIdx])) { - out = out.slice(1) - } - - const afterIdx = endLine - if (afterIdx < lines.length && out.length > 0 && equalsIgnoringWhitespace(out[out.length - 1], lines[afterIdx])) { - out = out.slice(0, -1) - } - - return out -} - -function toNewLines(input: string | string[]): string[] { - if (Array.isArray(input)) { - return stripLinePrefixes(input) - } - return stripLinePrefixes(input.split("\n")) +export interface HashlineApplyReport { + content: string + noopEdits: number + deduplicatedEdits: number } export function applySetLine(lines: string[], anchor: string, newText: string | string[]): string[] { @@ -137,10 +60,48 @@ export function applyInsertAfter(lines: string[], anchor: string, text: string | const { line } = parseLineRef(anchor) const result = [...lines] const newLines = stripInsertAnchorEcho(lines[line - 1], toNewLines(text)) + if (newLines.length === 0) { + throw new Error(`insert_after requires non-empty text for ${anchor}`) + } result.splice(line, 0, ...newLines) return result } +export function applyInsertBefore(lines: string[], anchor: string, text: string | string[]): string[] { + validateLineRef(lines, anchor) + const { line } = parseLineRef(anchor) + const result = [...lines] + const newLines = stripInsertBeforeEcho(lines[line - 1], toNewLines(text)) + if (newLines.length === 0) { + throw new Error(`insert_before requires non-empty text for ${anchor}`) + } + result.splice(line - 1, 0, ...newLines) + return result +} + +export function applyInsertBetween( + lines: string[], + afterAnchor: string, + beforeAnchor: string, + text: string | string[] +): string[] { + validateLineRef(lines, afterAnchor) + validateLineRef(lines, beforeAnchor) + const { line: afterLine } = parseLineRef(afterAnchor) + const { line: beforeLine } = parseLineRef(beforeAnchor) + if (beforeLine <= afterLine) { + throw new Error(`insert_between requires after_line (${afterLine}) < before_line (${beforeLine})`) + } + + const result = [...lines] + const newLines = stripInsertBoundaryEcho(lines[afterLine - 1], lines[beforeLine - 1], toNewLines(text)) + if (newLines.length === 0) { + throw new Error(`insert_between requires non-empty text for ${afterAnchor}..${beforeAnchor}`) + } + result.splice(beforeLine - 1, 0, ...newLines) + return result +} + export function applyReplace(content: string, oldText: string, newText: string | string[]): string { if (!content.includes(oldText)) { throw new Error(`Text not found: "${oldText}"`) @@ -157,6 +118,10 @@ function getEditLineNumber(edit: HashlineEdit): number { return parseLineRef(edit.end_line).line case "insert_after": return parseLineRef(edit.line).line + case "insert_before": + return parseLineRef(edit.line).line + case "insert_between": + return parseLineRef(edit.before_line).line case "replace": return Number.NEGATIVE_INFINITY default: @@ -164,12 +129,57 @@ function getEditLineNumber(edit: HashlineEdit): number { } } -export function applyHashlineEdits(content: string, edits: HashlineEdit[]): string { - if (edits.length === 0) { - return content +function normalizeEditPayload(payload: string | string[]): string { + return toNewLines(payload).join("\n") +} + +function dedupeEdits(edits: HashlineEdit[]): { edits: HashlineEdit[]; deduplicatedEdits: number } { + const seen = new Set() + const deduped: HashlineEdit[] = [] + let deduplicatedEdits = 0 + + for (const edit of edits) { + const key = (() => { + switch (edit.type) { + case "set_line": + return `set_line|${edit.line}|${normalizeEditPayload(edit.text)}` + case "replace_lines": + return `replace_lines|${edit.start_line}|${edit.end_line}|${normalizeEditPayload(edit.text)}` + case "insert_after": + return `insert_after|${edit.line}|${normalizeEditPayload(edit.text)}` + case "insert_before": + return `insert_before|${edit.line}|${normalizeEditPayload(edit.text)}` + case "insert_between": + return `insert_between|${edit.after_line}|${edit.before_line}|${normalizeEditPayload(edit.text)}` + case "replace": + return `replace|${edit.old_text}|${normalizeEditPayload(edit.new_text)}` + } + })() + + if (seen.has(key)) { + deduplicatedEdits += 1 + continue + } + seen.add(key) + deduped.push(edit) } - const sortedEdits = [...edits].sort((a, b) => getEditLineNumber(b) - getEditLineNumber(a)) + return { edits: deduped, deduplicatedEdits } +} + +export function applyHashlineEditsWithReport(content: string, edits: HashlineEdit[]): HashlineApplyReport { + if (edits.length === 0) { + return { + content, + noopEdits: 0, + deduplicatedEdits: 0, + } + } + + const dedupeResult = dedupeEdits(edits) + const sortedEdits = [...dedupeResult.edits].sort((a, b) => getEditLineNumber(b) - getEditLineNumber(a)) + + let noopEdits = 0 let result = content let lines = result.split("\n") @@ -182,6 +192,10 @@ export function applyHashlineEdits(content: string, edits: HashlineEdit[]): stri return [edit.start_line, edit.end_line] case "insert_after": return [edit.line] + case "insert_before": + return [edit.line] + case "insert_between": + return [edit.after_line, edit.before_line] case "replace": return [] default: @@ -201,7 +215,30 @@ export function applyHashlineEdits(content: string, edits: HashlineEdit[]): stri break } case "insert_after": { - lines = applyInsertAfter(lines, edit.line, edit.text) + const next = applyInsertAfter(lines, edit.line, edit.text) + if (next.join("\n") === lines.join("\n")) { + noopEdits += 1 + break + } + lines = next + break + } + case "insert_before": { + const next = applyInsertBefore(lines, edit.line, edit.text) + if (next.join("\n") === lines.join("\n")) { + noopEdits += 1 + break + } + lines = next + break + } + case "insert_between": { + const next = applyInsertBetween(lines, edit.after_line, edit.before_line, edit.text) + if (next.join("\n") === lines.join("\n")) { + noopEdits += 1 + break + } + lines = next break } case "replace": { @@ -210,12 +247,25 @@ export function applyHashlineEdits(content: string, edits: HashlineEdit[]): stri throw new Error(`Text not found: "${edit.old_text}"`) } const replacement = Array.isArray(edit.new_text) ? edit.new_text.join("\n") : edit.new_text - result = result.replaceAll(edit.old_text, replacement) + const replaced = result.replaceAll(edit.old_text, replacement) + if (replaced === result) { + noopEdits += 1 + break + } + result = replaced lines = result.split("\n") break } } } - return lines.join("\n") + return { + content: lines.join("\n"), + noopEdits, + deduplicatedEdits: dedupeResult.deduplicatedEdits, + } +} + +export function applyHashlineEdits(content: string, edits: HashlineEdit[]): string { + return applyHashlineEditsWithReport(content, edits).content } diff --git a/src/tools/hashline-edit/edit-text-normalization.ts b/src/tools/hashline-edit/edit-text-normalization.ts new file mode 100644 index 000000000..8d259e45f --- /dev/null +++ b/src/tools/hashline-edit/edit-text-normalization.ts @@ -0,0 +1,109 @@ +const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[A-Z]{2}:/ +const DIFF_PLUS_RE = /^[+-](?![+-])/ + +function equalsIgnoringWhitespace(a: string, b: string): boolean { + if (a === b) return true + return a.replace(/\s+/g, "") === b.replace(/\s+/g, "") +} + +function leadingWhitespace(text: string): string { + const match = text.match(/^\s*/) + return match ? match[0] : "" +} + +export function stripLinePrefixes(lines: string[]): string[] { + let hashPrefixCount = 0 + let diffPlusCount = 0 + let nonEmpty = 0 + + for (const line of lines) { + if (line.length === 0) continue + nonEmpty += 1 + if (HASHLINE_PREFIX_RE.test(line)) hashPrefixCount += 1 + if (DIFF_PLUS_RE.test(line)) diffPlusCount += 1 + } + + if (nonEmpty === 0) { + return lines + } + + const stripHash = hashPrefixCount > 0 && hashPrefixCount >= nonEmpty * 0.5 + const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5 + + if (!stripHash && !stripPlus) { + return lines + } + + return lines.map((line) => { + if (stripHash) return line.replace(HASHLINE_PREFIX_RE, "") + if (stripPlus) return line.replace(DIFF_PLUS_RE, "") + return line + }) +} + +export function toNewLines(input: string | string[]): string[] { + if (Array.isArray(input)) { + return stripLinePrefixes(input) + } + return stripLinePrefixes(input.split("\n")) +} + +export function restoreLeadingIndent(templateLine: string, line: string): string { + if (line.length === 0) return line + const templateIndent = leadingWhitespace(templateLine) + if (templateIndent.length === 0) return line + if (leadingWhitespace(line).length > 0) return line + return `${templateIndent}${line}` +} + +export function stripInsertAnchorEcho(anchorLine: string, newLines: string[]): string[] { + if (newLines.length <= 1) return newLines + if (equalsIgnoringWhitespace(newLines[0], anchorLine)) { + return newLines.slice(1) + } + return newLines +} + +export function stripInsertBeforeEcho(anchorLine: string, newLines: string[]): string[] { + if (newLines.length <= 1) return newLines + if (equalsIgnoringWhitespace(newLines[newLines.length - 1], anchorLine)) { + return newLines.slice(0, -1) + } + return newLines +} + +export function stripInsertBoundaryEcho(afterLine: string, beforeLine: string, newLines: string[]): string[] { + let out = newLines + if (out.length > 1 && equalsIgnoringWhitespace(out[0], afterLine)) { + out = out.slice(1) + } + if (out.length > 1 && equalsIgnoringWhitespace(out[out.length - 1], beforeLine)) { + out = out.slice(0, -1) + } + return out +} + +export function stripRangeBoundaryEcho( + lines: string[], + startLine: number, + endLine: number, + newLines: string[] +): string[] { + const replacedCount = endLine - startLine + 1 + if (newLines.length <= 1 || newLines.length <= replacedCount) { + return newLines + } + + let out = newLines + const beforeIdx = startLine - 2 + if (beforeIdx >= 0 && equalsIgnoringWhitespace(out[0], lines[beforeIdx])) { + out = out.slice(1) + } + + const afterIdx = endLine + if (afterIdx < lines.length && out.length > 0 && equalsIgnoringWhitespace(out[out.length - 1], lines[afterIdx])) { + out = out.slice(0, -1) + } + + return out +} diff --git a/src/tools/hashline-edit/hash-computation.test.ts b/src/tools/hashline-edit/hash-computation.test.ts index 7c2ae84da..feb617c6c 100644 --- a/src/tools/hashline-edit/hash-computation.test.ts +++ b/src/tools/hashline-edit/hash-computation.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from "bun:test" -import { computeLineHash, formatHashLine, formatHashLines } from "./hash-computation" +import { + computeLineHash, + formatHashLine, + formatHashLines, + streamHashLinesFromLines, + streamHashLinesFromUtf8, +} from "./hash-computation" describe("computeLineHash", () => { it("returns deterministic 2-char CID hash per line", () => { @@ -71,3 +77,43 @@ describe("formatHashLines", () => { expect(lines[2]).toMatch(/^3#[ZPMQVRWSNKTXJBYH]{2}:c$/) }) }) + +describe("streamHashLinesFrom*", () => { + async function collectStream(stream: AsyncIterable): Promise { + const chunks: string[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + return chunks.join("\n") + } + + async function* utf8Chunks(text: string, chunkSize: number): AsyncGenerator { + const encoded = new TextEncoder().encode(text) + for (let i = 0; i < encoded.length; i += chunkSize) { + yield encoded.slice(i, i + chunkSize) + } + } + + it("matches formatHashLines for utf8 stream input", async () => { + //#given + const content = "a\nb\nc" + + //#when + const result = await collectStream(streamHashLinesFromUtf8(utf8Chunks(content, 1), { maxChunkLines: 1 })) + + //#then + expect(result).toBe(formatHashLines(content)) + }) + + it("matches formatHashLines for line iterable input", async () => { + //#given + const content = "x\ny\n" + const lines = ["x", "y", ""] + + //#when + const result = await collectStream(streamHashLinesFromLines(lines, { maxChunkLines: 2 })) + + //#then + expect(result).toBe(formatHashLines(content)) + }) +}) diff --git a/src/tools/hashline-edit/hash-computation.ts b/src/tools/hashline-edit/hash-computation.ts index 0942b93c9..efed53bb6 100644 --- a/src/tools/hashline-edit/hash-computation.ts +++ b/src/tools/hashline-edit/hash-computation.ts @@ -18,3 +18,196 @@ export function formatHashLines(content: string): string { const lines = content.split("\n") return lines.map((line, index) => formatHashLine(index + 1, line)).join("\n") } + +export interface HashlineStreamOptions { + startLine?: number + maxChunkLines?: number + maxChunkBytes?: number +} + +function isReadableStream(value: unknown): value is ReadableStream { + return ( + typeof value === "object" && + value !== null && + "getReader" in value && + typeof (value as { getReader?: unknown }).getReader === "function" + ) +} + +async function* bytesFromReadableStream(stream: ReadableStream): AsyncGenerator { + const reader = stream.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) return + if (value) yield value + } + } finally { + reader.releaseLock() + } +} + +export async function* streamHashLinesFromUtf8( + source: ReadableStream | AsyncIterable, + options: HashlineStreamOptions = {} +): AsyncGenerator { + const startLine = options.startLine ?? 1 + const maxChunkLines = options.maxChunkLines ?? 200 + const maxChunkBytes = options.maxChunkBytes ?? 64 * 1024 + const decoder = new TextDecoder("utf-8") + const chunks = isReadableStream(source) ? bytesFromReadableStream(source) : source + + let lineNumber = startLine + let pending = "" + let sawAnyText = false + let endedWithNewline = false + let outputLines: string[] = [] + let outputBytes = 0 + + const flush = (): string | undefined => { + if (outputLines.length === 0) return undefined + const chunk = outputLines.join("\n") + outputLines = [] + outputBytes = 0 + return chunk + } + + const pushLine = (line: string): string[] => { + const formatted = `${lineNumber}#${computeLineHash(lineNumber, line)}:${line}` + lineNumber += 1 + + const chunksToYield: string[] = [] + const separatorBytes = outputLines.length === 0 ? 0 : 1 + const lineBytes = Buffer.byteLength(formatted, "utf-8") + + if ( + outputLines.length > 0 && + (outputLines.length >= maxChunkLines || outputBytes + separatorBytes + lineBytes > maxChunkBytes) + ) { + const flushed = flush() + if (flushed) chunksToYield.push(flushed) + } + + outputLines.push(formatted) + outputBytes += (outputLines.length === 1 ? 0 : 1) + lineBytes + + if (outputLines.length >= maxChunkLines || outputBytes >= maxChunkBytes) { + const flushed = flush() + if (flushed) chunksToYield.push(flushed) + } + + return chunksToYield + } + + const consumeText = (text: string): string[] => { + if (text.length === 0) return [] + sawAnyText = true + pending += text + const chunksToYield: string[] = [] + + while (true) { + const idx = pending.indexOf("\n") + if (idx === -1) break + const line = pending.slice(0, idx) + pending = pending.slice(idx + 1) + endedWithNewline = true + chunksToYield.push(...pushLine(line)) + } + + if (pending.length > 0) endedWithNewline = false + return chunksToYield + } + + for await (const chunk of chunks) { + for (const out of consumeText(decoder.decode(chunk, { stream: true }))) { + yield out + } + } + + for (const out of consumeText(decoder.decode())) { + yield out + } + + if (!sawAnyText) { + for (const out of pushLine("")) { + yield out + } + } else if (pending.length > 0 || endedWithNewline) { + for (const out of pushLine(pending)) { + yield out + } + } + + const finalChunk = flush() + if (finalChunk) yield finalChunk +} + +export async function* streamHashLinesFromLines( + lines: Iterable | AsyncIterable, + options: HashlineStreamOptions = {} +): AsyncGenerator { + const startLine = options.startLine ?? 1 + const maxChunkLines = options.maxChunkLines ?? 200 + const maxChunkBytes = options.maxChunkBytes ?? 64 * 1024 + + let lineNumber = startLine + let outputLines: string[] = [] + let outputBytes = 0 + let sawAnyLine = false + + const flush = (): string | undefined => { + if (outputLines.length === 0) return undefined + const chunk = outputLines.join("\n") + outputLines = [] + outputBytes = 0 + return chunk + } + + const pushLine = (line: string): string[] => { + sawAnyLine = true + const formatted = `${lineNumber}#${computeLineHash(lineNumber, line)}:${line}` + lineNumber += 1 + + const chunksToYield: string[] = [] + const separatorBytes = outputLines.length === 0 ? 0 : 1 + const lineBytes = Buffer.byteLength(formatted, "utf-8") + + if ( + outputLines.length > 0 && + (outputLines.length >= maxChunkLines || outputBytes + separatorBytes + lineBytes > maxChunkBytes) + ) { + const flushed = flush() + if (flushed) chunksToYield.push(flushed) + } + + outputLines.push(formatted) + outputBytes += (outputLines.length === 1 ? 0 : 1) + lineBytes + + if (outputLines.length >= maxChunkLines || outputBytes >= maxChunkBytes) { + const flushed = flush() + if (flushed) chunksToYield.push(flushed) + } + + return chunksToYield + } + + const asyncIterator = (lines as AsyncIterable)[Symbol.asyncIterator] + if (typeof asyncIterator === "function") { + for await (const line of lines as AsyncIterable) { + for (const out of pushLine(line)) yield out + } + } else { + for (const line of lines as Iterable) { + for (const out of pushLine(line)) yield out + } + } + + if (!sawAnyLine) { + for (const out of pushLine("")) { + yield out + } + } + + const finalChunk = flush() + if (finalChunk) yield finalChunk +} diff --git a/src/tools/hashline-edit/index.ts b/src/tools/hashline-edit/index.ts index 2f22a4fd3..c631afd8e 100644 --- a/src/tools/hashline-edit/index.ts +++ b/src/tools/hashline-edit/index.ts @@ -1,11 +1,19 @@ -export { computeLineHash, formatHashLine, formatHashLines } from "./hash-computation" +export { + computeLineHash, + formatHashLine, + formatHashLines, + streamHashLinesFromLines, + streamHashLinesFromUtf8, +} from "./hash-computation" export { parseLineRef, validateLineRef } from "./validation" export type { LineRef } from "./validation" -export type { SetLine, ReplaceLines, InsertAfter, Replace, HashlineEdit } from "./types" +export type { SetLine, ReplaceLines, InsertAfter, InsertBefore, InsertBetween, Replace, HashlineEdit } from "./types" export { NIBBLE_STR, HASHLINE_DICT, HASHLINE_REF_PATTERN, HASHLINE_OUTPUT_PATTERN } from "./constants" export { applyHashlineEdits, applyInsertAfter, + applyInsertBefore, + applyInsertBetween, applyReplace, applyReplaceLines, applySetLine, diff --git a/src/tools/hashline-edit/tool-description.ts b/src/tools/hashline-edit/tool-description.ts new file mode 100644 index 000000000..28680291b --- /dev/null +++ b/src/tools/hashline-edit/tool-description.ts @@ -0,0 +1,34 @@ +export const HASHLINE_EDIT_DESCRIPTION = `Edit files using LINE#ID format for precise, safe modifications. + +WORKFLOW: +1. Read the file and copy exact LINE#ID anchors. +2. Submit one edit call with all related operations for that file. +3. If more edits are needed after success, use the latest anchors from read/edit output. +4. Use anchors as "LINE#ID" only (never include trailing ":content"). + +VALIDATION: +- Payload shape: { "filePath": string, "edits": [...], "delete"?: boolean, "rename"?: string } +- Each edit must be one of: set_line, replace_lines, insert_after, insert_before, insert_between, replace +- text/new_text must contain plain replacement text only (no LINE#ID prefixes, no diff + markers) + +LINE#ID FORMAT (CRITICAL - READ CAREFULLY): +Each line reference must be in "LINE#ID" format where: +- LINE: 1-based line number +- ID: Two CID letters from the set ZPMQVRWSNKTXJBYH + +OPERATION TYPES: +1. set_line +2. replace_lines +3. insert_after +4. insert_before +5. insert_between +6. replace + +FILE MODES: +- delete=true deletes file and requires edits=[] with no rename +- rename moves final content to a new path and removes old path + +CONTENT FORMAT: +- text/new_text can be a string (single line) or string[] (multi-line, preferred). +- If you pass a multi-line string, it is split by real newline characters. +- Literal "\\n" is preserved as text.` diff --git a/src/tools/hashline-edit/tools.test.ts b/src/tools/hashline-edit/tools.test.ts index 37f9d2fd8..416918f85 100644 --- a/src/tools/hashline-edit/tools.test.ts +++ b/src/tools/hashline-edit/tools.test.ts @@ -11,12 +11,10 @@ function createMockContext(): ToolContext { sessionID: "test", messageID: "test", agent: "test", - directory: "/tmp", - worktree: "/tmp", abort: new AbortController().signal, metadata: mock(() => {}), ask: async () => {}, - } + } as unknown as ToolContext } describe("createHashlineEditTool", () => { @@ -103,7 +101,7 @@ describe("createHashlineEditTool", () => { //#then expect(result).toContain("Error") - expect(result).toContain("hash") + expect(result).toContain(">>>") }) it("preserves literal backslash-n and supports string[] payload", async () => { @@ -132,4 +130,90 @@ describe("createHashlineEditTool", () => { //#then expect(fs.readFileSync(filePath, "utf-8")).toBe("join(\\n)\na\nb\nline2") }) + + it("supports insert_before and insert_between", async () => { + //#given + const filePath = path.join(tempDir, "test.txt") + fs.writeFileSync(filePath, "line1\nline2\nline3") + const line1 = computeLineHash(1, "line1") + const line2 = computeLineHash(2, "line2") + const line3 = computeLineHash(3, "line3") + + //#when + await tool.execute( + { + filePath, + edits: [ + { type: "insert_before", line: `3#${line3}`, text: ["before3"] }, + { type: "insert_between", after_line: `1#${line1}`, before_line: `2#${line2}`, text: ["between"] }, + ], + }, + createMockContext(), + ) + + //#then + expect(fs.readFileSync(filePath, "utf-8")).toBe("line1\nbetween\nline2\nbefore3\nline3") + }) + + it("returns error when insert text is empty array", async () => { + //#given + const filePath = path.join(tempDir, "test.txt") + fs.writeFileSync(filePath, "line1\nline2") + const line1 = computeLineHash(1, "line1") + + //#when + const result = await tool.execute( + { + filePath, + edits: [{ type: "insert_after", line: `1#${line1}`, text: [] }], + }, + createMockContext(), + ) + + //#then + expect(result).toContain("Error") + expect(result).toContain("non-empty") + }) + + it("supports file rename with edits", async () => { + //#given + const filePath = path.join(tempDir, "source.txt") + const renamedPath = path.join(tempDir, "renamed.txt") + fs.writeFileSync(filePath, "line1\nline2") + const line2 = computeLineHash(2, "line2") + + //#when + await tool.execute( + { + filePath, + rename: renamedPath, + edits: [{ type: "set_line", line: `2#${line2}`, text: "line2-updated" }], + }, + createMockContext(), + ) + + //#then + expect(fs.existsSync(filePath)).toBe(false) + expect(fs.readFileSync(renamedPath, "utf-8")).toBe("line1\nline2-updated") + }) + + it("supports file delete mode", async () => { + //#given + const filePath = path.join(tempDir, "delete-me.txt") + fs.writeFileSync(filePath, "line1") + + //#when + const result = await tool.execute( + { + filePath, + delete: true, + edits: [], + }, + createMockContext(), + ) + + //#then + expect(fs.existsSync(filePath)).toBe(false) + expect(result).toContain("Successfully deleted") + }) }) diff --git a/src/tools/hashline-edit/tools.ts b/src/tools/hashline-edit/tools.ts index 4344d3d52..b1b255f40 100644 --- a/src/tools/hashline-edit/tools.ts +++ b/src/tools/hashline-edit/tools.ts @@ -1,13 +1,16 @@ import { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin/tool" import { storeToolMetadata } from "../../features/tool-metadata-store" import type { HashlineEdit } from "./types" -import { applyHashlineEdits } from "./edit-operations" +import { applyHashlineEditsWithReport } from "./edit-operations" import { computeLineHash } from "./hash-computation" import { toHashlineContent, generateUnifiedDiff, countLineDiffs } from "./diff-utils" +import { HASHLINE_EDIT_DESCRIPTION } from "./tool-description" interface HashlineEditArgs { filePath: string edits: HashlineEdit[] + delete?: boolean + rename?: string } type ToolContextWithCallID = ToolContext & { @@ -55,62 +58,11 @@ function generateDiff(oldContent: string, newContent: string, filePath: string): export function createHashlineEditTool(): ToolDefinition { return tool({ - description: `Edit files using LINE#ID format for precise, safe modifications. - -WORKFLOW: -1. Read the file and copy exact LINE#ID anchors. -2. Submit one edit call with all related operations for that file. -3. If more edits are needed after success, use the latest anchors from read/edit output. -4. Use anchors as "LINE#ID" only (never include trailing ":content"). - -VALIDATION: -- Payload shape: { "filePath": string, "edits": [...] } -- Each edit must be one of: set_line, replace_lines, insert_after, replace -- text/new_text must contain plain replacement text only (no LINE#ID prefixes, no diff + markers) - -LINE#ID FORMAT (CRITICAL - READ CAREFULLY): -Each line reference must be in "LINE#ID" format where: -- LINE: 1-based line number -- ID: Two CID letters from the set ZPMQVRWSNKTXJBYH -- Example: "5#VK" means line 5 with hash id "VK" -- WRONG: "2#aa" (invalid characters) - will fail! -- CORRECT: "2#VK" - -GETTING HASHES: -Use the read tool - it returns lines in "LINE#ID:content" format. -Successful edit output also includes updated file content in "LINE#ID:content" format. - -FOUR OPERATION TYPES: - -1. set_line: Replace a single line - { "type": "set_line", "line": "5#VK", "text": "const y = 2" } - -2. replace_lines: Replace a range of lines - { "type": "replace_lines", "start_line": "5#VK", "end_line": "7#NP", "text": ["new", "content"] } - -3. insert_after: Insert lines after a specific line - { "type": "insert_after", "line": "5#VK", "text": "console.log('hi')" } - -4. replace: Simple text replacement (no hash validation) - { "type": "replace", "old_text": "foo", "new_text": "bar" } - -HASH MISMATCH HANDLING: -If the hash doesn't match the current content, the edit fails with a hash mismatch error. This prevents editing stale content. - -SEQUENTIAL EDITS (ANTI-FLAKE): -- Always copy anchors exactly from the latest read/edit output. -- Never infer or guess hashes. -- For related edits, prefer batching them in one call. - -BOTTOM-UP APPLICATION: -Edits are applied from bottom to top (highest line numbers first) to preserve line number references. - -CONTENT FORMAT: -- text/new_text can be a string (single line) or string[] (multi-line, preferred). -- If you pass a multi-line string, it is split by real newline characters. -- Literal "\\n" is preserved as text.`, + description: HASHLINE_EDIT_DESCRIPTION, args: { filePath: tool.schema.string().describe("Absolute path to the file to edit"), + delete: tool.schema.boolean().optional().describe("Delete file instead of editing"), + rename: tool.schema.string().optional().describe("Rename output file path after edits"), edits: tool.schema .array( tool.schema.union([ @@ -136,6 +88,21 @@ CONTENT FORMAT: .union([tool.schema.string(), tool.schema.array(tool.schema.string())]) .describe("Content to insert after the line (string or string[] for multiline)"), }), + tool.schema.object({ + type: tool.schema.literal("insert_before"), + line: tool.schema.string().describe("Line reference in LINE#ID format"), + text: tool.schema + .union([tool.schema.string(), tool.schema.array(tool.schema.string())]) + .describe("Content to insert before the line (string or string[] for multiline)"), + }), + tool.schema.object({ + type: tool.schema.literal("insert_between"), + after_line: tool.schema.string().describe("After line in LINE#ID format"), + before_line: tool.schema.string().describe("Before line in LINE#ID format"), + text: tool.schema + .union([tool.schema.string(), tool.schema.array(tool.schema.string())]) + .describe("Content to insert between anchor lines (string or string[] for multiline)"), + }), tool.schema.object({ type: tool.schema.literal("replace"), old_text: tool.schema.string().describe("Text to find"), @@ -145,17 +112,24 @@ CONTENT FORMAT: }), ]) ) - .describe("Array of edit operations to apply"), + .describe("Array of edit operations to apply (empty when delete=true)"), }, execute: async (args: HashlineEditArgs, context: ToolContext) => { try { const metadataContext = context as ToolContextWithMetadata const filePath = args.filePath - const { edits } = args + const { edits, delete: deleteMode, rename } = args - if (!edits || !Array.isArray(edits) || edits.length === 0) { + if (deleteMode && rename) { + return "Error: delete and rename cannot be used together" + } + + if (!deleteMode && (!edits || !Array.isArray(edits) || edits.length === 0)) { return "Error: edits parameter must be a non-empty array" } + if (deleteMode && edits.length > 0) { + return "Error: delete mode requires edits to be an empty array" + } const file = Bun.file(filePath) const exists = await file.exists() @@ -163,28 +137,43 @@ CONTENT FORMAT: return `Error: File not found: ${filePath}` } + if (deleteMode) { + await Bun.file(filePath).delete() + return `Successfully deleted ${filePath}` + } + const oldContent = await file.text() - const newContent = applyHashlineEdits(oldContent, edits) + const applyResult = applyHashlineEditsWithReport(oldContent, edits) + const newContent = applyResult.content await Bun.write(filePath, newContent) - const diff = generateDiff(oldContent, newContent, filePath) + if (rename && rename !== filePath) { + await Bun.write(rename, newContent) + await Bun.file(filePath).delete() + } + + const effectivePath = rename && rename !== filePath ? rename : filePath + + const diff = generateDiff(oldContent, newContent, effectivePath) const newHashlined = toHashlineContent(newContent) - const unifiedDiff = generateUnifiedDiff(oldContent, newContent, filePath) + const unifiedDiff = generateUnifiedDiff(oldContent, newContent, effectivePath) const { additions, deletions } = countLineDiffs(oldContent, newContent) const meta = { - title: filePath, + title: effectivePath, metadata: { - filePath, - path: filePath, - file: filePath, + filePath: effectivePath, + path: effectivePath, + file: effectivePath, diff: unifiedDiff, + noopEdits: applyResult.noopEdits, + deduplicatedEdits: applyResult.deduplicatedEdits, filediff: { - file: filePath, - path: filePath, - filePath, + file: effectivePath, + path: effectivePath, + filePath: effectivePath, before: oldContent, after: newContent, additions, @@ -202,7 +191,8 @@ CONTENT FORMAT: storeToolMetadata(context.sessionID, callID, meta) } - return `Successfully applied ${edits.length} edit(s) to ${filePath} + return `Successfully applied ${edits.length} edit(s) to ${effectivePath} +No-op edits: ${applyResult.noopEdits}, deduplicated edits: ${applyResult.deduplicatedEdits} ${diff} diff --git a/src/tools/hashline-edit/types.ts b/src/tools/hashline-edit/types.ts index 38b57f279..a953d961c 100644 --- a/src/tools/hashline-edit/types.ts +++ b/src/tools/hashline-edit/types.ts @@ -17,10 +17,23 @@ export interface InsertAfter { text: string | string[] } +export interface InsertBefore { + type: "insert_before" + line: string + text: string | string[] +} + +export interface InsertBetween { + type: "insert_between" + after_line: string + before_line: string + text: string | string[] +} + export interface Replace { type: "replace" old_text: string new_text: string | string[] } -export type HashlineEdit = SetLine | ReplaceLines | InsertAfter | Replace +export type HashlineEdit = SetLine | ReplaceLines | InsertAfter | InsertBefore | InsertBetween | Replace diff --git a/src/tools/hashline-edit/validation.test.ts b/src/tools/hashline-edit/validation.test.ts index 4436b6b27..e821bea16 100644 --- a/src/tools/hashline-edit/validation.test.ts +++ b/src/tools/hashline-edit/validation.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "bun:test" import { computeLineHash } from "./hash-computation" -import { parseLineRef, validateLineRef } from "./validation" +import { parseLineRef, validateLineRef, validateLineRefs } from "./validation" describe("parseLineRef", () => { it("parses valid LINE#ID reference", () => { @@ -49,7 +49,16 @@ describe("validateLineRef", () => { const lines = ["function hello() {"] //#when / #then - expect(() => validateLineRef(lines, "1#ZZ")).toThrow(/current hash/) + expect(() => validateLineRef(lines, "1#ZZ")).toThrow(/>>>\s+1#[ZPMQVRWSNKTXJBYH]{2}:/) + }) + + it("shows >>> mismatch context in batched validation", () => { + //#given + const lines = ["one", "two", "three", "four"] + + //#when / #then + expect(() => validateLineRefs(lines, ["2#ZZ"])) + .toThrow(/>>>\s+2#[ZPMQVRWSNKTXJBYH]{2}:two/) }) }) @@ -81,7 +90,7 @@ describe("legacy LINE:HEX backward compatibility", () => { const lines = ["function hello() {"] //#when / #then - expect(() => validateLineRef(lines, "1:ab")).toThrow(/Hash mismatch|current hash/) + expect(() => validateLineRef(lines, "1:ab")).toThrow(/>>>\s+1#[ZPMQVRWSNKTXJBYH]{2}:/) }) it("extracts legacy ref from content with markers", () => { diff --git a/src/tools/hashline-edit/validation.ts b/src/tools/hashline-edit/validation.ts index bb32f3841..5f09610f6 100644 --- a/src/tools/hashline-edit/validation.ts +++ b/src/tools/hashline-edit/validation.ts @@ -6,6 +6,13 @@ export interface LineRef { hash: string } +interface HashMismatch { + line: number + expected: string +} + +const MISMATCH_CONTEXT = 2 + const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2}|[0-9]+:[0-9a-fA-F]{2,})/ function normalizeLineRef(ref: string): string { @@ -59,34 +66,85 @@ export function validateLineRef(lines: string[], ref: string): void { const currentHash = computeLineHash(line, content) if (currentHash !== hash) { - throw new Error( - `Hash mismatch at line ${line}. Expected hash: ${hash}, current hash: ${currentHash}. ` + - `Line content may have changed. Current content: "${content}"` + throw new HashlineMismatchError([{ line, expected: hash }], lines) + } +} + +export class HashlineMismatchError extends Error { + readonly remaps: ReadonlyMap + + constructor( + private readonly mismatches: HashMismatch[], + private readonly fileLines: string[] + ) { + super(HashlineMismatchError.formatMessage(mismatches, fileLines)) + this.name = "HashlineMismatchError" + const remaps = new Map() + for (const mismatch of mismatches) { + const actual = computeLineHash(mismatch.line, fileLines[mismatch.line - 1] ?? "") + remaps.set(`${mismatch.line}#${mismatch.expected}`, `${mismatch.line}#${actual}`) + } + this.remaps = remaps + } + + static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string { + const mismatchByLine = new Map() + for (const mismatch of mismatches) mismatchByLine.set(mismatch.line, mismatch) + + const displayLines = new Set() + for (const mismatch of mismatches) { + const low = Math.max(1, mismatch.line - MISMATCH_CONTEXT) + const high = Math.min(fileLines.length, mismatch.line + MISMATCH_CONTEXT) + for (let line = low; line <= high; line++) displayLines.add(line) + } + + const sortedLines = [...displayLines].sort((a, b) => a - b) + const output: string[] = [] + output.push( + `${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. ` + + "Use updated LINE#ID references below (>>> marks changed lines)." ) + output.push("") + + let previousLine = -1 + for (const line of sortedLines) { + if (previousLine !== -1 && line > previousLine + 1) { + output.push(" ...") + } + previousLine = line + + const content = fileLines[line - 1] ?? "" + const hash = computeLineHash(line, content) + const prefix = `${line}#${hash}:${content}` + if (mismatchByLine.has(line)) { + output.push(`>>> ${prefix}`) + } else { + output.push(` ${prefix}`) + } + } + + return output.join("\n") } } export function validateLineRefs(lines: string[], refs: string[]): void { - const mismatches: string[] = [] + const mismatches: HashMismatch[] = [] for (const ref of refs) { const { line, hash } = parseLineRef(ref) if (line < 1 || line > lines.length) { - mismatches.push(`Line number ${line} out of bounds (file has ${lines.length} lines)`) - continue + throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`) } const content = lines[line - 1] const currentHash = computeLineHash(line, content) if (currentHash !== hash) { - mismatches.push( - `line ${line}: expected ${hash}, current ${currentHash} (${line}#${currentHash}) content: "${content}"` - ) + mismatches.push({ line, expected: hash }) } } if (mismatches.length > 0) { - throw new Error(`Hash mismatches:\n- ${mismatches.join("\n- ")}`) + throw new HashlineMismatchError(mismatches, lines) } }