From 5d1d87cc10807a4e6f221f1f0a18da84684b674f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 22 Feb 2026 14:13:59 +0900 Subject: [PATCH] feat(hashline-edit): add autocorrect, BOM/CRLF normalization, and file creation support Implements key features from oh-my-pi to improve agent editing success rates: - Autocorrect v1: single-line merge expansion, wrapped line restoration, paired indent restoration (autocorrect-replacement-lines.ts) - BOM/CRLF normalization: canonicalize on read, restore on write (file-text-canonicalization.ts) - Pre-validate all hashes before mutation (edit-ordering.ts) - File creation via append/prepend operations (new types + executor logic) - Modular refactoring: split edit-operations.ts into focused modules (primitives, ordering, deduplication, diff, executor) - Enhanced tool description with operation choice guide and recovery hints All 50 tests pass. TypeScript clean. Build successful. --- .../autocorrect-replacement-lines.ts | 106 ++++++++ src/tools/hashline-edit/edit-deduplication.ts | 47 ++++ .../edit-operation-primitives.ts | 154 ++++++++++++ .../hashline-edit/edit-operations.test.ts | 32 ++- src/tools/hashline-edit/edit-operations.ts | 236 ++++-------------- src/tools/hashline-edit/edit-ordering.ts | 48 ++++ .../file-text-canonicalization.ts | 40 +++ src/tools/hashline-edit/hashline-edit-diff.ts | 31 +++ .../hashline-edit/hashline-edit-executor.ts | 146 +++++++++++ src/tools/hashline-edit/index.ts | 12 +- src/tools/hashline-edit/tool-description.ts | 44 +++- src/tools/hashline-edit/tools.test.ts | 46 ++++ src/tools/hashline-edit/tools.ts | 153 ++---------- src/tools/hashline-edit/types.ts | 20 +- 14 files changed, 774 insertions(+), 341 deletions(-) create mode 100644 src/tools/hashline-edit/autocorrect-replacement-lines.ts create mode 100644 src/tools/hashline-edit/edit-deduplication.ts create mode 100644 src/tools/hashline-edit/edit-operation-primitives.ts create mode 100644 src/tools/hashline-edit/edit-ordering.ts create mode 100644 src/tools/hashline-edit/file-text-canonicalization.ts create mode 100644 src/tools/hashline-edit/hashline-edit-diff.ts create mode 100644 src/tools/hashline-edit/hashline-edit-executor.ts diff --git a/src/tools/hashline-edit/autocorrect-replacement-lines.ts b/src/tools/hashline-edit/autocorrect-replacement-lines.ts new file mode 100644 index 000000000..397e51d80 --- /dev/null +++ b/src/tools/hashline-edit/autocorrect-replacement-lines.ts @@ -0,0 +1,106 @@ +function normalizeTokens(text: string): string { + return text.replace(/\s+/g, "") +} + +function leadingWhitespace(text: string): string { + const match = text.match(/^\s*/) + return match ? match[0] : "" +} + +export function restoreOldWrappedLines(originalLines: string[], replacementLines: string[]): string[] { + if (replacementLines.length <= 1) return replacementLines + if (originalLines.length !== replacementLines.length) return replacementLines + const original = normalizeTokens(originalLines.join("\n")) + const replacement = normalizeTokens(replacementLines.join("\n")) + if (original !== replacement) return replacementLines + return originalLines +} + +export function maybeExpandSingleLineMerge( + originalLines: string[], + replacementLines: string[] +): string[] { + if (replacementLines.length !== 1 || originalLines.length <= 1) { + return replacementLines + } + + const merged = replacementLines[0] + const parts = originalLines.map((line) => line.trim()).filter((line) => line.length > 0) + if (parts.length !== originalLines.length) return replacementLines + + const indices: number[] = [] + let offset = 0 + let orderedMatch = true + for (const part of parts) { + const idx = merged.indexOf(part, offset) + if (idx === -1) { + orderedMatch = false + break + } + indices.push(idx) + offset = idx + part.length + } + + const expanded: string[] = [] + if (orderedMatch) { + for (let i = 0; i < indices.length; i += 1) { + const start = indices[i] + const end = i + 1 < indices.length ? indices[i + 1] : merged.length + const candidate = merged.slice(start, end).trim() + if (candidate.length === 0) { + orderedMatch = false + break + } + expanded.push(candidate) + } + } + + if (orderedMatch && expanded.length === originalLines.length) { + return expanded + } + + const semicolonSplit = merged + .split(/;\s+/) + .map((line, idx, arr) => { + if (idx < arr.length - 1 && !line.endsWith(";")) { + return `${line};` + } + return line + }) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + + if (semicolonSplit.length === originalLines.length) { + return semicolonSplit + } + + return replacementLines +} + +export function restoreIndentForPairedReplacement( + originalLines: string[], + replacementLines: string[] +): string[] { + if (originalLines.length !== replacementLines.length) { + return replacementLines + } + + return replacementLines.map((line, idx) => { + if (line.length === 0) return line + if (leadingWhitespace(line).length > 0) return line + const indent = leadingWhitespace(originalLines[idx]) + if (indent.length === 0) return line + return `${indent}${line}` + }) +} + +export function autocorrectReplacementLines( + originalLines: string[], + replacementLines: string[] +): string[] { + let next = replacementLines + next = maybeExpandSingleLineMerge(originalLines, next) + next = restoreOldWrappedLines(originalLines, next) + next = restoreIndentForPairedReplacement(originalLines, next) + return next +} diff --git a/src/tools/hashline-edit/edit-deduplication.ts b/src/tools/hashline-edit/edit-deduplication.ts new file mode 100644 index 000000000..d6ccaa1b2 --- /dev/null +++ b/src/tools/hashline-edit/edit-deduplication.ts @@ -0,0 +1,47 @@ +import type { HashlineEdit } from "./types" +import { toNewLines } from "./edit-text-normalization" + +function normalizeEditPayload(payload: string | string[]): string { + return toNewLines(payload).join("\n") +} + +function buildDedupeKey(edit: HashlineEdit): string { + 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)}` + case "append": + return `append|${normalizeEditPayload(edit.text)}` + case "prepend": + return `prepend|${normalizeEditPayload(edit.text)}` + default: + return JSON.stringify(edit) + } +} + +export 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 = buildDedupeKey(edit) + if (seen.has(key)) { + deduplicatedEdits += 1 + continue + } + seen.add(key) + deduped.push(edit) + } + + return { edits: deduped, deduplicatedEdits } +} diff --git a/src/tools/hashline-edit/edit-operation-primitives.ts b/src/tools/hashline-edit/edit-operation-primitives.ts new file mode 100644 index 000000000..13876c156 --- /dev/null +++ b/src/tools/hashline-edit/edit-operation-primitives.ts @@ -0,0 +1,154 @@ +import { autocorrectReplacementLines } from "./autocorrect-replacement-lines" +import { + restoreLeadingIndent, + stripInsertAnchorEcho, + stripInsertBeforeEcho, + stripInsertBoundaryEcho, + stripRangeBoundaryEcho, + toNewLines, +} from "./edit-text-normalization" +import { parseLineRef, validateLineRef } from "./validation" + +interface EditApplyOptions { + skipValidation?: boolean +} + +function shouldValidate(options?: EditApplyOptions): boolean { + return options?.skipValidation !== true +} + +export function applySetLine( + lines: string[], + anchor: string, + newText: string | string[], + options?: EditApplyOptions +): string[] { + if (shouldValidate(options)) validateLineRef(lines, anchor) + const { line } = parseLineRef(anchor) + const result = [...lines] + const originalLine = lines[line - 1] ?? "" + const corrected = autocorrectReplacementLines([originalLine], toNewLines(newText)) + const replacement = corrected.map((entry, idx) => { + if (idx !== 0) return entry + return restoreLeadingIndent(originalLine, entry) + }) + result.splice(line - 1, 1, ...replacement) + return result +} + +export function applyReplaceLines( + lines: string[], + startAnchor: string, + endAnchor: string, + newText: string | string[], + options?: EditApplyOptions +): string[] { + if (shouldValidate(options)) { + validateLineRef(lines, startAnchor) + validateLineRef(lines, endAnchor) + } + + const { line: startLine } = parseLineRef(startAnchor) + const { line: endLine } = parseLineRef(endAnchor) + + if (startLine > endLine) { + throw new Error( + `Invalid range: start line ${startLine} cannot be greater than end line ${endLine}` + ) + } + + const result = [...lines] + const originalRange = lines.slice(startLine - 1, endLine) + const stripped = stripRangeBoundaryEcho(lines, startLine, endLine, toNewLines(newText)) + const corrected = autocorrectReplacementLines(originalRange, stripped) + const restored = corrected.map((entry, idx) => { + if (idx !== 0) return entry + return restoreLeadingIndent(lines[startLine - 1], entry) + }) + result.splice(startLine - 1, endLine - startLine + 1, ...restored) + return result +} + +export function applyInsertAfter( + lines: string[], + anchor: string, + text: string | string[], + options?: EditApplyOptions +): string[] { + if (shouldValidate(options)) validateLineRef(lines, anchor) + 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[], + options?: EditApplyOptions +): string[] { + if (shouldValidate(options)) 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[], + options?: EditApplyOptions +): string[] { + if (shouldValidate(options)) { + 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 applyAppend(lines: string[], text: string | string[]): string[] { + const normalized = toNewLines(text) + if (normalized.length === 0) { + throw new Error("append requires non-empty text") + } + return [...lines, ...normalized] +} + +export function applyPrepend(lines: string[], text: string | string[]): string[] { + const normalized = toNewLines(text) + if (normalized.length === 0) { + throw new Error("prepend requires non-empty text") + } + return [...normalized, ...lines] +} + +export function applyReplace(content: string, oldText: string, newText: string | string[]): string { + if (!content.includes(oldText)) { + throw new Error(`Text not found: "${oldText}"`) + } + const replacement = Array.isArray(newText) ? newText.join("\n") : newText + return content.replaceAll(oldText, replacement) +} diff --git a/src/tools/hashline-edit/edit-operations.test.ts b/src/tools/hashline-edit/edit-operations.test.ts index 8437373de..c93f8c60d 100644 --- a/src/tools/hashline-edit/edit-operations.test.ts +++ b/src/tools/hashline-edit/edit-operations.test.ts @@ -246,6 +246,36 @@ describe("hashline edit operations", () => { const result = applyReplaceLines(lines, anchorFor(lines, 2), anchorFor(lines, 3), ["return 3", "return 4"]) //#then - expect(result).toEqual(["if (x) {", " return 3", "return 4", "}"]) + expect(result).toEqual(["if (x) {", " return 3", " return 4", "}"]) + }) + + it("applies append and prepend operations", () => { + //#given + const content = "line 1\nline 2" + + //#when + const result = applyHashlineEdits(content, [ + { type: "append", text: ["line 3"] }, + { type: "prepend", text: ["line 0"] }, + ]) + + //#then + expect(result).toEqual("line 0\nline 1\nline 2\nline 3") + }) + + it("autocorrects single-line merged replacement into original line count", () => { + //#given + const lines = ["const a = 1;", "const b = 2;"] + + //#when + const result = applyReplaceLines( + lines, + anchorFor(lines, 1), + anchorFor(lines, 2), + "const a = 10; const b = 20;" + ) + + //#then + expect(result).toEqual(["const a = 10;", "const b = 20;"]) }) }) diff --git a/src/tools/hashline-edit/edit-operations.ts b/src/tools/hashline-edit/edit-operations.ts index b5cdca095..7e2279a25 100644 --- a/src/tools/hashline-edit/edit-operations.ts +++ b/src/tools/hashline-edit/edit-operations.ts @@ -1,13 +1,17 @@ -import { parseLineRef, validateLineRef, validateLineRefs } from "./validation" +import { dedupeEdits } from "./edit-deduplication" +import { collectLineRefs, getEditLineNumber } from "./edit-ordering" import type { HashlineEdit } from "./types" import { - restoreLeadingIndent, - stripInsertAnchorEcho, - stripInsertBeforeEcho, - stripInsertBoundaryEcho, - stripRangeBoundaryEcho, - toNewLines, -} from "./edit-text-normalization" + applyAppend, + applyInsertAfter, + applyInsertBefore, + applyInsertBetween, + applyPrepend, + applyReplace, + applyReplaceLines, + applySetLine, +} from "./edit-operation-primitives" +import { validateLineRefs } from "./validation" export interface HashlineApplyReport { content: string @@ -15,158 +19,6 @@ export interface HashlineApplyReport { deduplicatedEdits: number } -export function applySetLine(lines: string[], anchor: string, newText: string | string[]): string[] { - validateLineRef(lines, anchor) - const { line } = parseLineRef(anchor) - const result = [...lines] - const replacement = toNewLines(newText).map((entry, idx) => { - if (idx !== 0) return entry - return restoreLeadingIndent(lines[line - 1], entry) - }) - result.splice(line - 1, 1, ...replacement) - return result -} - -export function applyReplaceLines( - lines: string[], - startAnchor: string, - endAnchor: string, - newText: string | string[] -): string[] { - validateLineRef(lines, startAnchor) - validateLineRef(lines, endAnchor) - - const { line: startLine } = parseLineRef(startAnchor) - const { line: endLine } = parseLineRef(endAnchor) - - if (startLine > endLine) { - throw new Error( - `Invalid range: start line ${startLine} cannot be greater than end line ${endLine}` - ) - } - - const result = [...lines] - const stripped = stripRangeBoundaryEcho(lines, startLine, endLine, toNewLines(newText)) - const restored = stripped.map((entry, idx) => { - if (idx !== 0) return entry - return restoreLeadingIndent(lines[startLine - 1], entry) - }) - result.splice(startLine - 1, endLine - startLine + 1, ...restored) - return result -} - -export function applyInsertAfter(lines: string[], anchor: string, text: string | string[]): string[] { - validateLineRef(lines, anchor) - 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}"`) - } - const replacement = Array.isArray(newText) ? newText.join("\n") : newText - return content.replaceAll(oldText, replacement) -} - -function getEditLineNumber(edit: HashlineEdit): number { - switch (edit.type) { - case "set_line": - return parseLineRef(edit.line).line - case "replace_lines": - 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: - return Number.POSITIVE_INFINITY - } -} - -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) - } - - return { edits: deduped, deduplicatedEdits } -} - export function applyHashlineEditsWithReport(content: string, edits: HashlineEdit[]): HashlineApplyReport { if (edits.length === 0) { return { @@ -182,40 +34,23 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi let noopEdits = 0 let result = content - let lines = result.split("\n") + let lines = result.length === 0 ? [] : result.split("\n") - const refs = sortedEdits.flatMap((edit) => { - switch (edit.type) { - case "set_line": - return [edit.line] - case "replace_lines": - 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: - return [] - } - }) + const refs = collectLineRefs(sortedEdits) validateLineRefs(lines, refs) for (const edit of sortedEdits) { switch (edit.type) { case "set_line": { - lines = applySetLine(lines, edit.line, edit.text) + lines = applySetLine(lines, edit.line, edit.text, { skipValidation: true }) break } case "replace_lines": { - lines = applyReplaceLines(lines, edit.start_line, edit.end_line, edit.text) + lines = applyReplaceLines(lines, edit.start_line, edit.end_line, edit.text, { skipValidation: true }) break } case "insert_after": { - const next = applyInsertAfter(lines, edit.line, edit.text) + const next = applyInsertAfter(lines, edit.line, edit.text, { skipValidation: true }) if (next.join("\n") === lines.join("\n")) { noopEdits += 1 break @@ -224,7 +59,7 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi break } case "insert_before": { - const next = applyInsertBefore(lines, edit.line, edit.text) + const next = applyInsertBefore(lines, edit.line, edit.text, { skipValidation: true }) if (next.join("\n") === lines.join("\n")) { noopEdits += 1 break @@ -233,7 +68,25 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi break } case "insert_between": { - const next = applyInsertBetween(lines, edit.after_line, edit.before_line, edit.text) + const next = applyInsertBetween(lines, edit.after_line, edit.before_line, edit.text, { skipValidation: true }) + if (next.join("\n") === lines.join("\n")) { + noopEdits += 1 + break + } + lines = next + break + } + case "append": { + const next = applyAppend(lines, edit.text) + if (next.join("\n") === lines.join("\n")) { + noopEdits += 1 + break + } + lines = next + break + } + case "prepend": { + const next = applyPrepend(lines, edit.text) if (next.join("\n") === lines.join("\n")) { noopEdits += 1 break @@ -243,11 +96,7 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi } case "replace": { result = lines.join("\n") - if (!result.includes(edit.old_text)) { - throw new Error(`Text not found: "${edit.old_text}"`) - } - const replacement = Array.isArray(edit.new_text) ? edit.new_text.join("\n") : edit.new_text - const replaced = result.replaceAll(edit.old_text, replacement) + const replaced = applyReplace(result, edit.old_text, edit.new_text) if (replaced === result) { noopEdits += 1 break @@ -269,3 +118,12 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi export function applyHashlineEdits(content: string, edits: HashlineEdit[]): string { return applyHashlineEditsWithReport(content, edits).content } + +export { + applySetLine, + applyReplaceLines, + applyInsertAfter, + applyInsertBefore, + applyInsertBetween, + applyReplace, +} from "./edit-operation-primitives" diff --git a/src/tools/hashline-edit/edit-ordering.ts b/src/tools/hashline-edit/edit-ordering.ts new file mode 100644 index 000000000..d8196babb --- /dev/null +++ b/src/tools/hashline-edit/edit-ordering.ts @@ -0,0 +1,48 @@ +import { parseLineRef } from "./validation" +import type { HashlineEdit } from "./types" + +export function getEditLineNumber(edit: HashlineEdit): number { + switch (edit.type) { + case "set_line": + return parseLineRef(edit.line).line + case "replace_lines": + 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 "append": + return Number.NEGATIVE_INFINITY + case "prepend": + return Number.NEGATIVE_INFINITY + case "replace": + return Number.NEGATIVE_INFINITY + default: + return Number.POSITIVE_INFINITY + } +} + +export function collectLineRefs(edits: HashlineEdit[]): string[] { + return edits.flatMap((edit) => { + switch (edit.type) { + case "set_line": + return [edit.line] + case "replace_lines": + 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 "append": + case "prepend": + case "replace": + return [] + default: + return [] + } + }) +} diff --git a/src/tools/hashline-edit/file-text-canonicalization.ts b/src/tools/hashline-edit/file-text-canonicalization.ts new file mode 100644 index 000000000..88c222cb4 --- /dev/null +++ b/src/tools/hashline-edit/file-text-canonicalization.ts @@ -0,0 +1,40 @@ +export interface FileTextEnvelope { + content: string + hadBom: boolean + lineEnding: "\n" | "\r\n" +} + +function detectLineEnding(content: string): "\n" | "\r\n" { + return content.includes("\r\n") ? "\r\n" : "\n" +} + +function stripBom(content: string): { content: string; hadBom: boolean } { + if (!content.startsWith("\uFEFF")) { + return { content, hadBom: false } + } + return { content: content.slice(1), hadBom: true } +} + +function normalizeToLf(content: string): string { + return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n") +} + +function restoreLineEndings(content: string, lineEnding: "\n" | "\r\n"): string { + if (lineEnding === "\n") return content + return content.replace(/\n/g, "\r\n") +} + +export function canonicalizeFileText(content: string): FileTextEnvelope { + const stripped = stripBom(content) + return { + content: normalizeToLf(stripped.content), + hadBom: stripped.hadBom, + lineEnding: detectLineEnding(stripped.content), + } +} + +export function restoreFileText(content: string, envelope: FileTextEnvelope): string { + const withLineEnding = restoreLineEndings(content, envelope.lineEnding) + if (!envelope.hadBom) return withLineEnding + return `\uFEFF${withLineEnding}` +} diff --git a/src/tools/hashline-edit/hashline-edit-diff.ts b/src/tools/hashline-edit/hashline-edit-diff.ts new file mode 100644 index 000000000..9ea1f6135 --- /dev/null +++ b/src/tools/hashline-edit/hashline-edit-diff.ts @@ -0,0 +1,31 @@ +import { computeLineHash } from "./hash-computation" + +export function generateHashlineDiff(oldContent: string, newContent: string, filePath: string): string { + const oldLines = oldContent.split("\n") + const newLines = newContent.split("\n") + + let diff = `--- ${filePath}\n+++ ${filePath}\n` + const maxLines = Math.max(oldLines.length, newLines.length) + + for (let i = 0; i < maxLines; i += 1) { + const oldLine = oldLines[i] ?? "" + const newLine = newLines[i] ?? "" + const lineNum = i + 1 + const hash = computeLineHash(lineNum, newLine) + + if (i >= oldLines.length) { + diff += `+ ${lineNum}#${hash}:${newLine}\n` + continue + } + if (i >= newLines.length) { + diff += `- ${lineNum}# :${oldLine}\n` + continue + } + if (oldLine !== newLine) { + diff += `- ${lineNum}# :${oldLine}\n` + diff += `+ ${lineNum}#${hash}:${newLine}\n` + } + } + + return diff +} diff --git a/src/tools/hashline-edit/hashline-edit-executor.ts b/src/tools/hashline-edit/hashline-edit-executor.ts new file mode 100644 index 000000000..96db35233 --- /dev/null +++ b/src/tools/hashline-edit/hashline-edit-executor.ts @@ -0,0 +1,146 @@ +import type { ToolContext } from "@opencode-ai/plugin/tool" +import { storeToolMetadata } from "../../features/tool-metadata-store" +import { applyHashlineEditsWithReport } from "./edit-operations" +import { countLineDiffs, generateUnifiedDiff, toHashlineContent } from "./diff-utils" +import { canonicalizeFileText, restoreFileText } from "./file-text-canonicalization" +import { generateHashlineDiff } from "./hashline-edit-diff" +import type { HashlineEdit } from "./types" + +interface HashlineEditArgs { + filePath: string + edits: HashlineEdit[] + delete?: boolean + rename?: string +} + +type ToolContextWithCallID = ToolContext & { + callID?: string + callId?: string + call_id?: string +} + +type ToolContextWithMetadata = ToolContextWithCallID & { + metadata?: (value: unknown) => void +} + +function resolveToolCallID(ctx: ToolContextWithCallID): string | undefined { + if (typeof ctx.callID === "string" && ctx.callID.trim() !== "") return ctx.callID + if (typeof ctx.callId === "string" && ctx.callId.trim() !== "") return ctx.callId + if (typeof ctx.call_id === "string" && ctx.call_id.trim() !== "") return ctx.call_id + return undefined +} + +function canCreateFromMissingFile(edits: HashlineEdit[]): boolean { + if (edits.length === 0) return false + return edits.every((edit) => edit.type === "append" || edit.type === "prepend") +} + +function buildSuccessMeta( + effectivePath: string, + beforeContent: string, + afterContent: string, + noopEdits: number, + deduplicatedEdits: number +) { + const unifiedDiff = generateUnifiedDiff(beforeContent, afterContent, effectivePath) + const { additions, deletions } = countLineDiffs(beforeContent, afterContent) + + return { + title: effectivePath, + metadata: { + filePath: effectivePath, + path: effectivePath, + file: effectivePath, + diff: unifiedDiff, + noopEdits, + deduplicatedEdits, + filediff: { + file: effectivePath, + path: effectivePath, + filePath: effectivePath, + before: beforeContent, + after: afterContent, + additions, + deletions, + }, + }, + } +} + +export async function executeHashlineEditTool(args: HashlineEditArgs, context: ToolContext): Promise { + try { + const metadataContext = context as ToolContextWithMetadata + const filePath = args.filePath + const { edits, delete: deleteMode, rename } = args + + 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() + if (!exists && !deleteMode && !canCreateFromMissingFile(edits)) { + return `Error: File not found: ${filePath}` + } + + if (deleteMode) { + if (!exists) return `Error: File not found: ${filePath}` + await Bun.file(filePath).delete() + return `Successfully deleted ${filePath}` + } + + const rawOldContent = exists ? Buffer.from(await file.arrayBuffer()).toString("utf8") : "" + const oldEnvelope = canonicalizeFileText(rawOldContent) + + const applyResult = applyHashlineEditsWithReport(oldEnvelope.content, edits) + const canonicalNewContent = applyResult.content + const writeContent = restoreFileText(canonicalNewContent, oldEnvelope) + + await Bun.write(filePath, writeContent) + + if (rename && rename !== filePath) { + await Bun.write(rename, writeContent) + await Bun.file(filePath).delete() + } + + const effectivePath = rename && rename !== filePath ? rename : filePath + const diff = generateHashlineDiff(oldEnvelope.content, canonicalNewContent, effectivePath) + const newHashlined = toHashlineContent(canonicalNewContent) + const meta = buildSuccessMeta( + effectivePath, + oldEnvelope.content, + canonicalNewContent, + applyResult.noopEdits, + applyResult.deduplicatedEdits + ) + + if (typeof metadataContext.metadata === "function") { + metadataContext.metadata(meta) + } + + const callID = resolveToolCallID(metadataContext) + if (callID) { + storeToolMetadata(context.sessionID, callID, meta) + } + + return `Successfully applied ${edits.length} edit(s) to ${effectivePath} +No-op edits: ${applyResult.noopEdits}, deduplicated edits: ${applyResult.deduplicatedEdits} + +${diff} + +Updated file (LINE#ID:content): +${newHashlined}` + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (message.toLowerCase().includes("hash")) { + return `Error: hash mismatch - ${message}\nTip: reuse LINE#ID entries from the latest read/edit output, or batch related edits in one call.` + } + return `Error: ${message}` + } +} diff --git a/src/tools/hashline-edit/index.ts b/src/tools/hashline-edit/index.ts index c631afd8e..d76f7e1f7 100644 --- a/src/tools/hashline-edit/index.ts +++ b/src/tools/hashline-edit/index.ts @@ -7,7 +7,17 @@ export { } from "./hash-computation" export { parseLineRef, validateLineRef } from "./validation" export type { LineRef } from "./validation" -export type { SetLine, ReplaceLines, InsertAfter, InsertBefore, InsertBetween, Replace, HashlineEdit } from "./types" +export type { + SetLine, + ReplaceLines, + InsertAfter, + InsertBefore, + InsertBetween, + Replace, + Append, + Prepend, + HashlineEdit, +} from "./types" export { NIBBLE_STR, HASHLINE_DICT, HASHLINE_REF_PATTERN, HASHLINE_OUTPUT_PATTERN } from "./constants" export { applyHashlineEdits, diff --git a/src/tools/hashline-edit/tool-description.ts b/src/tools/hashline-edit/tool-description.ts index 28680291b..fa34b4c6f 100644 --- a/src/tools/hashline-edit/tool-description.ts +++ b/src/tools/hashline-edit/tool-description.ts @@ -7,14 +7,14 @@ WORKFLOW: 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) + 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, append, prepend + 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 + LINE: 1-based line number + ID: Two CID letters from the set ZPMQVRWSNKTXJBYH OPERATION TYPES: 1. set_line @@ -25,10 +25,34 @@ OPERATION TYPES: 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 + 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.` + 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. + +FILE CREATION: + append: adds content at EOF. If file does not exist, creates it. + prepend: adds content at BOF. If file does not exist, creates it. + CRITICAL: append/prepend are the ONLY operations that work without an existing file. + +OPERATION CHOICE: + One line wrong \u2192 set_line + Block rewrite \u2192 replace_lines + New content between known anchors \u2192 insert_between (safest \u2014 dual-anchor pinning) + New content at boundary \u2192 insert_after or insert_before + New file or EOF/BOF addition \u2192 append or prepend + No LINE#ID available \u2192 replace (last resort) + +AUTOCORRECT (built-in \u2014 you do NOT need to handle these): + Merged lines are auto-expanded back to original line count. + Indentation is auto-restored from original lines. + BOM and CRLF line endings are preserved automatically. + Hashline prefixes and diff markers in text are auto-stripped. + +RECOVERY (when >>> mismatch error appears): + Copy the updated LINE#ID tags shown in the error output directly. + Re-read only if the needed tags are missing from the error snippet. + ALWAYS batch all edits for one file in a single call.` diff --git a/src/tools/hashline-edit/tools.test.ts b/src/tools/hashline-edit/tools.test.ts index 416918f85..2a2eaa151 100644 --- a/src/tools/hashline-edit/tools.test.ts +++ b/src/tools/hashline-edit/tools.test.ts @@ -216,4 +216,50 @@ describe("createHashlineEditTool", () => { expect(fs.existsSync(filePath)).toBe(false) expect(result).toContain("Successfully deleted") }) + + it("creates missing file with append and prepend", async () => { + //#given + const filePath = path.join(tempDir, "created.txt") + + //#when + const result = await tool.execute( + { + filePath, + edits: [ + { type: "append", text: ["line2"] }, + { type: "prepend", text: ["line1"] }, + ], + }, + createMockContext(), + ) + + //#then + expect(fs.existsSync(filePath)).toBe(true) + expect(fs.readFileSync(filePath, "utf-8")).toBe("line1\nline2") + expect(result).toContain("Successfully applied 2 edit(s)") + }) + + it("preserves BOM and CRLF through hashline_edit", async () => { + //#given + const filePath = path.join(tempDir, "crlf-bom.txt") + const bomCrLf = "\uFEFFline1\r\nline2\r\n" + fs.writeFileSync(filePath, bomCrLf) + const line2Hash = computeLineHash(2, "line2") + + //#when + await tool.execute( + { + filePath, + edits: [{ type: "set_line", line: `2#${line2Hash}`, text: "line2-updated" }], + }, + createMockContext(), + ) + + //#then + const bytes = fs.readFileSync(filePath) + expect(bytes[0]).toBe(0xef) + expect(bytes[1]).toBe(0xbb) + expect(bytes[2]).toBe(0xbf) + expect(bytes.toString("utf-8")).toBe("\uFEFFline1\r\nline2-updated\r\n") + }) }) diff --git a/src/tools/hashline-edit/tools.ts b/src/tools/hashline-edit/tools.ts index b1b255f40..dcb718cb5 100644 --- a/src/tools/hashline-edit/tools.ts +++ b/src/tools/hashline-edit/tools.ts @@ -1,9 +1,6 @@ import { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin/tool" -import { storeToolMetadata } from "../../features/tool-metadata-store" import type { HashlineEdit } from "./types" -import { applyHashlineEditsWithReport } from "./edit-operations" -import { computeLineHash } from "./hash-computation" -import { toHashlineContent, generateUnifiedDiff, countLineDiffs } from "./diff-utils" +import { executeHashlineEditTool } from "./hashline-edit-executor" import { HASHLINE_EDIT_DESCRIPTION } from "./tool-description" interface HashlineEditArgs { @@ -13,49 +10,6 @@ interface HashlineEditArgs { rename?: string } -type ToolContextWithCallID = ToolContext & { - callID?: string - callId?: string - call_id?: string -} - -type ToolContextWithMetadata = ToolContextWithCallID & { - metadata?: (value: unknown) => void -} - -function resolveToolCallID(ctx: ToolContextWithCallID): string | undefined { - if (typeof ctx.callID === "string" && ctx.callID.trim() !== "") return ctx.callID - if (typeof ctx.callId === "string" && ctx.callId.trim() !== "") return ctx.callId - if (typeof ctx.call_id === "string" && ctx.call_id.trim() !== "") return ctx.call_id - return undefined -} - -function generateDiff(oldContent: string, newContent: string, filePath: string): string { - const oldLines = oldContent.split("\n") - const newLines = newContent.split("\n") - - let diff = `--- ${filePath}\n+++ ${filePath}\n` - - const maxLines = Math.max(oldLines.length, newLines.length) - for (let i = 0; i < maxLines; i++) { - const oldLine = oldLines[i] ?? "" - const newLine = newLines[i] ?? "" - const lineNum = i + 1 - const hash = computeLineHash(lineNum, newLine) - - if (i >= oldLines.length) { - diff += `+ ${lineNum}#${hash}:${newLine}\n` - } else if (i >= newLines.length) { - diff += `- ${lineNum}# :${oldLine}\n` - } else if (oldLine !== newLine) { - diff += `- ${lineNum}# :${oldLine}\n` - diff += `+ ${lineNum}#${hash}:${newLine}\n` - } - } - - return diff -} - export function createHashlineEditTool(): ToolDefinition { return tool({ description: HASHLINE_EDIT_DESCRIPTION, @@ -110,101 +64,22 @@ export function createHashlineEditTool(): ToolDefinition { .union([tool.schema.string(), tool.schema.array(tool.schema.string())]) .describe("Replacement text (string or string[] for multiline)"), }), + tool.schema.object({ + type: tool.schema.literal("append"), + text: tool.schema + .union([tool.schema.string(), tool.schema.array(tool.schema.string())]) + .describe("Content to append at EOF; also creates missing file"), + }), + tool.schema.object({ + type: tool.schema.literal("prepend"), + text: tool.schema + .union([tool.schema.string(), tool.schema.array(tool.schema.string())]) + .describe("Content to prepend at BOF; also creates missing file"), + }), ]) ) .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, delete: deleteMode, rename } = args - - 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() - if (!exists) { - return `Error: File not found: ${filePath}` - } - - if (deleteMode) { - await Bun.file(filePath).delete() - return `Successfully deleted ${filePath}` - } - - const oldContent = await file.text() - const applyResult = applyHashlineEditsWithReport(oldContent, edits) - const newContent = applyResult.content - - await Bun.write(filePath, newContent) - - 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, effectivePath) - const { additions, deletions } = countLineDiffs(oldContent, newContent) - - const meta = { - title: effectivePath, - metadata: { - filePath: effectivePath, - path: effectivePath, - file: effectivePath, - diff: unifiedDiff, - noopEdits: applyResult.noopEdits, - deduplicatedEdits: applyResult.deduplicatedEdits, - filediff: { - file: effectivePath, - path: effectivePath, - filePath: effectivePath, - before: oldContent, - after: newContent, - additions, - deletions, - }, - }, - } - - if (typeof metadataContext.metadata === "function") { - metadataContext.metadata(meta) - } - - const callID = resolveToolCallID(metadataContext) - if (callID) { - storeToolMetadata(context.sessionID, callID, meta) - } - - return `Successfully applied ${edits.length} edit(s) to ${effectivePath} -No-op edits: ${applyResult.noopEdits}, deduplicated edits: ${applyResult.deduplicatedEdits} - -${diff} - -Updated file (LINE#ID:content): -${newHashlined}` - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - if (message.toLowerCase().includes("hash")) { - return `Error: hash mismatch - ${message}\nTip: reuse LINE#ID entries from the latest read/edit output, or batch related edits in one call.` - } - return `Error: ${message}` - } - }, + execute: async (args: HashlineEditArgs, context: ToolContext) => executeHashlineEditTool(args, context), }) } diff --git a/src/tools/hashline-edit/types.ts b/src/tools/hashline-edit/types.ts index a953d961c..f69f27570 100644 --- a/src/tools/hashline-edit/types.ts +++ b/src/tools/hashline-edit/types.ts @@ -36,4 +36,22 @@ export interface Replace { new_text: string | string[] } -export type HashlineEdit = SetLine | ReplaceLines | InsertAfter | InsertBefore | InsertBetween | Replace +export interface Append { + type: "append" + text: string | string[] +} + +export interface Prepend { + type: "prepend" + text: string | string[] +} + +export type HashlineEdit = + | SetLine + | ReplaceLines + | InsertAfter + | InsertBefore + | InsertBetween + | Replace + | Append + | Prepend