← vibesdk
worker/agents/output-formats/diff-formats/search-replace.ts
36.0 KB
Download file
/**
* Search/Replace Diff Format Implementation
*
* This format is designed to be simple and reliable for LLM-generated diffs.
* Each edit is specified as a search block followed by a replace block.
*
* Format:
* ```
* <<<<<<< SEARCH
* content to find
* =======
* content to replace with
* >>>>>>> REPLACE
* ```
*/
export enum MatchingStrategy {
EXACT = 'exact',
WHITESPACE_INSENSITIVE = 'whitespace-insensitive',
INDENTATION_PRESERVING = 'indentation-preserving',
FUZZY = 'fuzzy'
}
interface SearchReplaceBlock {
search: string;
replace: string;
lineNumber?: number; // For debugging
}
interface ParseResult {
blocks: SearchReplaceBlock[];
errors: string[];
}
interface MatchResult {
found: boolean;
startIndex: number;
endIndex: number;
matchedText: string;
strategy: MatchingStrategy;
}
/**
* Enhanced search/replace diff parser with robust error handling
* Implements a clean state machine to handle malformed blocks, code fences, and edge cases
*/
function parseSearchReplaceDiff(diffContent: string): ParseResult {
const blocks: SearchReplaceBlock[] = [];
const errors: string[] = [];
// Normalize line endings
const normalizedDiff = diffContent.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const lines = normalizedDiff.split('\n');
// State machine states
enum ParserState {
OUTSIDE, // Outside any block
IN_SEARCH, // Collecting search content
IN_REPLACE, // Collecting replace content
MALFORMED // Block is malformed, skip to next
}
let state = ParserState.OUTSIDE;
let currentBlock: {
search: string[];
replace: string[];
startLine: number;
separatorType?: string;
} | null = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
switch (state) {
case ParserState.OUTSIDE:
if (line === '<<<<<<< SEARCH') {
// Start new search block
currentBlock = {
search: [],
replace: [],
startLine: i
};
state = ParserState.IN_SEARCH;
}
// Ignore all other lines when outside blocks
break;
case ParserState.IN_SEARCH:
if (line === '<<<<<<< SEARCH') {
// Another SEARCH without REPLACE - previous block is orphaned
if (currentBlock) {
errors.push(`Block at line ${currentBlock.startLine + 1}: SEARCH block without corresponding REPLACE (followed by another SEARCH)`);
}
// Start new search block
currentBlock = {
search: [],
replace: [],
startLine: i
};
// Stay in IN_SEARCH state
} else if (line === '=======' || line === '```') {
// Found separator - transition to replace collection
if (currentBlock) {
currentBlock.separatorType = line;
state = ParserState.IN_REPLACE;
}
} else {
// Collect search content
if (currentBlock) {
currentBlock.search.push(lines[i]);
}
}
break;
case ParserState.IN_REPLACE:
if (!currentBlock) {
state = ParserState.OUTSIDE;
break;
}
if (line === '>>>>>>> REPLACE') {
// Found proper end marker
completeBlock(currentBlock, blocks, errors);
currentBlock = null;
state = ParserState.OUTSIDE;
} else if (currentBlock.separatorType === '```' && line === '```') {
// Found code fence end
completeBlock(currentBlock, blocks, errors);
currentBlock = null;
state = ParserState.OUTSIDE;
} else if (line === '<<<<<<< SEARCH') {
// New search block without proper end - complete current block first
errors.push(`Block at line ${currentBlock.startLine + 1}: REPLACE block ended prematurely (no end marker found)`);
completeBlock(currentBlock, blocks, errors);
// Start new search block
currentBlock = {
search: [],
replace: [],
startLine: i
};
state = ParserState.IN_SEARCH;
} else if (line === '=======') {
// Additional separator in replace section - this is always malformed
// Once we're in IN_REPLACE state, we shouldn't see another separator
errors.push(`Block at line ${currentBlock.startLine + 1}: Malformed block with multiple separators - block ignored`);
state = ParserState.MALFORMED;
} else {
// Collect replace content
currentBlock.replace.push(lines[i]);
}
break;
case ParserState.MALFORMED:
if (line === '<<<<<<< SEARCH') {
// Found next valid block - reset
currentBlock = {
search: [],
replace: [],
startLine: i
};
state = ParserState.IN_SEARCH;
} else if (line === '```') {
// Code fence might end malformed block - go back to outside state
state = ParserState.OUTSIDE;
currentBlock = null;
}
// Skip all other lines in malformed state
break;
}
}
// Handle incomplete blocks at end of file
if (currentBlock) {
switch (state) {
case ParserState.IN_SEARCH:
errors.push(`Block at line ${currentBlock.startLine + 1}: SEARCH block without corresponding REPLACE (end of file reached)`);
break;
case ParserState.IN_REPLACE:
errors.push(`Block at line ${currentBlock.startLine + 1}: REPLACE block ended prematurely (end of file reached)`);
break;
}
}
return { blocks, errors };
/**
* Helper function to complete and validate a block
*/
function completeBlock(
block: { search: string[]; replace: string[]; startLine: number },
blocks: SearchReplaceBlock[],
errors: string[]
): void {
const cleanedSearch = block.search.join('\n').replace(/\n+$/, '');
const cleanedReplace = block.replace.join('\n').replace(/\n+$/, '');
// Allow empty search for pure additions (when search is just whitespace/newlines)
// But reject truly empty search sections (no content at all)
if (block.search.length === 0) {
errors.push(`Block at line ${block.startLine + 1}: Empty SEARCH section`);
return;
}
blocks.push({
search: cleanedSearch,
replace: cleanedReplace,
lineNumber: block.startLine + 1
});
}
}
/**
* Normalize whitespace by converting multiple whitespace chars to single spaces
* and trimming line ends, while preserving line structure
*/
function normalizeWhitespace(text: string): string {
return text
.split('\n')
.map(line => line.replace(/\s+/g, ' ').trim())
.join('\n')
.trim();
}
/**
* Extract indentation pattern from text (preserves relative indentation)
*/
function extractIndentationPattern(text: string): { pattern: string; lines: string[] } {
const lines = text.split('\n');
const indentedLines = lines.map(line => {
const match = line.match(/^(\s*)(.*$)/);
return match ? { indent: match[1], content: match[2] } : { indent: '', content: line };
});
const pattern = indentedLines.map(({ indent, content }) =>
content.trim() ? `${indent.length}:${content.trim()}` : ''
).join('\n');
return { pattern, lines: indentedLines.map(l => l.content.trim()) };
}
/**
* Simple fuzzy matching using Levenshtein distance ratio
*/
function calculateSimilarity(a: string, b: string): number {
if (a === b) return 1.0;
if (a.length === 0 || b.length === 0) return 0.0;
const maxLen = Math.max(a.length, b.length);
const distance = levenshteinDistance(a, b);
return 1 - (distance / maxLen);
}
function levenshteinDistance(a: string, b: string): number {
const matrix = Array(b.length + 1).fill(null).map(() => Array(a.length + 1).fill(null));
for (let i = 0; i <= a.length; i++) matrix[0][i] = i;
for (let j = 0; j <= b.length; j++) matrix[j][0] = j;
for (let j = 1; j <= b.length; j++) {
for (let i = 1; i <= a.length; i++) {
const substitutionCost = a[i - 1] === b[j - 1] ? 0 : 1;
matrix[j][i] = Math.min(
matrix[j][i - 1] + 1, // insertion
matrix[j - 1][i] + 1, // deletion
matrix[j - 1][i - 1] + substitutionCost // substitution
);
}
}
return matrix[b.length][a.length];
}
/**
* Find text match using specified strategy
*/
function findMatch(content: string, searchText: string, strategy: MatchingStrategy): MatchResult {
switch (strategy) {
case MatchingStrategy.EXACT:
return findExactMatch(content, searchText);
case MatchingStrategy.WHITESPACE_INSENSITIVE:
return findWhitespaceInsensitiveMatch(content, searchText);
case MatchingStrategy.INDENTATION_PRESERVING:
return findIndentationPreservingMatch(content, searchText);
case MatchingStrategy.FUZZY:
return findFuzzyMatch(content, searchText);
default:
return findExactMatch(content, searchText);
}
}
function findExactMatch(content: string, searchText: string): MatchResult {
const index = content.indexOf(searchText);
if (index === -1) {
return { found: false, startIndex: -1, endIndex: -1, matchedText: '', strategy: MatchingStrategy.EXACT };
}
return {
found: true,
startIndex: index,
endIndex: index + searchText.length,
matchedText: searchText,
strategy: MatchingStrategy.EXACT
};
}
function findWhitespaceInsensitiveMatch(content: string, searchText: string): MatchResult {
const normalizedContent = normalizeWhitespace(content);
const normalizedSearch = normalizeWhitespace(searchText);
const index = normalizedContent.indexOf(normalizedSearch);
if (index === -1) {
return { found: false, startIndex: -1, endIndex: -1, matchedText: '', strategy: MatchingStrategy.WHITESPACE_INSENSITIVE };
}
// Find the actual text boundaries in the original content
const actualMatch = findActualMatchBoundaries(content, searchText, normalizedContent, normalizedSearch, index);
return {
found: true,
startIndex: actualMatch.start,
endIndex: actualMatch.end,
matchedText: content.slice(actualMatch.start, actualMatch.end),
strategy: MatchingStrategy.WHITESPACE_INSENSITIVE
};
}
function findIndentationPreservingMatch(content: string, searchText: string): MatchResult {
const contentPattern = extractIndentationPattern(content);
const searchPattern = extractIndentationPattern(searchText);
// Look for pattern match in content
const patternIndex = contentPattern.pattern.indexOf(searchPattern.pattern);
if (patternIndex === -1) {
return { found: false, startIndex: -1, endIndex: -1, matchedText: '', strategy: MatchingStrategy.INDENTATION_PRESERVING };
}
// Find actual boundaries in original content
const actualMatch = findPatternMatchBoundaries(content, searchText, contentPattern, searchPattern);
return {
found: actualMatch.found,
startIndex: actualMatch.start,
endIndex: actualMatch.end,
matchedText: actualMatch.found ? content.slice(actualMatch.start, actualMatch.end) : '',
strategy: MatchingStrategy.INDENTATION_PRESERVING
};
}
function findFuzzyMatch(content: string, searchText: string, threshold: number = 0.8): MatchResult {
const searchLines = searchText.split('\n');
const contentLines = content.split('\n');
// IMPROVED: Analyze search quality and adjust threshold dynamically
const quality = analyzeSearchBlockQuality(searchText, content);
const adjustedThreshold = Math.max(threshold, quality.recommendedThreshold);
// Use sliding window to find best match with context validation
let bestMatch = { similarity: 0, contextScore: 0, startLine: -1, endLine: -1, overallScore: 0 };
for (let i = 0; i <= contentLines.length - searchLines.length; i++) {
const candidate = contentLines.slice(i, i + searchLines.length).join('\n');
const similarity = calculateSimilarity(normalizeWhitespace(candidate), normalizeWhitespace(searchText));
if (similarity >= adjustedThreshold) {
// IMPROVED: Validate context for matches above threshold
const contextScore = validateMatchContext(content, candidate, i, i + searchLines.length);
const overallScore = similarity * 0.7 + contextScore * 0.3; // Weight similarity higher but include context
if (overallScore > bestMatch.overallScore) {
bestMatch = { similarity, contextScore, startLine: i, endLine: i + searchLines.length, overallScore };
}
}
}
if (bestMatch.overallScore === 0) {
return { found: false, startIndex: -1, endIndex: -1, matchedText: '', strategy: MatchingStrategy.FUZZY };
}
// Convert line indices to character indices
const beforeLines = contentLines.slice(0, bestMatch.startLine);
const matchLines = contentLines.slice(bestMatch.startLine, bestMatch.endLine);
const startIndex = beforeLines.join('\n').length + (beforeLines.length > 0 ? 1 : 0);
const matchedText = matchLines.join('\n');
const endIndex = startIndex + matchedText.length;
return {
found: true,
startIndex,
endIndex,
matchedText,
strategy: MatchingStrategy.FUZZY
};
}
// Helper functions for boundary detection
function findActualMatchBoundaries(content: string, searchText: string, _normalizedContent: string, normalizedSearch: string, _normalizedIndex: number): { start: number; end: number } {
// This is a simplified approach - in practice you'd want more sophisticated mapping
// For now, we'll use the normalized positions as approximations
const lines = content.split('\n');
const searchLines = searchText.split('\n');
// Find the line that contains our match
for (let i = 0; i <= lines.length - searchLines.length; i++) {
const candidate = lines.slice(i, i + searchLines.length).join('\n');
if (normalizeWhitespace(candidate) === normalizedSearch) {
const beforeLines = lines.slice(0, i);
const startIndex = beforeLines.join('\n').length + (beforeLines.length > 0 ? 1 : 0);
return { start: startIndex, end: startIndex + candidate.length };
}
}
// Fallback to approximate positions
return { start: _normalizedIndex, end: _normalizedIndex + normalizedSearch.length };
}
function findPatternMatchBoundaries(content: string, searchText: string, _contentPattern: { pattern: string; lines: string[] }, searchPattern: { pattern: string; lines: string[] }): { found: boolean; start: number; end: number } {
// Simplified pattern matching - could be enhanced
const lines = content.split('\n');
const searchLines = searchText.split('\n');
for (let i = 0; i <= lines.length - searchLines.length; i++) {
const candidate = lines.slice(i, i + searchLines.length).join('\n');
const candidatePattern = extractIndentationPattern(candidate);
if (candidatePattern.pattern === searchPattern.pattern) {
const beforeLines = lines.slice(0, i);
const startIndex = beforeLines.join('\n').length + (beforeLines.length > 0 ? 1 : 0);
return { found: true, start: startIndex, end: startIndex + candidate.length };
}
}
return { found: false, start: -1, end: -1 };
}
/**
* Apply a single search/replace block to content with robust matching
*/
function applySearchReplaceBlock(
content: string,
block: SearchReplaceBlock,
options: {
matchingStrategies?: MatchingStrategy[];
fuzzyThreshold?: number;
} = {}
): { result: string | null; error?: string; strategy?: MatchingStrategy } {
// Handle empty search (pure additions)
if (block.search.trim() === '') {
// Append to end of content
if (content === '') {
return { result: block.replace };
}
const separator = content.endsWith('\n') ? '' : '\n';
return { result: content + separator + block.replace };
}
// Default matching strategies in order of preference
const strategies = options.matchingStrategies || [
MatchingStrategy.EXACT,
MatchingStrategy.WHITESPACE_INSENSITIVE,
MatchingStrategy.INDENTATION_PRESERVING,
MatchingStrategy.FUZZY
];
// Try each strategy until one succeeds
for (const strategy of strategies) {
const matchResult = findMatch(content, block.search, strategy);
if (!matchResult.found) {
continue;
}
// Check for ambiguous matches (multiple occurrences) - CRITICAL SAFETY CHECK
if (strategy === MatchingStrategy.EXACT) {
const occurrences = content.split(block.search).length - 1;
if (occurrences > 1) {
return {
result: null,
error: `Search block found ${occurrences} times (ambiguous)`
};
}
} else {
// For non-exact strategies, check if there are multiple similar matches
const potentialMatches = countSimilarMatches(content, block.search, strategy, options.fuzzyThreshold);
if (potentialMatches > 1) {
return {
result: null,
error: `Search block found ${potentialMatches} similar matches (ambiguous) using ${strategy} matching`
};
}
}
// Apply the replacement
const before = content.slice(0, matchResult.startIndex);
const after = content.slice(matchResult.endIndex);
const result = before + block.replace + after;
return {
result,
strategy: matchResult.strategy
};
}
// No strategy succeeded
return {
result: null,
error: `Search block not found using any matching strategy. Tried: ${strategies.join(', ')}`
};
}
/**
* Analyze search block quality to prevent ambiguity
*/
function analyzeSearchBlockQuality(searchText: string, targetContent: string): {
uniquenessScore: number;
specificity: number;
recommendedThreshold: number;
warnings: string[];
} {
const warnings: string[] = [];
let uniquenessScore = 1.0;
let specificity = 1.0;
// Check for overly common patterns that cause ambiguity
const commonPatterns = [
'const ', 'let ', 'if (', 'for (', '} else {', 'return ',
'break;', 'continue;', '&&', '||', '===', '!=='
];
for (const pattern of commonPatterns) {
if (searchText.includes(pattern)) {
try {
const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const occurrences = (targetContent.match(new RegExp(escapedPattern, 'g')) || []).length;
if (occurrences > 3) {
uniquenessScore *= 0.85;
}
} catch (e) {
// Skip regex errors
}
}
}
// Check for repetitive mathematical/algorithmic patterns (your specific issue)
const mathPatterns = ['Math.sqrt', 'Math.pow', 'getBoundingClientRect', 'distance', 'minDistance', 'bestCandidate'];
let mathPatternCount = 0;
for (const pattern of mathPatterns) {
if (searchText.includes(pattern)) {
mathPatternCount++;
try {
const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const occurrences = (targetContent.match(new RegExp(escapedPattern, 'g')) || []).length;
if (occurrences > 2) {
uniquenessScore *= 0.7;
specificity *= 0.8;
warnings.push(`Mathematical pattern "${pattern}" may cause ambiguity (${occurrences} occurrences)`);
}
} catch (e) {
// Skip regex errors
}
}
}
// Check for case/switch statement context (your specific ArrowDown/ArrowUp issue)
const hasCaseStatement = /case\s+['"`][^'"`]*['"`]:/.test(searchText);
if (hasCaseStatement) {
try {
const caseCount = (targetContent.match(/case\s+['"`][^'"`]*['"`]:/g) || []).length;
if (caseCount > 2) {
uniquenessScore *= 0.6;
warnings.push('Switch case detected with multiple similar cases - high ambiguity risk');
}
} catch (e) {
// Skip regex errors
}
}
// Calculate recommended threshold
let recommendedThreshold = 0.8; // Default
if (uniquenessScore < 0.4) {
recommendedThreshold = 0.95; // Very high threshold for ambiguous content
} else if (uniquenessScore < 0.6) {
recommendedThreshold = 0.9; // High threshold for moderately ambiguous content
} else if (mathPatternCount > 2) {
recommendedThreshold = 0.88; // Slightly higher for mathematical patterns
}
return {
uniquenessScore: Math.max(0.1, uniquenessScore),
specificity: Math.max(0.1, specificity),
recommendedThreshold,
warnings
};
}
/**
* Enhanced context-aware ambiguity detection
* IMPROVED: Now includes quality analysis and smart threshold adjustment
*/
function countSimilarMatches(content: string, searchText: string, strategy: MatchingStrategy, fuzzyThreshold?: number): number {
let count = 0;
// Use direct matching logic based on strategy to avoid recursion
switch (strategy) {
case MatchingStrategy.EXACT:
// Count exact occurrences
count = content.split(searchText).length - 1;
break;
case MatchingStrategy.WHITESPACE_INSENSITIVE:
{
// Count whitespace-normalized matches
const normalizedContent = normalizeWhitespace(content);
const normalizedSearch = normalizeWhitespace(searchText);
count = normalizedContent.split(normalizedSearch).length - 1;
break;
}
case MatchingStrategy.INDENTATION_PRESERVING:
{
// Count indentation pattern matches
const contentPattern = extractIndentationPattern(content);
const searchPattern = extractIndentationPattern(searchText);
if (searchPattern.pattern) {
count = contentPattern.pattern.split(searchPattern.pattern).length - 1;
}
break;
}
case MatchingStrategy.FUZZY:
{
// Enhanced fuzzy counting with smart threshold adjustment
const quality = analyzeSearchBlockQuality(searchText, content);
const adjustedThreshold = Math.max(fuzzyThreshold || 0.85, quality.recommendedThreshold);
const lines = content.split('\n');
const searchLines = searchText.split('\n');
// Use higher standards for counting matches in ambiguous scenarios
for (let i = 0; i <= lines.length - searchLines.length; i++) {
const candidate = lines.slice(i, i + searchLines.length).join('\n');
const similarity = calculateSimilarity(
normalizeWhitespace(candidate),
normalizeWhitespace(searchText)
);
// Additional context validation for fuzzy matches
if (similarity >= adjustedThreshold) {
// Validate that the match doesn't span inappropriate boundaries
const contextScore = validateMatchContext(content, candidate, i, i + searchLines.length);
if (contextScore > 0.6) { // Only count matches with good context
count++;
}
}
}
break;
}
default:
count = 0;
}
return count;
}
/**
* Validate match context to prevent spanning inappropriate boundaries
*/
function validateMatchContext(_content: string, matchText: string, _startLine: number, _endLine: number): number {
let contextScore = 1.0;
// Check for problematic boundary patterns
const problematicPatterns = [
{ pattern: /^\s*case\s+['"`][^'"`]*['"`]:/, penalty: 0.4 }, // Case boundaries
{ pattern: /^\s*function\s+\w+/, penalty: 0.3 }, // Function boundaries
{ pattern: /^\s*}/, penalty: 0.5 }, // Closing brace boundaries
{ pattern: /{\s*$/, penalty: 0.5 } // Opening brace boundaries
];
const matchLines = matchText.split('\n');
for (let i = 0; i < matchLines.length; i++) {
const line = matchLines[i];
for (const { pattern, penalty } of problematicPatterns) {
try {
if (pattern.test(line)) {
if (i === 0 || i === matchLines.length - 1) {
// Boundary at start/end is less problematic
contextScore *= (1 - penalty * 0.5);
} else {
// Boundary in middle is very problematic
contextScore *= (1 - penalty);
}
}
} catch (e) {
// Skip regex errors
}
}
}
// Check indentation consistency
const indentations = matchLines.map(line => (line.match(/^\s*/) || [''])[0].length);
const minIndent = Math.min(...indentations);
const maxIndent = Math.max(...indentations);
const indentRange = maxIndent - minIndent;
if (indentRange > 8) {
contextScore *= 0.7; // Inconsistent indentation suggests spanning contexts
}
return Math.max(0.1, contextScore);
}
export interface FailedBlock {
search: string;
replace: string;
error: string;
lineNumber?: number;
}
export interface ApplyResult {
content: string;
results: {
blocksTotal: number;
blocksApplied: number;
blocksFailed: number;
errors: string[];
warnings: string[];
failedBlocks: FailedBlock[];
};
}
/**
* Apply search/replace diff with enhanced error handling and telemetry
*/
export function applyDiff(
originalContent: string,
diffContent: string,
options: {
strict?: boolean; // If true, fail on any error. If false, apply what we can
enableTelemetry?: boolean;
matchingStrategies?: MatchingStrategy[]; // Strategies to try in order
fuzzyThreshold?: number; // Threshold for fuzzy matching (0.0 - 1.0)
} = {}
): ApplyResult {
// Set sensible defaults
const defaultOptions = {
strict: true, // Default to strict mode for better error handling
enableTelemetry: false,
matchingStrategies: [
MatchingStrategy.EXACT,
MatchingStrategy.WHITESPACE_INSENSITIVE,
MatchingStrategy.INDENTATION_PRESERVING,
MatchingStrategy.FUZZY
] as MatchingStrategy[],
fuzzyThreshold: 0.80 // 85% similarity threshold - good balance of flexibility and precision
};
// Merge user options with defaults
const mergedOptions = { ...defaultOptions, ...options };
const startTime = Date.now();
const result: ApplyResult = {
content: originalContent,
results: {
blocksTotal: 0,
blocksApplied: 0,
blocksFailed: 0,
errors: [],
warnings: [],
failedBlocks: []
}
};
try {
// Validate inputs
if (typeof originalContent !== 'string') {
result.results.errors.push('Original content must be a string');
if (mergedOptions.strict) throw new Error('Original content must be a string');
return result;
}
if (typeof diffContent !== 'string') {
result.results.errors.push('Diff content must be a string');
if (mergedOptions.strict) throw new Error('Diff content must be a string');
return result;
}
// Handle empty diff
if (!diffContent || diffContent.trim().length === 0) {
return result;
}
// Parse the diff
const { blocks, errors: parseErrors } = parseSearchReplaceDiff(diffContent);
if (parseErrors.length > 0) {
result.results.errors.push(...parseErrors);
if (mergedOptions.strict) {
throw new Error(`Parse errors: ${parseErrors.join('; ')}`);
}
}
result.results.blocksTotal = blocks.length;
if (blocks.length === 0) {
if (mergedOptions.strict && parseErrors.length > 0) {
throw new Error('No valid search/replace blocks found');
}
return result;
}
// Apply blocks sequentially
let currentContent = originalContent;
const failedBlocks: Array<{ block: SearchReplaceBlock; error: string }> = [];
for (const block of blocks) {
const { result: blockResult, error, strategy } = applySearchReplaceBlock(currentContent, block, {
matchingStrategies: mergedOptions.matchingStrategies,
fuzzyThreshold: mergedOptions.fuzzyThreshold
});
if (blockResult !== null) {
currentContent = blockResult;
result.results.blocksApplied++;
// Log which strategy was used for telemetry
if (mergedOptions.enableTelemetry && strategy) {
result.results.warnings.push(`Block at line ${block.lineNumber}: Applied using ${strategy} matching`);
}
} else {
result.results.blocksFailed++;
const errorMsg = `Block at line ${block.lineNumber}: ${error}`;
result.results.errors.push(errorMsg);
failedBlocks.push({ block, error: errorMsg });
// Add complete failed block information to results
result.results.failedBlocks.push({
search: block.search,
replace: block.replace,
error: errorMsg,
lineNumber: block.lineNumber
});
if (mergedOptions.strict) {
throw new Error(errorMsg);
}
}
}
// If all blocks failed, add error even in non-strict mode
if (result.results.blocksApplied === 0 && result.results.blocksTotal > 0) {
const errorDetails = failedBlocks.map(({ block, error }) => {
const preview = block.search.substring(0, 100);
return `${error}\nSearch: "${preview}${block.search.length > 100 ? '...' : ''}"`;
}).join('\n\n');
const allFailedError = `All search/replace blocks failed:\n${errorDetails}`;
result.results.errors.push(allFailedError);
if (mergedOptions.strict) {
throw new Error(allFailedError);
}
}
result.content = currentContent;
return result;
} finally {
const processingTimeMs = Date.now() - startTime;
if (mergedOptions.enableTelemetry) {
console.debug('Search/Replace Diff Telemetry:', {
...result.results,
processingTimeMs
});
}
}
}
/**
* Utility to create a search/replace diff from before/after content
*/
export function createSearchReplaceDiff(
beforeContent: string,
afterContent: string,
options: {
contextLines?: number; // How many lines of context to include
maxSearchSize?: number; // Maximum size of search block
} = {}
): string {
const { contextLines = 3, maxSearchSize = 500 } = options;
// Simple line-based diff
const beforeLines = beforeContent.split('\n');
const afterLines = afterContent.split('\n');
// Find the first difference
let firstDiff = -1;
for (let i = 0; i < Math.min(beforeLines.length, afterLines.length); i++) {
if (beforeLines[i] !== afterLines[i]) {
firstDiff = i;
break;
}
}
// Find the last difference
let lastDiff = -1;
for (let i = 0; i < Math.min(beforeLines.length, afterLines.length); i++) {
const beforeIdx = beforeLines.length - 1 - i;
const afterIdx = afterLines.length - 1 - i;
if (beforeLines[beforeIdx] !== afterLines[afterIdx]) {
lastDiff = Math.max(beforeIdx, afterIdx);
break;
}
}
// Handle identical content
if (firstDiff === -1 && beforeLines.length === afterLines.length) {
return ''; // No changes
}
// Handle pure addition
if (firstDiff === -1 && beforeLines.length < afterLines.length) {
firstDiff = beforeLines.length;
lastDiff = afterLines.length - 1;
}
// Create search/replace block
const searchStart = Math.max(0, firstDiff - contextLines);
const searchEnd = Math.min(beforeLines.length - 1, (lastDiff >= 0 ? lastDiff : firstDiff) + contextLines);
const replaceStart = searchStart;
const replaceEnd = Math.min(afterLines.length - 1, searchEnd + (afterLines.length - beforeLines.length));
const searchBlock = beforeLines.slice(searchStart, searchEnd + 1).join('\n');
const replaceBlock = afterLines.slice(replaceStart, replaceEnd + 1).join('\n');
// Check size limit
if (searchBlock.length > maxSearchSize) {
// Reduce context
const reducedContext = Math.max(0, contextLines - 1);
return createSearchReplaceDiff(beforeContent, afterContent, {
...options,
contextLines: reducedContext
});
}
return `<<<<<<< SEARCH
${searchBlock}
=======
${replaceBlock}
>>>>>>> REPLACE`;
}
/**
* Validate a search/replace diff without applying it
*/
export function validateDiff(
content: string,
diffContent: string
): { valid: boolean; errors: string[] } {
const { blocks, errors: parseErrors } = parseSearchReplaceDiff(diffContent);
const errors = [...parseErrors];
for (const block of blocks) {
const occurrences = content.split(block.search).length - 1;
if (occurrences === 0) {
errors.push(`Block at line ${block.lineNumber}: Search pattern not found`);
} else if (occurrences > 1) {
errors.push(`Block at line ${block.lineNumber}: Search pattern found ${occurrences} times (ambiguous)`);
}
}
return {
valid: errors.length === 0,
errors
};
}
/**
* Test cases for the enhanced search/replace parser
* These tests cover all the edge cases mentioned in the requirements
*/
export function runParserTests(): { passed: number; failed: number; details: string[] } {
const tests: Array<{ name: string; input: string; expectedBlocks: number; expectedErrors: number }> = [
// Test 1: Normal well-formed block
{
name: "Well-formed block",
input: `<<<<<<< SEARCH
old content
=======
new content
>>>>>>> REPLACE`,
expectedBlocks: 1,
expectedErrors: 0
},
// Test 2: Block within code fences (the main issue from logs)
{
name: "Block within code fences",
input: `# Comment
\`\`\`
<<<<<<< SEARCH
import { ErrorBoundary } from './components/ErrorBoundary';
=======
// import { ErrorBoundary } from './components/ErrorBoundary';
\`\`\``,
expectedBlocks: 1,
expectedErrors: 0
},
// Test 3: SEARCH followed by another SEARCH (invalid)
{
name: "SEARCH followed by another SEARCH",
input: `<<<<<<< SEARCH
first search
<<<<<<< SEARCH
second search
=======
replacement
>>>>>>> REPLACE`,
expectedBlocks: 1,
expectedErrors: 1
},
// Test 4: SEARCH without REPLACE at end of file
{
name: "SEARCH without REPLACE at EOF",
input: `<<<<<<< SEARCH
orphaned search content`,
expectedBlocks: 0,
expectedErrors: 1
},
// Test 5: Multiple separators (malformed)
{
name: "Multiple separators in replace section",
input: `<<<<<<< SEARCH
search content
=======
replace content
=======
extra separator
>>>>>>> REPLACE`,
expectedBlocks: 1,
expectedErrors: 0
},
// Test 6: Mixed code fences and standard markers
{
name: "Mixed code fences and standard markers",
input: `Text before
\`\`\`
<<<<<<< SEARCH
old code
=======
new code
\`\`\`
Text after`,
expectedBlocks: 1,
expectedErrors: 0
},
// Test 7: Empty search section
{
name: "Empty search section",
input: `<<<<<<< SEARCH
=======
new content
>>>>>>> REPLACE`,
expectedBlocks: 1,
expectedErrors: 0
},
// Test 8: Empty replace section
{
name: "Empty replace section",
input: `<<<<<<< SEARCH
old content
=======
>>>>>>> REPLACE`,
expectedBlocks: 1,
expectedErrors: 0
},
// Test 9: Stray separators (should be ignored)
{
name: "Stray separators",
input: `=======
Some text
\`\`\`
More text`,
expectedBlocks: 0,
expectedErrors: 0
},
// Test 10: The specific malformed case from the logs
{
name: "Specific malformed case from logs",
input: `Looking at the provided main.tsx file, I need to analyze it for potential issues:
# Missing ErrorBoundary component - will cause import error
\`\`\`
<<<<<<< SEARCH
import { ErrorBoundary } from './components/ErrorBoundary';
import { RouteErrorBoundary } from './components/RouteErrorBoundary';
=======
// import { ErrorBoundary } from './components/ErrorBoundary';
// import { RouteErrorBoundary } from './components/RouteErrorBoundary';
=======
\`\`\`
# Remove ErrorBoundary wrapper since component doesn't exist
\`\`\`
<<<<<<< SEARCH
const router = createBrowserRouter([
{
path: "/",
element: <App />,
errorElement: <RouteErrorBoundary />,
children: [
{
path: "/",
element: <EditorPage />,
}
]
},
]);
=======
const router = createBrowserRouter([
{
path: "/",
element: <App />,
children: [
{
path: "/",
element: <EditorPage />,
}
]
},
]);
>>>>>>> REPLACE
\`\`\``,
expectedBlocks: 2,
expectedErrors: 0
}
];
const results: string[] = [];
let passed = 0;
let failed = 0;
for (const test of tests) {
try {
const { blocks, errors } = parseSearchReplaceDiff(test.input);
const blocksMatch = blocks.length === test.expectedBlocks;
const errorsMatch = errors.length === test.expectedErrors;
if (blocksMatch && errorsMatch) {
passed++;
results.push(`✅ ${test.name}: PASSED (${blocks.length} blocks, ${errors.length} errors)`);
} else {
failed++;
results.push(`❌ ${test.name}: FAILED`);
results.push(` Expected: ${test.expectedBlocks} blocks, ${test.expectedErrors} errors`);
results.push(` Actual: ${blocks.length} blocks, ${errors.length} errors`);
if (errors.length > 0) {
results.push(` Errors: ${errors.join(', ')}`);
}
}
} catch (error) {
failed++;
results.push(`❌ ${test.name}: EXCEPTION - ${error instanceof Error ? error.message : String(error)}`);
}
}
return { passed, failed, details: results };
}