diff --git a/src/tools/hashline-edit/hashline-edit-executor.ts b/src/tools/hashline-edit/hashline-edit-executor.ts index 96db35233..777ae1622 100644 --- a/src/tools/hashline-edit/hashline-edit-executor.ts +++ b/src/tools/hashline-edit/hashline-edit-executor.ts @@ -1,14 +1,14 @@ import type { ToolContext } from "@opencode-ai/plugin/tool" import { storeToolMetadata } from "../../features/tool-metadata-store" import { applyHashlineEditsWithReport } from "./edit-operations" -import { countLineDiffs, generateUnifiedDiff, toHashlineContent } from "./diff-utils" +import { countLineDiffs, generateUnifiedDiff } from "./diff-utils" import { canonicalizeFileText, restoreFileText } from "./file-text-canonicalization" -import { generateHashlineDiff } from "./hashline-edit-diff" +import { normalizeHashlineEdits, type RawHashlineEdit } from "./normalize-edits" import type { HashlineEdit } from "./types" interface HashlineEditArgs { filePath: string - edits: HashlineEdit[] + edits: RawHashlineEdit[] delete?: boolean rename?: string } @@ -44,6 +44,17 @@ function buildSuccessMeta( ) { const unifiedDiff = generateUnifiedDiff(beforeContent, afterContent, effectivePath) const { additions, deletions } = countLineDiffs(beforeContent, afterContent) + const beforeLines = beforeContent.split("\n") + const afterLines = afterContent.split("\n") + const maxLength = Math.max(beforeLines.length, afterLines.length) + let firstChangedLine: number | undefined + + for (let index = 0; index < maxLength; index += 1) { + if ((beforeLines[index] ?? "") !== (afterLines[index] ?? "")) { + firstChangedLine = index + 1 + break + } + } return { title: effectivePath, @@ -54,6 +65,7 @@ function buildSuccessMeta( diff: unifiedDiff, noopEdits, deduplicatedEdits, + firstChangedLine, filediff: { file: effectivePath, path: effectivePath, @@ -71,14 +83,17 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T try { const metadataContext = context as ToolContextWithMetadata const filePath = args.filePath - const { edits, delete: deleteMode, rename } = args + const { delete: deleteMode, rename } = args + + if (!deleteMode && (!args.edits || !Array.isArray(args.edits) || args.edits.length === 0)) { + return "Error: edits parameter must be a non-empty array" + } + + const edits = deleteMode ? [] : normalizeHashlineEdits(args.edits) if (deleteMode && rename) { return "Error: delete and rename cannot be used together" } - if (!deleteMode && (!edits || !Array.isArray(edits) || edits.length === 0)) { - return "Error: edits parameter must be a non-empty array" - } if (deleteMode && edits.length > 0) { return "Error: delete mode requires edits to be an empty array" } @@ -100,6 +115,15 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T const applyResult = applyHashlineEditsWithReport(oldEnvelope.content, edits) const canonicalNewContent = applyResult.content + + if (canonicalNewContent === oldEnvelope.content && !rename) { + let diagnostic = `No changes made to ${filePath}. The edits produced identical content.` + if (applyResult.noopEdits > 0) { + diagnostic += ` No-op edits: ${applyResult.noopEdits}. Re-read the file and provide content that differs from current lines.` + } + return `Error: ${diagnostic}` + } + const writeContent = restoreFileText(canonicalNewContent, oldEnvelope) await Bun.write(filePath, writeContent) @@ -110,8 +134,6 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T } const effectivePath = rename && rename !== filePath ? rename : filePath - const diff = generateHashlineDiff(oldEnvelope.content, canonicalNewContent, effectivePath) - const newHashlined = toHashlineContent(canonicalNewContent) const meta = buildSuccessMeta( effectivePath, oldEnvelope.content, @@ -129,13 +151,11 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T storeToolMetadata(context.sessionID, callID, meta) } - return `Successfully applied ${edits.length} edit(s) to ${effectivePath} -No-op edits: ${applyResult.noopEdits}, deduplicated edits: ${applyResult.deduplicatedEdits} + if (rename && rename !== filePath) { + return `Moved ${filePath} to ${rename}` + } -${diff} - -Updated file (LINE#ID:content): -${newHashlined}` + return `Updated ${effectivePath}` } catch (error) { const message = error instanceof Error ? error.message : String(error) if (message.toLowerCase().includes("hash")) { diff --git a/src/tools/hashline-edit/normalize-edits.ts b/src/tools/hashline-edit/normalize-edits.ts new file mode 100644 index 000000000..b4e49d382 --- /dev/null +++ b/src/tools/hashline-edit/normalize-edits.ts @@ -0,0 +1,142 @@ +import type { HashlineEdit } from "./types" + +export interface RawHashlineEdit { + type?: + | "set_line" + | "replace_lines" + | "insert_after" + | "insert_before" + | "insert_between" + | "replace" + | "append" + | "prepend" + line?: string + start_line?: string + end_line?: string + after_line?: string + before_line?: string + text?: string | string[] + old_text?: string + new_text?: string | string[] +} + +function firstDefined(...values: Array): string | undefined { + for (const value of values) { + if (typeof value === "string" && value.trim() !== "") return value + } + return undefined +} + +function requireText(edit: RawHashlineEdit, index: number): string | string[] { + const text = edit.text ?? edit.new_text + if (text === undefined) { + throw new Error(`Edit ${index}: text is required for ${edit.type ?? "unknown"}`) + } + return text +} + +function requireLine(anchor: string | undefined, index: number, op: string): string { + if (!anchor) { + throw new Error(`Edit ${index}: ${op} requires at least one anchor line reference`) + } + return anchor +} + +export function normalizeHashlineEdits(rawEdits: RawHashlineEdit[]): HashlineEdit[] { + const normalized: HashlineEdit[] = [] + + for (let index = 0; index < rawEdits.length; index += 1) { + const edit = rawEdits[index] ?? {} + const type = edit.type + + switch (type) { + case "set_line": { + const anchor = firstDefined(edit.line, edit.start_line, edit.end_line, edit.after_line, edit.before_line) + normalized.push({ + type: "set_line", + line: requireLine(anchor, index, "set_line"), + text: requireText(edit, index), + }) + break + } + case "replace_lines": { + const startAnchor = firstDefined(edit.start_line, edit.line, edit.after_line) + const endAnchor = firstDefined(edit.end_line, edit.line, edit.before_line) + + if (!startAnchor && !endAnchor) { + throw new Error(`Edit ${index}: replace_lines requires start_line or end_line`) + } + + if (startAnchor && endAnchor) { + normalized.push({ + type: "replace_lines", + start_line: startAnchor, + end_line: endAnchor, + text: requireText(edit, index), + }) + } else { + normalized.push({ + type: "set_line", + line: requireLine(startAnchor ?? endAnchor, index, "replace_lines"), + text: requireText(edit, index), + }) + } + break + } + case "insert_after": { + const anchor = firstDefined(edit.line, edit.after_line, edit.end_line, edit.start_line) + normalized.push({ + type: "insert_after", + line: requireLine(anchor, index, "insert_after"), + text: requireText(edit, index), + }) + break + } + case "insert_before": { + const anchor = firstDefined(edit.line, edit.before_line, edit.start_line, edit.end_line) + normalized.push({ + type: "insert_before", + line: requireLine(anchor, index, "insert_before"), + text: requireText(edit, index), + }) + break + } + case "insert_between": { + const afterLine = firstDefined(edit.after_line, edit.line, edit.start_line) + const beforeLine = firstDefined(edit.before_line, edit.end_line, edit.line) + normalized.push({ + type: "insert_between", + after_line: requireLine(afterLine, index, "insert_between.after_line"), + before_line: requireLine(beforeLine, index, "insert_between.before_line"), + text: requireText(edit, index), + }) + break + } + case "replace": { + const oldText = edit.old_text + const newText = edit.new_text ?? edit.text + if (!oldText) { + throw new Error(`Edit ${index}: replace requires old_text`) + } + if (newText === undefined) { + throw new Error(`Edit ${index}: replace requires new_text or text`) + } + normalized.push({ type: "replace", old_text: oldText, new_text: newText }) + break + } + case "append": { + normalized.push({ type: "append", text: requireText(edit, index) }) + break + } + case "prepend": { + normalized.push({ type: "prepend", text: requireText(edit, index) }) + break + } + default: { + throw new Error(`Edit ${index}: unsupported type "${String(type)}"`) + } + } + } + + return normalized +} diff --git a/src/tools/hashline-edit/tools.ts b/src/tools/hashline-edit/tools.ts index dcb718cb5..98a258949 100644 --- a/src/tools/hashline-edit/tools.ts +++ b/src/tools/hashline-edit/tools.ts @@ -1,11 +1,11 @@ import { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin/tool" -import type { HashlineEdit } from "./types" import { executeHashlineEditTool } from "./hashline-edit-executor" import { HASHLINE_EDIT_DESCRIPTION } from "./tool-description" +import type { RawHashlineEdit } from "./normalize-edits" interface HashlineEditArgs { filePath: string - edits: HashlineEdit[] + edits: RawHashlineEdit[] delete?: boolean rename?: string } @@ -19,64 +19,34 @@ export function createHashlineEditTool(): ToolDefinition { rename: tool.schema.string().optional().describe("Rename output file path after edits"), 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#ID format"), - text: tool.schema - .union([tool.schema.string(), tool.schema.array(tool.schema.string())]) - .describe("New content for the line (string or string[] for multiline)"), - }), - tool.schema.object({ - type: tool.schema.literal("replace_lines"), - start_line: tool.schema.string().describe("Start line in LINE#ID format"), - end_line: tool.schema.string().describe("End line in LINE#ID format"), - text: tool.schema - .union([tool.schema.string(), tool.schema.array(tool.schema.string())]) - .describe("New content to replace the range (string or string[] for multiline)"), - }), - tool.schema.object({ - type: tool.schema.literal("insert_after"), - 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 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({ - type: tool.schema.literal("replace"), - old_text: tool.schema.string().describe("Text to find"), - new_text: tool.schema - .union([tool.schema.string(), tool.schema.array(tool.schema.string())]) - .describe("Replacement text (string or string[] for multiline)"), - }), - tool.schema.object({ - type: tool.schema.literal("append"), - text: tool.schema - .union([tool.schema.string(), tool.schema.array(tool.schema.string())]) - .describe("Content to append at EOF; also creates missing file"), - }), - tool.schema.object({ - type: tool.schema.literal("prepend"), - text: tool.schema - .union([tool.schema.string(), tool.schema.array(tool.schema.string())]) - .describe("Content to prepend at BOF; also creates missing file"), - }), - ]) + tool.schema.object({ + type: tool.schema + .union([ + tool.schema.literal("set_line"), + tool.schema.literal("replace_lines"), + tool.schema.literal("insert_after"), + tool.schema.literal("insert_before"), + tool.schema.literal("insert_between"), + tool.schema.literal("replace"), + tool.schema.literal("append"), + tool.schema.literal("prepend"), + ]) + .describe("Edit operation type"), + line: tool.schema.string().optional().describe("Anchor line in LINE#ID format"), + start_line: tool.schema.string().optional().describe("Range start in LINE#ID format"), + end_line: tool.schema.string().optional().describe("Range end in LINE#ID format"), + after_line: tool.schema.string().optional().describe("Insert boundary (after) in LINE#ID format"), + before_line: tool.schema.string().optional().describe("Insert boundary (before) in LINE#ID format"), + text: tool.schema + .union([tool.schema.string(), tool.schema.array(tool.schema.string())]) + .optional() + .describe("Operation content"), + old_text: tool.schema.string().optional().describe("Legacy text replacement source"), + new_text: tool.schema + .union([tool.schema.string(), tool.schema.array(tool.schema.string())]) + .optional() + .describe("Legacy text replacement target"), + }) ) .describe("Array of edit operations to apply (empty when delete=true)"), },