refactor(coding-agent): flattened directory structure, eliminated core/ folder
- Eliminated core/ directory (252 files, 23 subdirs → distributed)
- Reduced max nesting from 9 levels to 5 levels
- Promoted tool subdirs to top level: exa/, lsp/, patch/, task/, web/
- Merged web-scrapers/ + web-search/ into web/{scrapers,search}/
- Flattened modes/interactive/ to modes/
- Split execution/ into: ipy/ (python), exec/ (bash), ssh/
- Renamed ipy/python-*.ts to ipy/*.ts (executor, kernel, etc.)
- Flattened cursor/exec-bridge.ts to cursor.ts
- Created logical groupings: config/, session/, extensibility/, export/
- Updated all imports across 500+ files
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* 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 "$c/tools/path-utils";
|
||||
import { previewPatch } from "./applicator";
|
||||
import { DEFAULT_FUZZY_THRESHOLD, findMatch } from "./fuzzy";
|
||||
import { adjustIndentation, normalizeToLF, stripBom } from "./normalize";
|
||||
import type { DiffError, DiffResult, PatchInput } 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 countLines = (content: string): number => {
|
||||
const lines = content.split("\n");
|
||||
if (lines.length > 1 && lines[lines.length - 1] === "") {
|
||||
lines.pop();
|
||||
}
|
||||
return Math.max(1, lines.length);
|
||||
};
|
||||
|
||||
const maxLineNum = Math.max(countLines(oldContent), countLines(newContent));
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unified diff string without file headers.
|
||||
* Returns both the diff string and the first changed line number (in the new file).
|
||||
*/
|
||||
export function generateUnifiedDiffString(oldContent: string, newContent: string, contextLines = 3): DiffResult {
|
||||
const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: contextLines });
|
||||
const output: string[] = [];
|
||||
let firstChangedLine: number | undefined;
|
||||
|
||||
for (const hunk of patch.hunks) {
|
||||
output.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`);
|
||||
let newLine = hunk.newStart;
|
||||
for (const line of hunk.lines) {
|
||||
output.push(line);
|
||||
if (firstChangedLine === undefined && (line.startsWith("+") || line.startsWith("-"))) {
|
||||
firstChangedLine = newLine;
|
||||
}
|
||||
if (line.startsWith("+") || line.startsWith(" ")) {
|
||||
newLine++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { diff: output.join("\n"), firstChangedLine };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and replace text in content using fuzzy matching.
|
||||
*/
|
||||
export function replaceText(content: string, oldText: string, newText: string, options: ReplaceOptions): ReplaceResult {
|
||||
if (oldText.length === 0) {
|
||||
throw new Error("oldText must not be empty.");
|
||||
}
|
||||
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,
|
||||
});
|
||||
|
||||
const shouldUseClosest =
|
||||
options.fuzzy &&
|
||||
matchOutcome.closest &&
|
||||
matchOutcome.closest.confidence >= threshold &&
|
||||
(matchOutcome.fuzzyMatches === undefined || matchOutcome.fuzzyMatches <= 1);
|
||||
const match = matchOutcome.match || (shouldUseClosest ? matchOutcome.closest : undefined);
|
||||
if (!match) {
|
||||
break;
|
||||
}
|
||||
|
||||
const adjustedNewText = adjustIndentation(normalizedOldText, match.actualText, normalizedNewText);
|
||||
if (adjustedNewText === match.actualText) {
|
||||
break;
|
||||
}
|
||||
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) {
|
||||
const previews = matchOutcome.occurrencePreviews?.join("\n\n") ?? "";
|
||||
const moreMsg = matchOutcome.occurrences > 5 ? ` (showing first 5 of ${matchOutcome.occurrences})` : "";
|
||||
throw new Error(
|
||||
`Found ${matchOutcome.occurrences} occurrences${moreMsg}:\n\n${previews}\n\n` +
|
||||
`Add more context lines to disambiguate.`,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
threshold?: number,
|
||||
): Promise<DiffResult | DiffError> {
|
||||
if (oldText.length === 0) {
|
||||
return { error: "oldText must not be empty." };
|
||||
}
|
||||
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,
|
||||
threshold,
|
||||
});
|
||||
|
||||
if (result.count === 0) {
|
||||
// Get closest match for error message
|
||||
const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
|
||||
allowFuzzy: fuzzy,
|
||||
threshold: threshold ?? DEFAULT_FUZZY_THRESHOLD,
|
||||
});
|
||||
|
||||
if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
|
||||
const previews = matchOutcome.occurrencePreviews?.join("\n\n") ?? "";
|
||||
const moreMsg = matchOutcome.occurrences > 5 ? ` (showing first 5 of ${matchOutcome.occurrences})` : "";
|
||||
return {
|
||||
error: `Found ${matchOutcome.occurrences} occurrences in ${path}${moreMsg}:\n\n${previews}\n\nAdd more context lines to disambiguate.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error: EditMatchError.formatMessage(path, normalizedOldText, matchOutcome.closest, {
|
||||
allowFuzzy: fuzzy,
|
||||
threshold: 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) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the diff for a patch operation without applying it.
|
||||
* Used for preview rendering in the TUI before patch-mode edits execute.
|
||||
*/
|
||||
export async function computePatchDiff(
|
||||
input: PatchInput,
|
||||
cwd: string,
|
||||
options?: { fuzzyThreshold?: number; allowFuzzy?: boolean },
|
||||
): Promise<DiffResult | DiffError> {
|
||||
try {
|
||||
const result = await previewPatch(input, {
|
||||
cwd,
|
||||
fuzzyThreshold: options?.fuzzyThreshold,
|
||||
allowFuzzy: options?.allowFuzzy,
|
||||
});
|
||||
const oldContent = result.change.oldContent ?? "";
|
||||
const newContent = result.change.newContent ?? "";
|
||||
const normalizedOld = normalizeToLF(stripBom(oldContent).text);
|
||||
const normalizedNew = normalizeToLF(stripBom(newContent).text);
|
||||
if (!normalizedOld && !normalizedNew) {
|
||||
return { diff: "", firstChangedLine: undefined };
|
||||
}
|
||||
return generateUnifiedDiffString(normalizedOld, normalizedNew);
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
/**
|
||||
* 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) {
|
||||
// Find line numbers and previews for each occurrence (up to 5)
|
||||
const contentLines = content.split("\n");
|
||||
const occurrenceLines: number[] = [];
|
||||
const occurrencePreviews: string[] = [];
|
||||
let searchStart = 0;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const idx = content.indexOf(target, searchStart);
|
||||
if (idx === -1) break;
|
||||
const lineNumber = content.slice(0, idx).split("\n").length;
|
||||
occurrenceLines.push(lineNumber);
|
||||
// Extract 3 lines starting from match (0-indexed)
|
||||
const previewLines = contentLines.slice(lineNumber - 1, lineNumber + 2);
|
||||
const preview = previewLines
|
||||
.map((line, i) => ` ${lineNumber + i} | ${line.length > 60 ? `${line.slice(0, 57)}...` : line}`)
|
||||
.join("\n");
|
||||
occurrencePreviews.push(preview);
|
||||
searchStart = idx + 1;
|
||||
}
|
||||
return { occurrences, occurrenceLines, occurrencePreviews };
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
function stripCommentPrefix(line: string): string {
|
||||
let trimmed = line.trimStart();
|
||||
if (trimmed.startsWith("/*")) {
|
||||
trimmed = trimmed.slice(2);
|
||||
} else if (trimmed.startsWith("*/")) {
|
||||
trimmed = trimmed.slice(2);
|
||||
} else if (trimmed.startsWith("//")) {
|
||||
trimmed = trimmed.slice(2);
|
||||
} else if (trimmed.startsWith("*")) {
|
||||
trimmed = trimmed.slice(1);
|
||||
} else if (trimmed.startsWith("#")) {
|
||||
trimmed = trimmed.slice(1);
|
||||
} else if (trimmed.startsWith(";")) {
|
||||
trimmed = trimmed.slice(1);
|
||||
} else if (trimmed.startsWith("/") && trimmed[1] === " ") {
|
||||
trimmed = trimmed.slice(1);
|
||||
}
|
||||
return trimmed.trimStart();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
options?: { allowFuzzy?: boolean },
|
||||
): SequenceSearchResult {
|
||||
const allowFuzzy = options?.allowFuzzy ?? true;
|
||||
// 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;
|
||||
|
||||
const runExactPasses = (from: number, to: number): SequenceSearchResult | undefined => {
|
||||
// Pass 1: Exact match
|
||||
for (let i = from; i <= to; i++) {
|
||||
if (matchesAt(lines, pattern, i, (a, b) => a === b)) {
|
||||
return { index: i, confidence: 1.0 };
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Trailing whitespace stripped
|
||||
for (let i = from; i <= to; 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 = from; i <= to; i++) {
|
||||
if (matchesAt(lines, pattern, i, (a, b) => a.trim() === b.trim())) {
|
||||
return { index: i, confidence: 0.98 };
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3b: Comment-prefix normalized match
|
||||
for (let i = from; i <= to; i++) {
|
||||
if (matchesAt(lines, pattern, i, (a, b) => stripCommentPrefix(a) === stripCommentPrefix(b))) {
|
||||
return { index: i, confidence: 0.975 };
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 4: Normalize unicode punctuation
|
||||
for (let i = from; i <= to; i++) {
|
||||
if (matchesAt(lines, pattern, i, (a, b) => normalizeUnicode(a) === normalizeUnicode(b))) {
|
||||
return { index: i, confidence: 0.97 };
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowFuzzy) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Pass 5: Partial line prefix match (track all matches for ambiguity detection)
|
||||
{
|
||||
let firstMatch: number | undefined;
|
||||
let matchCount = 0;
|
||||
for (let i = from; i <= to; i++) {
|
||||
if (matchesAt(lines, pattern, i, lineStartsWithPattern)) {
|
||||
if (firstMatch === undefined) firstMatch = i;
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
if (matchCount > 0) {
|
||||
return { index: firstMatch, confidence: 0.965, matchCount };
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 6: Partial line substring match (track all matches for ambiguity detection)
|
||||
{
|
||||
let firstMatch: number | undefined;
|
||||
let matchCount = 0;
|
||||
for (let i = from; i <= to; i++) {
|
||||
if (matchesAt(lines, pattern, i, lineIncludesPattern)) {
|
||||
if (firstMatch === undefined) firstMatch = i;
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
if (matchCount > 0) {
|
||||
return { index: firstMatch, confidence: 0.94, matchCount };
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const primaryPassResult = runExactPasses(searchStart, maxStart);
|
||||
if (primaryPassResult) {
|
||||
return primaryPassResult;
|
||||
}
|
||||
|
||||
if (eof && searchStart > start) {
|
||||
const fromStartResult = runExactPasses(start, maxStart);
|
||||
if (fromStartResult) {
|
||||
return fromStartResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowFuzzy) {
|
||||
return { index: undefined, confidence: 0 };
|
||||
}
|
||||
|
||||
// Pass 7: Fuzzy matching - find best match above threshold
|
||||
let bestIndex: number | undefined;
|
||||
let bestScore = 0;
|
||||
let matchCount = 0;
|
||||
|
||||
for (let i = searchStart; i <= maxStart; i++) {
|
||||
const score = fuzzyScoreAt(lines, pattern, i);
|
||||
if (score >= SEQUENCE_FUZZY_THRESHOLD) {
|
||||
matchCount++;
|
||||
}
|
||||
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 >= SEQUENCE_FUZZY_THRESHOLD) {
|
||||
matchCount++;
|
||||
}
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestIndex = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestIndex !== undefined && bestScore >= SEQUENCE_FUZZY_THRESHOLD) {
|
||||
return { index: bestIndex, confidence: bestScore, matchCount };
|
||||
}
|
||||
|
||||
// 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;
|
||||
const fallbackMatchCount = matchOutcome.occurrences ?? matchOutcome.fuzzyMatches ?? 1;
|
||||
return { index: lineIndex, confidence: matchOutcome.match.confidence, matchCount: fallbackMatchCount };
|
||||
}
|
||||
|
||||
const fallbackMatchCount = matchOutcome.occurrences ?? matchOutcome.fuzzyMatches;
|
||||
return { index: undefined, confidence: bestScore, matchCount: fallbackMatchCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
options?: { allowFuzzy?: boolean; skipFunctionFallback?: boolean },
|
||||
): ContextLineResult {
|
||||
const allowFuzzy = options?.allowFuzzy ?? true;
|
||||
const trimmedContext = context.trim();
|
||||
|
||||
// Pass 1: Exact line match
|
||||
{
|
||||
let firstMatch: number | undefined;
|
||||
let matchCount = 0;
|
||||
for (let i = startFrom; i < lines.length; i++) {
|
||||
if (lines[i] === context) {
|
||||
if (firstMatch === undefined) firstMatch = i;
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
if (matchCount > 0) {
|
||||
return { index: firstMatch, confidence: 1.0, matchCount };
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Trimmed match
|
||||
{
|
||||
let firstMatch: number | undefined;
|
||||
let matchCount = 0;
|
||||
for (let i = startFrom; i < lines.length; i++) {
|
||||
if (lines[i].trim() === trimmedContext) {
|
||||
if (firstMatch === undefined) firstMatch = i;
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
if (matchCount > 0) {
|
||||
return { index: firstMatch, confidence: 0.99, matchCount };
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: Unicode normalization match
|
||||
const normalizedContext = normalizeUnicode(context);
|
||||
{
|
||||
let firstMatch: number | undefined;
|
||||
let matchCount = 0;
|
||||
for (let i = startFrom; i < lines.length; i++) {
|
||||
if (normalizeUnicode(lines[i]) === normalizedContext) {
|
||||
if (firstMatch === undefined) firstMatch = i;
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
if (matchCount > 0) {
|
||||
return { index: firstMatch, confidence: 0.98, matchCount };
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowFuzzy) {
|
||||
return { index: undefined, confidence: 0 };
|
||||
}
|
||||
|
||||
// Pass 4: Prefix match (file line starts with context)
|
||||
const contextNorm = normalizeForFuzzy(context);
|
||||
if (contextNorm.length > 0) {
|
||||
let firstMatch: number | undefined;
|
||||
let matchCount = 0;
|
||||
for (let i = startFrom; i < lines.length; i++) {
|
||||
const lineNorm = normalizeForFuzzy(lines[i]);
|
||||
if (lineNorm.startsWith(contextNorm)) {
|
||||
if (firstMatch === undefined) firstMatch = i;
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
if (matchCount > 0) {
|
||||
return { index: firstMatch, confidence: 0.96, matchCount };
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 5: Substring match (file line contains context)
|
||||
// First pass: find all substring matches (ignoring ratio)
|
||||
// If exactly one match exists, accept it (uniqueness is sufficient)
|
||||
// If multiple matches, apply ratio filter to disambiguate
|
||||
if (contextNorm.length >= PARTIAL_MATCH_MIN_LENGTH) {
|
||||
const allSubstringMatches: Array<{ index: number; ratio: number }> = [];
|
||||
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);
|
||||
allSubstringMatches.push({ index: i, ratio });
|
||||
}
|
||||
}
|
||||
|
||||
// If exactly one substring match, accept it regardless of ratio
|
||||
if (allSubstringMatches.length === 1) {
|
||||
return { index: allSubstringMatches[0].index, confidence: 0.94, matchCount: 1 };
|
||||
}
|
||||
|
||||
// Multiple matches: filter by ratio to disambiguate
|
||||
let firstMatch: number | undefined;
|
||||
let matchCount = 0;
|
||||
for (const match of allSubstringMatches) {
|
||||
if (match.ratio >= PARTIAL_MATCH_MIN_RATIO) {
|
||||
if (firstMatch === undefined) firstMatch = match.index;
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
if (matchCount > 0) {
|
||||
return { index: firstMatch, confidence: 0.94, matchCount };
|
||||
}
|
||||
|
||||
// If we had substring matches but none passed ratio filter,
|
||||
// return ambiguous result so caller knows matches exist
|
||||
if (allSubstringMatches.length > 1) {
|
||||
return { index: allSubstringMatches[0].index, confidence: 0.94, matchCount: allSubstringMatches.length };
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 6: Fuzzy match using similarity
|
||||
let bestIndex: number | undefined;
|
||||
let bestScore = 0;
|
||||
let matchCount = 0;
|
||||
|
||||
for (let i = startFrom; i < lines.length; i++) {
|
||||
const lineNorm = normalizeForFuzzy(lines[i]);
|
||||
const score = similarity(lineNorm, contextNorm);
|
||||
if (score >= CONTEXT_FUZZY_THRESHOLD) {
|
||||
matchCount++;
|
||||
}
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestIndex !== undefined && bestScore >= CONTEXT_FUZZY_THRESHOLD) {
|
||||
return { index: bestIndex, confidence: bestScore, matchCount };
|
||||
}
|
||||
|
||||
if (!options?.skipFunctionFallback && trimmedContext.endsWith("()")) {
|
||||
const withParen = trimmedContext.replace(/\(\)\s*$/u, "(");
|
||||
const withoutParen = trimmedContext.replace(/\(\)\s*$/u, "");
|
||||
const parenResult = findContextLine(lines, withParen, startFrom, { allowFuzzy, skipFunctionFallback: true });
|
||||
if (parenResult.index !== undefined || (parenResult.matchCount ?? 0) > 0) {
|
||||
return parenResult;
|
||||
}
|
||||
return findContextLine(lines, withoutParen, startFrom, { allowFuzzy, skipFunctionFallback: true });
|
||||
}
|
||||
|
||||
return { index: undefined, confidence: bestScore };
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* Edit tool module.
|
||||
*
|
||||
* Supports two modes:
|
||||
* - Replace mode (default): oldText/newText replacement with fuzzy matching
|
||||
* - Patch mode: structured diff format with explicit operation type
|
||||
*
|
||||
* The mode is determined by the `edit.patchMode` setting.
|
||||
*/
|
||||
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
||||
import { StringEnum } from "@oh-my-pi/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { renderPromptTemplate } from "$c/config/prompt-templates";
|
||||
import {
|
||||
createLspWritethrough,
|
||||
type FileDiagnosticsResult,
|
||||
flushLspWritethroughBatch,
|
||||
type WritethroughCallback,
|
||||
writethroughNoop,
|
||||
} from "$c/lsp/index";
|
||||
import patchDescription from "$c/prompts/tools/patch.md" with { type: "text" };
|
||||
import replaceDescription from "$c/prompts/tools/replace.md" with { type: "text" };
|
||||
import type { ToolSession } from "$c/tools/index";
|
||||
import { outputMeta } from "$c/tools/output-meta";
|
||||
import { resolveToCwd } from "$c/tools/path-utils";
|
||||
import { applyPatch } from "./applicator";
|
||||
import { generateDiffString, generateUnifiedDiffString, replaceText } from "./diff";
|
||||
import { DEFAULT_FUZZY_THRESHOLD, findMatch } from "./fuzzy";
|
||||
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
|
||||
import { buildNormativeUpdateInput } from "./normative";
|
||||
import { type EditToolDetails, getLspBatchRequest } from "./shared";
|
||||
// Internal imports
|
||||
import type { FileSystem, Operation, PatchInput } from "./types";
|
||||
import { EditMatchError } from "./types";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Re-exports
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// Application
|
||||
export { applyPatch, defaultFileSystem, previewPatch } from "./applicator";
|
||||
// Diff generation
|
||||
export { computeEditDiff, computePatchDiff, generateDiffString, generateUnifiedDiffString, replaceText } from "./diff";
|
||||
|
||||
// Fuzzy matching
|
||||
export { DEFAULT_FUZZY_THRESHOLD, findContextLine, findMatch as findEditMatch, findMatch, seekSequence } from "./fuzzy";
|
||||
|
||||
// Normalization
|
||||
export {
|
||||
adjustIndentation,
|
||||
detectLineEnding,
|
||||
normalizeToLF,
|
||||
restoreLineEndings,
|
||||
stripBom,
|
||||
} from "./normalize";
|
||||
|
||||
// Parsing
|
||||
export { normalizeCreateContent, normalizeDiff, parseHunks as parseDiffHunks } from "./parser";
|
||||
export type { EditRenderContext, EditToolDetails } from "./shared";
|
||||
// Rendering
|
||||
export { editToolRenderer, getLspBatchRequest } from "./shared";
|
||||
export type {
|
||||
ApplyPatchOptions,
|
||||
ApplyPatchResult,
|
||||
ContextLineResult,
|
||||
DiffError,
|
||||
DiffError as EditDiffError,
|
||||
DiffHunk,
|
||||
DiffHunk as UpdateChunk,
|
||||
DiffHunk as UpdateFileChunk,
|
||||
DiffResult,
|
||||
DiffResult as EditDiffResult,
|
||||
FileChange,
|
||||
FileSystem,
|
||||
FuzzyMatch as EditMatch,
|
||||
FuzzyMatch,
|
||||
MatchOutcome as EditMatchOutcome,
|
||||
MatchOutcome,
|
||||
Operation,
|
||||
PatchInput,
|
||||
SequenceSearchResult,
|
||||
} from "./types";
|
||||
// Types
|
||||
// Legacy aliases for backwards compatibility
|
||||
export { ApplyPatchError, EditMatchError, ParseError } from "./types";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Schemas
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const replaceEditSchema = Type.Object({
|
||||
path: Type.String({ description: "File path (relative or absolute)" }),
|
||||
old_text: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }),
|
||||
new_text: Type.String({ description: "Replacement text" }),
|
||||
all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: unique match required)" })),
|
||||
});
|
||||
|
||||
const patchEditSchema = Type.Object({
|
||||
path: Type.String({ description: "File path" }),
|
||||
op: Type.Optional(
|
||||
StringEnum(["create", "delete", "update"], {
|
||||
description: "Operation (default: update)",
|
||||
}),
|
||||
),
|
||||
rename: Type.Optional(Type.String({ description: "New path for move" })),
|
||||
diff: Type.Optional(Type.String({ description: "Diff hunks (update) or full content (create)" })),
|
||||
});
|
||||
|
||||
export type ReplaceParams = { path: string; old_text: string; new_text: string; all?: boolean };
|
||||
export type PatchParams = { path: string; op?: string; rename?: string; diff?: string };
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// LSP FileSystem for patch mode
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class LspFileSystem implements FileSystem {
|
||||
private lastDiagnostics: FileDiagnosticsResult | undefined;
|
||||
private fileCache: Record<string, Bun.BunFile> = {};
|
||||
|
||||
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 readBinary(path: string): Promise<Uint8Array> {
|
||||
const buffer = await this.#getFile(path).arrayBuffer();
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Tool Class
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
type TInput = typeof replaceEditSchema | typeof patchEditSchema;
|
||||
|
||||
/**
|
||||
* Edit tool implementation.
|
||||
*
|
||||
* Creates replace-mode or patch-mode behavior based on session settings.
|
||||
*/
|
||||
export class EditTool implements AgentTool<TInput> {
|
||||
public readonly name = "edit";
|
||||
public readonly label = "Edit";
|
||||
public readonly description: string;
|
||||
public readonly parameters: TInput;
|
||||
|
||||
private readonly session: ToolSession;
|
||||
private readonly patchMode: boolean;
|
||||
private readonly allowFuzzy: boolean;
|
||||
private readonly fuzzyThreshold: number;
|
||||
private readonly writethrough: WritethroughCallback;
|
||||
|
||||
constructor(session: ToolSession) {
|
||||
this.session = session;
|
||||
|
||||
const {
|
||||
OMP_EDIT_FUZZY: editFuzzy = "auto",
|
||||
OMP_EDIT_FUZZY_THRESHOLD: editFuzzyThreshold = "auto",
|
||||
OMP_EDIT_VARIANT: editVariant = "auto",
|
||||
} = process.env;
|
||||
|
||||
switch (editVariant) {
|
||||
case "replace":
|
||||
this.patchMode = false;
|
||||
break;
|
||||
case "patch":
|
||||
this.patchMode = true;
|
||||
break;
|
||||
case "auto":
|
||||
this.patchMode = session.settings?.getEditPatchMode?.() ?? true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid OMP_EDIT_VARIANT: ${process.env.OMP_EDIT_VARIANT}`);
|
||||
}
|
||||
switch (editFuzzy) {
|
||||
case "true":
|
||||
case "1":
|
||||
this.allowFuzzy = true;
|
||||
break;
|
||||
case "false":
|
||||
case "0":
|
||||
this.allowFuzzy = false;
|
||||
break;
|
||||
case "auto":
|
||||
this.allowFuzzy = session.settings?.getEditFuzzyMatch() ?? true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid OMP_EDIT_FUZZY: ${editFuzzy}`);
|
||||
}
|
||||
switch (editFuzzyThreshold) {
|
||||
case "auto":
|
||||
this.fuzzyThreshold = session.settings?.getEditFuzzyThreshold?.() ?? DEFAULT_FUZZY_THRESHOLD;
|
||||
break;
|
||||
default:
|
||||
this.fuzzyThreshold = parseFloat(editFuzzyThreshold);
|
||||
if (Number.isNaN(this.fuzzyThreshold) || this.fuzzyThreshold < 0 || this.fuzzyThreshold > 1) {
|
||||
throw new Error(`Invalid OMP_EDIT_FUZZY_THRESHOLD: ${editFuzzyThreshold}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const enableLsp = session.enableLsp ?? true;
|
||||
const enableDiagnostics = enableLsp ? (session.settings?.getLspDiagnosticsOnEdit() ?? false) : false;
|
||||
const enableFormat = enableLsp ? (session.settings?.getLspFormatOnWrite() ?? true) : false;
|
||||
this.writethrough = enableLsp
|
||||
? createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics })
|
||||
: writethroughNoop;
|
||||
this.description = this.patchMode
|
||||
? renderPromptTemplate(patchDescription)
|
||||
: renderPromptTemplate(replaceDescription);
|
||||
this.parameters = this.patchMode ? patchEditSchema : replaceEditSchema;
|
||||
}
|
||||
|
||||
public async execute(
|
||||
_toolCallId: string,
|
||||
params: ReplaceParams | PatchParams,
|
||||
signal?: AbortSignal,
|
||||
_onUpdate?: AgentToolUpdateCallback<EditToolDetails, TInput>,
|
||||
context?: AgentToolContext,
|
||||
): Promise<AgentToolResult<EditToolDetails, TInput>> {
|
||||
const batchRequest = getLspBatchRequest(context?.toolCall);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Patch mode execution
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
if (this.patchMode) {
|
||||
const { path, op: rawOp, rename, diff } = params as PatchParams;
|
||||
|
||||
// Normalize unrecognized operations to "update"
|
||||
const op: Operation = rawOp === "create" || rawOp === "delete" ? rawOp : "update";
|
||||
|
||||
if (path.endsWith(".ipynb")) {
|
||||
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
||||
}
|
||||
if (rename?.endsWith(".ipynb")) {
|
||||
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
||||
}
|
||||
|
||||
const input: PatchInput = { path, op, rename, diff };
|
||||
const fs = new LspFileSystem(this.writethrough, signal, batchRequest);
|
||||
const result = await applyPatch(input, {
|
||||
cwd: this.session.cwd,
|
||||
fs,
|
||||
fuzzyThreshold: this.fuzzyThreshold,
|
||||
allowFuzzy: this.allowFuzzy,
|
||||
});
|
||||
const effRename = result.change.newPath ? rename : undefined;
|
||||
|
||||
// Generate diff for display
|
||||
let diffResult = { diff: "", firstChangedLine: undefined as number | undefined };
|
||||
let normative: PatchInput | undefined;
|
||||
if (result.change.type === "update" && result.change.oldContent && result.change.newContent) {
|
||||
const normalizedOld = normalizeToLF(stripBom(result.change.oldContent).text);
|
||||
const normalizedNew = normalizeToLF(stripBom(result.change.newContent).text);
|
||||
diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew);
|
||||
normative = buildNormativeUpdateInput({
|
||||
path,
|
||||
rename: effRename,
|
||||
oldContent: result.change.oldContent,
|
||||
newContent: result.change.newContent,
|
||||
});
|
||||
}
|
||||
|
||||
let resultText: string;
|
||||
switch (result.change.type) {
|
||||
case "create":
|
||||
resultText = `Created ${path}`;
|
||||
break;
|
||||
case "delete":
|
||||
resultText = `Deleted ${path}`;
|
||||
break;
|
||||
case "update":
|
||||
resultText = effRename ? `Updated and moved ${path} to ${effRename}` : `Updated ${path}`;
|
||||
break;
|
||||
}
|
||||
|
||||
let diagnostics = fs.getDiagnostics();
|
||||
if (op === "delete" && batchRequest?.flush) {
|
||||
const flushedDiagnostics = await flushLspWritethroughBatch(batchRequest.id, this.session.cwd, signal);
|
||||
diagnostics ??= flushedDiagnostics;
|
||||
}
|
||||
|
||||
const meta = outputMeta()
|
||||
.diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
|
||||
.get();
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: resultText }],
|
||||
details: {
|
||||
diff: diffResult.diff,
|
||||
firstChangedLine: diffResult.firstChangedLine,
|
||||
diagnostics,
|
||||
op,
|
||||
rename: effRename,
|
||||
meta,
|
||||
},
|
||||
$normative: normative,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Replace mode execution
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
const { path, old_text, new_text, all } = params as ReplaceParams;
|
||||
|
||||
if (path.endsWith(".ipynb")) {
|
||||
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
||||
}
|
||||
|
||||
if (old_text.length === 0) {
|
||||
throw new Error("old_text must not be empty.");
|
||||
}
|
||||
|
||||
const absolutePath = resolveToCwd(path, this.session.cwd);
|
||||
const file = Bun.file(absolutePath);
|
||||
|
||||
if (!(await file.exists())) {
|
||||
throw new Error(`File not found: ${path}`);
|
||||
}
|
||||
|
||||
const rawContent = await file.text();
|
||||
const { bom, text: content } = stripBom(rawContent);
|
||||
const originalEnding = detectLineEnding(content);
|
||||
const normalizedContent = normalizeToLF(content);
|
||||
const normalizedOldText = normalizeToLF(old_text);
|
||||
const normalizedNewText = normalizeToLF(new_text);
|
||||
|
||||
const result = replaceText(normalizedContent, normalizedOldText, normalizedNewText, {
|
||||
fuzzy: this.allowFuzzy,
|
||||
all: all ?? false,
|
||||
threshold: this.fuzzyThreshold,
|
||||
});
|
||||
|
||||
if (result.count === 0) {
|
||||
// Get error details
|
||||
const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
|
||||
allowFuzzy: this.allowFuzzy,
|
||||
threshold: this.fuzzyThreshold,
|
||||
});
|
||||
|
||||
if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
|
||||
const previews = matchOutcome.occurrencePreviews?.join("\n\n") ?? "";
|
||||
const moreMsg = matchOutcome.occurrences > 5 ? ` (showing first 5 of ${matchOutcome.occurrences})` : "";
|
||||
throw new Error(
|
||||
`Found ${matchOutcome.occurrences} occurrences in ${path}${moreMsg}:\n\n${previews}\n\n` +
|
||||
`Add more context lines to disambiguate.`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new EditMatchError(path, normalizedOldText, matchOutcome.closest, {
|
||||
allowFuzzy: this.allowFuzzy,
|
||||
threshold: this.fuzzyThreshold,
|
||||
fuzzyMatches: matchOutcome.fuzzyMatches,
|
||||
});
|
||||
}
|
||||
|
||||
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(result.content, originalEnding);
|
||||
const diagnostics = await this.writethrough(absolutePath, finalContent, signal, file, batchRequest);
|
||||
const diffResult = generateDiffString(normalizedContent, result.content);
|
||||
|
||||
const resultText =
|
||||
result.count > 1
|
||||
? `Successfully replaced ${result.count} occurrences in ${path}.`
|
||||
: `Successfully replaced text in ${path}.`;
|
||||
|
||||
const meta = outputMeta()
|
||||
.diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
|
||||
.get();
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: resultText }],
|
||||
details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine, diagnostics, meta },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* 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 " ";
|
||||
}
|
||||
|
||||
function gcd(a: number, b: number): number {
|
||||
let x = Math.abs(a);
|
||||
let y = Math.abs(b);
|
||||
while (y !== 0) {
|
||||
const temp = y;
|
||||
y = x % y;
|
||||
x = temp;
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
interface IndentProfile {
|
||||
lines: string[];
|
||||
indentStrings: string[];
|
||||
indentCounts: number[];
|
||||
min: number;
|
||||
char: " " | "\t" | undefined;
|
||||
spaceOnly: boolean;
|
||||
tabOnly: boolean;
|
||||
mixed: boolean;
|
||||
unit: number;
|
||||
nonEmptyCount: number;
|
||||
}
|
||||
|
||||
function buildIndentProfile(text: string): IndentProfile {
|
||||
const lines = text.split("\n");
|
||||
const indentStrings: string[] = [];
|
||||
const indentCounts: number[] = [];
|
||||
let min = Infinity;
|
||||
let char: " " | "\t" | undefined;
|
||||
let spaceOnly = true;
|
||||
let tabOnly = true;
|
||||
let mixed = false;
|
||||
let nonEmptyCount = 0;
|
||||
let unit = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim().length === 0) continue;
|
||||
nonEmptyCount++;
|
||||
const indent = getLeadingWhitespace(line);
|
||||
indentStrings.push(indent);
|
||||
indentCounts.push(indent.length);
|
||||
min = Math.min(min, indent.length);
|
||||
if (indent.includes(" ")) {
|
||||
tabOnly = false;
|
||||
}
|
||||
if (indent.includes("\t")) {
|
||||
spaceOnly = false;
|
||||
}
|
||||
if (indent.includes(" ") && indent.includes("\t")) {
|
||||
mixed = true;
|
||||
}
|
||||
if (indent.length > 0) {
|
||||
const currentChar = indent[0] as " " | "\t";
|
||||
if (!char) {
|
||||
char = currentChar;
|
||||
} else if (char !== currentChar) {
|
||||
mixed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (min === Infinity) {
|
||||
min = 0;
|
||||
}
|
||||
|
||||
if (spaceOnly && nonEmptyCount > 0) {
|
||||
let current = 0;
|
||||
for (const count of indentCounts) {
|
||||
if (count === 0) continue;
|
||||
current = current === 0 ? count : gcd(current, count);
|
||||
}
|
||||
unit = current;
|
||||
}
|
||||
|
||||
if (tabOnly && nonEmptyCount > 0) {
|
||||
unit = 1;
|
||||
}
|
||||
|
||||
return {
|
||||
lines,
|
||||
indentStrings,
|
||||
indentCounts,
|
||||
min,
|
||||
char,
|
||||
spaceOnly,
|
||||
tabOnly,
|
||||
mixed,
|
||||
unit,
|
||||
nonEmptyCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function convertLeadingTabsToSpaces(text: string, spacesPerTab: number): string {
|
||||
if (spacesPerTab <= 0) return text;
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const trimmed = line.trimStart();
|
||||
if (trimmed.length === 0) return line;
|
||||
const leading = getLeadingWhitespace(line);
|
||||
if (!leading.includes("\t") || leading.includes(" ")) return line;
|
||||
const converted = " ".repeat(leading.length * spacesPerTab);
|
||||
return converted + trimmed;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 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 {
|
||||
// If old text already matches actual text exactly, preserve agent's intended indentation
|
||||
if (oldText === actualText) {
|
||||
return newText;
|
||||
}
|
||||
|
||||
// If the patch is purely an indentation change (same trimmed content), apply exactly as specified
|
||||
const oldLines = oldText.split("\n");
|
||||
const newLines = newText.split("\n");
|
||||
if (oldLines.length === newLines.length) {
|
||||
let indentationOnly = true;
|
||||
for (let i = 0; i < oldLines.length; i++) {
|
||||
if (oldLines[i].trim() !== newLines[i].trim()) {
|
||||
indentationOnly = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (indentationOnly) {
|
||||
return newText;
|
||||
}
|
||||
}
|
||||
|
||||
const oldProfile = buildIndentProfile(oldText);
|
||||
const actualProfile = buildIndentProfile(actualText);
|
||||
const newProfile = buildIndentProfile(newText);
|
||||
|
||||
if (newProfile.nonEmptyCount === 0 || oldProfile.nonEmptyCount === 0 || actualProfile.nonEmptyCount === 0) {
|
||||
return newText;
|
||||
}
|
||||
|
||||
if (oldProfile.mixed || actualProfile.mixed || newProfile.mixed) {
|
||||
return newText;
|
||||
}
|
||||
|
||||
if (oldProfile.char && actualProfile.char && oldProfile.char !== actualProfile.char) {
|
||||
if (actualProfile.spaceOnly && oldProfile.tabOnly && newProfile.tabOnly && actualProfile.unit > 0) {
|
||||
let consistent = true;
|
||||
const lineCount = Math.min(oldProfile.lines.length, actualProfile.lines.length);
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
const oldLine = oldProfile.lines[i];
|
||||
const actualLine = actualProfile.lines[i];
|
||||
if (oldLine.trim().length === 0 || actualLine.trim().length === 0) continue;
|
||||
const oldIndent = getLeadingWhitespace(oldLine);
|
||||
const actualIndent = getLeadingWhitespace(actualLine);
|
||||
if (oldIndent.length === 0) continue;
|
||||
if (actualIndent.length !== oldIndent.length * actualProfile.unit) {
|
||||
consistent = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return consistent ? convertLeadingTabsToSpaces(newText, actualProfile.unit) : newText;
|
||||
}
|
||||
return newText;
|
||||
}
|
||||
|
||||
const lineCount = Math.min(oldProfile.lines.length, actualProfile.lines.length);
|
||||
const deltas: number[] = [];
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
const oldLine = oldProfile.lines[i];
|
||||
const actualLine = actualProfile.lines[i];
|
||||
if (oldLine.trim().length === 0 || actualLine.trim().length === 0) continue;
|
||||
deltas.push(countLeadingWhitespace(actualLine) - countLeadingWhitespace(oldLine));
|
||||
}
|
||||
|
||||
if (deltas.length === 0) {
|
||||
return newText;
|
||||
}
|
||||
|
||||
const delta = deltas[0];
|
||||
if (!deltas.every((value) => value === delta)) {
|
||||
return newText;
|
||||
}
|
||||
|
||||
if (delta === 0) {
|
||||
return newText;
|
||||
}
|
||||
|
||||
if (newProfile.char && actualProfile.char && newProfile.char !== actualProfile.char) {
|
||||
return newText;
|
||||
}
|
||||
|
||||
const indentChar = actualProfile.char ?? oldProfile.char ?? detectIndentChar(actualText);
|
||||
const adjusted = newText.split("\n").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);
|
||||
});
|
||||
|
||||
return adjusted.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Normalize applied patch output into a canonical edit tool payload.
|
||||
*/
|
||||
|
||||
import { generateUnifiedDiffString } from "./diff";
|
||||
import { normalizeToLF, stripBom } from "./normalize";
|
||||
import { parseHunks } from "./parser";
|
||||
import type { PatchInput } from "./types";
|
||||
|
||||
export interface NormativePatchOptions {
|
||||
path: string;
|
||||
rename?: string;
|
||||
oldContent: string;
|
||||
newContent: string;
|
||||
contextLines?: number;
|
||||
anchor?: string | string[];
|
||||
}
|
||||
|
||||
/** Normative patch input is the MongoDB-style update variant */
|
||||
|
||||
function applyAnchors(diff: string, anchors: Array<string | undefined> | undefined): string {
|
||||
if (!anchors || anchors.length === 0) {
|
||||
return diff;
|
||||
}
|
||||
const lines = diff.split("\n");
|
||||
let anchorIndex = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (!lines[i].startsWith("@@")) continue;
|
||||
const anchor = anchors[anchorIndex];
|
||||
if (anchor !== undefined) {
|
||||
lines[i] = anchor.trim().length === 0 ? "@@" : `@@ ${anchor}`;
|
||||
}
|
||||
anchorIndex++;
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function deriveAnchors(diff: string): Array<string | undefined> {
|
||||
const hunks = parseHunks(diff);
|
||||
return hunks.map((hunk) => {
|
||||
if (hunk.oldLines.length === 0 || hunk.newLines.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const newLines = new Set(hunk.newLines);
|
||||
for (const line of hunk.oldLines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0) continue;
|
||||
if (!/[A-Za-z0-9_]/.test(trimmed)) continue;
|
||||
if (newLines.has(line)) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildNormativeUpdateInput(options: NormativePatchOptions): PatchInput {
|
||||
const normalizedOld = normalizeToLF(stripBom(options.oldContent).text);
|
||||
const normalizedNew = normalizeToLF(stripBom(options.newContent).text);
|
||||
const diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew, options.contextLines ?? 3);
|
||||
let anchors: Array<string | undefined> | undefined =
|
||||
typeof options.anchor === "string" ? [options.anchor] : options.anchor;
|
||||
if (!anchors) {
|
||||
anchors = deriveAnchors(diffResult.diff);
|
||||
}
|
||||
const diff = applyAnchors(diffResult.diff, anchors);
|
||||
return {
|
||||
path: options.path,
|
||||
op: "update",
|
||||
rename: options.rename,
|
||||
diff,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* 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 { ApplyPatchError, 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*(.*))?$/;
|
||||
|
||||
/** Regex to match @@ line/lines N or N-M pattern (model-generated line hints) */
|
||||
const LINE_HINT_REGEX = /^lines?\s+(\d+)(?:\s*-\s*(\d+))?(?:\s*@@)?$/i;
|
||||
const TOP_OF_FILE_REGEX = /^(top|start|beginning)\s+of\s+file$/i;
|
||||
|
||||
/**
|
||||
* Check if a line is a diff content line (context, addition, or removal).
|
||||
* These should never be treated as metadata even if their content looks like it.
|
||||
* Note: `--- ` and `+++ ` are metadata headers, not content lines.
|
||||
*/
|
||||
function isDiffContentLine(line: string): boolean {
|
||||
const firstChar = line[0];
|
||||
if (firstChar === " ") return true;
|
||||
if (firstChar === "+") {
|
||||
// `+++ ` is metadata, single `+` followed by content is addition
|
||||
return !line.startsWith("+++ ");
|
||||
}
|
||||
if (firstChar === "-") {
|
||||
// `--- ` is metadata, single `-` followed by content is removal
|
||||
return !line.startsWith("--- ");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 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 truly empty lines (not diff content lines like " " which represent blank context)
|
||||
while (lines.length > 0) {
|
||||
const lastLine = lines[lines.length - 1];
|
||||
// Only strip if line is completely empty (no characters) OR
|
||||
// if it's whitespace-only but NOT a diff content line (space prefix = context line)
|
||||
if (lastLine === "" || (lastLine?.trim() === "" && !isDiffContentLine(lastLine ?? ""))) {
|
||||
lines = lines.slice(0, -1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 1: Strip *** Begin Patch / *** End Patch (may have only one or both)
|
||||
if (lines[0]?.trim().startsWith("*** Begin Patch")) {
|
||||
lines = lines.slice(1);
|
||||
}
|
||||
// Also strip bare *** at the beginning (model hallucination)
|
||||
if (lines[0]?.trim() === "***") {
|
||||
lines = lines.slice(1);
|
||||
}
|
||||
if (lines.length > 0 && lines[lines.length - 1]?.trim().startsWith("*** End Patch")) {
|
||||
lines = lines.slice(0, -1);
|
||||
}
|
||||
// Also strip bare *** terminator (model hallucination)
|
||||
if (lines.length > 0 && lines[lines.length - 1]?.trim() === "***") {
|
||||
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
|
||||
// IMPORTANT: Only strip actual metadata lines, NOT diff content lines (starting with space, +, or -)
|
||||
lines = lines.filter((line) => {
|
||||
// Preserve diff content lines even if their content looks like metadata
|
||||
// Note: `--- ` and `+++ ` are metadata, not content lines
|
||||
if (isDiffContentLine(line)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* Handles several context formats:
|
||||
* - Empty: `@@` (no context, match from current position)
|
||||
* - Unified: `@@ -10,3 +10,3 @@` (line numbers as hints)
|
||||
* - Context: `@@ function foo` (search for context line)
|
||||
* - Line hint: `@@ line 125` (use line 125 as starting position)
|
||||
* - Nested: `@@ class Foo\n@@ method` (hierarchical context search)
|
||||
*/
|
||||
function parseOneHunk(lines: string[], lineNumber: number, allowMissingContext: boolean): ParseHunkResult {
|
||||
if (lines.length === 0) {
|
||||
throw new ParseError("Diff does not contain any lines", lineNumber);
|
||||
}
|
||||
|
||||
const changeContexts: string[] = [];
|
||||
let oldStartLine: number | undefined;
|
||||
let newStartLine: number | undefined;
|
||||
let startIndex: number;
|
||||
|
||||
const headerLine = lines[0];
|
||||
const headerTrimmed = headerLine.trimEnd();
|
||||
const isHeaderLine = headerLine.startsWith("@@");
|
||||
const unifiedHeader = isHeaderLine ? parseUnifiedHunkHeader(headerTrimmed) : undefined;
|
||||
const isEmptyContextMarker = /^@@\s*@@$/.test(headerTrimmed);
|
||||
|
||||
// Check for context marker
|
||||
if (isHeaderLine && (headerTrimmed === EMPTY_CHANGE_CONTEXT_MARKER || isEmptyContextMarker)) {
|
||||
startIndex = 1;
|
||||
} else if (unifiedHeader) {
|
||||
if (unifiedHeader.oldStartLine < 1 || unifiedHeader.newStartLine < 1) {
|
||||
throw new ParseError("Line numbers in @@ header must be >= 1", lineNumber);
|
||||
}
|
||||
if (unifiedHeader.changeContext) {
|
||||
changeContexts.push(unifiedHeader.changeContext);
|
||||
}
|
||||
oldStartLine = unifiedHeader.oldStartLine;
|
||||
newStartLine = unifiedHeader.newStartLine;
|
||||
startIndex = 1;
|
||||
} else if (isHeaderLine && headerTrimmed.startsWith(CHANGE_CONTEXT_MARKER)) {
|
||||
const contextValue = headerTrimmed.slice(CHANGE_CONTEXT_MARKER.length);
|
||||
const trimmedContextValue = contextValue.trim();
|
||||
const normalizedContextValue = trimmedContextValue.replace(/^@@\s*/u, "");
|
||||
|
||||
const lineHintMatch = normalizedContextValue.match(LINE_HINT_REGEX);
|
||||
if (lineHintMatch) {
|
||||
oldStartLine = Number(lineHintMatch[1]);
|
||||
newStartLine = oldStartLine;
|
||||
if (oldStartLine < 1) {
|
||||
throw new ParseError("Line hint must be >= 1", lineNumber);
|
||||
}
|
||||
} else if (TOP_OF_FILE_REGEX.test(normalizedContextValue)) {
|
||||
oldStartLine = 1;
|
||||
newStartLine = 1;
|
||||
} else if (trimmedContextValue.length > 0) {
|
||||
changeContexts.push(contextValue);
|
||||
}
|
||||
startIndex = 1;
|
||||
} else if (isHeaderLine) {
|
||||
const contextValue = headerTrimmed.slice(2).trim();
|
||||
if (contextValue.length > 0) {
|
||||
changeContexts.push(contextValue);
|
||||
}
|
||||
startIndex = 1;
|
||||
} else {
|
||||
if (!allowMissingContext) {
|
||||
throw new ParseError(`Expected hunk to start with @@ context marker, got: '${lines[0]}'`, lineNumber);
|
||||
}
|
||||
startIndex = 0;
|
||||
}
|
||||
|
||||
if (oldStartLine !== undefined && oldStartLine < 1) {
|
||||
throw new ParseError(`Line numbers must be >= 1 (got ${oldStartLine})`, lineNumber);
|
||||
}
|
||||
if (newStartLine !== undefined && newStartLine < 1) {
|
||||
throw new ParseError(`Line numbers must be >= 1 (got ${newStartLine})`, lineNumber);
|
||||
}
|
||||
|
||||
// Check for nested @@ anchors on subsequent lines
|
||||
// Format: @@ class Foo
|
||||
// @@ method
|
||||
while (startIndex < lines.length) {
|
||||
const nextLine = lines[startIndex];
|
||||
if (!nextLine.startsWith("@@")) {
|
||||
break;
|
||||
}
|
||||
const trimmed = nextLine.trimEnd();
|
||||
|
||||
// Check if it's another @@ line (nested anchor)
|
||||
if (trimmed.startsWith(CHANGE_CONTEXT_MARKER)) {
|
||||
const nestedContext = trimmed.slice(CHANGE_CONTEXT_MARKER.length);
|
||||
if (nestedContext.trim().length > 0) {
|
||||
changeContexts.push(nestedContext);
|
||||
}
|
||||
startIndex++;
|
||||
} else if (trimmed === EMPTY_CHANGE_CONTEXT_MARKER) {
|
||||
// Empty @@ as separator - skip it
|
||||
startIndex++;
|
||||
} else {
|
||||
// Not an @@ line, stop accumulating
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (startIndex >= lines.length) {
|
||||
throw new ParseError("Hunk does not contain any lines", lineNumber + 1);
|
||||
}
|
||||
|
||||
// Combine contexts: if multiple, join with newline for hierarchical matching
|
||||
const changeContext = changeContexts.length > 0 ? changeContexts.join("\n") : undefined;
|
||||
|
||||
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];
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!isDiffContentLine(line) && line.trimEnd() === EOF_MARKER && line.startsWith(EOF_MARKER)) {
|
||||
if (parsedLines === 0) {
|
||||
throw new ParseError("Hunk does not contain any lines", lineNumber + 1);
|
||||
}
|
||||
hunk.isEndOfFile = true;
|
||||
parsedLines++;
|
||||
break;
|
||||
}
|
||||
|
||||
if (trimmed === "..." || trimmed === "…") {
|
||||
hunk.hasContextLines = true;
|
||||
parsedLines++;
|
||||
continue;
|
||||
}
|
||||
|
||||
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 (!line.startsWith("@@")) {
|
||||
// Implicit context line (model omitted leading space)
|
||||
hunk.hasContextLines = true;
|
||||
hunk.oldLines.push(line);
|
||||
hunk.newLines.push(line);
|
||||
} 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);
|
||||
}
|
||||
|
||||
stripLineNumberPrefixes(hunk);
|
||||
return { hunk, linesConsumed: parsedLines + startIndex };
|
||||
}
|
||||
|
||||
function stripLineNumberPrefixes(hunk: DiffHunk): void {
|
||||
const allLines = [...hunk.oldLines, ...hunk.newLines].filter((line) => line.trim().length > 0);
|
||||
if (allLines.length < 2) return;
|
||||
|
||||
const numberMatches = allLines
|
||||
.map((line) => line.match(/^\s*(\d{1,6})\s+(.+)$/u))
|
||||
.filter((match): match is RegExpMatchArray => match !== null);
|
||||
|
||||
if (numberMatches.length < Math.max(2, Math.ceil(allLines.length * 0.6))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const numbers = numberMatches.map((match) => Number(match[1]));
|
||||
let sequential = 0;
|
||||
for (let i = 1; i < numbers.length; i++) {
|
||||
if (numbers[i] === numbers[i - 1] + 1) {
|
||||
sequential++;
|
||||
}
|
||||
}
|
||||
|
||||
if (numbers.length >= 3 && sequential < Math.max(1, numbers.length - 2)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const strip = (line: string): string => {
|
||||
const match = line.match(/^\s*\d{1,6}\s+(.+)$/u);
|
||||
return match ? match[1] : line;
|
||||
};
|
||||
|
||||
hunk.oldLines = hunk.oldLines.map(strip);
|
||||
hunk.newLines = hunk.newLines.map(strip);
|
||||
}
|
||||
|
||||
/** Multi-file patch markers that indicate this is not a single-file patch */
|
||||
const MULTI_FILE_MARKERS = ["*** Update File:", "*** Add File:", "*** Delete File:", "diff --git "];
|
||||
|
||||
/**
|
||||
* Count multi-file markers in a diff.
|
||||
* Returns the count of file-level markers found.
|
||||
* Only counts lines that are actual metadata (not diff content lines).
|
||||
*/
|
||||
function countMultiFileMarkers(diff: string): number {
|
||||
const counts = new Map<string, number>();
|
||||
const paths = new Set<string>();
|
||||
const lines = diff.split("\n");
|
||||
for (const line of lines) {
|
||||
if (isDiffContentLine(line)) {
|
||||
continue;
|
||||
}
|
||||
const trimmed = line.trim();
|
||||
for (const marker of MULTI_FILE_MARKERS) {
|
||||
if (trimmed.startsWith(marker)) {
|
||||
const path = extractMarkerPath(trimmed);
|
||||
if (path) {
|
||||
paths.add(path);
|
||||
}
|
||||
counts.set(marker, (counts.get(marker) ?? 0) + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (paths.size > 0) {
|
||||
return paths.size;
|
||||
}
|
||||
let maxCount = 0;
|
||||
for (const count of counts.values()) {
|
||||
if (count > maxCount) {
|
||||
maxCount = count;
|
||||
}
|
||||
}
|
||||
return maxCount;
|
||||
}
|
||||
|
||||
function extractMarkerPath(line: string): string | undefined {
|
||||
if (line.startsWith("diff --git ")) {
|
||||
const parts = line.split(/\s+/);
|
||||
const candidate = parts[3] ?? parts[2];
|
||||
if (!candidate) return undefined;
|
||||
return candidate.replace(/^(a|b)\//, "");
|
||||
}
|
||||
if (line.startsWith("*** Update File:")) {
|
||||
return line.slice("*** Update File:".length).trim();
|
||||
}
|
||||
if (line.startsWith("*** Add File:")) {
|
||||
return line.slice("*** Add File:".length).trim();
|
||||
}
|
||||
if (line.startsWith("*** Delete File:")) {
|
||||
return line.slice("*** Delete File:".length).trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all diff hunks from a diff string.
|
||||
*/
|
||||
export function parseHunks(diff: string): DiffHunk[] {
|
||||
const multiFileCount = countMultiFileMarkers(diff);
|
||||
if (multiFileCount > 1) {
|
||||
throw new ApplyPatchError(
|
||||
`Diff contains ${multiFileCount} file markers. Single-file patches cannot contain multi-file markers.`,
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedDiff = normalizeDiff(diff);
|
||||
const lines = normalizedDiff.split("\n");
|
||||
const hunks: DiffHunk[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Skip blank lines between hunks
|
||||
if (trimmed === "") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip unified diff metadata lines, but only if they're not diff content lines
|
||||
const firstChar = line[0];
|
||||
const isDiffContent = firstChar === " " || firstChar === "+" || firstChar === "-";
|
||||
if (!isDiffContent && isUnifiedDiffMetadataLine(trimmed)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("@@") && lines.slice(i + 1).every((l) => l.trim() === "")) {
|
||||
break;
|
||||
}
|
||||
|
||||
const { hunk, linesConsumed } = parseOneHunk(lines.slice(i), i + 1, true);
|
||||
hunks.push(hunk);
|
||||
i += linesConsumed;
|
||||
}
|
||||
|
||||
return hunks;
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Shared utilities for edit tool TUI rendering.
|
||||
*/
|
||||
|
||||
import type { ToolCallContext } from "@oh-my-pi/pi-agent-core";
|
||||
import type { Component } from "@oh-my-pi/pi-tui";
|
||||
import { Text } from "@oh-my-pi/pi-tui";
|
||||
import type { RenderResultOptions } from "$c/extensibility/custom-tools/types";
|
||||
import type { FileDiagnosticsResult } from "$c/lsp/index";
|
||||
import { renderDiff as renderDiffColored } from "$c/modes/components/diff";
|
||||
import { getLanguageFromPath, type Theme } from "$c/modes/theme/theme";
|
||||
import type { OutputMeta } from "$c/tools/output-meta";
|
||||
import {
|
||||
formatExpandHint,
|
||||
formatStatusIcon,
|
||||
getDiffStats,
|
||||
shortenPath,
|
||||
ToolUIKit,
|
||||
truncateDiffByHunk,
|
||||
} from "$c/tools/render-utils";
|
||||
import type { RenderCallOptions } from "$c/tools/renderers";
|
||||
import type { DiffError, DiffResult, Operation } from "./types";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// LSP Batching
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const LSP_BATCH_TOOLS = new Set(["edit", "write"]);
|
||||
|
||||
export function getLspBatchRequest(toolCall: ToolCallContext | undefined): { id: string; flush: boolean } | undefined {
|
||||
if (!toolCall) {
|
||||
return undefined;
|
||||
}
|
||||
const hasOtherWrites = toolCall.toolCalls.some(
|
||||
(call, index) => index !== toolCall.index && LSP_BATCH_TOOLS.has(call.name),
|
||||
);
|
||||
if (!hasOtherWrites) {
|
||||
return undefined;
|
||||
}
|
||||
const hasLaterWrites = toolCall.toolCalls.slice(toolCall.index + 1).some((call) => LSP_BATCH_TOOLS.has(call.name));
|
||||
return { id: toolCall.batchId, flush: !hasLaterWrites };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Tool Details Types
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export interface EditToolDetails {
|
||||
/** Unified diff of the changes made */
|
||||
diff: string;
|
||||
/** Line number of the first change in the new file (for editor navigation) */
|
||||
firstChangedLine?: number;
|
||||
/** Diagnostic result (if available) */
|
||||
diagnostics?: FileDiagnosticsResult;
|
||||
/** Operation type (patch mode only) */
|
||||
op?: Operation;
|
||||
/** New path after move/rename (patch mode only) */
|
||||
rename?: string;
|
||||
/** Structured output metadata */
|
||||
meta?: OutputMeta;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// TUI Renderer
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
interface EditRenderArgs {
|
||||
path?: string;
|
||||
file_path?: string;
|
||||
oldText?: string;
|
||||
newText?: string;
|
||||
patch?: string;
|
||||
all?: boolean;
|
||||
// Patch mode fields
|
||||
op?: Operation;
|
||||
rename?: string;
|
||||
diff?: string;
|
||||
}
|
||||
|
||||
/** Extended context for edit tool rendering */
|
||||
export interface EditRenderContext {
|
||||
/** Pre-computed diff preview (computed before tool executes) */
|
||||
editDiffPreview?: DiffResult | DiffError;
|
||||
/** Function to render diff text with syntax highlighting */
|
||||
renderDiff?: (diffText: string, options?: { filePath?: string }) => string;
|
||||
}
|
||||
|
||||
const EDIT_DIFF_PREVIEW_HUNKS = 2;
|
||||
const EDIT_DIFF_PREVIEW_LINES = 24;
|
||||
const EDIT_STREAMING_PREVIEW_LINES = 12;
|
||||
|
||||
function countLines(text: string): number {
|
||||
if (!text) return 0;
|
||||
return text.split("\n").length;
|
||||
}
|
||||
|
||||
function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme): string {
|
||||
if (!diff) return "";
|
||||
const lines = diff.split("\n");
|
||||
const total = lines.length;
|
||||
const displayLines = lines.slice(-EDIT_STREAMING_PREVIEW_LINES);
|
||||
const hidden = total - displayLines.length;
|
||||
|
||||
let text = "\n\n";
|
||||
if (hidden > 0) {
|
||||
text += uiTheme.fg("dim", `${uiTheme.format.ellipsis} (${hidden} earlier lines)\n`);
|
||||
}
|
||||
text += renderDiffColored(displayLines.join("\n"), { filePath: rawPath });
|
||||
text += uiTheme.fg("dim", `\n${uiTheme.format.ellipsis} (streaming)`);
|
||||
return text;
|
||||
}
|
||||
|
||||
function formatMetadataLine(lineCount: number | null, language: string | undefined, uiTheme: Theme): string {
|
||||
const icon = uiTheme.getLangIcon(language);
|
||||
if (lineCount !== null) {
|
||||
return uiTheme.fg("dim", `${icon} ${lineCount} lines`);
|
||||
}
|
||||
return uiTheme.fg("dim", `${icon}`);
|
||||
}
|
||||
|
||||
function renderDiffSection(
|
||||
diff: string,
|
||||
rawPath: string,
|
||||
expanded: boolean,
|
||||
uiTheme: Theme,
|
||||
ui: ToolUIKit,
|
||||
renderDiffFn: (t: string, o?: { filePath?: string }) => string,
|
||||
): string {
|
||||
let text = "";
|
||||
const diffStats = getDiffStats(diff);
|
||||
text += `\n${uiTheme.fg("dim", uiTheme.format.bracketLeft)}${ui.formatDiffStats(
|
||||
diffStats.added,
|
||||
diffStats.removed,
|
||||
diffStats.hunks,
|
||||
)}${uiTheme.fg("dim", uiTheme.format.bracketRight)}`;
|
||||
|
||||
const {
|
||||
text: truncatedDiff,
|
||||
hiddenHunks,
|
||||
hiddenLines,
|
||||
} = expanded
|
||||
? { text: diff, hiddenHunks: 0, hiddenLines: 0 }
|
||||
: truncateDiffByHunk(diff, EDIT_DIFF_PREVIEW_HUNKS, EDIT_DIFF_PREVIEW_LINES);
|
||||
|
||||
text += `\n\n${renderDiffFn(truncatedDiff, { filePath: rawPath })}`;
|
||||
if (!expanded && (hiddenHunks > 0 || hiddenLines > 0)) {
|
||||
const remainder: string[] = [];
|
||||
if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
|
||||
if (hiddenLines > 0) remainder.push(`${hiddenLines} more lines`);
|
||||
text += uiTheme.fg(
|
||||
"toolOutput",
|
||||
`\n${uiTheme.format.ellipsis} (${remainder.join(", ")}) ${formatExpandHint(uiTheme)}`,
|
||||
);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export const editToolRenderer = {
|
||||
mergeCallAndResult: true,
|
||||
|
||||
renderCall(args: EditRenderArgs, uiTheme: Theme, options?: RenderCallOptions): Component {
|
||||
const ui = new ToolUIKit(uiTheme);
|
||||
const rawPath = args.file_path || args.path || "";
|
||||
const filePath = shortenPath(rawPath);
|
||||
const editLanguage = getLanguageFromPath(rawPath) ?? "text";
|
||||
const editIcon = uiTheme.fg("muted", uiTheme.getLangIcon(editLanguage));
|
||||
let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", uiTheme.format.ellipsis);
|
||||
|
||||
// Add arrow for move/rename operations
|
||||
if (args.rename) {
|
||||
pathDisplay += ` ${uiTheme.fg("dim", "→")} ${uiTheme.fg("accent", shortenPath(args.rename))}`;
|
||||
}
|
||||
|
||||
// Show operation type for patch mode
|
||||
const opTitle = args.op === "create" ? "Create" : args.op === "delete" ? "Delete" : "Edit";
|
||||
const spinner =
|
||||
options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
|
||||
let text = `${ui.title(opTitle)} ${spinner ? `${spinner} ` : ""}${editIcon} ${pathDisplay}`;
|
||||
|
||||
// Show streaming preview of diff/content
|
||||
const streamingContent = args.diff ?? args.newText ?? args.patch;
|
||||
if (streamingContent) {
|
||||
text += formatStreamingDiff(streamingContent, rawPath, uiTheme);
|
||||
}
|
||||
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(
|
||||
result: { content: Array<{ type: string; text?: string }>; details?: EditToolDetails; isError?: boolean },
|
||||
options: RenderResultOptions & { renderContext?: EditRenderContext },
|
||||
uiTheme: Theme,
|
||||
args?: EditRenderArgs,
|
||||
): Component {
|
||||
const ui = new ToolUIKit(uiTheme);
|
||||
const { expanded, renderContext } = options;
|
||||
const rawPath = args?.file_path || args?.path || "";
|
||||
const filePath = shortenPath(rawPath);
|
||||
const editLanguage = getLanguageFromPath(rawPath) ?? "text";
|
||||
const editIcon = uiTheme.fg("muted", uiTheme.getLangIcon(editLanguage));
|
||||
const editDiffPreview = renderContext?.editDiffPreview;
|
||||
const renderDiffFn = renderContext?.renderDiff ?? ((t: string) => t);
|
||||
|
||||
// Get op and rename from args or details
|
||||
const op = args?.op || result.details?.op;
|
||||
const rename = args?.rename || result.details?.rename;
|
||||
|
||||
// Build path display with line number if available
|
||||
let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", uiTheme.format.ellipsis);
|
||||
const firstChangedLine =
|
||||
(editDiffPreview && "firstChangedLine" in editDiffPreview ? editDiffPreview.firstChangedLine : undefined) ||
|
||||
(result.details && !result.isError ? result.details.firstChangedLine : undefined);
|
||||
if (firstChangedLine) {
|
||||
pathDisplay += uiTheme.fg("warning", `:${firstChangedLine}`);
|
||||
}
|
||||
|
||||
// Add arrow for rename operations
|
||||
if (rename) {
|
||||
pathDisplay += ` ${uiTheme.fg("dim", "→")} ${uiTheme.fg("accent", shortenPath(rename))}`;
|
||||
}
|
||||
|
||||
// Show operation type for patch mode
|
||||
const opTitle = op === "create" ? "Create" : op === "delete" ? "Delete" : "Edit";
|
||||
let text = `${uiTheme.fg("toolTitle", uiTheme.bold(opTitle))} ${editIcon} ${pathDisplay}`;
|
||||
|
||||
// Skip metadata line for delete operations
|
||||
if (op !== "delete") {
|
||||
const editLineCount = countLines(args?.newText ?? args?.oldText ?? args?.diff ?? args?.patch ?? "");
|
||||
text += `\n${formatMetadataLine(editLineCount, editLanguage, uiTheme)}`;
|
||||
}
|
||||
|
||||
if (result.isError) {
|
||||
// Show error from result
|
||||
const errorText = result.content?.find((c) => c.type === "text")?.text ?? "";
|
||||
if (errorText) {
|
||||
text += `\n\n${uiTheme.fg("error", errorText)}`;
|
||||
}
|
||||
} else if (result.details?.diff) {
|
||||
// Prefer actual diff after execution
|
||||
text += renderDiffSection(result.details.diff, rawPath, expanded, uiTheme, ui, renderDiffFn);
|
||||
} else if (editDiffPreview) {
|
||||
// Use cached diff preview when no actual diff is available
|
||||
if ("error" in editDiffPreview) {
|
||||
text += `\n\n${uiTheme.fg("error", editDiffPreview.error)}`;
|
||||
} else if (editDiffPreview.diff) {
|
||||
text += renderDiffSection(editDiffPreview.diff, rawPath, expanded, uiTheme, ui, renderDiffFn);
|
||||
}
|
||||
}
|
||||
|
||||
// Show LSP diagnostics if available
|
||||
if (result.details?.diagnostics) {
|
||||
text += ui.formatDiagnostics(result.details.diagnostics, expanded, (fp: string) =>
|
||||
uiTheme.getLangIcon(getLanguageFromPath(fp)),
|
||||
);
|
||||
}
|
||||
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* 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>;
|
||||
readBinary?: (path: string) => Promise<Uint8Array>;
|
||||
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;
|
||||
/** Line numbers where occurrences were found (1-indexed) */
|
||||
occurrenceLines?: number[];
|
||||
/** Preview snippets for each occurrence (up to 5) */
|
||||
occurrencePreviews?: string[];
|
||||
/** 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;
|
||||
/** Number of matches at the same confidence level (for ambiguity detection) */
|
||||
matchCount?: 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;
|
||||
/** Number of matches at the same confidence level (for ambiguity detection) */
|
||||
matchCount?: 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 */
|
||||
op: Operation;
|
||||
/** New path for rename (update only) */
|
||||
rename?: string;
|
||||
/** File content (create) or diff hunks (update) */
|
||||
diff?: string;
|
||||
}
|
||||
|
||||
/** Normalized patch input used internally by the applicator. */
|
||||
export interface NormalizedPatchInput {
|
||||
path: string;
|
||||
op: Operation;
|
||||
rename?: string;
|
||||
diff?: string;
|
||||
}
|
||||
|
||||
export function normalizePatchInput(input: PatchInput): NormalizedPatchInput {
|
||||
return {
|
||||
path: input.path,
|
||||
op: input.op ?? "update",
|
||||
rename: input.rename,
|
||||
diff: input.diff,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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;
|
||||
/** Similarity threshold for fuzzy matching */
|
||||
fuzzyThreshold?: number;
|
||||
/** Allow fuzzy/partial matching when applying hunks */
|
||||
allowFuzzy?: 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] ?? "" };
|
||||
}
|
||||
Reference in New Issue
Block a user