diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1d7b08e45..47f8ce4ea 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,7 +1,6 @@ # Changelog ## [Unreleased] - ### Added - Added comprehensive apply-patch mode for edit tool with support for create, update, delete, and rename operations @@ -11,6 +10,12 @@ ### Changed +- Refactored edit tool implementation with modular patch architecture +- Moved edit tool implementation from `edit/` to `patch/` module +- Updated import paths for EditToolDetails across core modules +- Enhanced patch parsing with support for unified diff format and Codex-style patches +- Improved fuzzy matching algorithms for more robust text location +- Added comprehensive regression tests for patch application behaviors - Improved edit tool architecture with modular diff and apply-patch implementations - Enhanced MCP connection handling with waitForConnection for better reliability - Improved MCP startup by falling back to cached tool definitions after a short wait while connections complete in the background diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 8113e6f48..28919a3fe 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -30,7 +30,7 @@ import type { } from "../session-manager"; import type { BashToolDetails, FindToolDetails, GrepToolDetails, LsToolDetails, ReadToolDetails } from "../tools"; import type { BashOperations } from "../tools/bash"; -import type { EditToolDetails } from "../tools/edit"; +import type { EditToolDetails } from "../tools/patch"; export type { ExecOptions, ExecResult } from "../exec"; export type { AgentToolResult, AgentToolUpdateCallback }; diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 6dab39d1f..2e2241437 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -21,9 +21,8 @@ import type { SessionEntry, SessionManager, } from "../session-manager"; - -import type { EditToolDetails } from "../tools/edit"; import type { BashToolDetails, FindToolDetails, GrepToolDetails, LsToolDetails, ReadToolDetails } from "../tools/index"; +import type { EditToolDetails } from "../tools/patch"; // Re-export for backward compatibility export type { ExecOptions, ExecResult } from "../exec"; diff --git a/packages/coding-agent/src/core/tools/edit/apply-patch.ts b/packages/coding-agent/src/core/tools/edit/apply-patch.ts deleted file mode 100644 index 9700583c5..000000000 --- a/packages/coding-agent/src/core/tools/edit/apply-patch.ts +++ /dev/null @@ -1,534 +0,0 @@ -/** - * Apply-patch implementation for the edit tool. - * - * Simplified format with explicit operation type and path as parameters. - * The diff body contains either: - * - Full file content (for create) - * - Hunks with @@ markers, context lines, +/- lines (for update) - */ - -import { mkdirSync, unlinkSync } from "node:fs"; -import { dirname } from "node:path"; -import { adjustNewTextIndentation, DEFAULT_FUZZY_THRESHOLD, findEditMatch, normalizeToLF } from "./diff"; -import { seekSequence } from "./seek-sequence"; - -// ═══════════════════════════════════════════════════════════════════════════ -// File System Abstraction -// ═══════════════════════════════════════════════════════════════════════════ - -/** Abstraction for file system operations to support LSP writethrough */ -export interface FileSystem { - /** Check if a file exists */ - exists(path: string): Promise; - /** Read file contents */ - read(path: string): Promise; - /** Write file contents (may include LSP formatting/diagnostics) */ - write(path: string, content: string): Promise; - /** Delete a file */ - delete(path: string): Promise; - /** Create directory (recursive) */ - mkdir(path: string): Promise; -} - -/** Default filesystem implementation using Bun APIs */ -export const defaultFileSystem: FileSystem = { - async exists(path: string): Promise { - return Bun.file(path).exists(); - }, - async read(path: string): Promise { - return Bun.file(path).text(); - }, - async write(path: string, content: string): Promise { - await Bun.write(path, content); - }, - async delete(path: string): Promise { - unlinkSync(path); - }, - async mkdir(path: string): Promise { - mkdirSync(path, { recursive: true }); - }, -}; - -// ═══════════════════════════════════════════════════════════════════════════ -// Error Types -// ═══════════════════════════════════════════════════════════════════════════ - -export class ParseError extends Error { - constructor( - message: string, - public readonly lineNumber?: number, - ) { - super(lineNumber !== undefined ? `Line ${lineNumber}: ${message}` : message); - this.name = "ParseError"; - } -} - -export class ApplyPatchError extends Error { - constructor(message: string) { - super(message); - this.name = "ApplyPatchError"; - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Types -// ═══════════════════════════════════════════════════════════════════════════ - -export interface UpdateChunk { - /** Single line of context to narrow down position (e.g., class/method definition) */ - changeContext?: string; - /** True if the chunk contains context lines (space-prefixed) */ - hasContextLines: boolean; - /** Contiguous block of lines to be replaced */ - oldLines: string[]; - /** Lines to replace oldLines with */ - newLines: string[]; - /** If true, oldLines must occur at end of file */ - isEndOfFile: boolean; -} - -export type Operation = "create" | "delete" | "update"; - -export interface PatchInput { - /** File path (relative or absolute) */ - path: string; - /** Operation type */ - operation: Operation; - /** New path for rename (update only) */ - moveTo?: string; - /** File content (create) or diff hunks (update) */ - diff?: string; -} - -export interface FileChange { - type: Operation; - path: string; - newPath?: string; - oldContent?: string; - newContent?: string; -} - -export interface ApplyPatchResult { - change: FileChange; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Parser Constants -// ═══════════════════════════════════════════════════════════════════════════ - -const EOF_MARKER = "*** End of File"; -const CHANGE_CONTEXT_MARKER = "@@ "; -const EMPTY_CHANGE_CONTEXT_MARKER = "@@"; - -// ═══════════════════════════════════════════════════════════════════════════ -// Diff Parser (for update operations) -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * Parse diff hunks from a diff string. - */ -export function parseDiffHunks(diff: string): UpdateChunk[] { - const lines = diff.split("\n"); - const chunks: UpdateChunk[] = []; - let i = 0; - - while (i < lines.length) { - // Skip blank lines between chunks - if (lines[i].trim() === "") { - i++; - continue; - } - - const { chunk, linesConsumed } = parseOneChunk(lines.slice(i), i + 1, chunks.length === 0); - chunks.push(chunk); - i += linesConsumed; - } - - return chunks; -} - -function parseOneChunk( - lines: string[], - lineNumber: number, - allowMissingContext: boolean, -): { chunk: UpdateChunk; linesConsumed: number } { - if (lines.length === 0) { - throw new ParseError("Diff does not contain any lines", lineNumber); - } - - let changeContext: string | undefined; - let startIndex: number; - - // Check for context marker - if (lines[0] === EMPTY_CHANGE_CONTEXT_MARKER) { - changeContext = undefined; - startIndex = 1; - } else if (lines[0].startsWith(CHANGE_CONTEXT_MARKER)) { - changeContext = lines[0].slice(CHANGE_CONTEXT_MARKER.length); - startIndex = 1; - } else { - if (!allowMissingContext) { - throw new ParseError(`Expected hunk to start with @@ context marker, got: '${lines[0]}'`, lineNumber); - } - changeContext = undefined; - startIndex = 0; - } - - if (startIndex >= lines.length) { - throw new ParseError("Hunk does not contain any lines", lineNumber + 1); - } - - const chunk: UpdateChunk = { - changeContext, - hasContextLines: false, - oldLines: [], - newLines: [], - isEndOfFile: false, - }; - - let parsedLines = 0; - - for (let i = startIndex; i < lines.length; i++) { - const line = lines[i]; - - if (line === EOF_MARKER) { - if (parsedLines === 0) { - throw new ParseError("Hunk does not contain any lines", lineNumber + 1); - } - chunk.isEndOfFile = true; - parsedLines++; - break; - } - - const firstChar = line[0]; - - if (firstChar === undefined || firstChar === "") { - // Empty line - treat as context - chunk.hasContextLines = true; - chunk.oldLines.push(""); - chunk.newLines.push(""); - } else if (firstChar === " ") { - // Context line - chunk.hasContextLines = true; - chunk.oldLines.push(line.slice(1)); - chunk.newLines.push(line.slice(1)); - } else if (firstChar === "+") { - // Added line - chunk.newLines.push(line.slice(1)); - } else if (firstChar === "-") { - // Removed line - chunk.oldLines.push(line.slice(1)); - } else { - if (parsedLines === 0) { - throw new ParseError( - `Unexpected line in hunk: '${line}'. Lines must start with ' ' (context), '+' (add), or '-' (remove)`, - lineNumber + 1, - ); - } - // Assume start of next hunk - break; - } - parsedLines++; - } - - if (parsedLines === 0) { - throw new ParseError("Hunk does not contain any lines", lineNumber + startIndex); - } - - return { chunk, linesConsumed: parsedLines + startIndex }; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Applicator -// ═══════════════════════════════════════════════════════════════════════════ - -interface Replacement { - startIndex: number; - oldLen: number; - newLines: string[]; -} - -/** - * Compute replacements needed to transform originalLines using the diff chunks. - */ -function computeReplacements(originalLines: string[], path: string, chunks: UpdateChunk[]): Replacement[] { - const replacements: Replacement[] = []; - let lineIndex = 0; - - for (const chunk of chunks) { - // If chunk has a change_context, find it and adjust lineIndex - if (chunk.changeContext !== undefined) { - const idx = seekSequence(originalLines, [chunk.changeContext], lineIndex, false).index; - if (idx === undefined) { - throw new ApplyPatchError(`Failed to find context '${chunk.changeContext}' in ${path}`); - } - // If oldLines[0] matches changeContext, start search at idx (not idx+1) - // This handles the common case where @@ scope and first context line are identical - const firstOldLine = chunk.oldLines[0]; - if (firstOldLine !== undefined && firstOldLine.trim() === chunk.changeContext.trim()) { - lineIndex = idx; - } else { - lineIndex = idx + 1; - } - } - - if (chunk.oldLines.length === 0) { - // Pure addition - add at end or before final empty line - const insertionIdx = - originalLines.length > 0 && originalLines[originalLines.length - 1] === "" - ? originalLines.length - 1 - : originalLines.length; - replacements.push({ startIndex: insertionIdx, oldLen: 0, newLines: [...chunk.newLines] }); - continue; - } - - // Try to find the old lines in the file - let pattern = [...chunk.oldLines]; - let found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile).index; - let newSlice = [...chunk.newLines]; - - // Retry without trailing empty line if present - if (found === undefined && pattern.length > 0 && pattern[pattern.length - 1] === "") { - pattern = pattern.slice(0, -1); - if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") { - newSlice = newSlice.slice(0, -1); - } - found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile).index; - } - - if (found === undefined) { - throw new ApplyPatchError(`Failed to find expected lines in ${path}:\n${chunk.oldLines.join("\n")}`); - } - - replacements.push({ startIndex: found, oldLen: pattern.length, newLines: newSlice }); - lineIndex = found + pattern.length; - } - - // Sort by start index - replacements.sort((a, b) => a.startIndex - b.startIndex); - - return replacements; -} - -/** - * Apply replacements to lines, returning the modified content. - */ -function applyReplacements(lines: string[], replacements: Replacement[]): string[] { - const result = [...lines]; - - // Apply in reverse order to maintain indices - for (let i = replacements.length - 1; i >= 0; i--) { - const { startIndex, oldLen, newLines } = replacements[i]; - result.splice(startIndex, oldLen); - result.splice(startIndex, 0, ...newLines); - } - - return result; -} - -/** - * Apply a simple replacement using character-based fuzzy matching. - * Used when the diff contains only -/+ lines without context or @@ markers. - */ -function applySimpleReplace(originalContent: string, path: string, chunk: UpdateChunk): string { - const oldText = chunk.oldLines.join("\n"); - const newText = chunk.newLines.join("\n"); - - // Normalize content for matching - const normalizedContent = normalizeToLF(originalContent); - const normalizedOldText = normalizeToLF(oldText); - - // Use character-based fuzzy matching from diff.ts - const matchOutcome = findEditMatch(normalizedContent, normalizedOldText, { - allowFuzzy: true, - similarityThreshold: DEFAULT_FUZZY_THRESHOLD, - }); - - // Check for multiple exact occurrences - if (matchOutcome.occurrences && matchOutcome.occurrences > 1) { - throw new ApplyPatchError( - `Found ${matchOutcome.occurrences} occurrences of the text in ${path}. ` + - `The text must be unique. Please provide more context to make it unique.`, - ); - } - - if (!matchOutcome.match) { - const closest = matchOutcome.closest; - if (closest) { - const similarity = Math.round(closest.confidence * 100); - throw new ApplyPatchError( - `Could not find a close enough match in ${path}. ` + - `Closest match (${similarity}% similar) at line ${closest.startLine}.`, - ); - } - throw new ApplyPatchError(`Failed to find expected lines in ${path}:\n${oldText}`); - } - - // Adjust indentation to match what was actually found - const adjustedNewText = adjustNewTextIndentation(normalizedOldText, matchOutcome.match.actualText, newText); - - // Apply the replacement - const before = normalizedContent.substring(0, matchOutcome.match.startIndex); - const after = normalizedContent.substring(matchOutcome.match.startIndex + matchOutcome.match.actualText.length); - let result = before + adjustedNewText + after; - - // Ensure trailing newline - if (!result.endsWith("\n")) { - result += "\n"; - } - - return result; -} - -/** - * Apply diff chunks to file content. - */ -function applyDiffToContent(originalContent: string, path: string, chunks: UpdateChunk[]): string { - // Detect simple replace pattern: single chunk, no @@ context, no context lines, has old lines to match - if (chunks.length === 1) { - const chunk = chunks[0]; - if (chunk.changeContext === undefined && !chunk.hasContextLines && chunk.oldLines.length > 0) { - return applySimpleReplace(originalContent, path, chunk); - } - } - - let originalLines = originalContent.split("\n"); - - // Drop trailing empty element from final newline (matches diff behavior) - if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") { - originalLines = originalLines.slice(0, -1); - } - - const replacements = computeReplacements(originalLines, path, chunks); - const newLines = applyReplacements(originalLines, replacements); - - // Ensure trailing newline - if (newLines.length === 0 || newLines[newLines.length - 1] !== "") { - newLines.push(""); - } - - return newLines.join("\n"); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Public API -// ═══════════════════════════════════════════════════════════════════════════ - -export interface ApplyPatchOptions { - /** Working directory for resolving relative paths */ - cwd: string; - /** Dry run - compute changes without writing */ - dryRun?: boolean; - /** File system abstraction (defaults to Bun-based implementation) */ - fs?: FileSystem; -} - -/** - * Apply a patch operation to the filesystem. - */ -export async function applyPatch(input: PatchInput, options: ApplyPatchOptions): Promise { - const { cwd, dryRun = false, fs = defaultFileSystem } = options; - - const resolvePath = (p: string): string => (p.startsWith("/") ? p : `${cwd}/${p}`); - const absolutePath = resolvePath(input.path); - - if (input.operation === "create") { - if (!input.diff) { - throw new ApplyPatchError("Create operation requires diff (file content)"); - } - - // Ensure content ends with newline - const content = input.diff.endsWith("\n") ? input.diff : `${input.diff}\n`; - - if (!dryRun) { - const parentDir = dirname(absolutePath); - if (parentDir && parentDir !== ".") { - await fs.mkdir(parentDir); - } - await fs.write(absolutePath, content); - } - - return { - change: { - type: "create", - path: absolutePath, - newContent: content, - }, - }; - } - - if (input.operation === "delete") { - let oldContent: string | undefined; - - if (await fs.exists(absolutePath)) { - oldContent = await fs.read(absolutePath); - if (!dryRun) { - await fs.delete(absolutePath); - } - } - - return { - change: { - type: "delete", - path: absolutePath, - oldContent, - }, - }; - } - - // Update operation - if (!input.diff) { - throw new ApplyPatchError("Update operation requires diff (hunks)"); - } - - if (!(await fs.exists(absolutePath))) { - throw new ApplyPatchError(`File not found: ${input.path}`); - } - - const originalContent = await fs.read(absolutePath); - const chunks = parseDiffHunks(input.diff); - - if (chunks.length === 0) { - throw new ApplyPatchError("Diff contains no hunks"); - } - - const newContent = applyDiffToContent(originalContent, input.path, chunks); - const destPath = input.moveTo ? resolvePath(input.moveTo) : absolutePath; - - if (!dryRun) { - if (input.moveTo) { - const parentDir = dirname(destPath); - if (parentDir && parentDir !== ".") { - await fs.mkdir(parentDir); - } - await fs.write(destPath, newContent); - await fs.delete(absolutePath); - } else { - await fs.write(absolutePath, newContent); - } - } - - return { - change: { - type: "update", - path: absolutePath, - newPath: input.moveTo ? destPath : undefined, - oldContent: originalContent, - newContent, - }, - }; -} - -/** - * Preview what changes a patch would make without applying it. - */ -export async function previewPatch(input: PatchInput, options: ApplyPatchOptions): Promise { - return applyPatch(input, { ...options, dryRun: true }); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Re-exports for backwards compatibility -// ═══════════════════════════════════════════════════════════════════════════ - -// Keep these types exported for the index.ts re-exports -export type { UpdateChunk as UpdateFileChunk }; diff --git a/packages/coding-agent/src/core/tools/edit/diff.ts b/packages/coding-agent/src/core/tools/edit/diff.ts deleted file mode 100644 index ab6b8bcee..000000000 --- a/packages/coding-agent/src/core/tools/edit/diff.ts +++ /dev/null @@ -1,649 +0,0 @@ -/** - * Shared diff computation utilities for the edit tool. - * Used by both edit.ts (for execution) and tool-execution.ts (for preview rendering). - */ - -import * as Diff from "diff"; -import { resolveToCwd } from "../path-utils"; - -export function detectLineEnding(content: string): "\r\n" | "\n" { - const crlfIdx = content.indexOf("\r\n"); - const lfIdx = content.indexOf("\n"); - if (lfIdx === -1) return "\n"; - if (crlfIdx === -1) return "\n"; - return crlfIdx < lfIdx ? "\r\n" : "\n"; -} - -export function normalizeToLF(text: string): string { - return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); -} - -export function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string { - return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text; -} - -/** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */ -export function stripBom(content: string): { bom: string; text: string } { - return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content }; -} - -export const DEFAULT_FUZZY_THRESHOLD = 0.95; - -export interface EditMatch { - actualText: string; - startIndex: number; - startLine: number; - confidence: number; -} - -export interface EditMatchOutcome { - match?: EditMatch; - closest?: EditMatch; - occurrences?: number; - fuzzyMatches?: number; -} - -function countLeadingWhitespace(line: string): number { - let count = 0; - for (let i = 0; i < line.length; i++) { - const char = line[i]; - if (char === " " || char === "\t") { - count++; - } else { - break; - } - } - return count; -} - -function getLeadingWhitespace(line: string): string { - const count = countLeadingWhitespace(line); - return line.slice(0, count); -} - -/** - * Compute the minimum indentation (in characters) of non-empty lines. - * Returns 0 if all lines are empty. - */ -function minIndentOfNonEmptyLines(text: string): number { - const lines = text.split("\n"); - let min = Infinity; - for (const line of lines) { - if (line.trim().length > 0) { - min = Math.min(min, countLeadingWhitespace(line)); - } - } - return min === Infinity ? 0 : min; -} - -/** - * Detect the indentation character used in text (space or tab). - * Prefers the character used in the first non-empty line's leading whitespace. - */ -function detectIndentChar(text: string): string { - const lines = text.split("\n"); - for (const line of lines) { - const ws = getLeadingWhitespace(line); - if (ws.length > 0) { - return ws[0]; - } - } - return " "; -} - -/** - * Adjust newText indentation to match the indentation delta between - * what was provided (oldText) and what was actually matched (actualText). - * - * If oldText has 0 indent but actualText has 12 spaces, we add 12 spaces - * to each line in newText. - */ -export function adjustNewTextIndentation(oldText: string, actualText: string, newText: string): string { - const oldMin = minIndentOfNonEmptyLines(oldText); - const actualMin = minIndentOfNonEmptyLines(actualText); - const delta = actualMin - oldMin; - - if (delta === 0) { - return newText; - } - - const indentChar = detectIndentChar(actualText); - const lines = newText.split("\n"); - - const adjusted = lines.map((line) => { - if (line.trim().length === 0) { - // Preserve empty/whitespace-only lines as-is - return line; - } - - if (delta > 0) { - // Add indentation - return indentChar.repeat(delta) + line; - } - - // Remove indentation (delta < 0) - const toRemove = Math.min(-delta, countLeadingWhitespace(line)); - return line.slice(toRemove); - }); - - return adjusted.join("\n"); -} - -function computeRelativeIndentDepths(lines: string[]): number[] { - const indents = lines.map(countLeadingWhitespace); - const nonEmptyIndents: number[] = []; - for (let i = 0; i < lines.length; i++) { - if (lines[i].trim().length > 0) { - nonEmptyIndents.push(indents[i]); - } - } - const minIndent = nonEmptyIndents.length > 0 ? Math.min(...nonEmptyIndents) : 0; - const indentSteps = nonEmptyIndents.map((indent) => indent - minIndent).filter((step) => step > 0); - const indentUnit = indentSteps.length > 0 ? Math.min(...indentSteps) : 1; - - return lines.map((line, index) => { - if (line.trim().length === 0) { - return 0; - } - if (indentUnit <= 0) { - return 0; - } - const relativeIndent = indents[index] - minIndent; - return Math.round(relativeIndent / indentUnit); - }); -} - -function normalizeFuzzyText(text: string): string { - return text - .replace(/[“”„‟«»]/g, '"') - .replace(/[‘’‚‛`´]/g, "'") - .replace(/[‐‑‒–—−]/g, "-"); -} - -function normalizeLinesForMatch(lines: string[], includeDepth = true): string[] { - const indentDepths = includeDepth ? computeRelativeIndentDepths(lines) : null; - return lines.map((line, index) => { - const trimmed = line.trim(); - const prefix = indentDepths ? `${indentDepths[index]}|` : "|"; - if (trimmed.length === 0) { - return prefix; - } - const normalized = normalizeFuzzyText(trimmed); - const collapsed = normalized.replace(/[ \t]+/g, " "); - return `${prefix}${collapsed}`; - }); -} - -function levenshteinDistance(a: string, b: string): number { - if (a === b) return 0; - const aLen = a.length; - const bLen = b.length; - if (aLen === 0) return bLen; - if (bLen === 0) return aLen; - - let prev = new Array(bLen + 1); - let curr = new Array(bLen + 1); - for (let j = 0; j <= bLen; j++) { - prev[j] = j; - } - - for (let i = 1; i <= aLen; i++) { - curr[0] = i; - const aCode = a.charCodeAt(i - 1); - for (let j = 1; j <= bLen; j++) { - const cost = aCode === b.charCodeAt(j - 1) ? 0 : 1; - const deletion = prev[j] + 1; - const insertion = curr[j - 1] + 1; - const substitution = prev[j - 1] + cost; - curr[j] = Math.min(deletion, insertion, substitution); - } - const tmp = prev; - prev = curr; - curr = tmp; - } - - return prev[bLen]; -} - -function similarityScore(a: string, b: string): number { - if (a.length === 0 && b.length === 0) { - return 1; - } - const maxLen = Math.max(a.length, b.length); - if (maxLen === 0) { - return 1; - } - const distance = levenshteinDistance(a, b); - return 1 - distance / maxLen; -} - -function computeLineOffsets(lines: string[]): number[] { - const offsets: number[] = []; - let offset = 0; - for (let i = 0; i < lines.length; i++) { - offsets.push(offset); - offset += lines[i].length; - if (i < lines.length - 1) { - offset += 1; - } - } - return offsets; -} - -function findBestFuzzyMatchCore( - contentLines: string[], - targetLines: string[], - offsets: number[], - threshold: number, - includeDepth: boolean, -): { best?: EditMatch; aboveThresholdCount: number } { - const targetNormalized = normalizeLinesForMatch(targetLines, includeDepth); - - let best: EditMatch | undefined; - let bestScore = -1; - let aboveThresholdCount = 0; - - for (let start = 0; start <= contentLines.length - targetLines.length; start++) { - const windowLines = contentLines.slice(start, start + targetLines.length); - const windowNormalized = normalizeLinesForMatch(windowLines, includeDepth); - let score = 0; - for (let i = 0; i < targetLines.length; i++) { - score += similarityScore(targetNormalized[i], windowNormalized[i]); - } - score = score / targetLines.length; - - if (score >= threshold) { - aboveThresholdCount++; - } - - if (score > bestScore) { - bestScore = score; - best = { - actualText: windowLines.join("\n"), - startIndex: offsets[start], - startLine: start + 1, - confidence: score, - }; - } - } - - return { best, aboveThresholdCount }; -} - -const FALLBACK_THRESHOLD = 0.8; - -function findBestFuzzyMatch( - content: string, - target: string, - threshold: number, -): { best?: EditMatch; aboveThresholdCount: number } { - const contentLines = content.split("\n"); - const targetLines = target.split("\n"); - if (targetLines.length === 0 || target.length === 0) { - return { aboveThresholdCount: 0 }; - } - if (targetLines.length > contentLines.length) { - return { aboveThresholdCount: 0 }; - } - - const offsets = computeLineOffsets(contentLines); - - let result = findBestFuzzyMatchCore(contentLines, targetLines, offsets, threshold, true); - - if (result.best && result.best.confidence < threshold && result.best.confidence >= FALLBACK_THRESHOLD) { - const noDepthResult = findBestFuzzyMatchCore(contentLines, targetLines, offsets, threshold, false); - if (noDepthResult.best && noDepthResult.best.confidence > result.best.confidence) { - result = noDepthResult; - } - } - - return result; -} - -export function findEditMatch( - content: string, - target: string, - options: { allowFuzzy: boolean; similarityThreshold?: number }, -): EditMatchOutcome { - if (target.length === 0) { - return {}; - } - - const exactIndex = content.indexOf(target); - if (exactIndex !== -1) { - const occurrences = content.split(target).length - 1; - if (occurrences > 1) { - return { occurrences }; - } - const startLine = content.slice(0, exactIndex).split("\n").length; - return { - match: { - actualText: target, - startIndex: exactIndex, - startLine, - confidence: 1, - }, - }; - } - - const threshold = options.similarityThreshold ?? DEFAULT_FUZZY_THRESHOLD; - const { best, aboveThresholdCount } = findBestFuzzyMatch(content, target, threshold); - if (!best) { - return {}; - } - - if (options.allowFuzzy && best.confidence >= threshold && aboveThresholdCount === 1) { - return { match: best, closest: best }; - } - - return { closest: best, fuzzyMatches: aboveThresholdCount }; -} - -function findFirstDifferentLine(oldLines: string[], newLines: string[]): { oldLine: string; newLine: string } { - const max = Math.max(oldLines.length, newLines.length); - for (let i = 0; i < max; i++) { - const oldLine = oldLines[i] ?? ""; - const newLine = newLines[i] ?? ""; - if (oldLine !== newLine) { - return { oldLine, newLine }; - } - } - return { oldLine: oldLines[0] ?? "", newLine: newLines[0] ?? "" }; -} - -export class EditMatchError extends Error { - constructor( - public readonly path: string, - public readonly normalizedOldText: string, - public readonly closest: EditMatch | undefined, - public readonly options: { allowFuzzy: boolean; similarityThreshold: number; fuzzyMatches?: number }, - ) { - super(EditMatchError.formatMessage(path, normalizedOldText, closest, options)); - this.name = "EditMatchError"; - } - - static formatMessage( - path: string, - normalizedOldText: string, - closest: EditMatch | undefined, - options: { allowFuzzy: boolean; similarityThreshold: number; fuzzyMatches?: number }, - ): string { - if (!closest) { - return options.allowFuzzy - ? `Could not find a close enough match in ${path}.` - : `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`; - } - - const similarity = Math.round(closest.confidence * 100); - const oldLines = normalizedOldText.split("\n"); - const actualLines = closest.actualText.split("\n"); - const { oldLine, newLine } = findFirstDifferentLine(oldLines, actualLines); - const thresholdPercent = Math.round(options.similarityThreshold * 100); - - const hint = options.allowFuzzy - ? options.fuzzyMatches && options.fuzzyMatches > 1 - ? `Found ${options.fuzzyMatches} high-confidence matches. Provide more context to make it unique.` - : `Closest match was below the ${thresholdPercent}% similarity threshold.` - : "Fuzzy matching is disabled. Enable 'Edit fuzzy match' in settings to accept high-confidence matches."; - - return [ - options.allowFuzzy - ? `Could not find a close enough match in ${path}.` - : `Could not find the exact text in ${path}.`, - ``, - `Closest match (${similarity}% similar) at line ${closest.startLine}:`, - ` - ${oldLine}`, - ` + ${newLine}`, - hint, - ].join("\n"); - } -} - -/** - * Generate a unified diff string with line numbers and context. - * Returns both the diff string and the first changed line number (in the new file). - */ -export function generateDiffString( - oldContent: string, - newContent: string, - contextLines = 4, -): { diff: string; firstChangedLine: number | undefined } { - const parts = Diff.diffLines(oldContent, newContent); - const output: string[] = []; - - const oldLines = oldContent.split("\n"); - const newLines = newContent.split("\n"); - const maxLineNum = Math.max(oldLines.length, newLines.length); - const lineNumWidth = String(maxLineNum).length; - - let oldLineNum = 1; - let newLineNum = 1; - let lastWasChange = false; - let firstChangedLine: number | undefined; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - const raw = part.value.split("\n"); - if (raw[raw.length - 1] === "") { - raw.pop(); - } - - if (part.added || part.removed) { - // Capture the first changed line (in the new file) - if (firstChangedLine === undefined) { - firstChangedLine = newLineNum; - } - - // Show the change - for (const line of raw) { - if (part.added) { - const lineNum = String(newLineNum).padStart(lineNumWidth, " "); - output.push(`+${lineNum} ${line}`); - newLineNum++; - } else { - // removed - const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); - output.push(`-${lineNum} ${line}`); - oldLineNum++; - } - } - lastWasChange = true; - } else { - // Context lines - only show a few before/after changes - const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed); - - if (lastWasChange || nextPartIsChange) { - // Show context - let linesToShow = raw; - let skipStart = 0; - let skipEnd = 0; - - if (!lastWasChange) { - // Show only last N lines as leading context - skipStart = Math.max(0, raw.length - contextLines); - linesToShow = raw.slice(skipStart); - } - - if (!nextPartIsChange && linesToShow.length > contextLines) { - // Show only first N lines as trailing context - skipEnd = linesToShow.length - contextLines; - linesToShow = linesToShow.slice(0, contextLines); - } - - // Add ellipsis if we skipped lines at start - if (skipStart > 0) { - output.push(` ${"".padStart(lineNumWidth, " ")} ...`); - // Update line numbers for the skipped leading context - oldLineNum += skipStart; - newLineNum += skipStart; - } - - for (const line of linesToShow) { - const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); - output.push(` ${lineNum} ${line}`); - oldLineNum++; - newLineNum++; - } - - // Add ellipsis if we skipped lines at end - if (skipEnd > 0) { - output.push(` ${"".padStart(lineNumWidth, " ")} ...`); - // Update line numbers for the skipped trailing context - oldLineNum += skipEnd; - newLineNum += skipEnd; - } - } else { - // Skip these context lines entirely - oldLineNum += raw.length; - newLineNum += raw.length; - } - - lastWasChange = false; - } - } - - return { diff: output.join("\n"), firstChangedLine }; -} - -export interface EditDiffResult { - diff: string; - firstChangedLine: number | undefined; -} - -export interface EditDiffError { - error: string; -} - -/** - * Compute the diff for an edit operation without applying it. - * Used for preview rendering in the TUI before the tool executes. - */ -export async function computeEditDiff( - path: string, - oldText: string, - newText: string, - cwd: string, - fuzzy = true, - all = false, -): Promise { - const absolutePath = resolveToCwd(path, cwd); - - try { - // Check if file exists and is readable - const file = Bun.file(absolutePath); - try { - if (!(await file.exists())) { - return { error: `File not found: ${path}` }; - } - } catch { - return { error: `File not found: ${path}` }; - } - - // Read the file - let rawContent: string; - try { - rawContent = await file.text(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { error: message || `Unable to read ${path}` }; - } - - // Strip BOM before matching (LLM won't include invisible BOM in oldText) - const { text: content } = stripBom(rawContent); - - const normalizedContent = normalizeToLF(content); - const normalizedOldText = normalizeToLF(oldText); - const normalizedNewText = normalizeToLF(newText); - - let normalizedNewContent: string; - - if (all) { - // Replace all occurrences mode with fuzzy matching - normalizedNewContent = normalizedContent; - let replacementCount = 0; - - // First check: if exact matches exist, use simple replaceAll - const exactCount = normalizedContent.split(normalizedOldText).length - 1; - if (exactCount > 0) { - normalizedNewContent = normalizedContent.split(normalizedOldText).join(normalizedNewText); - replacementCount = exactCount; - } else { - // No exact matches - try fuzzy matching iteratively - while (true) { - const matchOutcome = findEditMatch(normalizedNewContent, normalizedOldText, { - allowFuzzy: fuzzy, - similarityThreshold: DEFAULT_FUZZY_THRESHOLD, - }); - - // In all mode, use closest match if it passes threshold (even with multiple matches) - const match = - matchOutcome.match || - (fuzzy && matchOutcome.closest && matchOutcome.closest.confidence >= DEFAULT_FUZZY_THRESHOLD - ? matchOutcome.closest - : undefined); - - if (!match) { - if (replacementCount === 0) { - return { - error: EditMatchError.formatMessage(path, normalizedOldText, matchOutcome.closest, { - allowFuzzy: fuzzy, - similarityThreshold: DEFAULT_FUZZY_THRESHOLD, - fuzzyMatches: matchOutcome.fuzzyMatches, - }), - }; - } - break; - } - - const adjustedNewText = adjustNewTextIndentation(normalizedOldText, match.actualText, normalizedNewText); - normalizedNewContent = - normalizedNewContent.substring(0, match.startIndex) + - adjustedNewText + - normalizedNewContent.substring(match.startIndex + match.actualText.length); - replacementCount++; - } - } - } else { - // Single replacement mode with fuzzy matching - const matchOutcome = findEditMatch(normalizedContent, normalizedOldText, { - allowFuzzy: fuzzy, - similarityThreshold: DEFAULT_FUZZY_THRESHOLD, - }); - - if (matchOutcome.occurrences && matchOutcome.occurrences > 1) { - return { - error: `Found ${matchOutcome.occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique, or use all: true to replace all.`, - }; - } - - if (!matchOutcome.match) { - return { - error: EditMatchError.formatMessage(path, normalizedOldText, matchOutcome.closest, { - allowFuzzy: fuzzy, - similarityThreshold: DEFAULT_FUZZY_THRESHOLD, - fuzzyMatches: matchOutcome.fuzzyMatches, - }), - }; - } - - const match = matchOutcome.match; - const adjustedNewText = adjustNewTextIndentation(normalizedOldText, match.actualText, normalizedNewText); - normalizedNewContent = - normalizedContent.substring(0, match.startIndex) + - adjustedNewText + - normalizedContent.substring(match.startIndex + match.actualText.length); - } - - // Check if it would actually change anything - if (normalizedContent === normalizedNewContent) { - return { - error: `No changes would be made to ${path}. The replacement produces identical content.`, - }; - } - - // Generate the diff - return generateDiffString(normalizedContent, normalizedNewContent); - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } -} diff --git a/packages/coding-agent/src/core/tools/edit/seek-sequence.ts b/packages/coding-agent/src/core/tools/edit/seek-sequence.ts deleted file mode 100644 index 322bc3058..000000000 --- a/packages/coding-agent/src/core/tools/edit/seek-sequence.ts +++ /dev/null @@ -1,260 +0,0 @@ -/** - * Sequence matching utilities for apply-patch. - * Port of codex-rs/apply-patch/src/seek_sequence.rs with fuzzy matching extensions. - * - * Attempts to find a sequence of pattern lines within lines beginning at or after start. - * Returns the starting index of the match or undefined if not found. Matches are attempted - * with decreasing strictness: exact match, then ignoring trailing whitespace, then ignoring - * leading and trailing whitespace, then normalizing unicode punctuation, and finally - * fuzzy line-by-line similarity matching. - * - * When eof is true, we first try starting at the end-of-file (so that patterns intended - * to match file endings are applied at the end), and fall back to searching from start if needed. - */ - -/** Result of a sequence search */ -export interface SeekSequenceResult { - /** Starting index of the match, or undefined if not found */ - index: number | undefined; - /** Confidence score (1.0 for exact match, lower for fuzzy matches) */ - confidence: number; -} - -/** - * Normalize common Unicode punctuation to ASCII equivalents. - * This allows diffs authored with plain ASCII characters to match source files - * containing typographic dashes/quotes, etc. - */ -function normalizeUnicode(s: string): string { - return s - .trim() - .split("") - .map((c) => { - const code = c.charCodeAt(0); - // Various dash/hyphen code-points → ASCII '-' - if ( - code === 0x2010 || // HYPHEN - code === 0x2011 || // NON-BREAKING HYPHEN - code === 0x2012 || // FIGURE DASH - code === 0x2013 || // EN DASH - code === 0x2014 || // EM DASH - code === 0x2015 || // HORIZONTAL BAR - code === 0x2212 // MINUS SIGN - ) { - return "-"; - } - // Fancy single quotes → ' - if ( - code === 0x2018 || // LEFT SINGLE QUOTATION MARK - code === 0x2019 || // RIGHT SINGLE QUOTATION MARK - code === 0x201a || // SINGLE LOW-9 QUOTATION MARK - code === 0x201b // SINGLE HIGH-REVERSED-9 QUOTATION MARK - ) { - return "'"; - } - // Fancy double quotes → " - if ( - code === 0x201c || // LEFT DOUBLE QUOTATION MARK - code === 0x201d || // RIGHT DOUBLE QUOTATION MARK - code === 0x201e || // DOUBLE LOW-9 QUOTATION MARK - code === 0x201f // DOUBLE HIGH-REVERSED-9 QUOTATION MARK - ) { - return '"'; - } - // Non-breaking space and other odd spaces → normal space - if ( - code === 0x00a0 || // NO-BREAK SPACE - code === 0x2002 || // EN SPACE - code === 0x2003 || // EM SPACE - code === 0x2004 || // THREE-PER-EM SPACE - code === 0x2005 || // FOUR-PER-EM SPACE - code === 0x2006 || // SIX-PER-EM SPACE - code === 0x2007 || // FIGURE SPACE - code === 0x2008 || // PUNCTUATION SPACE - code === 0x2009 || // THIN SPACE - code === 0x200a || // HAIR SPACE - code === 0x202f || // NARROW NO-BREAK SPACE - code === 0x205f || // MEDIUM MATHEMATICAL SPACE - code === 0x3000 // IDEOGRAPHIC SPACE - ) { - return " "; - } - return c; - }) - .join(""); -} - -/** - * Normalize fancy quotes and dashes to ASCII equivalents. - */ -function normalizeFuzzyText(text: string): string { - return text - .replace(/[""„‟«»]/g, '"') - .replace(/[''‚‛`´]/g, "'") - .replace(/[‐‑‒–—−]/g, "-"); -} - -/** - * Compute Levenshtein distance between two strings. - */ -function levenshteinDistance(a: string, b: string): number { - if (a === b) return 0; - const aLen = a.length; - const bLen = b.length; - if (aLen === 0) return bLen; - if (bLen === 0) return aLen; - - let prev = new Array(bLen + 1); - let curr = new Array(bLen + 1); - for (let j = 0; j <= bLen; j++) { - prev[j] = j; - } - - for (let i = 1; i <= aLen; i++) { - curr[0] = i; - const aCode = a.charCodeAt(i - 1); - for (let j = 1; j <= bLen; j++) { - const cost = aCode === b.charCodeAt(j - 1) ? 0 : 1; - const deletion = prev[j] + 1; - const insertion = curr[j - 1] + 1; - const substitution = prev[j - 1] + cost; - curr[j] = Math.min(deletion, insertion, substitution); - } - const tmp = prev; - prev = curr; - curr = tmp; - } - - return prev[bLen]; -} - -/** - * Compute similarity score between two strings (0 to 1). - */ -function similarityScore(a: string, b: string): number { - if (a.length === 0 && b.length === 0) return 1; - const maxLen = Math.max(a.length, b.length); - if (maxLen === 0) return 1; - const distance = levenshteinDistance(a, b); - return 1 - distance / maxLen; -} - -/** - * Normalize a line for fuzzy matching: trim, collapse whitespace, normalize quotes/dashes. - */ -function normalizeLineForFuzzy(line: string): string { - const trimmed = line.trim(); - if (trimmed.length === 0) return ""; - const normalized = normalizeFuzzyText(trimmed); - return normalized.replace(/[ \t]+/g, " "); -} - -/** Fuzzy matching threshold - must exceed this to be considered a match */ -const FUZZY_THRESHOLD = 0.92; - -/** - * Check if pattern matches lines starting at index i using the given comparison function. - */ -function matchesAt(lines: string[], pattern: string[], i: number, compare: (a: string, b: string) => boolean): boolean { - for (let j = 0; j < pattern.length; j++) { - if (!compare(lines[i + j], pattern[j])) { - return false; - } - } - return true; -} - -/** - * Compute average similarity score for pattern at position i. - */ -function fuzzyScoreAt(lines: string[], pattern: string[], i: number): number { - let totalScore = 0; - for (let j = 0; j < pattern.length; j++) { - const lineNorm = normalizeLineForFuzzy(lines[i + j]); - const patternNorm = normalizeLineForFuzzy(pattern[j]); - totalScore += similarityScore(lineNorm, patternNorm); - } - return totalScore / pattern.length; -} - -/** - * Attempt to find the sequence of pattern lines within lines beginning at or after start. - * Returns the starting index and confidence of the match, or undefined index if not found. - * - * @param lines - The lines of the file content - * @param pattern - The lines to search for - * @param start - Starting index for the search - * @param eof - If true, prefer matching at end of file first - */ -export function seekSequence(lines: string[], pattern: string[], start: number, eof: boolean): SeekSequenceResult { - // Empty pattern matches immediately - if (pattern.length === 0) { - return { index: start, confidence: 1.0 }; - } - - // Pattern longer than available input cannot match - if (pattern.length > lines.length) { - return { index: undefined, confidence: 0 }; - } - - // Determine search start position - const searchStart = eof && lines.length >= pattern.length ? lines.length - pattern.length : start; - const maxStart = lines.length - pattern.length; - - // Pass 1: Exact match - for (let i = searchStart; i <= maxStart; i++) { - if (matchesAt(lines, pattern, i, (a, b) => a === b)) { - return { index: i, confidence: 1.0 }; - } - } - - // Pass 2: Trailing whitespace stripped - for (let i = searchStart; i <= maxStart; i++) { - if (matchesAt(lines, pattern, i, (a, b) => a.trimEnd() === b.trimEnd())) { - return { index: i, confidence: 0.99 }; - } - } - - // Pass 3: Both leading and trailing whitespace stripped - for (let i = searchStart; i <= maxStart; i++) { - if (matchesAt(lines, pattern, i, (a, b) => a.trim() === b.trim())) { - return { index: i, confidence: 0.98 }; - } - } - - // Pass 4: Normalize unicode punctuation - for (let i = searchStart; i <= maxStart; i++) { - if (matchesAt(lines, pattern, i, (a, b) => normalizeUnicode(a) === normalizeUnicode(b))) { - return { index: i, confidence: 0.97 }; - } - } - - // Pass 5: Fuzzy matching - find best match above threshold - let bestIndex: number | undefined; - let bestScore = 0; - - for (let i = searchStart; i <= maxStart; i++) { - const score = fuzzyScoreAt(lines, pattern, i); - if (score > bestScore) { - bestScore = score; - bestIndex = i; - } - } - - // Also search from start if eof mode started from end - if (eof && searchStart > start) { - for (let i = start; i < searchStart; i++) { - const score = fuzzyScoreAt(lines, pattern, i); - if (score > bestScore) { - bestScore = score; - bestIndex = i; - } - } - } - - if (bestIndex !== undefined && bestScore >= FUZZY_THRESHOLD) { - return { index: bestIndex, confidence: bestScore }; - } - - return { index: undefined, confidence: bestScore }; -} diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index e70c2e74e..401e71ffa 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -2,7 +2,6 @@ export { type AskToolDetails, askTool, createAskTool } from "./ask"; export { type BashOperations, type BashToolDetails, type BashToolOptions, createBashTool } from "./bash"; export { type CalculatorToolDetails, createCalculatorTool } from "./calculator"; export { createCompleteTool } from "./complete"; -export { createEditTool, type EditToolDetails } from "./edit"; // Exa MCP tools (22 tools) export { exaTools } from "./exa/index"; export type { ExaRenderDetails, ExaSearchResponse, ExaSearchResult } from "./exa/types"; @@ -24,6 +23,7 @@ export { } from "./lsp/index"; export { createNotebookTool, type NotebookToolDetails } from "./notebook"; export { createOutputTool, type OutputToolDetails } from "./output"; +export { createEditTool, type EditToolDetails } from "./patch"; export { createPythonTool, type PythonToolDetails } from "./python"; export { createReadTool, type ReadToolDetails } from "./read"; export { reportFindingTool, type SubmitReviewDetails } from "./review"; @@ -72,7 +72,6 @@ import { createAskTool } from "./ask"; import { createBashTool } from "./bash"; import { createCalculatorTool } from "./calculator"; import { createCompleteTool } from "./complete"; -import { createEditTool } from "./edit"; import { createFindTool } from "./find"; import { createGitTool } from "./git"; import { createGrepTool } from "./grep"; @@ -80,6 +79,7 @@ import { createLsTool } from "./ls"; import { createLspTool } from "./lsp/index"; import { createNotebookTool } from "./notebook"; import { createOutputTool } from "./output"; +import { createEditTool } from "./patch"; import { createPythonTool } from "./python"; import { createReadTool } from "./read"; import { reportFindingTool } from "./review"; diff --git a/packages/coding-agent/src/core/tools/patch/applicator.ts b/packages/coding-agent/src/core/tools/patch/applicator.ts new file mode 100644 index 000000000..ae19168f9 --- /dev/null +++ b/packages/coding-agent/src/core/tools/patch/applicator.ts @@ -0,0 +1,443 @@ +/** + * Patch application logic for the edit tool. + * + * Applies parsed diff hunks to file content using fuzzy matching + * for robust handling of whitespace and formatting differences. + */ + +import { mkdirSync, unlinkSync } from "node:fs"; +import { dirname } from "node:path"; +import { DEFAULT_FUZZY_THRESHOLD, findContextLine, findMatch, seekSequence } from "./fuzzy"; +import { adjustIndentation, countLeadingWhitespace, getLeadingWhitespace, normalizeToLF } from "./normalize"; +import { normalizeCreateContent, parseHunks } from "./parser"; +import type { ApplyPatchOptions, ApplyPatchResult, DiffHunk, FileSystem, PatchInput } from "./types"; +import { ApplyPatchError } from "./types"; + +// ═══════════════════════════════════════════════════════════════════════════ +// Default File System +// ═══════════════════════════════════════════════════════════════════════════ + +/** Default filesystem implementation using Bun APIs */ +export const defaultFileSystem: FileSystem = { + async exists(path: string): Promise { + return Bun.file(path).exists(); + }, + async read(path: string): Promise { + return Bun.file(path).text(); + }, + async write(path: string, content: string): Promise { + await Bun.write(path, content); + }, + async delete(path: string): Promise { + unlinkSync(path); + }, + async mkdir(path: string): Promise { + mkdirSync(path, { recursive: true }); + }, +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// Internal Types +// ═══════════════════════════════════════════════════════════════════════════ + +interface Replacement { + startIndex: number; + oldLen: number; + newLines: string[]; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Replacement Computation +// ═══════════════════════════════════════════════════════════════════════════ + +/** Adjust indentation of newLines to match the delta between patternLines and actualLines */ +function adjustLinesIndentation(patternLines: string[], actualLines: string[], newLines: string[]): string[] { + if (patternLines.length === 0 || actualLines.length === 0 || newLines.length === 0) { + return newLines; + } + + let patternMin = Infinity; + for (const line of patternLines) { + if (line.trim().length > 0) { + patternMin = Math.min(patternMin, countLeadingWhitespace(line)); + } + } + if (patternMin === Infinity) patternMin = 0; + + let actualMin = Infinity; + for (const line of actualLines) { + if (line.trim().length > 0) { + actualMin = Math.min(actualMin, countLeadingWhitespace(line)); + } + } + if (actualMin === Infinity) actualMin = 0; + + const delta = actualMin - patternMin; + if (delta === 0) { + return newLines; + } + + let indentChar = " "; + for (const line of actualLines) { + const ws = getLeadingWhitespace(line); + if (ws.length > 0) { + indentChar = ws[0]; + break; + } + } + + return newLines.map((line) => { + if (line.trim().length === 0) { + return line; + } + if (delta > 0) { + return indentChar.repeat(delta) + line; + } + const toRemove = Math.min(-delta, countLeadingWhitespace(line)); + return line.slice(toRemove); + }); +} + +/** Get hint index from hunk's line number */ +function getHunkHintIndex(hunk: DiffHunk, currentIndex: number): number | undefined { + if (hunk.oldStartLine === undefined) return undefined; + const hintIndex = Math.max(0, hunk.oldStartLine - 1); + return hintIndex >= currentIndex ? hintIndex : undefined; +} + +/** Find sequence with optional hint position */ +function findSequenceWithHint( + lines: string[], + pattern: string[], + currentIndex: number, + hintIndex: number | undefined, + eof: boolean, +): number | undefined { + const primaryStart = hintIndex ?? currentIndex; + let found = seekSequence(lines, pattern, primaryStart, eof).index; + + // Retry from currentIndex if hint failed + if (found === undefined && hintIndex !== undefined && hintIndex !== currentIndex) { + found = seekSequence(lines, pattern, currentIndex, eof).index; + } + + return found; +} + +/** + * Apply a hunk using character-based fuzzy matching. + * Used when the hunk contains only -/+ lines without context. + */ +function applyCharacterMatch(originalContent: string, path: string, hunk: DiffHunk): string { + const oldText = hunk.oldLines.join("\n"); + const newText = hunk.newLines.join("\n"); + + const normalizedContent = normalizeToLF(originalContent); + const normalizedOldText = normalizeToLF(oldText); + + const matchOutcome = findMatch(normalizedContent, normalizedOldText, { + allowFuzzy: true, + threshold: DEFAULT_FUZZY_THRESHOLD, + }); + + // Check for multiple exact occurrences + if (matchOutcome.occurrences && matchOutcome.occurrences > 1) { + throw new ApplyPatchError( + `Found ${matchOutcome.occurrences} occurrences of the text in ${path}. ` + + `The text must be unique. Please provide more context to make it unique.`, + ); + } + + if (!matchOutcome.match) { + const closest = matchOutcome.closest; + if (closest) { + const similarity = Math.round(closest.confidence * 100); + throw new ApplyPatchError( + `Could not find a close enough match in ${path}. ` + + `Closest match (${similarity}% similar) at line ${closest.startLine}.`, + ); + } + throw new ApplyPatchError(`Failed to find expected lines in ${path}:\n${oldText}`); + } + + // Adjust indentation to match what was actually found + const adjustedNewText = adjustIndentation(normalizedOldText, matchOutcome.match.actualText, newText); + + // Apply the replacement + const before = normalizedContent.substring(0, matchOutcome.match.startIndex); + const after = normalizedContent.substring(matchOutcome.match.startIndex + matchOutcome.match.actualText.length); + let result = before + adjustedNewText + after; + + // Ensure trailing newline + if (!result.endsWith("\n")) { + result += "\n"; + } + + return result; +} + +/** + * Compute replacements needed to transform originalLines using the diff hunks. + */ +function computeReplacements(originalLines: string[], path: string, hunks: DiffHunk[]): Replacement[] { + const replacements: Replacement[] = []; + let lineIndex = 0; + + for (const hunk of hunks) { + const _hintIndex = getHunkHintIndex(hunk, lineIndex); + + // Use line number hints if available from unified diff format + const lineHint = hunk.oldStartLine; + if (lineHint !== undefined && hunk.changeContext === undefined) { + lineIndex = Math.max(0, Math.min(lineHint - 1, originalLines.length - 1)); + } + + // If hunk has a changeContext, find it and adjust lineIndex + if (hunk.changeContext !== undefined) { + // Use findContextLine for robust matching with substring/fuzzy fallback + const searchStart = lineHint !== undefined ? Math.max(0, lineHint - 1) : lineIndex; + const result = findContextLine(originalLines, hunk.changeContext, searchStart); + + // If hint-based search failed and hint was different from lineIndex, try from lineIndex + let idx = result.index; + if (idx === undefined && lineHint !== undefined && searchStart !== lineIndex) { + const fallbackResult = findContextLine(originalLines, hunk.changeContext, lineIndex); + idx = fallbackResult.index; + } + + if (idx === undefined) { + throw new ApplyPatchError(`Failed to find context '${hunk.changeContext}' in ${path}`); + } + + // If oldLines[0] matches changeContext, start search at idx (not idx+1) + // This handles the common case where @@ scope and first context line are identical + const firstOldLine = hunk.oldLines[0]; + if (firstOldLine !== undefined && firstOldLine.trim() === hunk.changeContext.trim()) { + lineIndex = idx; + } else { + lineIndex = idx + 1; + } + } + + if (hunk.oldLines.length === 0) { + // Pure addition - use line hint (oldStartLine or newStartLine) or append at end + const lineHintForInsertion = hunk.oldStartLine ?? hunk.newStartLine; + const insertionIdx = + lineHintForInsertion !== undefined + ? Math.max(0, Math.min(lineHintForInsertion - 1, originalLines.length)) + : originalLines.length > 0 && originalLines[originalLines.length - 1] === "" + ? originalLines.length - 1 + : originalLines.length; + + replacements.push({ startIndex: insertionIdx, oldLen: 0, newLines: [...hunk.newLines] }); + continue; + } + + // Try to find the old lines in the file + let pattern = [...hunk.oldLines]; + const matchHint = getHunkHintIndex(hunk, lineIndex); + let found = findSequenceWithHint(originalLines, pattern, lineIndex, matchHint, hunk.isEndOfFile); + let newSlice = [...hunk.newLines]; + + // Retry without trailing empty line if present + if (found === undefined && pattern.length > 0 && pattern[pattern.length - 1] === "") { + pattern = pattern.slice(0, -1); + if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") { + newSlice = newSlice.slice(0, -1); + } + found = findSequenceWithHint(originalLines, pattern, lineIndex, matchHint, hunk.isEndOfFile); + } + + if (found === undefined) { + throw new ApplyPatchError(`Failed to find expected lines in ${path}:\n${hunk.oldLines.join("\n")}`); + } + + // For simple diffs (no context marker, no context lines), check for multiple occurrences + // This ensures ambiguous replacements are rejected + if (hunk.changeContext === undefined && !hunk.hasContextLines) { + const secondMatch = seekSequence(originalLines, pattern, found + 1, false); + if (secondMatch.index !== undefined) { + throw new ApplyPatchError( + `Found 2 occurrences of the text in ${path}. ` + + `The text must be unique. Please provide more context to make it unique.`, + ); + } + } + + // Adjust indentation if needed (handles fuzzy matches where indentation differs) + const actualMatchedLines = originalLines.slice(found, found + pattern.length); + const adjustedNewLines = adjustLinesIndentation(pattern, actualMatchedLines, newSlice); + + replacements.push({ startIndex: found, oldLen: pattern.length, newLines: adjustedNewLines }); + lineIndex = found + pattern.length; + } + + // Sort by start index + replacements.sort((a, b) => a.startIndex - b.startIndex); + + return replacements; +} + +/** + * Apply replacements to lines, returning the modified content. + */ +function applyReplacements(lines: string[], replacements: Replacement[]): string[] { + const result = [...lines]; + + // Apply in reverse order to maintain indices + for (let i = replacements.length - 1; i >= 0; i--) { + const { startIndex, oldLen, newLines } = replacements[i]; + result.splice(startIndex, oldLen); + result.splice(startIndex, 0, ...newLines); + } + + return result; +} + +/** + * Apply diff hunks to file content. + */ +function applyHunksToContent(originalContent: string, path: string, hunks: DiffHunk[]): string { + // Detect simple replace pattern: single hunk, no @@ context, no context lines, has old lines to match + // Only use character-based matching when there are no hints to disambiguate + if (hunks.length === 1) { + const hunk = hunks[0]; + if ( + hunk.changeContext === undefined && + !hunk.hasContextLines && + hunk.oldLines.length > 0 && + hunk.oldStartLine === undefined && // No line hint to use for positioning + !hunk.isEndOfFile // No EOF targeting (prefer end of file) + ) { + return applyCharacterMatch(originalContent, path, hunk); + } + } + + let originalLines = originalContent.split("\n"); + + // Drop trailing empty element from final newline (matches diff behavior) + if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") { + originalLines = originalLines.slice(0, -1); + } + + const replacements = computeReplacements(originalLines, path, hunks); + const newLines = applyReplacements(originalLines, replacements); + + // Ensure trailing newline + if (newLines.length === 0 || newLines[newLines.length - 1] !== "") { + newLines.push(""); + } + + return newLines.join("\n"); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Public API +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Apply a patch operation to the filesystem. + */ +export async function applyPatch(input: PatchInput, options: ApplyPatchOptions): Promise { + const { cwd, dryRun = false, fs = defaultFileSystem } = options; + + const resolvePath = (p: string): string => (p.startsWith("/") ? p : `${cwd}/${p}`); + const absolutePath = resolvePath(input.path); + + // Handle CREATE operation + if (input.operation === "create") { + if (!input.diff) { + throw new ApplyPatchError("Create operation requires diff (file content)"); + } + + // Strip + prefixes if present (handles diffs formatted as additions) + const normalizedContent = normalizeCreateContent(input.diff); + // Ensure content ends with newline + const content = normalizedContent.endsWith("\n") ? normalizedContent : `${normalizedContent}\n`; + + if (!dryRun) { + const parentDir = dirname(absolutePath); + if (parentDir && parentDir !== ".") { + await fs.mkdir(parentDir); + } + await fs.write(absolutePath, content); + } + + return { + change: { + type: "create", + path: absolutePath, + newContent: content, + }, + }; + } + + // Handle DELETE operation + if (input.operation === "delete") { + let oldContent: string | undefined; + + if (await fs.exists(absolutePath)) { + oldContent = await fs.read(absolutePath); + if (!dryRun) { + await fs.delete(absolutePath); + } + } + + return { + change: { + type: "delete", + path: absolutePath, + oldContent, + }, + }; + } + + // Handle UPDATE operation + if (!input.diff) { + throw new ApplyPatchError("Update operation requires diff (hunks)"); + } + + if (!(await fs.exists(absolutePath))) { + throw new ApplyPatchError(`File not found: ${input.path}`); + } + + const originalContent = await fs.read(absolutePath); + const hunks = parseHunks(input.diff); + + if (hunks.length === 0) { + throw new ApplyPatchError("Diff contains no hunks"); + } + + const newContent = applyHunksToContent(originalContent, input.path, hunks); + const destPath = input.moveTo ? resolvePath(input.moveTo) : absolutePath; + + if (!dryRun) { + if (input.moveTo) { + const parentDir = dirname(destPath); + if (parentDir && parentDir !== ".") { + await fs.mkdir(parentDir); + } + await fs.write(destPath, newContent); + await fs.delete(absolutePath); + } else { + await fs.write(absolutePath, newContent); + } + } + + return { + change: { + type: "update", + path: absolutePath, + newPath: input.moveTo ? destPath : undefined, + oldContent: originalContent, + newContent, + }, + }; +} + +/** + * Preview what changes a patch would make without applying it. + */ +export async function previewPatch(input: PatchInput, options: ApplyPatchOptions): Promise { + return applyPatch(input, { ...options, dryRun: true }); +} diff --git a/packages/coding-agent/src/core/tools/patch/diff.ts b/packages/coding-agent/src/core/tools/patch/diff.ts new file mode 100644 index 000000000..351409334 --- /dev/null +++ b/packages/coding-agent/src/core/tools/patch/diff.ts @@ -0,0 +1,291 @@ +/** + * Diff generation and replace-mode utilities for the edit tool. + * + * Provides diff string generation and the replace-mode edit logic + * used when not in patch mode. + */ + +import * as Diff from "diff"; +import { resolveToCwd } from "../path-utils"; +import { DEFAULT_FUZZY_THRESHOLD, findMatch } from "./fuzzy"; +import { adjustIndentation, normalizeToLF, stripBom } from "./normalize"; +import type { DiffError, DiffResult } from "./types"; +import { EditMatchError } from "./types"; + +// ═══════════════════════════════════════════════════════════════════════════ +// Diff String Generation +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Generate a unified diff string with line numbers and context. + * Returns both the diff string and the first changed line number (in the new file). + */ +export function generateDiffString(oldContent: string, newContent: string, contextLines = 4): DiffResult { + const parts = Diff.diffLines(oldContent, newContent); + const output: string[] = []; + + const oldLines = oldContent.split("\n"); + const newLines = newContent.split("\n"); + const maxLineNum = Math.max(oldLines.length, newLines.length); + const lineNumWidth = String(maxLineNum).length; + + let oldLineNum = 1; + let newLineNum = 1; + let lastWasChange = false; + let firstChangedLine: number | undefined; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const raw = part.value.split("\n"); + if (raw[raw.length - 1] === "") { + raw.pop(); + } + + if (part.added || part.removed) { + // Capture the first changed line (in the new file) + if (firstChangedLine === undefined) { + firstChangedLine = newLineNum; + } + + // Show the change + for (const line of raw) { + if (part.added) { + const lineNum = String(newLineNum).padStart(lineNumWidth, " "); + output.push(`+${lineNum} ${line}`); + newLineNum++; + } else { + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(`-${lineNum} ${line}`); + oldLineNum++; + } + } + lastWasChange = true; + } else { + // Context lines - only show a few before/after changes + const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed); + + if (lastWasChange || nextPartIsChange) { + let linesToShow = raw; + let skipStart = 0; + let skipEnd = 0; + + if (!lastWasChange) { + // Show only last N lines as leading context + skipStart = Math.max(0, raw.length - contextLines); + linesToShow = raw.slice(skipStart); + } + + if (!nextPartIsChange && linesToShow.length > contextLines) { + // Show only first N lines as trailing context + skipEnd = linesToShow.length - contextLines; + linesToShow = linesToShow.slice(0, contextLines); + } + + // Add ellipsis if we skipped lines at start + if (skipStart > 0) { + output.push(` ${"".padStart(lineNumWidth, " ")} ...`); + oldLineNum += skipStart; + newLineNum += skipStart; + } + + for (const line of linesToShow) { + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(` ${lineNum} ${line}`); + oldLineNum++; + newLineNum++; + } + + // Add ellipsis if we skipped lines at end + if (skipEnd > 0) { + output.push(` ${"".padStart(lineNumWidth, " ")} ...`); + oldLineNum += skipEnd; + newLineNum += skipEnd; + } + } else { + // Skip these context lines entirely + oldLineNum += raw.length; + newLineNum += raw.length; + } + + lastWasChange = false; + } + } + + return { diff: output.join("\n"), firstChangedLine }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Replace Mode Logic +// ═══════════════════════════════════════════════════════════════════════════ + +export interface ReplaceOptions { + /** Allow fuzzy matching */ + fuzzy: boolean; + /** Replace all occurrences */ + all: boolean; + /** Similarity threshold for fuzzy matching */ + threshold?: number; +} + +export interface ReplaceResult { + /** The new content after replacements */ + content: string; + /** Number of replacements made */ + count: number; +} + +/** + * Find and replace text in content using fuzzy matching. + */ +export function replaceText(content: string, oldText: string, newText: string, options: ReplaceOptions): ReplaceResult { + const threshold = options.threshold ?? DEFAULT_FUZZY_THRESHOLD; + let normalizedContent = normalizeToLF(content); + const normalizedOldText = normalizeToLF(oldText); + const normalizedNewText = normalizeToLF(newText); + let count = 0; + + if (options.all) { + // Check for exact matches first + const exactCount = normalizedContent.split(normalizedOldText).length - 1; + if (exactCount > 0) { + return { + content: normalizedContent.split(normalizedOldText).join(normalizedNewText), + count: exactCount, + }; + } + + // No exact matches - try fuzzy matching iteratively + while (true) { + const matchOutcome = findMatch(normalizedContent, normalizedOldText, { + allowFuzzy: options.fuzzy, + threshold, + }); + + // In all mode, use closest match if it passes threshold + const match = + matchOutcome.match || + (options.fuzzy && matchOutcome.closest && matchOutcome.closest.confidence >= threshold + ? matchOutcome.closest + : undefined); + + if (!match) { + break; + } + + const adjustedNewText = adjustIndentation(normalizedOldText, match.actualText, normalizedNewText); + normalizedContent = + normalizedContent.substring(0, match.startIndex) + + adjustedNewText + + normalizedContent.substring(match.startIndex + match.actualText.length); + count++; + } + + return { content: normalizedContent, count }; + } + + // Single replacement mode + const matchOutcome = findMatch(normalizedContent, normalizedOldText, { + allowFuzzy: options.fuzzy, + threshold, + }); + + if (matchOutcome.occurrences && matchOutcome.occurrences > 1) { + throw new Error( + `Found ${matchOutcome.occurrences} occurrences of the text. ` + + `The text must be unique. Please provide more context to make it unique, or use all: true to replace all.`, + ); + } + + if (!matchOutcome.match) { + return { content: normalizedContent, count: 0 }; + } + + const match = matchOutcome.match; + const adjustedNewText = adjustIndentation(normalizedOldText, match.actualText, normalizedNewText); + normalizedContent = + normalizedContent.substring(0, match.startIndex) + + adjustedNewText + + normalizedContent.substring(match.startIndex + match.actualText.length); + + return { content: normalizedContent, count: 1 }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Preview/Diff Computation +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Compute the diff for an edit operation without applying it. + * Used for preview rendering in the TUI before the tool executes. + */ +export async function computeEditDiff( + path: string, + oldText: string, + newText: string, + cwd: string, + fuzzy = true, + all = false, +): Promise { + const absolutePath = resolveToCwd(path, cwd); + + try { + const file = Bun.file(absolutePath); + try { + if (!(await file.exists())) { + return { error: `File not found: ${path}` }; + } + } catch { + return { error: `File not found: ${path}` }; + } + + let rawContent: string; + try { + rawContent = await file.text(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { error: message || `Unable to read ${path}` }; + } + + const { text: content } = stripBom(rawContent); + const normalizedContent = normalizeToLF(content); + const normalizedOldText = normalizeToLF(oldText); + const normalizedNewText = normalizeToLF(newText); + + const result = replaceText(normalizedContent, normalizedOldText, normalizedNewText, { + fuzzy, + all, + }); + + if (result.count === 0) { + // Get closest match for error message + const matchOutcome = findMatch(normalizedContent, normalizedOldText, { + allowFuzzy: fuzzy, + threshold: DEFAULT_FUZZY_THRESHOLD, + }); + + if (matchOutcome.occurrences && matchOutcome.occurrences > 1) { + return { + error: `Found ${matchOutcome.occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique, or use all: true to replace all.`, + }; + } + + return { + error: EditMatchError.formatMessage(path, normalizedOldText, matchOutcome.closest, { + allowFuzzy: fuzzy, + threshold: DEFAULT_FUZZY_THRESHOLD, + fuzzyMatches: matchOutcome.fuzzyMatches, + }), + }; + } + + if (normalizedContent === result.content) { + return { + error: `No changes would be made to ${path}. The replacement produces identical content.`, + }; + } + + return generateDiffString(normalizedContent, result.content); + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/packages/coding-agent/src/core/tools/patch/fuzzy.ts b/packages/coding-agent/src/core/tools/patch/fuzzy.ts new file mode 100644 index 000000000..3821052ec --- /dev/null +++ b/packages/coding-agent/src/core/tools/patch/fuzzy.ts @@ -0,0 +1,484 @@ +/** + * Fuzzy matching utilities for the edit tool. + * + * Provides both character-level and line-level fuzzy matching with progressive + * fallback strategies for finding text in files. + */ + +import { countLeadingWhitespace, normalizeForFuzzy, normalizeUnicode } from "./normalize"; +import type { ContextLineResult, FuzzyMatch, MatchOutcome, SequenceSearchResult } from "./types"; + +// ═══════════════════════════════════════════════════════════════════════════ +// Constants +// ═══════════════════════════════════════════════════════════════════════════ + +/** Default similarity threshold for fuzzy matching */ +export const DEFAULT_FUZZY_THRESHOLD = 0.95; + +/** Threshold for sequence-based fuzzy matching */ +const SEQUENCE_FUZZY_THRESHOLD = 0.92; + +/** Fallback threshold for line-based matching */ +const FALLBACK_THRESHOLD = 0.8; + +/** Threshold for context line matching */ +const CONTEXT_FUZZY_THRESHOLD = 0.8; + +/** Minimum length for partial/substring matching */ +const PARTIAL_MATCH_MIN_LENGTH = 6; + +/** Minimum ratio of pattern to line length for substring match */ +const PARTIAL_MATCH_MIN_RATIO = 0.3; + +// ═══════════════════════════════════════════════════════════════════════════ +// Core Algorithms +// ═══════════════════════════════════════════════════════════════════════════ + +/** Compute Levenshtein distance between two strings */ +export function levenshteinDistance(a: string, b: string): number { + if (a === b) return 0; + const aLen = a.length; + const bLen = b.length; + if (aLen === 0) return bLen; + if (bLen === 0) return aLen; + + let prev = new Array(bLen + 1); + let curr = new Array(bLen + 1); + for (let j = 0; j <= bLen; j++) { + prev[j] = j; + } + + for (let i = 1; i <= aLen; i++) { + curr[0] = i; + const aCode = a.charCodeAt(i - 1); + for (let j = 1; j <= bLen; j++) { + const cost = aCode === b.charCodeAt(j - 1) ? 0 : 1; + const deletion = prev[j] + 1; + const insertion = curr[j - 1] + 1; + const substitution = prev[j - 1] + cost; + curr[j] = Math.min(deletion, insertion, substitution); + } + const tmp = prev; + prev = curr; + curr = tmp; + } + + return prev[bLen]; +} + +/** Compute similarity score between two strings (0 to 1) */ +export function similarity(a: string, b: string): number { + if (a.length === 0 && b.length === 0) return 1; + const maxLen = Math.max(a.length, b.length); + if (maxLen === 0) return 1; + const distance = levenshteinDistance(a, b); + return 1 - distance / maxLen; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Line-Based Utilities +// ═══════════════════════════════════════════════════════════════════════════ + +/** Compute relative indent depths for lines */ +function computeRelativeIndentDepths(lines: string[]): number[] { + const indents = lines.map(countLeadingWhitespace); + const nonEmptyIndents: number[] = []; + for (let i = 0; i < lines.length; i++) { + if (lines[i].trim().length > 0) { + nonEmptyIndents.push(indents[i]); + } + } + const minIndent = nonEmptyIndents.length > 0 ? Math.min(...nonEmptyIndents) : 0; + const indentSteps = nonEmptyIndents.map((indent) => indent - minIndent).filter((step) => step > 0); + const indentUnit = indentSteps.length > 0 ? Math.min(...indentSteps) : 1; + + return lines.map((line, index) => { + if (line.trim().length === 0) return 0; + if (indentUnit <= 0) return 0; + const relativeIndent = indents[index] - minIndent; + return Math.round(relativeIndent / indentUnit); + }); +} + +/** Normalize lines for matching, optionally including indent depth */ +function normalizeLines(lines: string[], includeDepth = true): string[] { + const indentDepths = includeDepth ? computeRelativeIndentDepths(lines) : null; + return lines.map((line, index) => { + const trimmed = line.trim(); + const prefix = indentDepths ? `${indentDepths[index]}|` : "|"; + if (trimmed.length === 0) return prefix; + return `${prefix}${normalizeForFuzzy(trimmed)}`; + }); +} + +/** Compute character offsets for each line in content */ +function computeLineOffsets(lines: string[]): number[] { + const offsets: number[] = []; + let offset = 0; + for (let i = 0; i < lines.length; i++) { + offsets.push(offset); + offset += lines[i].length; + if (i < lines.length - 1) offset += 1; // newline + } + return offsets; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Character-Level Fuzzy Match (for replace mode) +// ═══════════════════════════════════════════════════════════════════════════ + +interface BestFuzzyMatchResult { + best?: FuzzyMatch; + aboveThresholdCount: number; +} + +function findBestFuzzyMatchCore( + contentLines: string[], + targetLines: string[], + offsets: number[], + threshold: number, + includeDepth: boolean, +): BestFuzzyMatchResult { + const targetNormalized = normalizeLines(targetLines, includeDepth); + + let best: FuzzyMatch | undefined; + let bestScore = -1; + let aboveThresholdCount = 0; + + for (let start = 0; start <= contentLines.length - targetLines.length; start++) { + const windowLines = contentLines.slice(start, start + targetLines.length); + const windowNormalized = normalizeLines(windowLines, includeDepth); + let score = 0; + for (let i = 0; i < targetLines.length; i++) { + score += similarity(targetNormalized[i], windowNormalized[i]); + } + score = score / targetLines.length; + + if (score >= threshold) { + aboveThresholdCount++; + } + + if (score > bestScore) { + bestScore = score; + best = { + actualText: windowLines.join("\n"), + startIndex: offsets[start], + startLine: start + 1, + confidence: score, + }; + } + } + + return { best, aboveThresholdCount }; +} + +function findBestFuzzyMatch(content: string, target: string, threshold: number): BestFuzzyMatchResult { + const contentLines = content.split("\n"); + const targetLines = target.split("\n"); + + if (targetLines.length === 0 || target.length === 0) { + return { aboveThresholdCount: 0 }; + } + if (targetLines.length > contentLines.length) { + return { aboveThresholdCount: 0 }; + } + + const offsets = computeLineOffsets(contentLines); + let result = findBestFuzzyMatchCore(contentLines, targetLines, offsets, threshold, true); + + // Retry without indent depth if match is close but below threshold + if (result.best && result.best.confidence < threshold && result.best.confidence >= FALLBACK_THRESHOLD) { + const noDepthResult = findBestFuzzyMatchCore(contentLines, targetLines, offsets, threshold, false); + if (noDepthResult.best && noDepthResult.best.confidence > result.best.confidence) { + result = noDepthResult; + } + } + + return result; +} + +/** + * Find a match for target text within content. + * Used primarily for replace-mode edits. + */ +export function findMatch( + content: string, + target: string, + options: { allowFuzzy: boolean; threshold?: number }, +): MatchOutcome { + if (target.length === 0) { + return {}; + } + + // Try exact match first + const exactIndex = content.indexOf(target); + if (exactIndex !== -1) { + const occurrences = content.split(target).length - 1; + if (occurrences > 1) { + return { occurrences }; + } + const startLine = content.slice(0, exactIndex).split("\n").length; + return { + match: { + actualText: target, + startIndex: exactIndex, + startLine, + confidence: 1, + }, + }; + } + + // Try fuzzy match + const threshold = options.threshold ?? DEFAULT_FUZZY_THRESHOLD; + const { best, aboveThresholdCount } = findBestFuzzyMatch(content, target, threshold); + + if (!best) { + return {}; + } + + if (options.allowFuzzy && best.confidence >= threshold && aboveThresholdCount === 1) { + return { match: best, closest: best }; + } + + return { closest: best, fuzzyMatches: aboveThresholdCount }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Line-Based Sequence Match (for patch mode) +// ═══════════════════════════════════════════════════════════════════════════ + +/** Check if pattern matches lines starting at index using comparison function */ +function matchesAt(lines: string[], pattern: string[], i: number, compare: (a: string, b: string) => boolean): boolean { + for (let j = 0; j < pattern.length; j++) { + if (!compare(lines[i + j], pattern[j])) { + return false; + } + } + return true; +} + +/** Compute average similarity score for pattern at position */ +function fuzzyScoreAt(lines: string[], pattern: string[], i: number): number { + let totalScore = 0; + for (let j = 0; j < pattern.length; j++) { + const lineNorm = normalizeForFuzzy(lines[i + j]); + const patternNorm = normalizeForFuzzy(pattern[j]); + totalScore += similarity(lineNorm, patternNorm); + } + return totalScore / pattern.length; +} + +/** Check if line starts with pattern (normalized) */ +function lineStartsWithPattern(line: string, pattern: string): boolean { + const lineNorm = normalizeForFuzzy(line); + const patternNorm = normalizeForFuzzy(pattern); + if (patternNorm.length === 0) return lineNorm.length === 0; + return lineNorm.startsWith(patternNorm); +} + +/** Check if line contains pattern as significant substring */ +function lineIncludesPattern(line: string, pattern: string): boolean { + const lineNorm = normalizeForFuzzy(line); + const patternNorm = normalizeForFuzzy(pattern); + if (patternNorm.length === 0) return lineNorm.length === 0; + if (patternNorm.length < PARTIAL_MATCH_MIN_LENGTH) return false; + if (!lineNorm.includes(patternNorm)) return false; + return patternNorm.length / Math.max(1, lineNorm.length) >= PARTIAL_MATCH_MIN_RATIO; +} + +/** + * Find a sequence of pattern lines within content lines. + * + * Attempts matches with decreasing strictness: + * 1. Exact match + * 2. Trailing whitespace ignored + * 3. All whitespace trimmed + * 4. Unicode punctuation normalized + * 5. Prefix match (pattern is prefix of line) + * 6. Substring match (pattern is substring of line) + * 7. Fuzzy similarity match + * + * @param lines - The lines of the file content + * @param pattern - The lines to search for + * @param start - Starting index for the search + * @param eof - If true, prefer matching at end of file first + */ +export function seekSequence(lines: string[], pattern: string[], start: number, eof: boolean): SequenceSearchResult { + // Empty pattern matches immediately + if (pattern.length === 0) { + return { index: start, confidence: 1.0 }; + } + + // Pattern longer than available content cannot match + if (pattern.length > lines.length) { + return { index: undefined, confidence: 0 }; + } + + // Determine search start position + const searchStart = eof && lines.length >= pattern.length ? lines.length - pattern.length : start; + const maxStart = lines.length - pattern.length; + + // Pass 1: Exact match + for (let i = searchStart; i <= maxStart; i++) { + if (matchesAt(lines, pattern, i, (a, b) => a === b)) { + return { index: i, confidence: 1.0 }; + } + } + + // Pass 2: Trailing whitespace stripped + for (let i = searchStart; i <= maxStart; i++) { + if (matchesAt(lines, pattern, i, (a, b) => a.trimEnd() === b.trimEnd())) { + return { index: i, confidence: 0.99 }; + } + } + + // Pass 3: Both leading and trailing whitespace stripped + for (let i = searchStart; i <= maxStart; i++) { + if (matchesAt(lines, pattern, i, (a, b) => a.trim() === b.trim())) { + return { index: i, confidence: 0.98 }; + } + } + + // Pass 4: Normalize unicode punctuation + for (let i = searchStart; i <= maxStart; i++) { + if (matchesAt(lines, pattern, i, (a, b) => normalizeUnicode(a) === normalizeUnicode(b))) { + return { index: i, confidence: 0.97 }; + } + } + + // Pass 5: Partial line prefix match + for (let i = searchStart; i <= maxStart; i++) { + if (matchesAt(lines, pattern, i, lineStartsWithPattern)) { + return { index: i, confidence: 0.965 }; + } + } + + // Pass 6: Partial line substring match + for (let i = searchStart; i <= maxStart; i++) { + if (matchesAt(lines, pattern, i, lineIncludesPattern)) { + return { index: i, confidence: 0.94 }; + } + } + + // Pass 7: Fuzzy matching - find best match above threshold + let bestIndex: number | undefined; + let bestScore = 0; + + for (let i = searchStart; i <= maxStart; i++) { + const score = fuzzyScoreAt(lines, pattern, i); + if (score > bestScore) { + bestScore = score; + bestIndex = i; + } + } + + // Also search from start if eof mode started from end + if (eof && searchStart > start) { + for (let i = start; i < searchStart; i++) { + const score = fuzzyScoreAt(lines, pattern, i); + if (score > bestScore) { + bestScore = score; + bestIndex = i; + } + } + } + + if (bestIndex !== undefined && bestScore >= SEQUENCE_FUZZY_THRESHOLD) { + return { index: bestIndex, confidence: bestScore }; + } + + // Pass 8: Character-based fuzzy matching via findMatch + // This is the final fallback for when line-based matching fails + const CHARACTER_MATCH_THRESHOLD = 0.92; + const patternText = pattern.join("\n"); + const contentText = lines.slice(start).join("\n"); + const matchOutcome = findMatch(contentText, patternText, { + allowFuzzy: true, + threshold: CHARACTER_MATCH_THRESHOLD, + }); + + if (matchOutcome.match) { + // Convert character index back to line index + const matchedContent = contentText.substring(0, matchOutcome.match.startIndex); + const lineIndex = start + matchedContent.split("\n").length - 1; + return { index: lineIndex, confidence: matchOutcome.match.confidence }; + } + + return { index: undefined, confidence: bestScore }; +} + +/** + * Find a context line in the file using progressive matching strategies. + * + * @param lines - The lines of the file content + * @param context - The context line to search for + * @param startFrom - Starting index for the search + */ +export function findContextLine(lines: string[], context: string, startFrom: number): ContextLineResult { + const trimmedContext = context.trim(); + + // Pass 1: Exact line match + for (let i = startFrom; i < lines.length; i++) { + if (lines[i] === context) { + return { index: i, confidence: 1.0 }; + } + } + + // Pass 2: Trimmed match + for (let i = startFrom; i < lines.length; i++) { + if (lines[i].trim() === trimmedContext) { + return { index: i, confidence: 0.99 }; + } + } + + // Pass 3: Unicode normalization match + const normalizedContext = normalizeUnicode(context); + for (let i = startFrom; i < lines.length; i++) { + if (normalizeUnicode(lines[i]) === normalizedContext) { + return { index: i, confidence: 0.98 }; + } + } + + // Pass 4: Prefix match (file line starts with context) + const contextNorm = normalizeForFuzzy(context); + if (contextNorm.length > 0) { + for (let i = startFrom; i < lines.length; i++) { + const lineNorm = normalizeForFuzzy(lines[i]); + if (lineNorm.startsWith(contextNorm)) { + return { index: i, confidence: 0.96 }; + } + } + } + + // Pass 5: Substring match (file line contains context) + if (contextNorm.length >= PARTIAL_MATCH_MIN_LENGTH) { + for (let i = startFrom; i < lines.length; i++) { + const lineNorm = normalizeForFuzzy(lines[i]); + if (lineNorm.includes(contextNorm)) { + const ratio = contextNorm.length / Math.max(1, lineNorm.length); + if (ratio >= PARTIAL_MATCH_MIN_RATIO) { + return { index: i, confidence: 0.94 }; + } + } + } + } + + // Pass 6: Fuzzy match using similarity + let bestIndex: number | undefined; + let bestScore = 0; + + for (let i = startFrom; i < lines.length; i++) { + const lineNorm = normalizeForFuzzy(lines[i]); + const score = similarity(lineNorm, contextNorm); + if (score > bestScore) { + bestScore = score; + bestIndex = i; + } + } + + if (bestIndex !== undefined && bestScore >= CONTEXT_FUZZY_THRESHOLD) { + return { index: bestIndex, confidence: bestScore }; + } + + return { index: undefined, confidence: bestScore }; +} diff --git a/packages/coding-agent/src/core/tools/edit/index.ts b/packages/coding-agent/src/core/tools/patch/index.ts similarity index 59% rename from packages/coding-agent/src/core/tools/edit/index.ts rename to packages/coding-agent/src/core/tools/patch/index.ts index ce842672e..738042d58 100644 --- a/packages/coding-agent/src/core/tools/edit/index.ts +++ b/packages/coding-agent/src/core/tools/patch/index.ts @@ -8,67 +8,80 @@ * The mode is determined by the `edit.patchMode` setting. */ -import { mkdirSync, unlinkSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; import type { AgentTool, AgentToolContext } from "@oh-my-pi/pi-agent-core"; import { Type } from "@sinclair/typebox"; -import applyPatchDescription from "../../../prompts/tools/apply-patch.md" with { type: "text" }; -import editDescription from "../../../prompts/tools/edit.md" with { type: "text" }; +import patchDescription from "../../../prompts/tools/patch.md" with { type: "text" }; +import replaceDescription from "../../../prompts/tools/replace.md" with { type: "text" }; import { renderPromptTemplate } from "../../prompt-templates"; import type { ToolSession } from "../index"; import { createLspWritethrough, type FileDiagnosticsResult, writethroughNoop } from "../lsp/index"; import { resolveToCwd } from "../path-utils"; -import { applyPatch, type FileSystem, type Operation, type PatchInput } from "./apply-patch"; -import { - adjustNewTextIndentation, - DEFAULT_FUZZY_THRESHOLD, - detectLineEnding, - EditMatchError, - findEditMatch, - generateDiffString, - normalizeToLF, - restoreLineEndings, - stripBom, -} from "./diff"; +import { applyPatch } from "./applicator"; +import { generateDiffString, replaceText } from "./diff"; +import { DEFAULT_FUZZY_THRESHOLD, findMatch } from "./fuzzy"; +import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize"; import { type EditToolDetails, getLspBatchRequest } from "./shared"; +// Internal imports +import type { FileSystem, Operation, PatchInput } from "./types"; +import { EditMatchError } from "./types"; -// Re-export apply-patch types and functions -export { - ApplyPatchError, - type ApplyPatchOptions, - type ApplyPatchResult, - applyPatch, - defaultFileSystem, - type FileChange, - type FileSystem, - type Operation, - ParseError, - type PatchInput, - parseDiffHunks, - previewPatch, - type UpdateChunk, - type UpdateFileChunk, -} from "./apply-patch"; +// ═══════════════════════════════════════════════════════════════════════════ +// Re-exports +// ═══════════════════════════════════════════════════════════════════════════ -// Re-export diff utilities +// Application +export { applyPatch, defaultFileSystem, previewPatch } from "./applicator"; +// Diff generation +export { computeEditDiff, generateDiffString, replaceText } from "./diff"; + +// Fuzzy matching export { - adjustNewTextIndentation, - computeEditDiff, DEFAULT_FUZZY_THRESHOLD, + findContextLine, + findMatch, + findMatch as findEditMatch, + seekSequence, +} from "./fuzzy"; + +// Normalization +export { + adjustIndentation as adjustNewTextIndentation, detectLineEnding, - type EditDiffError, - type EditDiffResult, - type EditMatch, - EditMatchError, - type EditMatchOutcome, - findEditMatch, - generateDiffString, normalizeToLF, restoreLineEndings, stripBom, -} from "./diff"; -export { type SeekSequenceResult, seekSequence } from "./seek-sequence"; -// Re-export shared utilities (renderer, LSP batching, types) -export { type EditRenderContext, type EditToolDetails, editToolRenderer, getLspBatchRequest } from "./shared"; +} from "./normalize"; + +// Parsing +export { normalizeCreateContent, normalizeDiff, parseHunks as parseDiffHunks } from "./parser"; +// Rendering +export type { EditRenderContext, EditToolDetails } from "./shared"; +export { editToolRenderer, getLspBatchRequest } from "./shared"; +// Types +// Legacy aliases for backwards compatibility +export type { + ApplyPatchOptions, + ApplyPatchResult, + ContextLineResult, + DiffError, + DiffError as EditDiffError, + DiffHunk, + DiffHunk as UpdateChunk, + DiffHunk as UpdateFileChunk, + DiffResult, + DiffResult as EditDiffResult, + FileChange, + FileSystem, + FuzzyMatch, + FuzzyMatch as EditMatch, + MatchOutcome, + MatchOutcome as EditMatchOutcome, + Operation, + PatchInput, + SequenceSearchResult, +} from "./types"; +export { ApplyPatchError, EditMatchError, ParseError } from "./types"; // ═══════════════════════════════════════════════════════════════════════════ // Schemas @@ -104,43 +117,58 @@ type PatchParams = { path: string; operation: Operation; moveTo?: string; diff?: // LSP FileSystem for patch mode // ═══════════════════════════════════════════════════════════════════════════ -function createLspFileSystem( - writethrough: ( - dst: string, - content: string, - signal?: AbortSignal, - file?: import("bun").BunFile, - batch?: { id: string; flush: boolean }, - ) => Promise, - signal?: AbortSignal, - batchRequest?: { id: string; flush: boolean }, -): FileSystem & { getDiagnostics: () => FileDiagnosticsResult | undefined } { - let lastDiagnostics: FileDiagnosticsResult | undefined; +class LspFileSystem implements FileSystem { + private lastDiagnostics: FileDiagnosticsResult | undefined; + private fileCache: Record = {}; - return { - async exists(path: string): Promise { - return Bun.file(path).exists(); - }, - async read(path: string): Promise { - return Bun.file(path).text(); - }, - async write(path: string, content: string): Promise { - const file = Bun.file(path); - const result = await writethrough(path, content, signal, file, batchRequest); - if (result) { - lastDiagnostics = result; - } - }, - async delete(path: string): Promise { - unlinkSync(path); - }, - async mkdir(path: string): Promise { - mkdirSync(path, { recursive: true }); - }, - getDiagnostics(): FileDiagnosticsResult | undefined { - return lastDiagnostics; - }, - }; + constructor( + private readonly writethrough: ( + dst: string, + content: string, + signal?: AbortSignal, + file?: import("bun").BunFile, + batch?: { id: string; flush: boolean }, + ) => Promise, + private readonly signal?: AbortSignal, + private readonly batchRequest?: { id: string; flush: boolean }, + ) {} + + #getFile(path: string): Bun.BunFile { + if (this.fileCache[path]) { + return this.fileCache[path]; + } + const file = Bun.file(path); + this.fileCache[path] = file; + return file; + } + + async exists(path: string): Promise { + return this.#getFile(path).exists(); + } + + async read(path: string): Promise { + return this.#getFile(path).text(); + } + + async write(path: string, content: string): Promise { + const file = this.#getFile(path); + const result = await this.writethrough(path, content, this.signal, file, this.batchRequest); + if (result) { + this.lastDiagnostics = result; + } + } + + async delete(path: string): Promise { + await this.#getFile(path).unlink(); + } + + async mkdir(path: string): Promise { + await mkdir(path, { recursive: true }); + } + + getDiagnostics(): FileDiagnosticsResult | undefined { + return this.lastDiagnostics; + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -166,7 +194,7 @@ export function createEditTool( return { name: "edit", label: "Edit", - description: patchMode ? renderPromptTemplate(applyPatchDescription) : renderPromptTemplate(editDescription), + description: patchMode ? renderPromptTemplate(patchDescription) : renderPromptTemplate(replaceDescription), parameters: patchMode ? patchEditSchema : replaceEditSchema, execute: async ( _toolCallId: string, @@ -177,7 +205,9 @@ export function createEditTool( ) => { const batchRequest = getLspBatchRequest(context?.toolCall); + // ───────────────────────────────────────────────────────────────── // Patch mode execution + // ───────────────────────────────────────────────────────────────── if ("operation" in params) { const { path, operation, moveTo, diff } = params as PatchParams; @@ -186,7 +216,7 @@ export function createEditTool( } const input: PatchInput = { path, operation, moveTo, diff }; - const fs = createLspFileSystem(writethrough, signal, batchRequest); + const fs = new LspFileSystem(writethrough, signal, batchRequest); const result = await applyPatch(input, { cwd: session.cwd, fs }); // Generate diff for display @@ -226,7 +256,9 @@ export function createEditTool( }; } + // ───────────────────────────────────────────────────────────────── // Replace mode execution + // ───────────────────────────────────────────────────────────────── const { path, oldText, newText, all } = params as ReplaceParams; if (path.endsWith(".ipynb")) { @@ -247,89 +279,44 @@ export function createEditTool( const normalizedOldText = normalizeToLF(oldText); const normalizedNewText = normalizeToLF(newText); - let normalizedNewContent: string; - let replacementCount = 0; + const result = replaceText(normalizedContent, normalizedOldText, normalizedNewText, { + fuzzy: allowFuzzy, + all: all ?? false, + }); - if (all) { - normalizedNewContent = normalizedContent; - const exactCount = normalizedContent.split(normalizedOldText).length - 1; - if (exactCount > 0) { - normalizedNewContent = normalizedContent.split(normalizedOldText).join(normalizedNewText); - replacementCount = exactCount; - } else { - while (true) { - const matchOutcome = findEditMatch(normalizedNewContent, normalizedOldText, { - allowFuzzy, - similarityThreshold: DEFAULT_FUZZY_THRESHOLD, - }); - const match = - matchOutcome.match || - (allowFuzzy && matchOutcome.closest && matchOutcome.closest.confidence >= DEFAULT_FUZZY_THRESHOLD - ? matchOutcome.closest - : undefined); - if (!match) { - if (replacementCount === 0) { - throw new EditMatchError(path, normalizedOldText, matchOutcome.closest, { - allowFuzzy, - similarityThreshold: DEFAULT_FUZZY_THRESHOLD, - fuzzyMatches: matchOutcome.fuzzyMatches, - }); - } - break; - } - // Adjust newText indentation for each match (may vary across file) - const adjustedNewText = adjustNewTextIndentation( - normalizedOldText, - match.actualText, - normalizedNewText, - ); - normalizedNewContent = - normalizedNewContent.substring(0, match.startIndex) + - adjustedNewText + - normalizedNewContent.substring(match.startIndex + match.actualText.length); - replacementCount++; - } - } - } else { - const matchOutcome = findEditMatch(normalizedContent, normalizedOldText, { + if (result.count === 0) { + // Get error details + const matchOutcome = findMatch(normalizedContent, normalizedOldText, { allowFuzzy, - similarityThreshold: DEFAULT_FUZZY_THRESHOLD, + threshold: DEFAULT_FUZZY_THRESHOLD, }); + if (matchOutcome.occurrences && matchOutcome.occurrences > 1) { throw new Error( `Found ${matchOutcome.occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique, or use all: true to replace all.`, ); } - if (!matchOutcome.match) { - throw new EditMatchError(path, normalizedOldText, matchOutcome.closest, { - allowFuzzy, - similarityThreshold: DEFAULT_FUZZY_THRESHOLD, - fuzzyMatches: matchOutcome.fuzzyMatches, - }); - } - const match = matchOutcome.match; - // Adjust newText indentation if fuzzy match found text at different indent level - const adjustedNewText = adjustNewTextIndentation(normalizedOldText, match.actualText, normalizedNewText); - normalizedNewContent = - normalizedContent.substring(0, match.startIndex) + - adjustedNewText + - normalizedContent.substring(match.startIndex + match.actualText.length); - replacementCount = 1; + + throw new EditMatchError(path, normalizedOldText, matchOutcome.closest, { + allowFuzzy, + threshold: DEFAULT_FUZZY_THRESHOLD, + fuzzyMatches: matchOutcome.fuzzyMatches, + }); } - if (normalizedContent === normalizedNewContent) { + if (normalizedContent === result.content) { throw new Error( `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`, ); } - const finalContent = bom + restoreLineEndings(normalizedNewContent, originalEnding); + const finalContent = bom + restoreLineEndings(result.content, originalEnding); const diagnostics = await writethrough(absolutePath, finalContent, signal, file, batchRequest); - const diffResult = generateDiffString(normalizedContent, normalizedNewContent); + const diffResult = generateDiffString(normalizedContent, result.content); let resultText = - replacementCount > 1 - ? `Successfully replaced ${replacementCount} occurrences in ${path}.` + result.count > 1 + ? `Successfully replaced ${result.count} occurrences in ${path}.` : `Successfully replaced text in ${path}.`; if (diagnostics?.messages?.length) { diff --git a/packages/coding-agent/src/core/tools/patch/normalize.ts b/packages/coding-agent/src/core/tools/patch/normalize.ts new file mode 100644 index 000000000..9920b4e90 --- /dev/null +++ b/packages/coding-agent/src/core/tools/patch/normalize.ts @@ -0,0 +1,220 @@ +/** + * Text normalization utilities for the edit tool. + * + * Handles line endings, BOM, whitespace, and Unicode normalization. + */ + +// ═══════════════════════════════════════════════════════════════════════════ +// Line Ending Utilities +// ═══════════════════════════════════════════════════════════════════════════ + +export type LineEnding = "\r\n" | "\n"; + +/** Detect the predominant line ending in content */ +export function detectLineEnding(content: string): LineEnding { + const crlfIdx = content.indexOf("\r\n"); + const lfIdx = content.indexOf("\n"); + if (lfIdx === -1) return "\n"; + if (crlfIdx === -1) return "\n"; + return crlfIdx < lfIdx ? "\r\n" : "\n"; +} + +/** Normalize all line endings to LF */ +export function normalizeToLF(text: string): string { + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +} + +/** Restore line endings to the specified type */ +export function restoreLineEndings(text: string, ending: LineEnding): string { + return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// BOM Handling +// ═══════════════════════════════════════════════════════════════════════════ + +export interface BomResult { + /** The BOM character if present, empty string otherwise */ + bom: string; + /** The text without the BOM */ + text: string; +} + +/** Strip UTF-8 BOM if present */ +export function stripBom(content: string): BomResult { + return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Whitespace Utilities +// ═══════════════════════════════════════════════════════════════════════════ + +/** Count leading whitespace characters in a line */ +export function countLeadingWhitespace(line: string): number { + let count = 0; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (char === " " || char === "\t") { + count++; + } else { + break; + } + } + return count; +} + +/** Get the leading whitespace string from a line */ +export function getLeadingWhitespace(line: string): string { + return line.slice(0, countLeadingWhitespace(line)); +} + +/** Compute minimum indentation of non-empty lines */ +export function minIndent(text: string): number { + const lines = text.split("\n"); + let min = Infinity; + for (const line of lines) { + if (line.trim().length > 0) { + min = Math.min(min, countLeadingWhitespace(line)); + } + } + return min === Infinity ? 0 : min; +} + +/** Detect the indentation character used in text (space or tab) */ +export function detectIndentChar(text: string): string { + const lines = text.split("\n"); + for (const line of lines) { + const ws = getLeadingWhitespace(line); + if (ws.length > 0) { + return ws[0]; + } + } + return " "; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Unicode Normalization +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Normalize common Unicode punctuation to ASCII equivalents. + * Allows diffs with ASCII characters to match source files with typographic punctuation. + */ +export function normalizeUnicode(s: string): string { + return s + .trim() + .split("") + .map((c) => { + const code = c.charCodeAt(0); + + // Various dash/hyphen code-points → ASCII '-' + if ( + code === 0x2010 || // HYPHEN + code === 0x2011 || // NON-BREAKING HYPHEN + code === 0x2012 || // FIGURE DASH + code === 0x2013 || // EN DASH + code === 0x2014 || // EM DASH + code === 0x2015 || // HORIZONTAL BAR + code === 0x2212 // MINUS SIGN + ) { + return "-"; + } + + // Fancy single quotes → ' + if ( + code === 0x2018 || // LEFT SINGLE QUOTATION MARK + code === 0x2019 || // RIGHT SINGLE QUOTATION MARK + code === 0x201a || // SINGLE LOW-9 QUOTATION MARK + code === 0x201b // SINGLE HIGH-REVERSED-9 QUOTATION MARK + ) { + return "'"; + } + + // Fancy double quotes → " + if ( + code === 0x201c || // LEFT DOUBLE QUOTATION MARK + code === 0x201d || // RIGHT DOUBLE QUOTATION MARK + code === 0x201e || // DOUBLE LOW-9 QUOTATION MARK + code === 0x201f // DOUBLE HIGH-REVERSED-9 QUOTATION MARK + ) { + return '"'; + } + + // Non-breaking space and other odd spaces → normal space + if ( + code === 0x00a0 || // NO-BREAK SPACE + code === 0x2002 || // EN SPACE + code === 0x2003 || // EM SPACE + code === 0x2004 || // THREE-PER-EM SPACE + code === 0x2005 || // FOUR-PER-EM SPACE + code === 0x2006 || // SIX-PER-EM SPACE + code === 0x2007 || // FIGURE SPACE + code === 0x2008 || // PUNCTUATION SPACE + code === 0x2009 || // THIN SPACE + code === 0x200a || // HAIR SPACE + code === 0x202f || // NARROW NO-BREAK SPACE + code === 0x205f || // MEDIUM MATHEMATICAL SPACE + code === 0x3000 // IDEOGRAPHIC SPACE + ) { + return " "; + } + + return c; + }) + .join(""); +} + +/** + * Normalize a line for fuzzy comparison. + * Trims, collapses whitespace, and normalizes punctuation. + */ +export function normalizeForFuzzy(line: string): string { + const trimmed = line.trim(); + if (trimmed.length === 0) return ""; + + return trimmed + .replace(/[""„‟«»]/g, '"') + .replace(/[''‚‛`´]/g, "'") + .replace(/[‐‑‒–—−]/g, "-") + .replace(/[ \t]+/g, " "); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Indentation Adjustment +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Adjust newText indentation to match the indentation delta between + * what was provided (oldText) and what was actually matched (actualText). + * + * If oldText has 0 indent but actualText has 12 spaces, we add 12 spaces + * to each line in newText. + */ +export function adjustIndentation(oldText: string, actualText: string, newText: string): string { + const oldMin = minIndent(oldText); + const actualMin = minIndent(actualText); + const delta = actualMin - oldMin; + + if (delta === 0) { + return newText; + } + + const indentChar = detectIndentChar(actualText); + const lines = newText.split("\n"); + + const adjusted = lines.map((line) => { + if (line.trim().length === 0) { + return line; // Preserve empty/whitespace-only lines as-is + } + + if (delta > 0) { + return indentChar.repeat(delta) + line; + } + + // Remove indentation (delta < 0) + const toRemove = Math.min(-delta, countLeadingWhitespace(line)); + return line.slice(toRemove); + }); + + return adjusted.join("\n"); +} diff --git a/packages/coding-agent/src/core/tools/patch/parser.ts b/packages/coding-agent/src/core/tools/patch/parser.ts new file mode 100644 index 000000000..0aad7667d --- /dev/null +++ b/packages/coding-agent/src/core/tools/patch/parser.ts @@ -0,0 +1,292 @@ +/** + * Diff/patch parsing for the edit tool. + * + * Supports multiple input formats: + * - Simple +/- diffs + * - Unified diff format (@@ -X,Y +A,B @@) + * - Codex-style wrapped patches (*** Begin Patch / *** End Patch) + */ + +import type { DiffHunk } from "./types"; +import { ParseError } from "./types"; + +// ═══════════════════════════════════════════════════════════════════════════ +// Constants +// ═══════════════════════════════════════════════════════════════════════════ + +const EOF_MARKER = "*** End of File"; +const CHANGE_CONTEXT_MARKER = "@@ "; +const EMPTY_CHANGE_CONTEXT_MARKER = "@@"; + +/** Regex to match unified diff hunk headers: @@ -OLD,COUNT +NEW,COUNT @@ optional-context */ +const UNIFIED_HUNK_HEADER_REGEX = /^@@\s*-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s*@@(?:\s*(.*))?$/; + +// ═══════════════════════════════════════════════════════════════════════════ +// Normalization +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Normalize a diff by stripping various wrapper formats and metadata. + * + * Handles: + * - `*** Begin Patch` / `*** End Patch` markers (partial or complete) + * - Codex file markers: `*** Update File:`, `*** Add File:`, `*** Delete File:`, `*** End of File` + * - Unified diff metadata: `diff --git`, `index`, `---`, `+++`, mode changes, rename markers + */ +export function normalizeDiff(diff: string): string { + let lines = diff.split("\n"); + + // Strip trailing empty lines first + while (lines.length > 0 && lines[lines.length - 1]?.trim() === "") { + lines = lines.slice(0, -1); + } + + // Layer 1: Strip *** Begin Patch / *** End Patch (may have only one or both) + if (lines[0]?.trim().startsWith("*** Begin Patch")) { + lines = lines.slice(1); + } + if (lines.length > 0 && lines[lines.length - 1]?.trim().startsWith("*** End Patch")) { + lines = lines.slice(0, -1); + } + + // Layer 2: Strip Codex-style file operation markers and unified diff metadata + // NOTE: Do NOT strip "*** End of File" - that's a valid marker within hunks, not a wrapper + lines = lines.filter((line) => { + const trimmed = line.trim(); + + // Codex file operation markers (these wrap multiple file changes) + if (trimmed.startsWith("*** Update File:")) return false; + if (trimmed.startsWith("*** Add File:")) return false; + if (trimmed.startsWith("*** Delete File:")) return false; + + // Unified diff metadata + if (trimmed.startsWith("diff --git ")) return false; + if (trimmed.startsWith("index ")) return false; + if (trimmed.startsWith("--- ")) return false; + if (trimmed.startsWith("+++ ")) return false; + if (trimmed.startsWith("new file mode ")) return false; + if (trimmed.startsWith("deleted file mode ")) return false; + if (trimmed.startsWith("rename from ")) return false; + if (trimmed.startsWith("rename to ")) return false; + if (trimmed.startsWith("similarity index ")) return false; + if (trimmed.startsWith("dissimilarity index ")) return false; + if (trimmed.startsWith("old mode ")) return false; + if (trimmed.startsWith("new mode ")) return false; + + return true; + }); + + return lines.join("\n"); +} + +/** + * Strip `+ ` prefix from file creation content if all non-empty lines have it. + * This handles diffs where file content is formatted as additions. + */ +export function normalizeCreateContent(content: string): string { + const lines = content.split("\n"); + const nonEmptyLines = lines.filter((l) => l.length > 0); + + // Check if all non-empty lines start with "+ " or "+" + if (nonEmptyLines.length > 0 && nonEmptyLines.every((l) => l.startsWith("+ ") || l.startsWith("+"))) { + return lines + .map((l) => { + if (l.startsWith("+ ")) return l.slice(2); + if (l.startsWith("+")) return l.slice(1); + return l; + }) + .join("\n"); + } + + return content; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Header Parsing +// ═══════════════════════════════════════════════════════════════════════════ + +interface UnifiedHunkHeader { + oldStartLine: number; + oldLineCount: number; + newStartLine: number; + newLineCount: number; + changeContext?: string; +} + +function parseUnifiedHunkHeader(line: string): UnifiedHunkHeader | undefined { + const match = line.match(UNIFIED_HUNK_HEADER_REGEX); + if (!match) return undefined; + + const oldStartLine = Number(match[1]); + const oldLineCount = match[2] ? Number(match[2]) : 1; + const newStartLine = Number(match[3]); + const newLineCount = match[4] ? Number(match[4]) : 1; + const changeContext = match[5]?.trim(); + + return { + oldStartLine, + oldLineCount, + newStartLine, + newLineCount, + changeContext: changeContext && changeContext.length > 0 ? changeContext : undefined, + }; +} + +function isUnifiedDiffMetadataLine(line: string): boolean { + return ( + line.startsWith("diff --git ") || + line.startsWith("index ") || + line.startsWith("--- ") || + line.startsWith("+++ ") || + line.startsWith("new file mode ") || + line.startsWith("deleted file mode ") || + line.startsWith("rename from ") || + line.startsWith("rename to ") || + line.startsWith("similarity index ") || + line.startsWith("dissimilarity index ") || + line.startsWith("old mode ") || + line.startsWith("new mode ") + ); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Hunk Parsing +// ═══════════════════════════════════════════════════════════════════════════ + +interface ParseHunkResult { + hunk: DiffHunk; + linesConsumed: number; +} + +/** + * Parse a single hunk from lines starting at the current position. + */ +function parseOneHunk(lines: string[], lineNumber: number, allowMissingContext: boolean): ParseHunkResult { + if (lines.length === 0) { + throw new ParseError("Diff does not contain any lines", lineNumber); + } + + let changeContext: string | undefined; + let oldStartLine: number | undefined; + let newStartLine: number | undefined; + let startIndex: number; + + const headerLine = lines[0].trim(); + const unifiedHeader = parseUnifiedHunkHeader(headerLine); + + // Check for context marker + if (headerLine === EMPTY_CHANGE_CONTEXT_MARKER) { + changeContext = undefined; + startIndex = 1; + } else if (unifiedHeader) { + changeContext = unifiedHeader.changeContext; + oldStartLine = unifiedHeader.oldStartLine; + newStartLine = unifiedHeader.newStartLine; + startIndex = 1; + } else if (headerLine.startsWith(CHANGE_CONTEXT_MARKER)) { + changeContext = headerLine.slice(CHANGE_CONTEXT_MARKER.length); + startIndex = 1; + } else { + if (!allowMissingContext) { + throw new ParseError(`Expected hunk to start with @@ context marker, got: '${lines[0]}'`, lineNumber); + } + changeContext = undefined; + startIndex = 0; + } + + if (startIndex >= lines.length) { + throw new ParseError("Hunk does not contain any lines", lineNumber + 1); + } + + const hunk: DiffHunk = { + changeContext, + oldStartLine, + newStartLine, + hasContextLines: false, + oldLines: [], + newLines: [], + isEndOfFile: false, + }; + + let parsedLines = 0; + + for (let i = startIndex; i < lines.length; i++) { + const line = lines[i]; + + if (line === EOF_MARKER) { + if (parsedLines === 0) { + throw new ParseError("Hunk does not contain any lines", lineNumber + 1); + } + hunk.isEndOfFile = true; + parsedLines++; + break; + } + + const firstChar = line[0]; + + if (firstChar === undefined || firstChar === "") { + // Empty line - treat as context + hunk.hasContextLines = true; + hunk.oldLines.push(""); + hunk.newLines.push(""); + } else if (firstChar === " ") { + // Context line + hunk.hasContextLines = true; + hunk.oldLines.push(line.slice(1)); + hunk.newLines.push(line.slice(1)); + } else if (firstChar === "+") { + // Added line + hunk.newLines.push(line.slice(1)); + } else if (firstChar === "-") { + // Removed line + hunk.oldLines.push(line.slice(1)); + } else { + if (parsedLines === 0) { + throw new ParseError( + `Unexpected line in hunk: '${line}'. Lines must start with ' ' (context), '+' (add), or '-' (remove)`, + lineNumber + 1, + ); + } + // Assume start of next hunk + break; + } + parsedLines++; + } + + if (parsedLines === 0) { + throw new ParseError("Hunk does not contain any lines", lineNumber + startIndex); + } + + return { hunk, linesConsumed: parsedLines + startIndex }; +} + +/** + * Parse all diff hunks from a diff string. + */ +export function parseHunks(diff: string): DiffHunk[] { + const normalizedDiff = normalizeDiff(diff); + const lines = normalizedDiff.split("\n"); + const hunks: DiffHunk[] = []; + let i = 0; + + while (i < lines.length) { + // Skip blank lines between hunks + const trimmed = lines[i].trim(); + if (trimmed === "") { + i++; + continue; + } + + // Skip unified diff metadata lines + if (isUnifiedDiffMetadataLine(trimmed)) { + i++; + continue; + } + + const { hunk, linesConsumed } = parseOneHunk(lines.slice(i), i + 1, hunks.length === 0); + hunks.push(hunk); + i += linesConsumed; + } + + return hunks; +} diff --git a/packages/coding-agent/src/core/tools/edit/shared.ts b/packages/coding-agent/src/core/tools/patch/shared.ts similarity index 97% rename from packages/coding-agent/src/core/tools/edit/shared.ts rename to packages/coding-agent/src/core/tools/patch/shared.ts index c0e3db5de..adef038ed 100644 --- a/packages/coding-agent/src/core/tools/edit/shared.ts +++ b/packages/coding-agent/src/core/tools/patch/shared.ts @@ -1,5 +1,5 @@ /** - * Shared utilities for edit tools (replace-mode and patch-mode). + * Shared utilities for edit tool TUI rendering. */ import type { ToolCallContext } from "@oh-my-pi/pi-agent-core"; @@ -9,7 +9,7 @@ import { getLanguageFromPath, type Theme } from "../../../modes/interactive/them import type { RenderResultOptions } from "../../custom-tools/types"; import type { FileDiagnosticsResult } from "../lsp/index"; import { createToolUIKit, formatExpandHint, getDiffStats, shortenPath, truncateDiffByHunk } from "../render-utils"; -import type { EditDiffError, EditDiffResult } from "./diff"; +import type { DiffError, DiffResult, Operation } from "./types"; // ═══════════════════════════════════════════════════════════════════════════ // LSP Batching @@ -43,7 +43,7 @@ export interface EditToolDetails { /** Diagnostic result (if available) */ diagnostics?: FileDiagnosticsResult; /** Operation type (patch mode only) */ - operation?: "create" | "delete" | "update"; + operation?: Operation; /** New path after move/rename (patch mode only) */ moveTo?: string; } @@ -60,7 +60,7 @@ interface EditRenderArgs { patch?: string; all?: boolean; // Patch mode fields - operation?: "create" | "delete" | "update"; + operation?: Operation; moveTo?: string; diff?: string; } @@ -68,7 +68,7 @@ interface EditRenderArgs { /** Extended context for edit tool rendering */ export interface EditRenderContext { /** Pre-computed diff preview (computed before tool executes) */ - editDiffPreview?: EditDiffResult | EditDiffError; + editDiffPreview?: DiffResult | DiffError; /** Function to render diff text with syntax highlighting */ renderDiff?: (diffText: string, options?: { filePath?: string }) => string; } diff --git a/packages/coding-agent/src/core/tools/patch/types.ts b/packages/coding-agent/src/core/tools/patch/types.ts new file mode 100644 index 000000000..db384743e --- /dev/null +++ b/packages/coding-agent/src/core/tools/patch/types.ts @@ -0,0 +1,218 @@ +/** + * Shared types for the edit tool module. + */ + +// ═══════════════════════════════════════════════════════════════════════════ +// File System Abstraction +// ═══════════════════════════════════════════════════════════════════════════ + +/** Abstraction for file system operations to support LSP writethrough */ +export interface FileSystem { + exists(path: string): Promise; + read(path: string): Promise; + write(path: string, content: string): Promise; + delete(path: string): Promise; + mkdir(path: string): Promise; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Fuzzy Matching Types +// ═══════════════════════════════════════════════════════════════════════════ + +/** Result of a fuzzy match operation */ +export interface FuzzyMatch { + /** The actual text that was matched */ + actualText: string; + /** Character index where the match starts */ + startIndex: number; + /** Line number where the match starts (1-indexed) */ + startLine: number; + /** Confidence score (0-1, where 1 is exact match) */ + confidence: number; +} + +/** Outcome of attempting to find a match */ +export interface MatchOutcome { + /** The match if found with sufficient confidence */ + match?: FuzzyMatch; + /** The closest match found (may be below threshold) */ + closest?: FuzzyMatch; + /** Number of occurrences if multiple exact matches found */ + occurrences?: number; + /** Number of fuzzy matches above threshold */ + fuzzyMatches?: number; +} + +/** Result of a sequence search */ +export interface SequenceSearchResult { + /** Starting line index of the match (0-indexed) */ + index: number | undefined; + /** Confidence score (1.0 for exact match, lower for fuzzy) */ + confidence: number; +} + +/** Result of a context line search */ +export interface ContextLineResult { + /** Index of the matching line (0-indexed) */ + index: number | undefined; + /** Confidence score (1.0 for exact match, lower for fuzzy) */ + confidence: number; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Patch Types +// ═══════════════════════════════════════════════════════════════════════════ + +export type Operation = "create" | "delete" | "update"; + +/** Input for a patch operation */ +export interface PatchInput { + /** File path (relative or absolute) */ + path: string; + /** Operation type */ + operation: Operation; + /** New path for rename (update only) */ + moveTo?: string; + /** File content (create) or diff hunks (update) */ + diff?: string; +} + +/** A single hunk/chunk in a diff */ +export interface DiffHunk { + /** Context line to narrow down position (e.g., class/method definition) */ + changeContext?: string; + /** 1-based line hint from unified diff headers (old file) */ + oldStartLine?: number; + /** 1-based line hint from unified diff headers (new file) */ + newStartLine?: number; + /** True if the hunk contains context lines (space-prefixed) */ + hasContextLines: boolean; + /** Lines to be replaced (old content) */ + oldLines: string[]; + /** Lines to replace with (new content) */ + newLines: string[]; + /** If true, oldLines must occur at end of file */ + isEndOfFile: boolean; +} + +/** Describes a change made to a file */ +export interface FileChange { + type: Operation; + path: string; + newPath?: string; + oldContent?: string; + newContent?: string; +} + +/** Result of applying a patch */ +export interface ApplyPatchResult { + change: FileChange; +} + +/** Options for applying a patch */ +export interface ApplyPatchOptions { + /** Working directory for resolving relative paths */ + cwd: string; + /** Dry run - compute changes without writing */ + dryRun?: boolean; + /** File system abstraction (defaults to Bun-based implementation) */ + fs?: FileSystem; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Diff Generation Types +// ═══════════════════════════════════════════════════════════════════════════ + +/** Result of generating a diff */ +export interface DiffResult { + /** The unified diff string */ + diff: string; + /** Line number of the first change in the new file */ + firstChangedLine: number | undefined; +} + +/** Error from diff computation */ +export interface DiffError { + error: string; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Error Classes +// ═══════════════════════════════════════════════════════════════════════════ + +export class ParseError extends Error { + constructor( + message: string, + public readonly lineNumber?: number, + ) { + super(lineNumber !== undefined ? `Line ${lineNumber}: ${message}` : message); + this.name = "ParseError"; + } +} + +export class ApplyPatchError extends Error { + constructor(message: string) { + super(message); + this.name = "ApplyPatchError"; + } +} + +export class EditMatchError extends Error { + constructor( + public readonly path: string, + public readonly searchText: string, + public readonly closest: FuzzyMatch | undefined, + public readonly options: { allowFuzzy: boolean; threshold: number; fuzzyMatches?: number }, + ) { + super(EditMatchError.formatMessage(path, searchText, closest, options)); + this.name = "EditMatchError"; + } + + static formatMessage( + path: string, + searchText: string, + closest: FuzzyMatch | undefined, + options: { allowFuzzy: boolean; threshold: number; fuzzyMatches?: number }, + ): string { + if (!closest) { + return options.allowFuzzy + ? `Could not find a close enough match in ${path}.` + : `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`; + } + + const similarity = Math.round(closest.confidence * 100); + const searchLines = searchText.split("\n"); + const actualLines = closest.actualText.split("\n"); + const { oldLine, newLine } = findFirstDifferentLine(searchLines, actualLines); + const thresholdPercent = Math.round(options.threshold * 100); + + const hint = options.allowFuzzy + ? options.fuzzyMatches && options.fuzzyMatches > 1 + ? `Found ${options.fuzzyMatches} high-confidence matches. Provide more context to make it unique.` + : `Closest match was below the ${thresholdPercent}% similarity threshold.` + : "Fuzzy matching is disabled. Enable 'Edit fuzzy match' in settings to accept high-confidence matches."; + + return [ + options.allowFuzzy + ? `Could not find a close enough match in ${path}.` + : `Could not find the exact text in ${path}.`, + ``, + `Closest match (${similarity}% similar) at line ${closest.startLine}:`, + ` - ${oldLine}`, + ` + ${newLine}`, + hint, + ].join("\n"); + } +} + +function findFirstDifferentLine(oldLines: string[], newLines: string[]): { oldLine: string; newLine: string } { + const max = Math.max(oldLines.length, newLines.length); + for (let i = 0; i < max; i++) { + const oldLine = oldLines[i] ?? ""; + const newLine = newLines[i] ?? ""; + if (oldLine !== newLine) { + return { oldLine, newLine }; + } + } + return { oldLine: oldLines[0] ?? "", newLine: newLines[0] ?? "" }; +} diff --git a/packages/coding-agent/src/core/tools/renderers.ts b/packages/coding-agent/src/core/tools/renderers.ts index 1a2f1b686..463166f18 100644 --- a/packages/coding-agent/src/core/tools/renderers.ts +++ b/packages/coding-agent/src/core/tools/renderers.ts @@ -10,13 +10,13 @@ import type { RenderResultOptions } from "../custom-tools/types"; import { askToolRenderer } from "./ask"; import { bashToolRenderer } from "./bash"; import { calculatorToolRenderer } from "./calculator"; -import { editToolRenderer } from "./edit"; import { findToolRenderer } from "./find"; import { grepToolRenderer } from "./grep"; import { lsToolRenderer } from "./ls"; import { lspToolRenderer } from "./lsp/render"; import { notebookToolRenderer } from "./notebook"; import { outputToolRenderer } from "./output"; +import { editToolRenderer } from "./patch"; import { pythonToolRenderer } from "./python"; import { readToolRenderer } from "./read"; import { sshToolRenderer } from "./ssh"; diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index 225937877..4acbfddd7 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -12,7 +12,7 @@ import { } from "@oh-my-pi/pi-tui"; import stripAnsi from "strip-ansi"; import { BASH_DEFAULT_PREVIEW_LINES } from "../../../core/tools/bash"; -import { computeEditDiff, type EditDiffError, type EditDiffResult } from "../../../core/tools/edit"; +import { computeEditDiff, type EditDiffError, type EditDiffResult } from "../../../core/tools/patch"; import { PYTHON_DEFAULT_PREVIEW_LINES } from "../../../core/tools/python"; import { toolRenderers } from "../../../core/tools/renderers"; import { convertToPng } from "../../../utils/image-convert"; diff --git a/packages/coding-agent/src/prompts/tools/apply-patch.md b/packages/coding-agent/src/prompts/tools/patch.md similarity index 100% rename from packages/coding-agent/src/prompts/tools/apply-patch.md rename to packages/coding-agent/src/prompts/tools/patch.md diff --git a/packages/coding-agent/src/prompts/tools/edit.md b/packages/coding-agent/src/prompts/tools/replace.md similarity index 100% rename from packages/coding-agent/src/prompts/tools/edit.md rename to packages/coding-agent/src/prompts/tools/replace.md diff --git a/packages/coding-agent/test/core/apply-patch-regression.test.ts b/packages/coding-agent/test/core/apply-patch-regression.test.ts new file mode 100644 index 000000000..ac891ef62 --- /dev/null +++ b/packages/coding-agent/test/core/apply-patch-regression.test.ts @@ -0,0 +1,738 @@ +/** + * Regression tests for apply-patch behaviors. + * + * These tests verify that the edit/ module correctly implements features + * that were identified as missing or regressed in other implementations. + * Each test corresponds to a specific scenario from patchv2/TODO.md. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { applyPatch, findContextLine, seekSequence } from "../../src/core/tools/patch"; + +describe("regression: indentation adjustment for line-based replacements (2B)", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `regression-2b-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("line-based patch adjusts indentation when fuzzy matching at different indent level", async () => { + const filePath = join(tempDir, "indent.ts"); + // File has 4-space indentation + await Bun.write( + filePath, + `class Example { + constructor() { + this.value = 1; + this.name = "test"; + } +} +`, + ); + + // Patch uses 0 indentation - should be adjusted to match the 8-space indent in file + await applyPatch( + { + path: "indent.ts", + operation: "update", + diff: `@@ constructor() { +-this.value = 1; +-this.name = "test"; ++this.value = 42; ++this.name = "updated";`, + }, + { cwd: tempDir }, + ); + + const result = readFileSync(filePath, "utf-8"); + expect(result).toContain(" this.value = 42;"); + expect(result).toContain(' this.name = "updated";'); + }); + + test("multi-hunk patch adjusts indentation independently per hunk", async () => { + const filePath = join(tempDir, "multi-indent.ts"); + await Bun.write( + filePath, + `function outer() { + function inner1() { + return 1; + } + function inner2() { + return 2; + } +} +`, + ); + + // Different indentation levels in file - each hunk should adjust independently + await applyPatch( + { + path: "multi-indent.ts", + operation: "update", + diff: `@@ function inner1() { +-return 1; ++return 10; +@@ function inner2() { +-return 2; ++return 20;`, + }, + { cwd: tempDir }, + ); + + const result = readFileSync(filePath, "utf-8"); + expect(result).toContain(" return 10;"); // 4 spaces for inner1 + expect(result).toContain(" return 20;"); // 6 spaces for inner2 + }); +}); + +describe("regression: ambiguity detection for context-less hunks (2C)", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `regression-2c-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("single-hunk simple diff rejects multiple occurrences", async () => { + const filePath = join(tempDir, "dupe.txt"); + await Bun.write(filePath, "foo\nbar\nfoo\nbaz\n"); + + await expect( + applyPatch( + { + path: "dupe.txt", + operation: "update", + diff: "-foo\n+FOO", + }, + { cwd: tempDir }, + ), + ).rejects.toThrow(/2 occurrences/); + }); + + test("multi-hunk context-less diff rejects ambiguous patterns", async () => { + const filePath = join(tempDir, "multi-dupe.txt"); + // Each pattern appears twice + await Bun.write(filePath, "aaa\nbbb\naaa\nccc\nbbb\nddd\n"); + + // First hunk for "aaa" is ambiguous (appears at lines 1 and 3) + await expect( + applyPatch( + { + path: "multi-dupe.txt", + operation: "update", + diff: "@@\n-aaa\n+AAA\n@@\n-ccc\n+CCC", + }, + { cwd: tempDir }, + ), + ).rejects.toThrow(/2 occurrences/); + }); + + test("context lines disambiguate otherwise ambiguous patterns", async () => { + const filePath = join(tempDir, "context-disambig.txt"); + await Bun.write(filePath, "header\nfoo\nbar\nmiddle\nfoo\nbaz\nfooter\n"); + + // Context line "middle" disambiguates which "foo" to change + await applyPatch( + { + path: "context-disambig.txt", + operation: "update", + diff: "@@\n middle\n-foo\n+FOO", + }, + { cwd: tempDir }, + ); + + expect(readFileSync(filePath, "utf-8")).toBe("header\nfoo\nbar\nmiddle\nFOO\nbaz\nfooter\n"); + }); +}); + +describe("regression: context search uses line hints (2D)", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `regression-2d-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("unified diff line numbers help locate correct position", async () => { + const filePath = join(tempDir, "hints.txt"); + // File with repeated function definitions + await Bun.write( + filePath, + `function process() { + return 1; +} + +function process() { + return 2; +} + +function process() { + return 3; +} +`, + ); + + // Use unified diff format with line hint to target the second process() + await applyPatch( + { + path: "hints.txt", + operation: "update", + diff: `@@ -5,3 +5,3 @@ function process() { + function process() { +- return 2; ++ return 200; + }`, + }, + { cwd: tempDir }, + ); + + const result = readFileSync(filePath, "utf-8"); + expect(result).toContain("return 1;"); // First unchanged + expect(result).toContain("return 200;"); // Second changed + expect(result).toContain("return 3;"); // Third unchanged + }); + + test("line hint overrides context-only search when appropriate", async () => { + const filePath = join(tempDir, "hint-priority.txt"); + await Bun.write( + filePath, + `# Section A +def helper(): + pass + +# Section B +def helper(): + pass +`, + ); + + // Line hint points to Section B's helper (line 6) + await applyPatch( + { + path: "hint-priority.txt", + operation: "update", + diff: `@@ -6,2 +6,2 @@ def helper(): + def helper(): +- pass ++ return True`, + }, + { cwd: tempDir }, + ); + + const result = readFileSync(filePath, "utf-8"); + const lines = result.split("\n"); + expect(lines[2]).toBe(" pass"); // Section A unchanged + expect(lines[6]).toBe(" return True"); // Section B changed + }); +}); + +describe("regression: insertion uses newStartLine fallback (2E)", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `regression-2e-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("pure addition with context uses context to find insertion point", async () => { + const filePath = join(tempDir, "insert.txt"); + await Bun.write(filePath, "line1\nline2\nline3\n"); + + // Insert after line1 using context + await applyPatch( + { + path: "insert.txt", + operation: "update", + diff: `@@ + line1 ++inserted`, + }, + { cwd: tempDir }, + ); + + expect(readFileSync(filePath, "utf-8")).toBe("line1\ninserted\nline2\nline3\n"); + }); + + test("pure addition with line hint inserts at correct position", async () => { + const filePath = join(tempDir, "insert-hint.txt"); + await Bun.write(filePath, "aaa\nbbb\nccc\n"); + + // Use unified diff format line hints to insert at specific location + await applyPatch( + { + path: "insert-hint.txt", + operation: "update", + diff: `@@ -2,1 +2,2 @@ + bbb ++inserted after bbb`, + }, + { cwd: tempDir }, + ); + + expect(readFileSync(filePath, "utf-8")).toBe("aaa\nbbb\ninserted after bbb\nccc\n"); + }); + + test("insertion at end of file works correctly", async () => { + const filePath = join(tempDir, "append.txt"); + await Bun.write(filePath, "first\nsecond\n"); + + await applyPatch( + { + path: "append.txt", + operation: "update", + diff: `@@ ++appended line +*** End of File`, + }, + { cwd: tempDir }, + ); + + expect(readFileSync(filePath, "utf-8")).toBe("first\nsecond\nappended line\n"); + }); +}); + +describe("regression: seekSequence character-based fallback (2F)", () => { + test("seekSequence falls back to character-based matching when line-based fails", () => { + // Lines with subtle differences that line-based fuzzy matching might miss + const lines = [ + "function calculateTotal(items) {", + " let sum = 0;", + " for (const item of items) {", + " sum += item.price * item.quantity;", + " }", + " return sum;", + "}", + ]; + + // Pattern has minor differences: extra space, different quote style + const pattern = [ + " for (const item of items) {", // extra space before { + " sum += item.price*item.quantity;", // no spaces around * + ]; + + const result = seekSequence(lines, pattern, 0, false); + expect(result.index).toBe(2); + expect(result.confidence).toBeGreaterThan(0.9); + }); + + test("seekSequence handles normalized unicode matching", () => { + const lines = ['const message = "Hello – World";', "console.log(message);"]; + + // Pattern uses ASCII dash instead of en-dash + const pattern = ['const message = "Hello - World";']; + + const result = seekSequence(lines, pattern, 0, false); + expect(result.index).toBe(0); + }); + + test("seekSequence finds pattern with whitespace differences", () => { + const lines = [" function foo() {", " return 42;", " }"]; + + // Pattern has normalized whitespace + const pattern = ["function foo() {", "return 42;"]; + + const result = seekSequence(lines, pattern, 0, false); + expect(result.index).toBe(0); + }); +}); + +describe("regression: findContextLine progressive matching (2D related)", () => { + test("finds exact context line", () => { + const lines = ["function foo() {", " return 1;", "}"]; + const result = findContextLine(lines, "function foo() {", 0); + expect(result.index).toBe(0); + expect(result.confidence).toBe(1.0); + }); + + test("finds context line with whitespace differences", () => { + const lines = [" function foo() {", " return 1;", "}"]; + const result = findContextLine(lines, "function foo() {", 0); + expect(result.index).toBe(0); + expect(result.confidence).toBeGreaterThan(0.9); + }); + + test("finds context line with unicode normalization", () => { + const lines = ['const msg = "Hello – World";', "return msg;"]; + // ASCII dash in pattern, en-dash in content + const result = findContextLine(lines, 'const msg = "Hello - World";', 0); + expect(result.index).toBe(0); + }); + + test("finds context line as prefix match", () => { + const lines = ["function calculateTotalWithTax(items, taxRate) {", " return 0;", "}"]; + // Partial function name matches as prefix + const result = findContextLine(lines, "function calculateTotalWithTax(items", 0); + expect(result.index).toBe(0); + expect(result.confidence).toBeGreaterThan(0.9); + }); + + test("finds context line as substring match", () => { + // Substring must be at least 6 chars and 30% of line length + const lines = ["// comment: calculateTotal here", "function foo() {}"]; + const result = findContextLine(lines, "calculateTotal", 0); + expect(result.index).toBe(0); + expect(result.confidence).toBeGreaterThan(0.9); + }); + + test("falls back to fuzzy match for similar lines", () => { + const lines = ["functoin calclateTotal(itms) {", " return 0;", "}"]; + // Typos in content, correct in pattern + const result = findContextLine(lines, "function calculateTotal(items) {", 0); + expect(result.index).toBe(0); + expect(result.confidence).toBeGreaterThan(0.8); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Plan: Make `@@` Context Matching Robust - Expected Behaviors +// These tests document expected behaviors from the plan. Some may fail if +// the feature is not yet implemented. +// ═══════════════════════════════════════════════════════════════════════════ + +describe("plan: partial line matching for @@ context", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `plan-partial-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("@@ context matches when actual line contains it as substring", async () => { + const filePath = join(tempDir, "imports.ts"); + // Actual line has more content than the @@ context + await Bun.write( + filePath, + 'import { mkdirSync, unlinkSync } from "node:fs";\n\nfunction cleanup() {\n unlinkSync("temp");\n}\n', + ); + + // @@ context is a partial match (substring of actual line) + await applyPatch( + { + path: "imports.ts", + operation: "update", + diff: `@@ import { mkdirSync, unlinkSync } + + function cleanup() { +- unlinkSync("temp"); ++ rmSync("temp", { recursive: true });`, + }, + { cwd: tempDir }, + ); + + const result = readFileSync(filePath, "utf-8"); + expect(result).toContain('rmSync("temp", { recursive: true });'); + }); + + test("@@ context matches function signature even with trailing content", async () => { + const filePath = join(tempDir, "funcs.ts"); + await Bun.write( + filePath, + `function processItems(items: Item[], options?: Options): Result { + return items.map(i => i.value); +} +`, + ); + + // @@ has partial function signature + await applyPatch( + { + path: "funcs.ts", + operation: "update", + diff: `@@ function processItems(items +- return items.map(i => i.value); ++ return items.filter(i => i.valid).map(i => i.value);`, + }, + { cwd: tempDir }, + ); + + expect(readFileSync(filePath, "utf-8")).toContain("filter(i => i.valid)"); + }); +}); + +describe("plan: unified diff format line numbers", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `plan-unified-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("@@ -10,6 +10,7 @@ is parsed as line numbers not literal text", async () => { + const filePath = join(tempDir, "lines.txt"); + // Create file with 15 lines + const lines = Array.from({ length: 15 }, (_, i) => `line ${i + 1}`); + await Bun.write(filePath, `${lines.join("\n")}\n`); + + // Use unified diff format to target line 10 + await applyPatch( + { + path: "lines.txt", + operation: "update", + diff: `@@ -10,3 +10,3 @@ + line 10 +-line 11 ++LINE ELEVEN + line 12`, + }, + { cwd: tempDir }, + ); + + const result = readFileSync(filePath, "utf-8"); + expect(result).toContain("LINE ELEVEN"); + expect(result).toContain("line 10"); // unchanged + expect(result).toContain("line 12"); // unchanged + }); + + test("unified diff line numbers take precedence over context search", async () => { + const filePath = join(tempDir, "repeat.txt"); + // Same pattern appears at lines 3 and 8 + await Bun.write( + filePath, + `header +line 2 +target line +line 4 +line 5 +line 6 +line 7 +target line +line 9 +`, + ); + + // Line hint says line 8, should change second "target line" + await applyPatch( + { + path: "repeat.txt", + operation: "update", + diff: `@@ -8,1 +8,1 @@ +-target line ++MODIFIED TARGET`, + }, + { cwd: tempDir }, + ); + + const result = readFileSync(filePath, "utf-8"); + const lines = result.split("\n"); + expect(lines[2]).toBe("target line"); // First unchanged + expect(lines[7]).toBe("MODIFIED TARGET"); // Second changed + }); +}); + +describe("plan: Codex-style wrapped patches", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `plan-codex-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("strips *** Begin Patch / *** End Patch wrapper", async () => { + const filePath = join(tempDir, "wrapped.txt"); + await Bun.write(filePath, "old content\n"); + + // Full Codex-style wrapper - the diff inside should be extracted + await applyPatch( + { + path: "wrapped.txt", + operation: "update", + diff: `*** Begin Patch +@@ +-old content ++new content +*** End Patch`, + }, + { cwd: tempDir }, + ); + + expect(readFileSync(filePath, "utf-8")).toBe("new content\n"); + }); + + test("strips partial wrapper (only *** End Patch)", async () => { + const filePath = join(tempDir, "partial.txt"); + await Bun.write(filePath, "original\n"); + + // Only end marker present + await applyPatch( + { + path: "partial.txt", + operation: "update", + diff: `@@ +-original ++modified +*** End Patch`, + }, + { cwd: tempDir }, + ); + + expect(readFileSync(filePath, "utf-8")).toBe("modified\n"); + }); + + test("strips unified diff metadata lines", async () => { + const filePath = join(tempDir, "unified-meta.txt"); + await Bun.write(filePath, "first\nsecond\nthird\n"); + + // Full unified diff format with metadata + await applyPatch( + { + path: "unified-meta.txt", + operation: "update", + diff: `diff --git a/unified-meta.txt b/unified-meta.txt +index abc123..def456 100644 +--- a/unified-meta.txt ++++ b/unified-meta.txt +@@ -1,3 +1,3 @@ + first +-second ++SECOND + third`, + }, + { cwd: tempDir }, + ); + + expect(readFileSync(filePath, "utf-8")).toBe("first\nSECOND\nthird\n"); + }); +}); + +describe("plan: strip + prefix from file creation", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `plan-create-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("create file strips + prefix when all lines have it", async () => { + await applyPatch( + { + path: "newfile.txt", + operation: "create", + diff: `+line one ++line two ++line three`, + }, + { cwd: tempDir }, + ); + + expect(readFileSync(join(tempDir, "newfile.txt"), "utf-8")).toBe("line one\nline two\nline three\n"); + }); + + test("create file strips + space prefix", async () => { + await applyPatch( + { + path: "spaced.txt", + operation: "create", + diff: `+ first line ++ second line`, + }, + { cwd: tempDir }, + ); + + expect(readFileSync(join(tempDir, "spaced.txt"), "utf-8")).toBe("first line\nsecond line\n"); + }); + + test("create file preserves content when not all lines have + prefix", async () => { + await applyPatch( + { + path: "mixed.txt", + operation: "create", + diff: `+line one +regular line ++line three`, + }, + { cwd: tempDir }, + ); + + // Should preserve as-is since not all lines have + + expect(readFileSync(join(tempDir, "mixed.txt"), "utf-8")).toBe("+line one\nregular line\n+line three\n"); + }); +}); + +describe("regression: *** End of File marker handling (2A/2G)", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `regression-eof-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("*** End of File marker is preserved in hunk parsing", async () => { + const filePath = join(tempDir, "eof.txt"); + await Bun.write(filePath, "line1\nline2\nlast line\n"); + + await applyPatch( + { + path: "eof.txt", + operation: "update", + diff: `@@ +-last line ++modified last line +*** End of File`, + }, + { cwd: tempDir }, + ); + + expect(readFileSync(filePath, "utf-8")).toBe("line1\nline2\nmodified last line\n"); + }); + + test("EOF marker targets end of file for pattern matching", async () => { + const filePath = join(tempDir, "eof-target.txt"); + // Pattern appears twice - EOF should target the last one + await Bun.write(filePath, "item\nmore content\nitem\n"); + + await applyPatch( + { + path: "eof-target.txt", + operation: "update", + diff: `@@ +-item ++FINAL ITEM +*** End of File`, + }, + { cwd: tempDir }, + ); + + const result = readFileSync(filePath, "utf-8"); + expect(result).toBe("item\nmore content\nFINAL ITEM\n"); + }); +}); diff --git a/packages/coding-agent/test/core/apply-patch.test.ts b/packages/coding-agent/test/core/apply-patch.test.ts index 503decfe3..c633e046f 100644 --- a/packages/coding-agent/test/core/apply-patch.test.ts +++ b/packages/coding-agent/test/core/apply-patch.test.ts @@ -8,8 +8,8 @@ import { ParseError, type PatchInput, parseDiffHunks, -} from "../../src/core/tools/edit/apply-patch"; -import { seekSequence } from "../../src/core/tools/edit/seek-sequence"; + seekSequence, +} from "../../src/core/tools/patch"; // ═══════════════════════════════════════════════════════════════════════════ // Legacy parser for test fixtures (*** Begin Patch format) @@ -707,7 +707,7 @@ describe("simple replace mode", () => { describe("module exports", () => { test("exports all expected types and functions", async () => { - const mod = await import("../../src/core/tools/edit/apply-patch"); + const mod = await import("../../src/core/tools/patch"); expect(typeof mod.applyPatch).toBe("function"); expect(typeof mod.parseDiffHunks).toBe("function"); expect(typeof mod.previewPatch).toBe("function"); diff --git a/packages/coding-agent/test/edit-diff.test.ts b/packages/coding-agent/test/edit-diff.test.ts index 64dc8990c..95d68a358 100644 --- a/packages/coding-agent/test/edit-diff.test.ts +++ b/packages/coding-agent/test/edit-diff.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { adjustNewTextIndentation, DEFAULT_FUZZY_THRESHOLD, findEditMatch } from "../src/core/tools/edit"; +import { adjustNewTextIndentation, DEFAULT_FUZZY_THRESHOLD, findEditMatch } from "../src/core/tools/patch"; describe("findEditMatch", () => { describe("exact matching", () => { @@ -103,13 +103,13 @@ describe("findEditMatch", () => { const target = "function bar() {}"; const strictResult = findEditMatch(content, target, { allowFuzzy: true, - similarityThreshold: 0.99, + threshold: 0.99, }); expect(strictResult.match).toBeUndefined(); const lenientResult = findEditMatch(content, target, { allowFuzzy: true, - similarityThreshold: 0.7, + threshold: 0.7, }); expect(lenientResult.match).toBeDefined(); }); @@ -119,7 +119,7 @@ describe("findEditMatch", () => { const target = " itemX"; const result = findEditMatch(content, target, { allowFuzzy: true, - similarityThreshold: 0.7, + threshold: 0.7, }); expect(result.fuzzyMatches).toBeGreaterThan(1); }); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 71a344dd7..330043595 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -4,11 +4,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { nanoid } from "nanoid"; import { createBashTool } from "../src/core/tools/bash"; -import { createEditTool } from "../src/core/tools/edit"; import { createFindTool } from "../src/core/tools/find"; import { createGrepTool } from "../src/core/tools/grep"; import type { ToolSession } from "../src/core/tools/index"; import { createLsTool } from "../src/core/tools/ls"; +import { createEditTool } from "../src/core/tools/patch"; import { createReadTool } from "../src/core/tools/read"; import { createWriteTool } from "../src/core/tools/write"; import * as shellModule from "../src/utils/shell";