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:
can1357
2026-01-23 12:24:47 +01:00
parent 7b5af2dd9f
commit cb5bbbf382
531 changed files with 1316 additions and 1322 deletions
File diff suppressed because it is too large Load Diff
+366
View File
@@ -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) };
}
}
+665
View File
@@ -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 };
}
+428
View File
@@ -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,
};
}
+528
View File
@@ -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;
}
+259
View File
@@ -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);
},
};
+248
View File
@@ -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] ?? "" };
}