@@ -108,29 +108,16 @@ const WriteFileArgsSchema = z.object({
108
108
} ) ;
109
109
110
110
const EditOperation = z . object ( {
111
- // The text to search for
112
- oldText : z . string ( ) . describe ( 'Text to search for - can be a substring of the target' ) ,
113
- // The new text to replace with
114
- newText : z . string ( ) . describe ( 'Text to replace the found text with' ) ,
115
- } ) ;
116
-
117
- const EditOptions = z . object ( {
118
- preserveIndentation : z . boolean ( ) . default ( true ) . describe ( 'Preserve existing indentation patterns in the file' ) ,
119
- normalizeWhitespace : z . boolean ( ) . default ( true ) . describe ( 'Normalize whitespace while preserving structure' ) ,
120
- partialMatch : z . boolean ( ) . default ( true ) . describe ( 'Enable fuzzy matching with confidence scoring' )
111
+ oldText : z . string ( ) . describe ( 'Text to search for - must match exactly' ) ,
112
+ newText : z . string ( ) . describe ( 'Text to replace with' )
121
113
} ) ;
122
114
123
115
const EditFileArgsSchema = z . object ( {
124
116
path : z . string ( ) ,
125
117
edits : z . array ( EditOperation ) ,
126
- // Optional: preview changes without applying them
127
- dryRun : z . boolean ( ) . default ( false ) . describe ( 'Preview changes using git-style diff format' ) ,
128
- // Optional: configure matching and formatting behavior
129
- options : EditOptions . default ( { } )
118
+ dryRun : z . boolean ( ) . default ( false ) . describe ( 'Preview changes using git-style diff format' )
130
119
} ) ;
131
120
132
-
133
-
134
121
const CreateDirectoryArgsSchema = z . object ( {
135
122
path : z . string ( ) ,
136
123
} ) ;
@@ -228,227 +215,101 @@ async function searchFiles(
228
215
}
229
216
230
217
// file editing and diffing utilities
218
+ function normalizeLineEndings ( text : string ) : string {
219
+ return text . replace ( / \r \n / g, '\n' ) ;
220
+ }
221
+
231
222
function createUnifiedDiff ( originalContent : string , newContent : string , filepath : string = 'file' ) : string {
223
+ // Ensure consistent line endings for diff
224
+ const normalizedOriginal = normalizeLineEndings ( originalContent ) ;
225
+ const normalizedNew = normalizeLineEndings ( newContent ) ;
226
+
232
227
return createTwoFilesPatch (
233
228
filepath ,
234
229
filepath ,
235
- originalContent ,
236
- newContent ,
230
+ normalizedOriginal ,
231
+ normalizedNew ,
237
232
'original' ,
238
233
'modified'
239
234
) ;
240
235
}
241
236
242
- // Utility functions for text normalization and matching
243
- function normalizeLineEndings ( text : string ) : string {
244
- return text . replace ( / \r \n / g, '\n' ) . replace ( / \r / g, '\n' ) ;
245
- }
246
-
247
- function normalizeWhitespace ( text : string , preserveIndentation : boolean = true ) : string {
248
- if ( ! preserveIndentation ) {
249
- // Collapse all whitespace to single spaces if not preserving indentation
250
- return text . replace ( / \s + / g, ' ' ) ;
251
- }
252
-
253
- // Preserve line structure but normalize inline whitespace
254
- return text . split ( '\n' ) . map ( line => {
255
- // Preserve leading whitespace
256
- const indent = line . match ( / ^ [ \s \t ] * / ) ?. [ 0 ] || '' ;
257
- // Normalize rest of line
258
- const content = line . slice ( indent . length ) . trim ( ) . replace ( / \s + / g, ' ' ) ;
259
- return indent + content ;
260
- } ) . join ( '\n' ) ;
261
- }
262
-
263
- interface EditMatch {
264
- start: number ;
265
- end: number ;
266
- confidence: number ;
267
- }
268
-
269
- function findBestMatch ( content : string , searchText : string , options : z . infer < typeof EditOptions > ) : EditMatch | null {
270
- const normalizedContent = normalizeLineEndings ( content ) ;
271
- const normalizedSearch = normalizeLineEndings ( searchText ) ;
272
-
273
- // Try exact match first
274
- const exactPos = normalizedContent . indexOf ( normalizedSearch ) ;
275
- if ( exactPos !== - 1 ) {
276
- return {
277
- start : exactPos ,
278
- end : exactPos + searchText . length ,
279
- confidence : 1.0
280
- } ;
281
- }
282
-
283
- // If whitespace normalization is enabled, try that next
284
- if ( options . normalizeWhitespace ) {
285
- const normContent = normalizeWhitespace ( normalizedContent , options . preserveIndentation ) ;
286
- const normSearch = normalizeWhitespace ( normalizedSearch , options . preserveIndentation ) ;
287
- const normPos = normContent . indexOf ( normSearch ) ;
288
-
289
- if ( normPos !== - 1 ) {
290
- // Find the corresponding position in original text
291
- const beforeMatch = normContent . slice ( 0 , normPos ) ;
292
- const originalPos = findOriginalPosition ( content , beforeMatch ) ;
293
- return {
294
- start : originalPos ,
295
- end : originalPos + searchText . length ,
296
- confidence : 0.9
297
- } ;
298
- }
299
- }
300
-
301
- // If partial matching is enabled, try to find the best partial match
302
- if ( options . partialMatch ) {
303
- const lines = normalizedContent . split ( '\n' ) ;
304
- const searchLines = normalizedSearch . split ( '\n' ) ;
305
-
306
- let bestMatch : EditMatch | null = null ;
307
- let bestScore = 0 ;
308
-
309
- // Sliding window search through the content
310
- for ( let i = 0 ; i < lines . length - searchLines . length + 1 ; i ++ ) {
311
- let matchScore = 0 ;
312
- let matchLength = 0 ;
313
-
314
- for ( let j = 0 ; j < searchLines . length ; j ++ ) {
315
- const contentLine = options . normalizeWhitespace
316
- ? normalizeWhitespace ( lines [ i + j ] , options . preserveIndentation )
317
- : lines [ i + j ] ;
318
- const searchLine = options . normalizeWhitespace
319
- ? normalizeWhitespace ( searchLines [ j ] , options . preserveIndentation )
320
- : searchLines [ j ] ;
321
-
322
- const similarity = calculateSimilarity ( contentLine , searchLine ) ;
323
- matchScore += similarity ;
324
- matchLength += lines [ i + j ] . length + 1 ; // +1 for newline
325
- }
326
-
327
- const averageScore = matchScore / searchLines . length ;
328
- if ( averageScore > bestScore && averageScore > 0.7 ) { // Threshold for minimum match quality
329
- bestScore = averageScore ;
330
- const start = lines . slice ( 0 , i ) . reduce ( ( acc , line ) => acc + line . length + 1 , 0 ) ;
331
- bestMatch = {
332
- start ,
333
- end : start + matchLength ,
334
- confidence : averageScore
335
- } ;
336
- }
337
- }
338
-
339
- return bestMatch ;
340
- }
341
-
342
- return null ;
343
- }
344
-
345
- function calculateSimilarity ( str1 : string , str2 : string ) : number {
346
- const len1 = str1 . length ;
347
- const len2 = str2 . length ;
348
- const matrix : number [ ] [ ] = Array ( len1 + 1 ) . fill ( null ) . map ( ( ) => Array ( len2 + 1 ) . fill ( 0 ) ) ;
349
-
350
- for ( let i = 0 ; i <= len1 ; i ++ ) matrix [ i ] [ 0 ] = i ;
351
- for ( let j = 0 ; j <= len2 ; j ++ ) matrix [ 0 ] [ j ] = j ;
352
-
353
- for ( let i = 1 ; i <= len1 ; i ++ ) {
354
- for ( let j = 1 ; j <= len2 ; j ++ ) {
355
- const cost = str1 [ i - 1 ] === str2 [ j - 1 ] ? 0 : 1 ;
356
- matrix [ i ] [ j ] = Math . min (
357
- matrix [ i - 1 ] [ j ] + 1 ,
358
- matrix [ i ] [ j - 1 ] + 1 ,
359
- matrix [ i - 1 ] [ j - 1 ] + cost
360
- ) ;
361
- }
362
- }
363
-
364
- const maxLength = Math . max ( len1 , len2 ) ;
365
- return maxLength === 0 ? 1 : ( maxLength - matrix [ len1 ] [ len2 ] ) / maxLength ;
366
- }
367
-
368
- function findOriginalPosition ( original : string , normalizedPrefix : string ) : number {
369
- let origPos = 0 ;
370
- let normPos = 0 ;
371
-
372
- while ( normPos < normalizedPrefix . length && origPos < original . length ) {
373
- if ( normalizeWhitespace ( original [ origPos ] , true ) === normalizedPrefix [ normPos ] ) {
374
- normPos ++ ;
375
- }
376
- origPos ++ ;
377
- }
378
-
379
- return origPos ;
380
- }
381
-
382
237
async function applyFileEdits (
383
238
filePath : string ,
384
239
edits : Array < { oldText : string , newText : string } > ,
385
- dryRun = false ,
386
- options : z . infer < typeof EditOptions > = EditOptions . parse ( { } )
240
+ dryRun = false
387
241
) : Promise < string > {
388
- const content = await fs . readFile ( filePath , 'utf-8' ) ;
389
- let modifiedContent = content ;
390
- const failedEdits : Array < { edit : typeof edits [ 0 ] , error : string } > = [ ] ;
391
- const successfulEdits : Array < { edit : typeof edits [ 0 ] , match : EditMatch } > = [ ] ;
242
+ // Read file content and normalize line endings
243
+ const content = normalizeLineEndings ( await fs . readFile ( filePath , 'utf-8' ) ) ;
392
244
393
- // Sort edits by position (if found) to apply them in order
245
+ // Apply edits sequentially
246
+ let modifiedContent = content ;
394
247
for ( const edit of edits ) {
395
- const match = findBestMatch ( modifiedContent , edit . oldText , options ) ;
248
+ const normalizedOld = normalizeLineEndings ( edit . oldText ) ;
249
+ const normalizedNew = normalizeLineEndings ( edit . newText ) ;
396
250
397
- if ( ! match ) {
398
- failedEdits . push ( {
399
- edit,
400
- error : 'No suitable match found'
401
- } ) ;
251
+ // If exact match exists, use it
252
+ if ( modifiedContent . includes ( normalizedOld ) ) {
253
+ modifiedContent = modifiedContent . replace ( normalizedOld , normalizedNew ) ;
402
254
continue ;
403
255
}
404
256
405
- // For low confidence matches in non-dry-run mode, we might want to throw
406
- if ( ! dryRun && match . confidence < 0.8 ) {
407
- failedEdits . push ( {
408
- edit,
409
- error : `Match confidence too low: ${ ( match . confidence * 100 ) . toFixed ( 1 ) } %`
257
+ // Otherwise, try line-by-line matching with flexibility for whitespace
258
+ const oldLines = normalizedOld . split ( '\n' ) ;
259
+ const contentLines = modifiedContent . split ( '\n' ) ;
260
+ let matchFound = false ;
261
+
262
+ for ( let i = 0 ; i <= contentLines . length - oldLines . length ; i ++ ) {
263
+ const potentialMatch = contentLines . slice ( i , i + oldLines . length ) ;
264
+
265
+ // Compare lines with normalized whitespace
266
+ const isMatch = oldLines . every ( ( oldLine , j ) => {
267
+ const contentLine = potentialMatch [ j ] ;
268
+ return oldLine . trim ( ) === contentLine . trim ( ) ;
410
269
} ) ;
411
- continue ;
270
+
271
+ if ( isMatch ) {
272
+ // Preserve original indentation of first line
273
+ const originalIndent = contentLines [ i ] . match ( / ^ \s * / ) ?. [ 0 ] || '' ;
274
+ const newLines = normalizedNew . split ( '\n' ) . map ( ( line , j ) => {
275
+ if ( j === 0 ) return originalIndent + line . trimStart ( ) ;
276
+ // For subsequent lines, try to preserve relative indentation
277
+ const oldIndent = oldLines [ j ] ?. match ( / ^ \s * / ) ?. [ 0 ] || '' ;
278
+ const newIndent = line . match ( / ^ \s * / ) ?. [ 0 ] || '' ;
279
+ if ( oldIndent && newIndent ) {
280
+ const relativeIndent = newIndent . length - oldIndent . length ;
281
+ return originalIndent + ' ' . repeat ( Math . max ( 0 , relativeIndent ) ) + line . trimStart ( ) ;
282
+ }
283
+ return line ;
284
+ } ) ;
285
+
286
+ contentLines . splice ( i , oldLines . length , ...newLines ) ;
287
+ modifiedContent = contentLines . join ( '\n' ) ;
288
+ matchFound = true ;
289
+ break ;
290
+ }
412
291
}
413
292
414
- successfulEdits . push ( { edit , match } ) ;
293
+ if ( ! matchFound ) {
294
+ throw new Error ( `Could not find exact match for edit:\n${ edit . oldText } ` ) ;
295
+ }
415
296
}
416
297
417
- // Sort successful edits by position (reverse order to maintain positions)
418
- successfulEdits . sort ( ( a , b ) => b . match . start - a . match . start ) ;
419
-
420
- // Apply successful edits
421
- for ( const { edit, match } of successfulEdits ) {
422
- modifiedContent =
423
- modifiedContent . slice ( 0 , match . start ) +
424
- edit . newText +
425
- modifiedContent . slice ( match . end ) ;
426
- }
298
+ // Create unified diff
299
+ const diff = createUnifiedDiff ( content , modifiedContent , filePath ) ;
427
300
428
- if ( dryRun ) {
429
- let report = createUnifiedDiff ( content , modifiedContent , filePath ) ;
430
-
431
- if ( failedEdits . length > 0 ) {
432
- report += '\nFailed edits :\n' + failedEdits . map ( ( { edit, error } ) =>
433
- `- Error: ${ error } \n Old text: ${ edit . oldText . split ( '\n' ) [ 0 ] } ...\n`
434
- ) . join ( '\n' ) ;
435
- }
436
-
437
- if ( successfulEdits . length > 0 ) {
438
- report += '\nSuccessful edits :\n' + successfulEdits . map ( ( { edit, match } ) =>
439
- `- Match confidence: ${ ( match . confidence * 100 ) . toFixed ( 1 ) } %\n Position: ${ match . start } -${ match . end } \n`
440
- ) . join ( '\n' ) ;
441
- }
442
-
443
- return report ;
301
+ // Format diff with appropriate number of backticks
302
+ let numBackticks = 3 ;
303
+ while ( diff . includes ( '`' . repeat ( numBackticks ) ) ) {
304
+ numBackticks ++ ;
444
305
}
306
+ const formattedDiff = `${ '`' . repeat ( numBackticks ) } diff\n${ diff } ${ '`' . repeat ( numBackticks ) } \n\n` ;
445
307
446
- if ( failedEdits . length > 0 ) {
447
- const errors = failedEdits . map ( ( { error } ) => error ) . join ( '\n' ) ;
448
- throw new Error ( `Some edits failed:\n${ errors } ` ) ;
308
+ if ( ! dryRun ) {
309
+ await fs . writeFile ( filePath , modifiedContent , 'utf-8' ) ;
449
310
}
450
311
451
- return modifiedContent ;
312
+ return formattedDiff ;
452
313
}
453
314
454
315
// Tool handlers
@@ -485,17 +346,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
485
346
{
486
347
name : "edit_file" ,
487
348
description :
488
- "Make selective edits to a text file using advanced pattern matching and smart formatting preservation. Features include:\n" +
489
- "- Line-based and multi-line content matching\n" +
490
- "- Whitespace normalization with indentation preservation\n" +
491
- "- Fuzzy matching with confidence scoring\n" +
492
- "- Multiple simultaneous edits with correct positioning\n" +
493
- "- Indentation style detection and preservation\n" +
494
- "- Detailed diff output with context in git format\n" +
495
- "- Dry run mode for previewing changes\n" +
496
- "- Failed match debugging with match confidence scores\n\n" +
497
- "Configure behavior with options.preserveIndentation, options.normalizeWhitespace, and options.partialMatch. " +
498
- "See schema for detailed option descriptions. Only works within allowed directories." ,
349
+ "Make line-based edits to a text file. Each edit replaces exact line sequences " +
350
+ "with new content. Returns a git-style diff showing the changes made. " +
351
+ "Only works within allowed directories." ,
499
352
inputSchema : zodToJsonSchema ( EditFileArgsSchema ) as ToolInput ,
500
353
} ,
501
354
{
@@ -617,18 +470,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
617
470
throw new Error ( `Invalid arguments for edit_file: ${ parsed . error } ` ) ;
618
471
}
619
472
const validPath = await validatePath ( parsed . data . path ) ;
620
- const result = await applyFileEdits ( validPath , parsed . data . edits , parsed . data . dryRun , parsed . data . options ) ;
621
-
622
- // If it's a dry run, show the unified diff
623
- if ( parsed . data . dryRun ) {
624
- return {
625
- content : [ { type : "text" , text : `Edit preview:\n${ result } ` } ] ,
626
- } ;
627
- }
628
-
629
- await fs . writeFile ( validPath , result , "utf-8" ) ;
473
+ const result = await applyFileEdits ( validPath , parsed . data . edits , parsed . data . dryRun ) ;
630
474
return {
631
- content : [ { type : "text" , text : `Successfully applied edits to ${ parsed . data . path } ` } ] ,
475
+ content : [ { type : "text" , text : result } ] ,
632
476
} ;
633
477
}
634
478
0 commit comments