Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/opencode/src/tool/hashline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export type HashlineEdit =
anchor: Anchor
text: string
}
| {
op: "delete_file"
}

export function normalizeLine(line: string): string {
let result = line.replace(/\r$/, "")
Expand Down Expand Up @@ -77,6 +80,9 @@ export function applyHashlineEdits(content: string, edits: HashlineEdit[]): stri
const relocatedMap: Map<number, number> = new Map()

for (const edit of edits) {
// Skip delete_file operations - they don't have anchors to validate
if (edit.op === "delete_file") continue

const anchors = [
...(edit.op === "set_line"
? [edit.anchor]
Expand Down Expand Up @@ -125,6 +131,7 @@ export function applyHashlineEdits(content: string, edits: HashlineEdit[]): stri

const sortedEdits = [...edits].sort((a, b) => {
const getOriginalLine = (e: HashlineEdit): number => {
if (e.op === "delete_file") return Infinity
if (e.op === "replace_lines") return e.start_anchor.line
return e.anchor.line
}
Expand Down Expand Up @@ -204,4 +211,4 @@ export function applyHashlineEdits(content: string, edits: HashlineEdit[]): stri
}

return result
}
}
110 changes: 75 additions & 35 deletions packages/opencode/src/tool/hashline_edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,53 @@ import { Instance } from "../project/instance"
import { assertExternalDirectory } from "./external-directory"
import { applyHashlineEdits, type HashlineEdit, parseAnchor } from "./hashline"

const editSchema = z.discriminatedUnion("op", [
z.object({
op: z.literal("set_line"),
anchor: z.string().describe('Line anchor e.g. "14丐"'),
new_text: z.string().describe("Replacement text, or empty string to delete the line"),
}),
z.object({
op: z.literal("replace_lines"),
start_anchor: z.string().describe('Start anchor e.g. "10乙"'),
end_anchor: z.string().describe('End anchor e.g. "14丐"'),
new_text: z.string().describe("Replacement lines, or empty string to delete the range"),
}),
z.object({
op: z.literal("insert_after"),
anchor: z.string().describe('Line anchor e.g. "14丐"'),
text: z.string().describe("Text to insert after the anchor line"),
}),
z.object({
op: z.literal("delete_file"),
}),
])

type EditSchema = z.infer<typeof editSchema>

function parseEdit(edit: EditSchema): HashlineEdit {
if (edit.op === "set_line") {
return { op: edit.op, anchor: parseAnchor(edit.anchor), new_text: edit.new_text }
}
if (edit.op === "replace_lines") {
return {
op: edit.op,
start_anchor: parseAnchor(edit.start_anchor),
end_anchor: parseAnchor(edit.end_anchor),
new_text: edit.new_text,
}
}
if (edit.op === "insert_after") {
return { op: edit.op, anchor: parseAnchor(edit.anchor), text: edit.text }
}
throw new Error(`Unknown edit operation: ${edit.op}`)
}

export const HashlineEditTool = Tool.define("hashline_edit", {
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("Absolute path to the file to edit"),
edits: z
.array(
z.discriminatedUnion("op", [
z.object({
op: z.literal("set_line"),
anchor: z.string().describe('Line anchor e.g. "14丐"'),
new_text: z.string().describe("Replacement text, or empty string to delete the line"),
}),
z.object({
op: z.literal("replace_lines"),
start_anchor: z.string().describe('Start anchor e.g. "10乙"'),
end_anchor: z.string().describe('End anchor e.g. "14丐"'),
new_text: z.string().describe("Replacement lines, or empty string to delete the range"),
}),
z.object({
op: z.literal("insert_after"),
anchor: z.string().describe('Line anchor e.g. "14丐"'),
text: z.string().describe("Text to insert after the anchor line"),
}),
])
)
.min(1)
.describe("List of edits to apply atomically"),
edits: z.array(editSchema).min(1).describe("List of edits to apply atomically"),
}),
async execute(params, ctx) {
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
Expand All @@ -49,20 +69,40 @@ export const HashlineEditTool = Tool.define("hashline_edit", {
},
})

const parsedEdits: HashlineEdit[] = params.edits.map((edit) => {
if (edit.op === "set_line") {
return { op: edit.op, anchor: parseAnchor(edit.anchor), new_text: edit.new_text }
}
if (edit.op === "replace_lines") {
return {
op: edit.op,
start_anchor: parseAnchor(edit.start_anchor),
end_anchor: parseAnchor(edit.end_anchor),
new_text: edit.new_text,
const isDeleteFile = params.edits.length === 1 && params.edits[0].op === "delete_file"

if (isDeleteFile) {
await FileTime.withLock(filepath, async () => {
try {
await FileTime.assertHashlineRead(ctx.sessionID, filepath)
} catch (error) {
// If file doesn't exist, that's an ENOENT error from stat()
// Wrap it in a clean message
if (error instanceof Error && error.message.includes("no such file")) {
throw new Error(`File not found: ${filepath}`)
}
throw error
}
const file = Bun.file(filepath)
const exists = await file.exists().catch(() => false)
if (!exists) throw new Error(`File not found: ${filepath}`)
await file.delete().catch((error) => {
throw new Error(`File not found: ${filepath}`)
})
// Clear FileTime state for this file
const { hashlineRead } = FileTime.state()
hashlineRead[ctx.sessionID] = hashlineRead[ctx.sessionID] || {}
delete hashlineRead[ctx.sessionID][filepath]
})

return {
title: path.relative(Instance.worktree, filepath),
output: `File deleted successfully: ${params.filePath}`,
metadata: {},
}
return { op: edit.op, anchor: parseAnchor(edit.anchor), text: edit.text }
})
}

const parsedEdits = params.edits.map(parseEdit)

await FileTime.withLock(filepath, async () => {
const file = Bun.file(filepath)
Expand Down
148 changes: 148 additions & 0 deletions packages/opencode/test/tool/hashline_edit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,4 +413,152 @@ describe("tool.hashline_edit", () => {
},
})
})

test("delete_file successfully deletes a file after hashline_read", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tool = await HashlineEditTool.init()
const filepath = path.join(tmp.path, "test.txt")
FileTime.hashlineRead(ctx.sessionID, filepath)
const result = await tool.execute(
{
filePath: filepath,
edits: [{ op: "delete_file" }],
},
ctx
)
expect(result.output).toContain("File deleted successfully")
const exists = await Bun.file(filepath).exists()
expect(exists).toBe(false)
},
})
})

test("delete_file is rejected without prior hashline_read", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), "content")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tool = await HashlineEditTool.init()
const filepath = path.join(tmp.path, "test.txt")
const result = await tool.execute(
{
filePath: filepath,
edits: [{ op: "delete_file" }],
},
ctx
).catch((e) => e)
expect(result.message).toContain("You must use hashline_read before hashline_edit")
const exists = await Bun.file(filepath).exists()
expect(exists).toBe(true)
},
})
})

test("delete_file returns clear error if file already gone", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), "content")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tool = await HashlineEditTool.init()
const filepath = path.join(tmp.path, "test.txt")
FileTime.hashlineRead(ctx.sessionID, filepath)
// Delete the file externally
await Bun.file(filepath).delete()
const result = await tool.execute(
{
filePath: filepath,
edits: [{ op: "delete_file" }],
},
ctx
).catch((e) => e)
expect(result.message).toContain("File not found")
},
})
})

test("after delete_file, subsequent hashline_edit fails until new hashline_read", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), "content")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tool = await HashlineEditTool.init()
const filepath = path.join(tmp.path, "test.txt")
FileTime.hashlineRead(ctx.sessionID, filepath)
await tool.execute(
{
filePath: filepath,
edits: [{ op: "delete_file" }],
},
ctx
)
// Try to edit the same path (file is now gone)
const result = await tool.execute(
{
filePath: filepath,
edits: [{ op: "delete_file" }],
},
ctx
).catch((e) => e)
expect(result.message).toContain("You must use hashline_read before hashline_edit")
},
})
})

test("after delete_file + new hashline_read, operations work on re-created file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tool = await HashlineEditTool.init()
const filepath = path.join(tmp.path, "test.txt")
FileTime.hashlineRead(ctx.sessionID, filepath)
// Delete the file
await tool.execute(
{
filePath: filepath,
edits: [{ op: "delete_file" }],
},
ctx
)
// Re-create the file
await Bun.write(filepath, "new line1\nnew line2")
// Read it again with hashline_read
FileTime.hashlineRead(ctx.sessionID, filepath)
// Now editing should work
const result = await tool.execute(
{
filePath: filepath,
edits: [{ op: "set_line", anchor: "1赙", new_text: "modified" }],
},
ctx
)
expect(result.output).toContain("Edit applied successfully")
const content = await Bun.file(filepath).text()
expect(content).toContain("modified")
},
})
})
})