summaryrefslogtreecommitdiff
path: root/packages/core/src/utils/positionBasedEditProcessor.ts
diff options
context:
space:
mode:
authorKeith Ballinger <[email protected]>2025-06-06 22:54:37 -0700
committerGitHub <[email protected]>2025-06-06 22:54:37 -0700
commit0c868746777e95255ce870aff4a61fb584d60a62 (patch)
tree07fd51b91eee0df77d7014828308facaea03778f /packages/core/src/utils/positionBasedEditProcessor.ts
parent76ec9122c0dd36f0535a74c65811c0f7bd138f4d (diff)
Add batch editing capabilities to Edit Tool (#648)
Co-authored-by: N. Taylor Mullen <[email protected]>
Diffstat (limited to 'packages/core/src/utils/positionBasedEditProcessor.ts')
-rw-r--r--packages/core/src/utils/positionBasedEditProcessor.ts239
1 files changed, 239 insertions, 0 deletions
diff --git a/packages/core/src/utils/positionBasedEditProcessor.ts b/packages/core/src/utils/positionBasedEditProcessor.ts
new file mode 100644
index 00000000..977d6859
--- /dev/null
+++ b/packages/core/src/utils/positionBasedEditProcessor.ts
@@ -0,0 +1,239 @@
+/**
+ * @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';
+ }
+ }
+}