refactor(coding-agent/core): restructured edit tool into modular patch architecture

- 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 unified diff and Codex-style patch support.
- Improved fuzzy matching algorithms for more robust text location.
This commit is contained in:
can1357
2026-01-19 07:47:08 +01:00
parent 436a0065df
commit c014c309d6
23 changed files with 2847 additions and 1613 deletions
+6 -1
View File
@@ -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
@@ -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 };
@@ -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";
@@ -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<boolean>;
/** Read file contents */
read(path: string): Promise<string>;
/** Write file contents (may include LSP formatting/diagnostics) */
write(path: string, content: string): Promise<void>;
/** Delete a file */
delete(path: string): Promise<void>;
/** Create directory (recursive) */
mkdir(path: string): Promise<void>;
}
/** Default filesystem implementation using Bun APIs */
export const defaultFileSystem: FileSystem = {
async exists(path: string): Promise<boolean> {
return Bun.file(path).exists();
},
async read(path: string): Promise<string> {
return Bun.file(path).text();
},
async write(path: string, content: string): Promise<void> {
await Bun.write(path, content);
},
async delete(path: string): Promise<void> {
unlinkSync(path);
},
async mkdir(path: string): Promise<void> {
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<ApplyPatchResult> {
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<ApplyPatchResult> {
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 };
@@ -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<number>(bLen + 1);
let curr = new Array<number>(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<EditDiffResult | EditDiffError> {
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) };
}
}
@@ -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<number>(bLen + 1);
let curr = new Array<number>(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 };
}
@@ -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";
@@ -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<boolean> {
return Bun.file(path).exists();
},
async read(path: string): Promise<string> {
return Bun.file(path).text();
},
async write(path: string, content: string): Promise<void> {
await Bun.write(path, content);
},
async delete(path: string): Promise<void> {
unlinkSync(path);
},
async mkdir(path: string): Promise<void> {
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<ApplyPatchResult> {
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<ApplyPatchResult> {
return applyPatch(input, { ...options, dryRun: true });
}
@@ -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<DiffResult | DiffError> {
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) };
}
}
@@ -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<number>(bLen + 1);
let curr = new Array<number>(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 };
}
@@ -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<FileDiagnosticsResult | undefined>,
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<string, Bun.BunFile> = {};
return {
async exists(path: string): Promise<boolean> {
return Bun.file(path).exists();
},
async read(path: string): Promise<string> {
return Bun.file(path).text();
},
async write(path: string, content: string): Promise<void> {
const file = Bun.file(path);
const result = await writethrough(path, content, signal, file, batchRequest);
if (result) {
lastDiagnostics = result;
}
},
async delete(path: string): Promise<void> {
unlinkSync(path);
},
async mkdir(path: string): Promise<void> {
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<FileDiagnosticsResult | undefined>,
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<boolean> {
return this.#getFile(path).exists();
}
async read(path: string): Promise<string> {
return this.#getFile(path).text();
}
async write(path: string, content: string): Promise<void> {
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<void> {
await this.#getFile(path).unlink();
}
async mkdir(path: string): Promise<void> {
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) {
@@ -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");
}
@@ -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;
}
@@ -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;
}
@@ -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<boolean>;
read(path: string): Promise<string>;
write(path: string, content: string): Promise<void>;
delete(path: string): Promise<void>;
mkdir(path: string): Promise<void>;
}
// ═══════════════════════════════════════════════════════════════════════════
// 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] ?? "" };
}
@@ -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";
@@ -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";
@@ -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");
});
});
@@ -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");
+4 -4
View File
@@ -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);
});
+1 -1
View File
@@ -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";