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:
@@ -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 };
|
||||
}
|
||||
+136
-149
@@ -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;
|
||||
}
|
||||
+5
-5
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user