/** * edit — built-in tool to edit a file by exact-text replacement (ARCHITECTURE §4.1). * * Replaces a single, unambiguous occurrence of `oldText` with `newText` in an * existing file (resolved relative to cwd). Does NOT create files or directories — * use `write` for that. Emits a unified-diff style `DiffDescriptor` via the tool's * `render` field and `onResult` hook so clients can show the change without per-client * adapters. * - Empty `oldText`, zero matches, or multiple matches each produce an `isError` * result — the tool never throws across the seam. */ import { Type, type Static } from '@sinclair/typebox'; import { Value } from '@sinclair/typebox/value'; import { resolve } from 'node:path'; import type { Tool, ToolContext, ToolResult } from '@/contracts/tool'; import type { Hunk, RenderDescriptor } from '@/contracts/render-descriptor'; /** Lines of context shown around the changed region in the diff hunk. */ const DEFAULT_DIFF_CONTEXT = 3; // ── Input schema ────────────────────────────────────────────────────────────── export const EditInputSchema = Type.Object({ path: Type.String({ description: 'File path, resolved relative to cwd' }), oldText: Type.String({ description: 'Exact text to find. Must match exactly one occurrence.', }), newText: Type.String({ description: 'Replacement text' }), }); export type EditInput = Static; // ── Diff hunk computation ───────────────────────────────────────────────────── /** * Compute a single unified-diff `Hunk` covering the contiguous changed region * between `before` and `after`, with `context` lines of surrounding context. * * Assumes exactly one localized replacement (which `edit` performs). Lines are * the full file text split on `\n`, so a trailing newline yields a trailing `''` * element — this is preserved for fidelity. */ export function computeDiffHunk( before: string, after: string, context = DEFAULT_DIFF_CONTEXT, ): Hunk { const oldLines = before.split('\n'); const newLines = after.split('\n'); // Common prefix (whole-line equality). const maxPrefix = Math.min(oldLines.length, newLines.length); let prefix = 0; while (prefix < maxPrefix && oldLines[prefix] === newLines[prefix]) { prefix++; } // Common suffix (whole-line equality), not overlapping the prefix. const maxSuffix = Math.min(oldLines.length - prefix, newLines.length - prefix); let suffix = 0; while ( suffix < maxSuffix && oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix] ) { suffix++; } // Changed region (0-indexed, inclusive) in both arrays. const firstDiffOld = prefix; const lastDiffOld = oldLines.length - 1 - suffix; const firstDiffNew = prefix; const lastDiffNew = newLines.length - 1 - suffix; // Hunk bounds with context, clamped to file bounds. const hunkStartOld = Math.max(0, firstDiffOld - context); const hunkEndOld = Math.min(oldLines.length - 1, lastDiffOld + context); const hunkStartNew = Math.max(0, firstDiffNew - context); const hunkEndNew = Math.min(newLines.length - 1, lastDiffNew + context); const lines: string[] = []; // Context before the change (identical in both; read from old). for (let i = hunkStartOld; i < firstDiffOld; i++) { lines.push(` ${oldLines[i]}`); } // Removed lines. for (let i = firstDiffOld; i <= lastDiffOld; i++) { lines.push(`-${oldLines[i]}`); } // Added lines. for (let i = firstDiffNew; i <= lastDiffNew; i++) { lines.push(`+${newLines[i]}`); } // Context after the change (identical in both; read from new). for (let i = lastDiffNew + 1; i <= hunkEndNew; i++) { lines.push(` ${newLines[i]}`); } return { oldStart: hunkStartOld + 1, // 1-indexed oldLines: hunkEndOld - hunkStartOld + 1, newStart: hunkStartNew + 1, // 1-indexed newLines: hunkEndNew - hunkStartNew + 1, lines, }; } // ── Tool definition ─────────────────────────────────────────────────────────── export const editTool: Tool = { name: 'edit', description: 'Edit an existing file by exact-text replacement. Finds the single occurrence of ' + '`oldText` and replaces it with `newText`. Fails if oldText is empty, not found, or ' + 'matches more than once (ambiguous — include more surrounding context). ' + 'Does not create files; use `write` for new files.', inputSchema: EditInputSchema, isReadOnly: false, async execute(input: EditInput, ctx: ToolContext): Promise { // Validate input against schema if (!Value.Check(EditInputSchema, input)) { return { content: { kind: 'error', message: `Invalid input for edit tool: ${JSON.stringify(input)}`, }, isError: true, }; } const filePath = resolve(ctx.cwd, input.path); // Reject empty oldText — it is ambiguous and a no-op. if (input.oldText.length === 0) { return { content: { kind: 'error', message: `edit ${input.path}: oldText must not be empty`, }, isError: true, }; } try { const file = Bun.file(filePath); const exists = await file.exists(); if (!exists) { return { content: { kind: 'error', message: `edit ${input.path}: file not found (${filePath}) — use the write tool to create files`, }, isError: true, }; } const before = await file.text(); // Find occurrences of oldText. Use indexOf from successive offsets. const first = before.indexOf(input.oldText); if (first === -1) { return { content: { kind: 'error', message: `edit ${input.path}: oldText not found`, }, isError: true, }; } const second = before.indexOf(input.oldText, first + 1); if (second !== -1) { return { content: { kind: 'error', message: `edit ${input.path}: oldText matches more than once (ambiguous) — include more surrounding context so the match is unique`, }, isError: true, }; } // Exactly one match — perform the replacement. const after = before.slice(0, first) + input.newText + before.slice(first + input.oldText.length); await Bun.write(filePath, after); const hunk = computeDiffHunk(before, after); return { content: { kind: 'text', text: `edited ${input.path}`, }, isError: false, render: { kind: 'diff', path: input.path, hunks: [hunk] }, // duoh76.6: absolute path for the FileMutationNotifier. Error/early-return paths // don't set this (no mutation happened). mutatedFiles: [filePath], }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { content: { kind: 'error', message: `Failed to edit ${filePath}: ${message}` }, isError: true, }; } }, onStart(input: EditInput): RenderDescriptor { return { kind: 'status', label: `editing ${input.path}`, tone: 'info' }; }, onResult(input: EditInput, result: ToolResult): RenderDescriptor { if (result.isError) { const content = result.content as { kind: 'error'; message: string }; return { kind: 'status', label: `error editing ${input.path}: ${content.message}`, tone: 'error', }; } // Prefer the diff computed at execute time; fall back to a plain status. if (result.render?.kind === 'diff') { return result.render; } return { kind: 'status', label: `edited ${input.path}`, tone: 'info' }; }, };