feat(hashline-edit): add anchor insert modes and strict insert validation
This commit is contained in:
23
bun-test.d.ts
vendored
Normal file
23
bun-test.d.ts
vendored
Normal file
@@ -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>): void
|
||||||
|
export function beforeEach(fn: () => void | Promise<void>): void
|
||||||
|
export function afterEach(fn: () => void | Promise<void>): void
|
||||||
|
export function beforeAll(fn: () => void | Promise<void>): void
|
||||||
|
export function afterAll(fn: () => void | Promise<void>): void
|
||||||
|
export function mock<T extends (...args: never[]) => 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
|
||||||
|
}
|
||||||
@@ -41,6 +41,75 @@ describe("hashline edit operations", () => {
|
|||||||
expect(result).toEqual(["line 1", "line 2", "inserted", "line 3"])
|
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", () => {
|
it("applies replace operation", () => {
|
||||||
//#given
|
//#given
|
||||||
const content = "hello world foo"
|
const content = "hello world foo"
|
||||||
@@ -68,6 +137,22 @@ describe("hashline edit operations", () => {
|
|||||||
expect(result).toEqual("line 1\ninserted\nline 2\nmodified")
|
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", () => {
|
it("keeps literal backslash-n in plain string text", () => {
|
||||||
//#given
|
//#given
|
||||||
const lines = ["line 1", "line 2", "line 3"]
|
const lines = ["line 1", "line 2", "line 3"]
|
||||||
|
|||||||
@@ -1,95 +1,18 @@
|
|||||||
import { parseLineRef, validateLineRef, validateLineRefs } from "./validation"
|
import { parseLineRef, validateLineRef, validateLineRefs } from "./validation"
|
||||||
import type { HashlineEdit } from "./types"
|
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}:/
|
export interface HashlineApplyReport {
|
||||||
const DIFF_PLUS_RE = /^[+-](?![+-])/
|
content: string
|
||||||
|
noopEdits: number
|
||||||
function stripLinePrefixes(lines: string[]): string[] {
|
deduplicatedEdits: number
|
||||||
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 function applySetLine(lines: string[], anchor: string, newText: string | string[]): string[] {
|
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 { line } = parseLineRef(anchor)
|
||||||
const result = [...lines]
|
const result = [...lines]
|
||||||
const newLines = stripInsertAnchorEcho(lines[line - 1], toNewLines(text))
|
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)
|
result.splice(line, 0, ...newLines)
|
||||||
return result
|
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 {
|
export function applyReplace(content: string, oldText: string, newText: string | string[]): string {
|
||||||
if (!content.includes(oldText)) {
|
if (!content.includes(oldText)) {
|
||||||
throw new Error(`Text not found: "${oldText}"`)
|
throw new Error(`Text not found: "${oldText}"`)
|
||||||
@@ -157,6 +118,10 @@ function getEditLineNumber(edit: HashlineEdit): number {
|
|||||||
return parseLineRef(edit.end_line).line
|
return parseLineRef(edit.end_line).line
|
||||||
case "insert_after":
|
case "insert_after":
|
||||||
return parseLineRef(edit.line).line
|
return parseLineRef(edit.line).line
|
||||||
|
case "insert_before":
|
||||||
|
return parseLineRef(edit.line).line
|
||||||
|
case "insert_between":
|
||||||
|
return parseLineRef(edit.before_line).line
|
||||||
case "replace":
|
case "replace":
|
||||||
return Number.NEGATIVE_INFINITY
|
return Number.NEGATIVE_INFINITY
|
||||||
default:
|
default:
|
||||||
@@ -164,12 +129,57 @@ function getEditLineNumber(edit: HashlineEdit): number {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyHashlineEdits(content: string, edits: HashlineEdit[]): string {
|
function normalizeEditPayload(payload: string | string[]): string {
|
||||||
if (edits.length === 0) {
|
return toNewLines(payload).join("\n")
|
||||||
return content
|
}
|
||||||
|
|
||||||
|
function dedupeEdits(edits: HashlineEdit[]): { edits: HashlineEdit[]; deduplicatedEdits: number } {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
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 result = content
|
||||||
let lines = result.split("\n")
|
let lines = result.split("\n")
|
||||||
@@ -182,6 +192,10 @@ export function applyHashlineEdits(content: string, edits: HashlineEdit[]): stri
|
|||||||
return [edit.start_line, edit.end_line]
|
return [edit.start_line, edit.end_line]
|
||||||
case "insert_after":
|
case "insert_after":
|
||||||
return [edit.line]
|
return [edit.line]
|
||||||
|
case "insert_before":
|
||||||
|
return [edit.line]
|
||||||
|
case "insert_between":
|
||||||
|
return [edit.after_line, edit.before_line]
|
||||||
case "replace":
|
case "replace":
|
||||||
return []
|
return []
|
||||||
default:
|
default:
|
||||||
@@ -201,7 +215,30 @@ export function applyHashlineEdits(content: string, edits: HashlineEdit[]): stri
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "insert_after": {
|
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
|
break
|
||||||
}
|
}
|
||||||
case "replace": {
|
case "replace": {
|
||||||
@@ -210,12 +247,25 @@ export function applyHashlineEdits(content: string, edits: HashlineEdit[]): stri
|
|||||||
throw new Error(`Text not found: "${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 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")
|
lines = result.split("\n")
|
||||||
break
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
109
src/tools/hashline-edit/edit-text-normalization.ts
Normal file
109
src/tools/hashline-edit/edit-text-normalization.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { describe, it, expect } from "bun:test"
|
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", () => {
|
describe("computeLineHash", () => {
|
||||||
it("returns deterministic 2-char CID hash per line", () => {
|
it("returns deterministic 2-char CID hash per line", () => {
|
||||||
@@ -71,3 +77,43 @@ describe("formatHashLines", () => {
|
|||||||
expect(lines[2]).toMatch(/^3#[ZPMQVRWSNKTXJBYH]{2}:c$/)
|
expect(lines[2]).toMatch(/^3#[ZPMQVRWSNKTXJBYH]{2}:c$/)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("streamHashLinesFrom*", () => {
|
||||||
|
async function collectStream(stream: AsyncIterable<string>): Promise<string> {
|
||||||
|
const chunks: string[] = []
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(chunk)
|
||||||
|
}
|
||||||
|
return chunks.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* utf8Chunks(text: string, chunkSize: number): AsyncGenerator<Uint8Array> {
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -18,3 +18,196 @@ export function formatHashLines(content: string): string {
|
|||||||
const lines = content.split("\n")
|
const lines = content.split("\n")
|
||||||
return lines.map((line, index) => formatHashLine(index + 1, line)).join("\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<Uint8Array> {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"getReader" in value &&
|
||||||
|
typeof (value as { getReader?: unknown }).getReader === "function"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* bytesFromReadableStream(stream: ReadableStream<Uint8Array>): AsyncGenerator<Uint8Array> {
|
||||||
|
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<Uint8Array> | AsyncIterable<Uint8Array>,
|
||||||
|
options: HashlineStreamOptions = {}
|
||||||
|
): AsyncGenerator<string> {
|
||||||
|
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<string> | AsyncIterable<string>,
|
||||||
|
options: HashlineStreamOptions = {}
|
||||||
|
): AsyncGenerator<string> {
|
||||||
|
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<string>)[Symbol.asyncIterator]
|
||||||
|
if (typeof asyncIterator === "function") {
|
||||||
|
for await (const line of lines as AsyncIterable<string>) {
|
||||||
|
for (const out of pushLine(line)) yield out
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const line of lines as Iterable<string>) {
|
||||||
|
for (const out of pushLine(line)) yield out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sawAnyLine) {
|
||||||
|
for (const out of pushLine("")) {
|
||||||
|
yield out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalChunk = flush()
|
||||||
|
if (finalChunk) yield finalChunk
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { parseLineRef, validateLineRef } from "./validation"
|
||||||
export type { LineRef } 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 { NIBBLE_STR, HASHLINE_DICT, HASHLINE_REF_PATTERN, HASHLINE_OUTPUT_PATTERN } from "./constants"
|
||||||
export {
|
export {
|
||||||
applyHashlineEdits,
|
applyHashlineEdits,
|
||||||
applyInsertAfter,
|
applyInsertAfter,
|
||||||
|
applyInsertBefore,
|
||||||
|
applyInsertBetween,
|
||||||
applyReplace,
|
applyReplace,
|
||||||
applyReplaceLines,
|
applyReplaceLines,
|
||||||
applySetLine,
|
applySetLine,
|
||||||
|
|||||||
34
src/tools/hashline-edit/tool-description.ts
Normal file
34
src/tools/hashline-edit/tool-description.ts
Normal file
@@ -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.`
|
||||||
@@ -11,12 +11,10 @@ function createMockContext(): ToolContext {
|
|||||||
sessionID: "test",
|
sessionID: "test",
|
||||||
messageID: "test",
|
messageID: "test",
|
||||||
agent: "test",
|
agent: "test",
|
||||||
directory: "/tmp",
|
|
||||||
worktree: "/tmp",
|
|
||||||
abort: new AbortController().signal,
|
abort: new AbortController().signal,
|
||||||
metadata: mock(() => {}),
|
metadata: mock(() => {}),
|
||||||
ask: async () => {},
|
ask: async () => {},
|
||||||
}
|
} as unknown as ToolContext
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("createHashlineEditTool", () => {
|
describe("createHashlineEditTool", () => {
|
||||||
@@ -103,7 +101,7 @@ describe("createHashlineEditTool", () => {
|
|||||||
|
|
||||||
//#then
|
//#then
|
||||||
expect(result).toContain("Error")
|
expect(result).toContain("Error")
|
||||||
expect(result).toContain("hash")
|
expect(result).toContain(">>>")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("preserves literal backslash-n and supports string[] payload", async () => {
|
it("preserves literal backslash-n and supports string[] payload", async () => {
|
||||||
@@ -132,4 +130,90 @@ describe("createHashlineEditTool", () => {
|
|||||||
//#then
|
//#then
|
||||||
expect(fs.readFileSync(filePath, "utf-8")).toBe("join(\\n)\na\nb\nline2")
|
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")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
import { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||||
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
||||||
import type { HashlineEdit } from "./types"
|
import type { HashlineEdit } from "./types"
|
||||||
import { applyHashlineEdits } from "./edit-operations"
|
import { applyHashlineEditsWithReport } from "./edit-operations"
|
||||||
import { computeLineHash } from "./hash-computation"
|
import { computeLineHash } from "./hash-computation"
|
||||||
import { toHashlineContent, generateUnifiedDiff, countLineDiffs } from "./diff-utils"
|
import { toHashlineContent, generateUnifiedDiff, countLineDiffs } from "./diff-utils"
|
||||||
|
import { HASHLINE_EDIT_DESCRIPTION } from "./tool-description"
|
||||||
|
|
||||||
interface HashlineEditArgs {
|
interface HashlineEditArgs {
|
||||||
filePath: string
|
filePath: string
|
||||||
edits: HashlineEdit[]
|
edits: HashlineEdit[]
|
||||||
|
delete?: boolean
|
||||||
|
rename?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolContextWithCallID = ToolContext & {
|
type ToolContextWithCallID = ToolContext & {
|
||||||
@@ -55,62 +58,11 @@ function generateDiff(oldContent: string, newContent: string, filePath: string):
|
|||||||
|
|
||||||
export function createHashlineEditTool(): ToolDefinition {
|
export function createHashlineEditTool(): ToolDefinition {
|
||||||
return tool({
|
return tool({
|
||||||
description: `Edit files using LINE#ID format for precise, safe modifications.
|
description: HASHLINE_EDIT_DESCRIPTION,
|
||||||
|
|
||||||
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.`,
|
|
||||||
args: {
|
args: {
|
||||||
filePath: tool.schema.string().describe("Absolute path to the file to edit"),
|
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
|
edits: tool.schema
|
||||||
.array(
|
.array(
|
||||||
tool.schema.union([
|
tool.schema.union([
|
||||||
@@ -136,6 +88,21 @@ CONTENT FORMAT:
|
|||||||
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
|
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
|
||||||
.describe("Content to insert after the line (string or string[] for multiline)"),
|
.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({
|
tool.schema.object({
|
||||||
type: tool.schema.literal("replace"),
|
type: tool.schema.literal("replace"),
|
||||||
old_text: tool.schema.string().describe("Text to find"),
|
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) => {
|
execute: async (args: HashlineEditArgs, context: ToolContext) => {
|
||||||
try {
|
try {
|
||||||
const metadataContext = context as ToolContextWithMetadata
|
const metadataContext = context as ToolContextWithMetadata
|
||||||
const filePath = args.filePath
|
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"
|
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 file = Bun.file(filePath)
|
||||||
const exists = await file.exists()
|
const exists = await file.exists()
|
||||||
@@ -163,28 +137,43 @@ CONTENT FORMAT:
|
|||||||
return `Error: File not found: ${filePath}`
|
return `Error: File not found: ${filePath}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (deleteMode) {
|
||||||
|
await Bun.file(filePath).delete()
|
||||||
|
return `Successfully deleted ${filePath}`
|
||||||
|
}
|
||||||
|
|
||||||
const oldContent = await file.text()
|
const oldContent = await file.text()
|
||||||
const newContent = applyHashlineEdits(oldContent, edits)
|
const applyResult = applyHashlineEditsWithReport(oldContent, edits)
|
||||||
|
const newContent = applyResult.content
|
||||||
|
|
||||||
await Bun.write(filePath, newContent)
|
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 newHashlined = toHashlineContent(newContent)
|
||||||
|
|
||||||
const unifiedDiff = generateUnifiedDiff(oldContent, newContent, filePath)
|
const unifiedDiff = generateUnifiedDiff(oldContent, newContent, effectivePath)
|
||||||
const { additions, deletions } = countLineDiffs(oldContent, newContent)
|
const { additions, deletions } = countLineDiffs(oldContent, newContent)
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: filePath,
|
title: effectivePath,
|
||||||
metadata: {
|
metadata: {
|
||||||
filePath,
|
filePath: effectivePath,
|
||||||
path: filePath,
|
path: effectivePath,
|
||||||
file: filePath,
|
file: effectivePath,
|
||||||
diff: unifiedDiff,
|
diff: unifiedDiff,
|
||||||
|
noopEdits: applyResult.noopEdits,
|
||||||
|
deduplicatedEdits: applyResult.deduplicatedEdits,
|
||||||
filediff: {
|
filediff: {
|
||||||
file: filePath,
|
file: effectivePath,
|
||||||
path: filePath,
|
path: effectivePath,
|
||||||
filePath,
|
filePath: effectivePath,
|
||||||
before: oldContent,
|
before: oldContent,
|
||||||
after: newContent,
|
after: newContent,
|
||||||
additions,
|
additions,
|
||||||
@@ -202,7 +191,8 @@ CONTENT FORMAT:
|
|||||||
storeToolMetadata(context.sessionID, callID, meta)
|
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}
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,23 @@ export interface InsertAfter {
|
|||||||
text: string | string[]
|
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 {
|
export interface Replace {
|
||||||
type: "replace"
|
type: "replace"
|
||||||
old_text: string
|
old_text: string
|
||||||
new_text: string | string[]
|
new_text: string | string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HashlineEdit = SetLine | ReplaceLines | InsertAfter | Replace
|
export type HashlineEdit = SetLine | ReplaceLines | InsertAfter | InsertBefore | InsertBetween | Replace
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from "bun:test"
|
import { describe, it, expect } from "bun:test"
|
||||||
import { computeLineHash } from "./hash-computation"
|
import { computeLineHash } from "./hash-computation"
|
||||||
import { parseLineRef, validateLineRef } from "./validation"
|
import { parseLineRef, validateLineRef, validateLineRefs } from "./validation"
|
||||||
|
|
||||||
describe("parseLineRef", () => {
|
describe("parseLineRef", () => {
|
||||||
it("parses valid LINE#ID reference", () => {
|
it("parses valid LINE#ID reference", () => {
|
||||||
@@ -49,7 +49,16 @@ describe("validateLineRef", () => {
|
|||||||
const lines = ["function hello() {"]
|
const lines = ["function hello() {"]
|
||||||
|
|
||||||
//#when / #then
|
//#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() {"]
|
const lines = ["function hello() {"]
|
||||||
|
|
||||||
//#when / #then
|
//#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", () => {
|
it("extracts legacy ref from content with markers", () => {
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ export interface LineRef {
|
|||||||
hash: string
|
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,})/
|
const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2}|[0-9]+:[0-9a-fA-F]{2,})/
|
||||||
|
|
||||||
function normalizeLineRef(ref: string): string {
|
function normalizeLineRef(ref: string): string {
|
||||||
@@ -59,34 +66,85 @@ export function validateLineRef(lines: string[], ref: string): void {
|
|||||||
const currentHash = computeLineHash(line, content)
|
const currentHash = computeLineHash(line, content)
|
||||||
|
|
||||||
if (currentHash !== hash) {
|
if (currentHash !== hash) {
|
||||||
throw new Error(
|
throw new HashlineMismatchError([{ line, expected: hash }], lines)
|
||||||
`Hash mismatch at line ${line}. Expected hash: ${hash}, current hash: ${currentHash}. ` +
|
}
|
||||||
`Line content may have changed. Current content: "${content}"`
|
}
|
||||||
|
|
||||||
|
export class HashlineMismatchError extends Error {
|
||||||
|
readonly remaps: ReadonlyMap<string, string>
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly mismatches: HashMismatch[],
|
||||||
|
private readonly fileLines: string[]
|
||||||
|
) {
|
||||||
|
super(HashlineMismatchError.formatMessage(mismatches, fileLines))
|
||||||
|
this.name = "HashlineMismatchError"
|
||||||
|
const remaps = new Map<string, string>()
|
||||||
|
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<number, HashMismatch>()
|
||||||
|
for (const mismatch of mismatches) mismatchByLine.set(mismatch.line, mismatch)
|
||||||
|
|
||||||
|
const displayLines = new Set<number>()
|
||||||
|
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 {
|
export function validateLineRefs(lines: string[], refs: string[]): void {
|
||||||
const mismatches: string[] = []
|
const mismatches: HashMismatch[] = []
|
||||||
|
|
||||||
for (const ref of refs) {
|
for (const ref of refs) {
|
||||||
const { line, hash } = parseLineRef(ref)
|
const { line, hash } = parseLineRef(ref)
|
||||||
|
|
||||||
if (line < 1 || line > lines.length) {
|
if (line < 1 || line > lines.length) {
|
||||||
mismatches.push(`Line number ${line} out of bounds (file has ${lines.length} lines)`)
|
throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`)
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = lines[line - 1]
|
const content = lines[line - 1]
|
||||||
const currentHash = computeLineHash(line, content)
|
const currentHash = computeLineHash(line, content)
|
||||||
if (currentHash !== hash) {
|
if (currentHash !== hash) {
|
||||||
mismatches.push(
|
mismatches.push({ line, expected: hash })
|
||||||
`line ${line}: expected ${hash}, current ${currentHash} (${line}#${currentHash}) content: "${content}"`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mismatches.length > 0) {
|
if (mismatches.length > 0) {
|
||||||
throw new Error(`Hash mismatches:\n- ${mismatches.join("\n- ")}`)
|
throw new HashlineMismatchError(mismatches, lines)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user