mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-18 18:11:02 -07:00
240 lines
5.7 KiB
TypeScript
240 lines
5.7 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* Single edit operation with position information
|
|
*/
|
|
export interface PositionedEdit {
|
|
/**
|
|
* Original edit operation
|
|
*/
|
|
original: EditOperation;
|
|
|
|
/**
|
|
* Index in the original edits array
|
|
*/
|
|
index: number;
|
|
|
|
/**
|
|
* Start position in the file content
|
|
*/
|
|
startPos: number;
|
|
|
|
/**
|
|
* End position in the file content
|
|
*/
|
|
endPos: number;
|
|
|
|
/**
|
|
* The replacement text
|
|
*/
|
|
newString: string;
|
|
}
|
|
|
|
/**
|
|
* Result of failed edit with reason
|
|
*/
|
|
export interface FailedEdit {
|
|
index: number;
|
|
edit: EditOperation;
|
|
reason: 'not_found' | 'multiple_matches' | 'empty_old_string';
|
|
}
|
|
|
|
/**
|
|
* Result of position-based edit processing
|
|
*/
|
|
export interface PositionBasedEditResult {
|
|
finalContent: string;
|
|
appliedEdits: PositionedEdit[];
|
|
failedEdits: FailedEdit[];
|
|
}
|
|
|
|
/**
|
|
* Edit operation interface (matches existing EditOperation)
|
|
*/
|
|
export interface EditOperation {
|
|
old_string: string;
|
|
new_string: string;
|
|
}
|
|
|
|
/**
|
|
* Efficient position-based edit processor that minimizes memory usage
|
|
* and applies edits in optimal order to prevent position conflicts.
|
|
*/
|
|
export class PositionBasedEditProcessor {
|
|
/**
|
|
* Process edits with minimal memory usage and optimal ordering
|
|
*/
|
|
processEdits(
|
|
content: string,
|
|
edits: EditOperation[],
|
|
): PositionBasedEditResult {
|
|
// Phase 1: Find positions for all edits
|
|
const { validEdits, failedEdits } = this.analyzeEdits(content, edits);
|
|
|
|
// Phase 2: Sort by position (highest first) to prevent position drift
|
|
const sortedEdits = validEdits.sort((a, b) => b.startPos - a.startPos);
|
|
|
|
// Phase 3: Build final content in single pass
|
|
const finalContent = this.buildFinalContent(content, sortedEdits);
|
|
|
|
return {
|
|
finalContent,
|
|
appliedEdits: sortedEdits,
|
|
failedEdits,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Analyze all edits and categorize into valid/failed
|
|
*/
|
|
private analyzeEdits(
|
|
content: string,
|
|
edits: EditOperation[],
|
|
): {
|
|
validEdits: PositionedEdit[];
|
|
failedEdits: FailedEdit[];
|
|
} {
|
|
const validEdits: PositionedEdit[] = [];
|
|
const failedEdits: FailedEdit[] = [];
|
|
|
|
for (let i = 0; i < edits.length; i++) {
|
|
const edit = edits[i];
|
|
|
|
// Handle empty old_string (file creation case)
|
|
if (edit.old_string === '') {
|
|
failedEdits.push({
|
|
index: i,
|
|
edit,
|
|
reason: 'empty_old_string',
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Find all positions of old_string
|
|
const positions = this.findAllPositions(content, edit.old_string);
|
|
|
|
if (positions.length === 0) {
|
|
failedEdits.push({
|
|
index: i,
|
|
edit,
|
|
reason: 'not_found',
|
|
});
|
|
} else if (positions.length > 1) {
|
|
failedEdits.push({
|
|
index: i,
|
|
edit,
|
|
reason: 'multiple_matches',
|
|
});
|
|
} else {
|
|
// Exactly one match - valid edit
|
|
const startPos = positions[0];
|
|
validEdits.push({
|
|
original: edit,
|
|
index: i,
|
|
startPos,
|
|
endPos: startPos + edit.old_string.length,
|
|
newString: edit.new_string,
|
|
});
|
|
}
|
|
}
|
|
|
|
return { validEdits, failedEdits };
|
|
}
|
|
|
|
/**
|
|
* Find all positions where searchString occurs in content
|
|
*/
|
|
private findAllPositions(content: string, searchString: string): number[] {
|
|
const positions: number[] = [];
|
|
let index = content.indexOf(searchString);
|
|
|
|
while (index !== -1) {
|
|
positions.push(index);
|
|
index = content.indexOf(searchString, index + 1);
|
|
}
|
|
|
|
return positions;
|
|
}
|
|
|
|
/**
|
|
* Build final content in single pass with minimal memory allocation
|
|
*/
|
|
private buildFinalContent(original: string, edits: PositionedEdit[]): string {
|
|
if (edits.length === 0) {
|
|
return original;
|
|
}
|
|
|
|
// Check for overlapping edits (should not happen with our validation, but safety check)
|
|
if (this.hasOverlappingEdits(edits)) {
|
|
throw new Error('Overlapping edits detected - this should not happen');
|
|
}
|
|
|
|
// Build segments array working backwards through the string
|
|
const segments: string[] = [];
|
|
let currentPos = original.length;
|
|
|
|
// Process edits from end to beginning (edits are already sorted by startPos desc)
|
|
for (const edit of edits) {
|
|
// Add text after this edit position
|
|
if (currentPos > edit.endPos) {
|
|
segments.unshift(original.slice(edit.endPos, currentPos));
|
|
}
|
|
|
|
// Add the replacement text
|
|
segments.unshift(edit.newString);
|
|
|
|
// Update current position
|
|
currentPos = edit.startPos;
|
|
}
|
|
|
|
// Add remaining text from beginning
|
|
if (currentPos > 0) {
|
|
segments.unshift(original.slice(0, currentPos));
|
|
}
|
|
|
|
// Single join operation creates final string
|
|
return segments.join('');
|
|
}
|
|
|
|
/**
|
|
* Check if any edits overlap (safety validation)
|
|
*/
|
|
private hasOverlappingEdits(edits: PositionedEdit[]): boolean {
|
|
for (let i = 0; i < edits.length - 1; i++) {
|
|
for (let j = i + 1; j < edits.length; j++) {
|
|
const edit1 = edits[i];
|
|
const edit2 = edits[j];
|
|
|
|
// Check if ranges overlap
|
|
if (
|
|
!(edit1.endPos <= edit2.startPos || edit2.endPos <= edit1.startPos)
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get human-readable error message for failed edit reason
|
|
*/
|
|
static getErrorMessage(reason: FailedEdit['reason']): string {
|
|
switch (reason) {
|
|
case 'not_found':
|
|
return 'Old string not found in current content';
|
|
case 'multiple_matches':
|
|
return 'Old string found multiple times - please be more specific';
|
|
case 'empty_old_string':
|
|
return 'Cannot use empty old_string on existing file';
|
|
default:
|
|
return 'Unknown error';
|
|
}
|
|
}
|
|
}
|