2026-02-26 17:41:02 +03:00
package core
import (
"context"
"fmt"
"os"
2026-03-27 10:34:43 +03:00
"sort"
2026-02-26 17:41:02 +03:00
"strings"
"unicode"
2026-03-22 18:02:07 +03:00
"unicode/utf8"
2026-02-26 17:41:02 +03:00
"charm.land/fantasy"
2026-03-22 18:02:07 +03:00
udiff "github.com/aymanbagabas/go-udiff"
2026-02-26 17:41:02 +03:00
)
2026-03-27 10:34:43 +03:00
// Edit represents a single replacement in a multi-edit operation.
type Edit struct {
2026-02-26 17:41:02 +03:00
OldText string ` json:"old_text" `
NewText string ` json:"new_text" `
}
2026-03-27 10:34:43 +03:00
// editArgs holds the arguments for the edit tool.
type editArgs struct {
2026-04-23 16:33:55 +03:00
Path string ` json:"path" `
Edits [ ] Edit ` json:"edits" `
2026-03-27 10:34:43 +03:00
}
// replacement represents a normalized edit ready for processing.
type replacement struct {
oldText string // normalized old text for matching
newText string // normalized new text
originalOld string // original old text for metadata
originalNew string // original new text for metadata
index int // index in the original edits array (for error messages)
}
// matchedReplacement represents a replacement with its match location.
type matchedReplacement struct {
replacement
start int // start index in normalized content
end int // end index in normalized content
usedFuzzyMatch bool // true if fuzzy matching was used
}
2026-02-26 17:41:02 +03:00
// NewEditTool creates the edit core tool.
2026-02-27 11:37:46 +03:00
func NewEditTool ( opts ... ToolOption ) fantasy . AgentTool {
cfg := ApplyOptions ( opts )
2026-02-26 17:41:02 +03:00
return & coreTool {
info : fantasy . ToolInfo {
Name : "edit" ,
2026-04-23 16:33:55 +03:00
Description : "Edit a file by replacing exact text. All edits in the array are matched against the original file content (non-incremental) and must be non-overlapping." ,
2026-02-26 17:41:02 +03:00
Parameters : map [ string ] any {
"path" : map [ string ] any {
"type" : "string" ,
"description" : "Path to the file to edit (relative or absolute)" ,
} ,
2026-03-27 10:34:43 +03:00
"edits" : map [ string ] any {
"type" : "array" ,
"description" : "Array of edits for multi-region replacement. Each edit must have unique, non-overlapping old_text. All matches are against the original file content." ,
"items" : map [ string ] any {
"type" : "object" ,
"properties" : map [ string ] any {
"old_text" : map [ string ] any {
"type" : "string" ,
"description" : "Exact text to find and replace for this edit" ,
} ,
"new_text" : map [ string ] any {
"type" : "string" ,
"description" : "New text for this edit" ,
} ,
} ,
"required" : [ ] string { "old_text" , "new_text" } ,
} ,
2026-02-26 17:41:02 +03:00
} ,
} ,
2026-04-23 16:33:55 +03:00
Required : [ ] string { "path" , "edits" } ,
2026-02-26 17:41:02 +03:00
} ,
2026-02-27 11:37:46 +03:00
handler : func ( ctx context . Context , call fantasy . ToolCall ) ( fantasy . ToolResponse , error ) {
return executeEdit ( ctx , call , cfg . WorkDir )
} ,
2026-02-26 17:41:02 +03:00
}
}
2026-02-27 11:37:46 +03:00
func executeEdit ( ctx context . Context , call fantasy . ToolCall , workDir string ) ( fantasy . ToolResponse , error ) {
2026-06-06 19:22:05 +03:00
if err := ctx . Err ( ) ; err != nil {
return fantasy . ToolResponse { } , err
}
2026-02-26 17:41:02 +03:00
var args editArgs
if err := parseArgs ( call . Input , & args ) ; err != nil {
2026-03-27 10:34:43 +03:00
return fantasy . NewTextErrorResponse ( "failed to parse arguments: " + err . Error ( ) ) , nil
2026-02-26 17:41:02 +03:00
}
if args . Path == "" {
return fantasy . NewTextErrorResponse ( "path parameter is required" ) , nil
}
2026-02-27 11:37:46 +03:00
absPath , err := resolvePathWithWorkDir ( args . Path , workDir )
2026-02-26 17:41:02 +03:00
if err != nil {
return fantasy . NewTextErrorResponse ( fmt . Sprintf ( "invalid path: %v" , err ) ) , nil
}
contentBytes , err := os . ReadFile ( absPath )
if err != nil {
return fantasy . NewTextErrorResponse ( fmt . Sprintf ( "failed to read file: %v" , err ) ) , nil
}
content := string ( contentBytes )
2026-03-27 10:34:43 +03:00
// Normalize and validate input
replacements , err := normalizeEditInput ( args )
if err != nil {
return fantasy . NewTextErrorResponse ( err . Error ( ) ) , nil
}
2026-02-26 17:41:02 +03:00
2026-03-27 10:34:43 +03:00
// Apply all edits
newContent , applied , err := applyEdits ( content , replacements )
if err != nil {
return fantasy . NewTextErrorResponse ( err . Error ( ) ) , nil
}
2026-02-26 17:41:02 +03:00
2026-03-27 10:34:43 +03:00
// Write the file
if err := os . WriteFile ( absPath , [ ] byte ( newContent ) , 0644 ) ; err != nil {
return fantasy . NewTextErrorResponse ( fmt . Sprintf ( "failed to write file: %v" , err ) ) , nil
}
// Generate diff
normalizedContent := strings . ReplaceAll ( content , "\r\n" , "\n" )
diff := generateDiff ( absPath , normalizedContent , newContent )
// Build response with fuzzy match indication
fuzzyCount := 0
for _ , m := range applied {
if m . usedFuzzyMatch {
fuzzyCount ++
2026-02-26 17:41:02 +03:00
}
}
2026-03-27 10:34:43 +03:00
var msg string
if len ( applied ) == 1 {
if fuzzyCount > 0 {
msg = fmt . Sprintf ( "Applied edit (fuzzy match) to %s\n%s" , args . Path , diff )
} else {
msg = fmt . Sprintf ( "Applied edit to %s\n%s" , args . Path , diff )
}
} else {
if fuzzyCount > 0 {
msg = fmt . Sprintf ( "Applied %d edits (%d fuzzy) to %s\n%s" , len ( applied ) , fuzzyCount , args . Path , diff )
} else {
msg = fmt . Sprintf ( "Applied %d edits to %s\n%s" , len ( applied ) , args . Path , diff )
}
2026-02-26 17:41:02 +03:00
}
2026-03-27 10:34:43 +03:00
resp := fantasy . NewTextResponse ( msg )
return fantasy . WithResponseMetadata ( resp , editDiffMeta ( absPath , applied ) ) , nil
}
2026-02-26 17:41:02 +03:00
2026-03-27 10:34:43 +03:00
// normalizeEditInput validates and normalizes the edit input.
func normalizeEditInput ( args editArgs ) ( [ ] replacement , error ) {
2026-04-23 16:33:55 +03:00
if len ( args . Edits ) == 0 {
return nil , fmt . Errorf ( "edits array is required and must not be empty" )
2026-03-27 10:34:43 +03:00
}
var reps [ ] replacement
for i , edit := range args . Edits {
if edit . OldText == "" {
return nil , fmt . Errorf ( "edits[%d].old_text is required" , i )
}
reps = append ( reps , replacement {
oldText : strings . ReplaceAll ( edit . OldText , "\r\n" , "\n" ) ,
newText : strings . ReplaceAll ( edit . NewText , "\r\n" , "\n" ) ,
originalOld : edit . OldText ,
originalNew : edit . NewText ,
index : i ,
} )
}
return reps , nil
}
// applyEdits applies multiple replacements to the content.
// All matches are against the original content (non-incremental).
// Returns the new content, the applied matches, and any error.
func applyEdits ( content string , edits [ ] replacement ) ( string , [ ] matchedReplacement , error ) {
normalizedContent := strings . ReplaceAll ( content , "\r\n" , "\n" )
// Find all matches
var matched [ ] matchedReplacement
for _ , edit := range edits {
m , err := findMatch ( normalizedContent , edit )
if err != nil {
return "" , nil , err
}
matched = append ( matched , * m )
}
// Sort by position
sort . Slice ( matched , func ( i , j int ) bool {
return matched [ i ] . start < matched [ j ] . start
} )
// Check for overlaps
for i := 1 ; i < len ( matched ) ; i ++ {
if matched [ i - 1 ] . end > matched [ i ] . start {
return "" , nil , fmt . Errorf ( "edits[%d] and edits[%d] overlap; merge them into a single edit" ,
matched [ i - 1 ] . index , matched [ i ] . index )
}
}
// Apply edits in reverse order (end to start) to maintain stable offsets
result := normalizedContent
for i := len ( matched ) - 1 ; i >= 0 ; i -- {
m := matched [ i ]
result = result [ : m . start ] + m . newText + result [ m . end : ]
}
return result , matched , nil
}
// findMatch finds a unique match for the edit in the content.
// Returns error if not found or ambiguous.
func findMatch ( content string , edit replacement ) ( * matchedReplacement , error ) {
// Try exact match first
count := strings . Count ( content , edit . oldText )
if count == 0 {
// Try fuzzy match
idx , matchLen := fuzzyMatch ( content , edit . oldText )
if idx < 0 {
return nil , fmt . Errorf ( "edits[%d]: could not find old_text in file. The text must match exactly (including whitespace)" , edit . index )
}
// Use the matched text from content for the replacement
matchedText := content [ idx : idx + matchLen ]
return & matchedReplacement {
replacement : replacement {
oldText : matchedText ,
newText : edit . newText ,
originalOld : edit . originalOld ,
originalNew : edit . originalNew ,
index : edit . index ,
} ,
start : idx ,
end : idx + matchLen ,
usedFuzzyMatch : true ,
} , nil
}
if count > 1 {
return nil , fmt . Errorf ( "found %d matches for edits[%d].old_text; each old_text must be unique, provide more context to identify the correct match" , count , edit . index )
2026-02-26 17:41:02 +03:00
}
2026-03-27 10:34:43 +03:00
// Single exact match
idx := strings . Index ( content , edit . oldText )
return & matchedReplacement {
replacement : edit ,
start : idx ,
end : idx + len ( edit . oldText ) ,
} , nil
2026-03-16 11:10:05 +03:00
}
// editDiffMeta builds the structured metadata attached to edit tool responses.
2026-03-27 10:34:43 +03:00
func editDiffMeta ( path string , applied [ ] matchedReplacement ) map [ string ] any {
var diffBlocks [ ] map [ string ] any
totalAdditions , totalDeletions := 0 , 0
for _ , m := range applied {
diffBlocks = append ( diffBlocks , map [ string ] any {
"old_text" : m . originalOld ,
"new_text" : m . originalNew ,
} )
totalAdditions += strings . Count ( m . originalNew , "\n" ) + 1
totalDeletions += strings . Count ( m . originalOld , "\n" ) + 1
}
2026-03-16 11:10:05 +03:00
return map [ string ] any {
"file_diffs" : [ ] map [ string ] any { {
2026-03-27 10:34:43 +03:00
"path" : path ,
"additions" : totalAdditions ,
"deletions" : totalDeletions ,
"diff_blocks" : diffBlocks ,
2026-03-16 11:10:05 +03:00
} } ,
}
2026-02-26 17:41:02 +03:00
}
// fuzzyMatch tries to find old_text with relaxed matching:
2026-03-22 18:02:07 +03:00
// - Strips trailing whitespace per line
// - Normalizes unicode quotes to ASCII
// - Normalizes unicode dashes/spaces
//
// Returns (index, matchLength) in the original content, or (-1, 0) if not
// found or ambiguous (multiple matches).
2026-02-26 17:41:02 +03:00
func fuzzyMatch ( content , search string ) ( int , int ) {
2026-03-22 18:02:07 +03:00
normContent , contentMap := normalizeWithMap ( content )
normSearch := normalizeForFuzzy ( search )
2026-02-26 17:41:02 +03:00
2026-03-22 18:02:07 +03:00
if normSearch == "" {
2026-02-26 17:41:02 +03:00
return - 1 , 0
}
2026-03-22 18:02:07 +03:00
idx := strings . Index ( normContent , normSearch )
if idx < 0 {
return - 1 , 0
2026-02-26 17:41:02 +03:00
}
2026-03-22 18:02:07 +03:00
// Reject ambiguous matches — if there are multiple fuzzy matches
// we can't safely pick one.
if strings . Count ( normContent , normSearch ) > 1 {
return - 1 , 0
2026-02-26 17:41:02 +03:00
}
2026-03-22 18:02:07 +03:00
// Map normalized byte positions back to original byte positions.
origStart := contentMap [ idx ]
endNorm := idx + len ( normSearch )
var origEnd int
if endNorm >= len ( normContent ) {
origEnd = len ( content )
} else {
origEnd = contentMap [ endNorm ]
2026-02-26 17:41:02 +03:00
}
2026-03-22 18:02:07 +03:00
return origStart , origEnd - origStart
2026-02-26 17:41:02 +03:00
}
2026-03-22 18:02:07 +03:00
// normalizeWithMap normalizes s for fuzzy matching and returns both the
// normalized string and a byte-position mapping where mapping[i] is the
// original byte position corresponding to normalized byte position i.
//
// Normalization: trim trailing whitespace per line, replace unicode
// quotes/dashes/spaces with their ASCII equivalents.
func normalizeWithMap ( s string ) ( string , [ ] int ) {
var result [ ] byte
var mapping [ ] int // mapping[i] = original byte position for result byte i
2026-02-26 17:41:02 +03:00
2026-03-22 18:02:07 +03:00
lines := strings . Split ( s , "\n" )
origPos := 0
for li , line := range lines {
if li > 0 {
result = append ( result , '\n' )
mapping = append ( mapping , origPos )
origPos ++ // skip \n in original
}
2026-02-26 17:41:02 +03:00
2026-03-22 18:02:07 +03:00
trimmed := strings . TrimRightFunc ( line , unicode . IsSpace )
2026-02-26 17:41:02 +03:00
2026-03-22 18:02:07 +03:00
for j := 0 ; j < len ( trimmed ) ; {
r , size := utf8 . DecodeRuneInString ( trimmed [ j : ] )
repl := normalizeRune ( r )
for k := 0 ; k < len ( repl ) ; k ++ {
mapping = append ( mapping , origPos + j )
}
result = append ( result , repl ... )
j += size
}
2026-02-26 17:41:02 +03:00
2026-03-22 18:02:07 +03:00
origPos += len ( line ) // advance past full original line including trailing ws
}
2026-02-26 17:41:02 +03:00
2026-03-22 18:02:07 +03:00
return string ( result ) , mapping
}
2026-02-26 17:41:02 +03:00
2026-03-22 18:02:07 +03:00
// normalizeRune maps unicode quotes, dashes, and non-breaking spaces to
// their ASCII equivalents. Returns the original rune as a string for all
// other characters.
func normalizeRune ( r rune ) string {
switch r {
case '\u201c' , '\u201d' : // left/right double quote
return "\""
case '\u2018' , '\u2019' : // left/right single quote
return "'"
case '\u2013' , '\u2014' : // en dash, em dash
return "-"
case '\u00a0' : // non-breaking space
return " "
default :
return string ( r )
2026-02-26 17:41:02 +03:00
}
2026-03-22 18:02:07 +03:00
}
2026-02-26 17:41:02 +03:00
2026-03-22 18:02:07 +03:00
// normalizeForFuzzy normalizes s for fuzzy matching (without position mapping).
// Used for the search string where position mapping is not needed.
func normalizeForFuzzy ( s string ) string {
norm , _ := normalizeWithMap ( s )
return norm
2026-02-26 17:41:02 +03:00
}
2026-03-22 18:02:07 +03:00
// generateDiff creates a unified diff showing the change between old and new
// file contents. Uses the go-udiff library for correct diff computation.
func generateDiff ( path , old , new string ) string {
return udiff . Unified ( path , path , old , new )
2026-02-26 17:41:02 +03:00
}