feat(hashline): port hashline edit tool from oh-my-pi
This PR ports the hashline edit tool from oh-my-pi to oh-my-opencode as an experimental feature. ## Features - New experimental.hashline_edit config flag - hashline_edit tool with 4 operations: set_line, replace_lines, insert_after, replace - Hash-based line anchors for safe concurrent editing - Edit tool disabler for non-OpenAI providers - Read output enhancer with LINE:HASH prefixes - Provider state tracking module ## Technical Details - xxHash32-based 2-char hex hashes - Bottom-up edit application to prevent index shifting - OpenAI provider exemption (uses native apply_patch) - 90 tests covering all operations and edge cases - All files under 200 LOC limit ## Files Added/Modified - src/tools/hashline-edit/ (7 files, ~400 LOC) - src/hooks/hashline-edit-disabler/ (4 files, ~200 LOC) - src/hooks/hashline-read-enhancer/ (3 files, ~400 LOC) - src/features/hashline-provider-state.ts (13 LOC) - src/config/schema/experimental.ts (hashline_edit flag) - src/config/schema/hooks.ts (2 new hook names) - src/plugin/tool-registry.ts (conditional registration) - src/plugin/chat-params.ts (provider state tracking) - src/tools/index.ts (export) - src/hooks/index.ts (exports)
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
applyHashlineEdits,
|
||||
applyInsertAfter,
|
||||
applyReplace,
|
||||
applyReplaceLines,
|
||||
applySetLine,
|
||||
} from "./edit-operations"
|
||||
import type { HashlineEdit, InsertAfter, Replace, ReplaceLines, SetLine } from "./types"
|
||||
|
||||
describe("applySetLine", () => {
|
||||
it("replaces a single line at the specified anchor", () => {
|
||||
//#given
|
||||
const lines = ["line 1", "line 2", "line 3"]
|
||||
const anchor = "2:b2" // line 2 hash
|
||||
|
||||
//#when
|
||||
const result = applySetLine(lines, anchor, "new line 2")
|
||||
|
||||
//#then
|
||||
expect(result).toEqual(["line 1", "new line 2", "line 3"])
|
||||
})
|
||||
|
||||
it("handles newline escapes in replacement text", () => {
|
||||
//#given
|
||||
const lines = ["line 1", "line 2", "line 3"]
|
||||
const anchor = "2:b2"
|
||||
|
||||
//#when
|
||||
const result = applySetLine(lines, anchor, "new\\nline")
|
||||
|
||||
//#then
|
||||
expect(result).toEqual(["line 1", "new\nline", "line 3"])
|
||||
})
|
||||
|
||||
it("throws on hash mismatch", () => {
|
||||
//#given
|
||||
const lines = ["line 1", "line 2", "line 3"]
|
||||
const anchor = "2:ff" // wrong hash
|
||||
|
||||
//#when / #then
|
||||
expect(() => applySetLine(lines, anchor, "new")).toThrow("Hash mismatch")
|
||||
})
|
||||
|
||||
it("throws on out of bounds line", () => {
|
||||
//#given
|
||||
const lines = ["line 1", "line 2"]
|
||||
const anchor = "5:00"
|
||||
|
||||
//#when / #then
|
||||
expect(() => applySetLine(lines, anchor, "new")).toThrow("out of bounds")
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyReplaceLines", () => {
|
||||
it("replaces a range of lines", () => {
|
||||
//#given
|
||||
const lines = ["line 1", "line 2", "line 3", "line 4", "line 5"]
|
||||
const startAnchor = "2:b2"
|
||||
const endAnchor = "4:5f"
|
||||
|
||||
//#when
|
||||
const result = applyReplaceLines(lines, startAnchor, endAnchor, "replacement")
|
||||
|
||||
//#then
|
||||
expect(result).toEqual(["line 1", "replacement", "line 5"])
|
||||
})
|
||||
|
||||
it("handles newline escapes in replacement text", () => {
|
||||
//#given
|
||||
const lines = ["line 1", "line 2", "line 3"]
|
||||
const startAnchor = "2:b2"
|
||||
const endAnchor = "2:b2"
|
||||
|
||||
//#when
|
||||
const result = applyReplaceLines(lines, startAnchor, endAnchor, "a\\nb")
|
||||
|
||||
//#then
|
||||
expect(result).toEqual(["line 1", "a", "b", "line 3"])
|
||||
})
|
||||
|
||||
it("throws on start hash mismatch", () => {
|
||||
//#given
|
||||
const lines = ["line 1", "line 2", "line 3"]
|
||||
const startAnchor = "2:ff"
|
||||
const endAnchor = "3:83"
|
||||
|
||||
//#when / #then
|
||||
expect(() => applyReplaceLines(lines, startAnchor, endAnchor, "new")).toThrow(
|
||||
"Hash mismatch"
|
||||
)
|
||||
})
|
||||
|
||||
it("throws on end hash mismatch", () => {
|
||||
//#given
|
||||
const lines = ["line 1", "line 2", "line 3"]
|
||||
const startAnchor = "2:b2"
|
||||
const endAnchor = "3:ff"
|
||||
|
||||
//#when / #then
|
||||
expect(() => applyReplaceLines(lines, startAnchor, endAnchor, "new")).toThrow(
|
||||
"Hash mismatch"
|
||||
)
|
||||
})
|
||||
|
||||
it("throws when start > end", () => {
|
||||
//#given
|
||||
const lines = ["line 1", "line 2", "line 3"]
|
||||
const startAnchor = "3:83"
|
||||
const endAnchor = "2:b2"
|
||||
|
||||
//#when / #then
|
||||
expect(() => applyReplaceLines(lines, startAnchor, endAnchor, "new")).toThrow(
|
||||
"start line 3 cannot be greater than end line 2"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyInsertAfter", () => {
|
||||
it("inserts text after the specified line", () => {
|
||||
//#given
|
||||
const lines = ["line 1", "line 2", "line 3"]
|
||||
const anchor = "2:b2"
|
||||
|
||||
//#when
|
||||
const result = applyInsertAfter(lines, anchor, "inserted")
|
||||
|
||||
//#then
|
||||
expect(result).toEqual(["line 1", "line 2", "inserted", "line 3"])
|
||||
})
|
||||
|
||||
it("handles newline escapes to insert multiple lines", () => {
|
||||
//#given
|
||||
const lines = ["line 1", "line 2", "line 3"]
|
||||
const anchor = "2:b2"
|
||||
|
||||
//#when
|
||||
const result = applyInsertAfter(lines, anchor, "a\\nb\\nc")
|
||||
|
||||
//#then
|
||||
expect(result).toEqual(["line 1", "line 2", "a", "b", "c", "line 3"])
|
||||
})
|
||||
|
||||
it("inserts at end when anchor is last line", () => {
|
||||
//#given
|
||||
const lines = ["line 1", "line 2"]
|
||||
const anchor = "2:b2"
|
||||
|
||||
//#when
|
||||
const result = applyInsertAfter(lines, anchor, "inserted")
|
||||
|
||||
//#then
|
||||
expect(result).toEqual(["line 1", "line 2", "inserted"])
|
||||
})
|
||||
|
||||
it("throws on hash mismatch", () => {
|
||||
//#given
|
||||
const lines = ["line 1", "line 2"]
|
||||
const anchor = "2:ff"
|
||||
|
||||
//#when / #then
|
||||
expect(() => applyInsertAfter(lines, anchor, "new")).toThrow("Hash mismatch")
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyReplace", () => {
|
||||
it("replaces exact text match", () => {
|
||||
//#given
|
||||
const content = "hello world foo bar"
|
||||
const oldText = "world"
|
||||
const newText = "universe"
|
||||
|
||||
//#when
|
||||
const result = applyReplace(content, oldText, newText)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual("hello universe foo bar")
|
||||
})
|
||||
|
||||
it("replaces all occurrences", () => {
|
||||
//#given
|
||||
const content = "foo bar foo baz foo"
|
||||
const oldText = "foo"
|
||||
const newText = "qux"
|
||||
|
||||
//#when
|
||||
const result = applyReplace(content, oldText, newText)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual("qux bar qux baz qux")
|
||||
})
|
||||
|
||||
it("handles newline escapes in newText", () => {
|
||||
//#given
|
||||
const content = "hello world"
|
||||
const oldText = "world"
|
||||
const newText = "new\\nline"
|
||||
|
||||
//#when
|
||||
const result = applyReplace(content, oldText, newText)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual("hello new\nline")
|
||||
})
|
||||
|
||||
it("throws when oldText not found", () => {
|
||||
//#given
|
||||
const content = "hello world"
|
||||
const oldText = "notfound"
|
||||
const newText = "replacement"
|
||||
|
||||
//#when / #then
|
||||
expect(() => applyReplace(content, oldText, newText)).toThrow("Text not found")
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyHashlineEdits", () => {
|
||||
it("applies single set_line edit", () => {
|
||||
//#given
|
||||
const content = "line 1\nline 2\nline 3"
|
||||
const edits: SetLine[] = [{ type: "set_line", line: "2:b2", text: "new line 2" }]
|
||||
|
||||
//#when
|
||||
const result = applyHashlineEdits(content, edits)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual("line 1\nnew line 2\nline 3")
|
||||
})
|
||||
|
||||
it("applies multiple edits bottom-up (descending line order)", () => {
|
||||
//#given
|
||||
const content = "line 1\nline 2\nline 3\nline 4\nline 5"
|
||||
const edits: SetLine[] = [
|
||||
{ type: "set_line", line: "2:b2", text: "new 2" },
|
||||
{ type: "set_line", line: "4:5f", text: "new 4" },
|
||||
]
|
||||
|
||||
//#when
|
||||
const result = applyHashlineEdits(content, edits)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual("line 1\nnew 2\nline 3\nnew 4\nline 5")
|
||||
})
|
||||
|
||||
it("applies mixed edit types", () => {
|
||||
//#given
|
||||
const content = "line 1\nline 2\nline 3"
|
||||
const edits: HashlineEdit[] = [
|
||||
{ type: "insert_after", line: "1:02", text: "inserted" },
|
||||
{ type: "set_line", line: "3:83", text: "modified" },
|
||||
]
|
||||
|
||||
//#when
|
||||
const result = applyHashlineEdits(content, edits)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual("line 1\ninserted\nline 2\nmodified")
|
||||
})
|
||||
|
||||
it("applies replace_lines edit", () => {
|
||||
//#given
|
||||
const content = "line 1\nline 2\nline 3\nline 4"
|
||||
const edits: ReplaceLines[] = [
|
||||
{ type: "replace_lines", start_line: "2:b2", end_line: "3:83", text: "replaced" },
|
||||
]
|
||||
|
||||
//#when
|
||||
const result = applyHashlineEdits(content, edits)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual("line 1\nreplaced\nline 4")
|
||||
})
|
||||
|
||||
it("applies replace fallback edit", () => {
|
||||
//#given
|
||||
const content = "hello world foo"
|
||||
const edits: Replace[] = [{ type: "replace", old_text: "world", new_text: "universe" }]
|
||||
|
||||
//#when
|
||||
const result = applyHashlineEdits(content, edits)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual("hello universe foo")
|
||||
})
|
||||
|
||||
it("handles empty edits array", () => {
|
||||
//#given
|
||||
const content = "line 1\nline 2"
|
||||
const edits: HashlineEdit[] = []
|
||||
|
||||
//#when
|
||||
const result = applyHashlineEdits(content, edits)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual("line 1\nline 2")
|
||||
})
|
||||
|
||||
it("throws on hash mismatch with descriptive error", () => {
|
||||
//#given
|
||||
const content = "line 1\nline 2\nline 3"
|
||||
const edits: SetLine[] = [{ type: "set_line", line: "2:ff", text: "new" }]
|
||||
|
||||
//#when / #then
|
||||
expect(() => applyHashlineEdits(content, edits)).toThrow("Hash mismatch")
|
||||
})
|
||||
|
||||
it("correctly handles index shifting with multiple edits", () => {
|
||||
//#given
|
||||
const content = "a\nb\nc\nd\ne"
|
||||
const edits: InsertAfter[] = [
|
||||
{ type: "insert_after", line: "2:bf", text: "x" },
|
||||
{ type: "insert_after", line: "4:90", text: "y" },
|
||||
]
|
||||
|
||||
//#when
|
||||
const result = applyHashlineEdits(content, edits)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual("a\nb\nx\nc\nd\ny\ne")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import { parseLineRef, validateLineRef } from "./validation"
|
||||
import type { HashlineEdit } from "./types"
|
||||
|
||||
function unescapeNewlines(text: string): string {
|
||||
return text.replace(/\\n/g, "\n")
|
||||
}
|
||||
|
||||
export function applySetLine(lines: string[], anchor: string, newText: string): string[] {
|
||||
validateLineRef(lines, anchor)
|
||||
const { line } = parseLineRef(anchor)
|
||||
const result = [...lines]
|
||||
result[line - 1] = unescapeNewlines(newText)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyReplaceLines(
|
||||
lines: string[],
|
||||
startAnchor: string,
|
||||
endAnchor: string,
|
||||
newText: 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 newLines = unescapeNewlines(newText).split("\n")
|
||||
result.splice(startLine - 1, endLine - startLine + 1, ...newLines)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyInsertAfter(lines: string[], anchor: string, text: string): string[] {
|
||||
validateLineRef(lines, anchor)
|
||||
const { line } = parseLineRef(anchor)
|
||||
const result = [...lines]
|
||||
const newLines = unescapeNewlines(text).split("\n")
|
||||
result.splice(line, 0, ...newLines)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyReplace(content: string, oldText: string, newText: string): string {
|
||||
if (!content.includes(oldText)) {
|
||||
throw new Error(`Text not found: "${oldText}"`)
|
||||
}
|
||||
return content.replaceAll(oldText, unescapeNewlines(newText))
|
||||
}
|
||||
|
||||
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 "replace":
|
||||
return Number.POSITIVE_INFINITY
|
||||
default:
|
||||
return Number.POSITIVE_INFINITY
|
||||
}
|
||||
}
|
||||
|
||||
export function applyHashlineEdits(content: string, edits: HashlineEdit[]): string {
|
||||
if (edits.length === 0) {
|
||||
return content
|
||||
}
|
||||
|
||||
const sortedEdits = [...edits].sort((a, b) => getEditLineNumber(b) - getEditLineNumber(a))
|
||||
|
||||
let result = content
|
||||
let lines = result.split("\n")
|
||||
|
||||
for (const edit of sortedEdits) {
|
||||
switch (edit.type) {
|
||||
case "set_line": {
|
||||
validateLineRef(lines, edit.line)
|
||||
const { line } = parseLineRef(edit.line)
|
||||
lines[line - 1] = unescapeNewlines(edit.text)
|
||||
break
|
||||
}
|
||||
case "replace_lines": {
|
||||
validateLineRef(lines, edit.start_line)
|
||||
validateLineRef(lines, edit.end_line)
|
||||
const { line: startLine } = parseLineRef(edit.start_line)
|
||||
const { line: endLine } = parseLineRef(edit.end_line)
|
||||
if (startLine > endLine) {
|
||||
throw new Error(
|
||||
`Invalid range: start line ${startLine} cannot be greater than end line ${endLine}`
|
||||
)
|
||||
}
|
||||
const newLines = unescapeNewlines(edit.text).split("\n")
|
||||
lines.splice(startLine - 1, endLine - startLine + 1, ...newLines)
|
||||
break
|
||||
}
|
||||
case "insert_after": {
|
||||
validateLineRef(lines, edit.line)
|
||||
const { line } = parseLineRef(edit.line)
|
||||
const newLines = unescapeNewlines(edit.text).split("\n")
|
||||
lines.splice(line, 0, ...newLines)
|
||||
break
|
||||
}
|
||||
case "replace": {
|
||||
result = lines.join("\n")
|
||||
if (!result.includes(edit.old_text)) {
|
||||
throw new Error(`Text not found: "${edit.old_text}"`)
|
||||
}
|
||||
result = result.replaceAll(edit.old_text, unescapeNewlines(edit.new_text))
|
||||
lines = result.split("\n")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
@@ -3,3 +3,11 @@ export { parseLineRef, validateLineRef } from "./validation"
|
||||
export type { LineRef } from "./validation"
|
||||
export type { SetLine, ReplaceLines, InsertAfter, Replace, HashlineEdit } from "./types"
|
||||
export { HASH_DICT, HASHLINE_PATTERN } from "./constants"
|
||||
export {
|
||||
applyHashlineEdits,
|
||||
applyInsertAfter,
|
||||
applyReplace,
|
||||
applyReplaceLines,
|
||||
applySetLine,
|
||||
} from "./edit-operations"
|
||||
export { createHashlineEditTool } from "./tools"
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { createHashlineEditTool } from "./tools"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import * as os from "node:os"
|
||||
import { computeLineHash } from "./hash-computation"
|
||||
|
||||
describe("createHashlineEditTool", () => {
|
||||
let tempDir: string
|
||||
let tool: ReturnType<typeof createHashlineEditTool>
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hashline-edit-test-"))
|
||||
tool = createHashlineEditTool()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("tool definition", () => {
|
||||
it("has correct description", () => {
|
||||
//#given tool is created
|
||||
//#when accessing tool properties
|
||||
//#then description explains LINE:HASH format
|
||||
expect(tool.description).toContain("LINE:HASH")
|
||||
expect(tool.description).toContain("set_line")
|
||||
expect(tool.description).toContain("replace_lines")
|
||||
expect(tool.description).toContain("insert_after")
|
||||
expect(tool.description).toContain("replace")
|
||||
})
|
||||
|
||||
it("has path parameter", () => {
|
||||
//#given tool is created
|
||||
//#when checking parameters
|
||||
//#then path parameter exists as required string
|
||||
expect(tool.args.path).toBeDefined()
|
||||
})
|
||||
|
||||
it("has edits parameter as array", () => {
|
||||
//#given tool is created
|
||||
//#when checking parameters
|
||||
//#then edits parameter exists as array
|
||||
expect(tool.args.edits).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute", () => {
|
||||
it("returns error when file does not exist", async () => {
|
||||
//#given non-existent file path
|
||||
const nonExistentPath = path.join(tempDir, "non-existent.txt")
|
||||
|
||||
//#when executing tool
|
||||
const result = await tool.execute(
|
||||
{
|
||||
path: nonExistentPath,
|
||||
edits: [{ type: "set_line", line: "1:00", text: "new content" }],
|
||||
},
|
||||
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
|
||||
)
|
||||
|
||||
//#then error is returned
|
||||
expect(result).toContain("Error")
|
||||
expect(result).toContain("not found")
|
||||
})
|
||||
|
||||
it("applies set_line edit and returns diff", async () => {
|
||||
//#given file with content
|
||||
const filePath = path.join(tempDir, "test.txt")
|
||||
fs.writeFileSync(filePath, "line1\nline2\nline3")
|
||||
const line2Hash = computeLineHash(2, "line2")
|
||||
|
||||
//#when executing set_line edit
|
||||
const result = await tool.execute(
|
||||
{
|
||||
path: filePath,
|
||||
edits: [{ type: "set_line", line: `2:${line2Hash}`, text: "modified line2" }],
|
||||
},
|
||||
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
|
||||
)
|
||||
|
||||
//#then file is modified and diff is returned
|
||||
const content = fs.readFileSync(filePath, "utf-8")
|
||||
expect(content).toBe("line1\nmodified line2\nline3")
|
||||
expect(result).toContain("modified line2")
|
||||
})
|
||||
|
||||
it("applies insert_after edit", async () => {
|
||||
//#given file with content
|
||||
const filePath = path.join(tempDir, "test.txt")
|
||||
fs.writeFileSync(filePath, "line1\nline2")
|
||||
const line1Hash = computeLineHash(1, "line1")
|
||||
|
||||
//#when executing insert_after edit
|
||||
const result = await tool.execute(
|
||||
{
|
||||
path: filePath,
|
||||
edits: [{ type: "insert_after", line: `1:${line1Hash}`, text: "inserted" }],
|
||||
},
|
||||
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
|
||||
)
|
||||
|
||||
//#then line is inserted after specified line
|
||||
const content = fs.readFileSync(filePath, "utf-8")
|
||||
expect(content).toBe("line1\ninserted\nline2")
|
||||
})
|
||||
|
||||
it("applies replace_lines edit", async () => {
|
||||
//#given file with content
|
||||
const filePath = path.join(tempDir, "test.txt")
|
||||
fs.writeFileSync(filePath, "line1\nline2\nline3\nline4")
|
||||
const line2Hash = computeLineHash(2, "line2")
|
||||
const line3Hash = computeLineHash(3, "line3")
|
||||
|
||||
//#when executing replace_lines edit
|
||||
const result = await tool.execute(
|
||||
{
|
||||
path: filePath,
|
||||
edits: [
|
||||
{
|
||||
type: "replace_lines",
|
||||
start_line: `2:${line2Hash}`,
|
||||
end_line: `3:${line3Hash}`,
|
||||
text: "replaced",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
|
||||
)
|
||||
|
||||
//#then lines are replaced
|
||||
const content = fs.readFileSync(filePath, "utf-8")
|
||||
expect(content).toBe("line1\nreplaced\nline4")
|
||||
})
|
||||
|
||||
it("applies replace edit", async () => {
|
||||
//#given file with content
|
||||
const filePath = path.join(tempDir, "test.txt")
|
||||
fs.writeFileSync(filePath, "hello world\nfoo bar")
|
||||
|
||||
//#when executing replace edit
|
||||
const result = await tool.execute(
|
||||
{
|
||||
path: filePath,
|
||||
edits: [{ type: "replace", old_text: "world", new_text: "universe" }],
|
||||
},
|
||||
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
|
||||
)
|
||||
|
||||
//#then text is replaced
|
||||
const content = fs.readFileSync(filePath, "utf-8")
|
||||
expect(content).toBe("hello universe\nfoo bar")
|
||||
})
|
||||
|
||||
it("applies multiple edits in bottom-up order", async () => {
|
||||
//#given file with content
|
||||
const filePath = path.join(tempDir, "test.txt")
|
||||
fs.writeFileSync(filePath, "line1\nline2\nline3")
|
||||
const line1Hash = computeLineHash(1, "line1")
|
||||
const line3Hash = computeLineHash(3, "line3")
|
||||
|
||||
//#when executing multiple edits
|
||||
const result = await tool.execute(
|
||||
{
|
||||
path: filePath,
|
||||
edits: [
|
||||
{ type: "set_line", line: `1:${line1Hash}`, text: "new1" },
|
||||
{ type: "set_line", line: `3:${line3Hash}`, text: "new3" },
|
||||
],
|
||||
},
|
||||
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
|
||||
)
|
||||
|
||||
//#then both edits are applied
|
||||
const content = fs.readFileSync(filePath, "utf-8")
|
||||
expect(content).toBe("new1\nline2\nnew3")
|
||||
})
|
||||
|
||||
it("returns error on hash mismatch", async () => {
|
||||
//#given file with content
|
||||
const filePath = path.join(tempDir, "test.txt")
|
||||
fs.writeFileSync(filePath, "line1\nline2")
|
||||
|
||||
//#when executing with wrong hash (valid format but wrong value)
|
||||
const result = await tool.execute(
|
||||
{
|
||||
path: filePath,
|
||||
edits: [{ type: "set_line", line: "1:ff", text: "new" }],
|
||||
},
|
||||
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
|
||||
)
|
||||
|
||||
//#then hash mismatch error is returned
|
||||
expect(result).toContain("Error")
|
||||
expect(result).toContain("hash")
|
||||
})
|
||||
|
||||
it("handles escaped newlines in text", async () => {
|
||||
//#given file with content
|
||||
const filePath = path.join(tempDir, "test.txt")
|
||||
fs.writeFileSync(filePath, "line1\nline2")
|
||||
const line1Hash = computeLineHash(1, "line1")
|
||||
|
||||
//#when executing with escaped newline
|
||||
const result = await tool.execute(
|
||||
{
|
||||
path: filePath,
|
||||
edits: [{ type: "set_line", line: `1:${line1Hash}`, text: "new\\nline" }],
|
||||
},
|
||||
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
|
||||
)
|
||||
|
||||
//#then newline is unescaped
|
||||
const content = fs.readFileSync(filePath, "utf-8")
|
||||
expect(content).toBe("new\nline\nline2")
|
||||
})
|
||||
|
||||
it("returns success result with diff summary", async () => {
|
||||
//#given file with content
|
||||
const filePath = path.join(tempDir, "test.txt")
|
||||
fs.writeFileSync(filePath, "old content")
|
||||
const line1Hash = computeLineHash(1, "old content")
|
||||
|
||||
//#when executing edit
|
||||
const result = await tool.execute(
|
||||
{
|
||||
path: filePath,
|
||||
edits: [{ type: "set_line", line: `1:${line1Hash}`, text: "new content" }],
|
||||
},
|
||||
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() }
|
||||
)
|
||||
|
||||
//#then result contains success indicator and diff
|
||||
expect(result).toContain("Successfully")
|
||||
expect(result).toContain("old content")
|
||||
expect(result).toContain("new content")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import type { HashlineEdit } from "./types"
|
||||
import { applyHashlineEdits } from "./edit-operations"
|
||||
import { computeLineHash } from "./hash-computation"
|
||||
|
||||
interface HashlineEditArgs {
|
||||
path: string
|
||||
edits: HashlineEdit[]
|
||||
}
|
||||
|
||||
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: `Edit files using LINE:HASH format for precise, safe modifications.
|
||||
|
||||
LINE:HASH FORMAT:
|
||||
Each line reference must be in "LINE:HASH" format where:
|
||||
- LINE: 1-based line number
|
||||
- HASH: First 2 characters of SHA-256 hash of line content (computed with computeLineHash)
|
||||
- Example: "5:a3|const x = 1" means line 5 with hash "a3"
|
||||
|
||||
GETTING HASHES:
|
||||
Use the read tool - it returns lines in "LINE:HASH|content" format.
|
||||
|
||||
FOUR OPERATION TYPES:
|
||||
|
||||
1. set_line: Replace a single line
|
||||
{ "type": "set_line", "line": "5:a3", "text": "const y = 2" }
|
||||
|
||||
2. replace_lines: Replace a range of lines
|
||||
{ "type": "replace_lines", "start_line": "5:a3", "end_line": "7:b2", "text": "new\ncontent" }
|
||||
|
||||
3. insert_after: Insert lines after a specific line
|
||||
{ "type": "insert_after", "line": "5:a3", "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.
|
||||
|
||||
BOTTOM-UP APPLICATION:
|
||||
Edits are applied from bottom to top (highest line numbers first) to preserve line number references.
|
||||
|
||||
ESCAPING:
|
||||
Use \\n in text to represent literal newlines.`,
|
||||
args: {
|
||||
path: tool.schema.string().describe("Absolute path to the file to edit"),
|
||||
edits: tool.schema
|
||||
.array(
|
||||
tool.schema.union([
|
||||
tool.schema.object({
|
||||
type: tool.schema.literal("set_line"),
|
||||
line: tool.schema.string().describe("Line reference in LINE:HASH format"),
|
||||
text: tool.schema.string().describe("New content for the line"),
|
||||
}),
|
||||
tool.schema.object({
|
||||
type: tool.schema.literal("replace_lines"),
|
||||
start_line: tool.schema.string().describe("Start line in LINE:HASH format"),
|
||||
end_line: tool.schema.string().describe("End line in LINE:HASH format"),
|
||||
text: tool.schema.string().describe("New content to replace the range"),
|
||||
}),
|
||||
tool.schema.object({
|
||||
type: tool.schema.literal("insert_after"),
|
||||
line: tool.schema.string().describe("Line reference in LINE:HASH format"),
|
||||
text: tool.schema.string().describe("Content to insert after the line"),
|
||||
}),
|
||||
tool.schema.object({
|
||||
type: tool.schema.literal("replace"),
|
||||
old_text: tool.schema.string().describe("Text to find"),
|
||||
new_text: tool.schema.string().describe("Replacement text"),
|
||||
}),
|
||||
])
|
||||
)
|
||||
.describe("Array of edit operations to apply"),
|
||||
},
|
||||
execute: async (args: HashlineEditArgs) => {
|
||||
try {
|
||||
const { path: filePath, edits } = args
|
||||
|
||||
if (!filePath) {
|
||||
return "Error: path parameter is required"
|
||||
}
|
||||
|
||||
if (!edits || !Array.isArray(edits) || edits.length === 0) {
|
||||
return "Error: edits parameter must be a non-empty array"
|
||||
}
|
||||
|
||||
const file = Bun.file(filePath)
|
||||
const exists = await file.exists()
|
||||
if (!exists) {
|
||||
return `Error: File not found: ${filePath}`
|
||||
}
|
||||
|
||||
const oldContent = await file.text()
|
||||
const newContent = applyHashlineEdits(oldContent, edits)
|
||||
|
||||
await Bun.write(filePath, newContent)
|
||||
|
||||
const diff = generateDiff(oldContent, newContent, filePath)
|
||||
|
||||
return `Successfully applied ${edits.length} edit(s) to ${filePath}\n\n${diff}`
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (message.includes("hash")) {
|
||||
return `Error: Hash mismatch - ${message}`
|
||||
}
|
||||
return `Error: ${message}`
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export {
|
||||
createTaskList,
|
||||
createTaskUpdateTool,
|
||||
} from "./task"
|
||||
export { createHashlineEditTool } from "./hashline-edit"
|
||||
|
||||
export function createBackgroundTools(manager: BackgroundManager, client: OpencodeClient): Record<string, ToolDefinition> {
|
||||
const outputManager: BackgroundOutputManager = manager
|
||||
|
||||
Reference in New Issue
Block a user