359c6b6655
- P2: Change replace edit sorting from POSITIVE_INFINITY to NEGATIVE_INFINITY so replace edits run LAST after line-based edits, preventing line number shifts that would invalidate subsequent anchors - P3: Update tool description from SHA-256 to xxHash32 to match actual implementation in hash-computation.ts
138 lines
4.9 KiB
TypeScript
138 lines
4.9 KiB
TypeScript
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 xxHash32 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}`
|
|
}
|
|
},
|
|
})
|
|
}
|