diff --git a/internal/agent/tools/edit.go b/internal/agent/tools/edit.go index e4503e8127a750647c659353a018d36ee42643a1..2b9ff587ab81bcd1fbddc9b78d5683ec019a9725 100644 --- a/internal/agent/tools/edit.go +++ b/internal/agent/tools/edit.go @@ -7,6 +7,7 @@ import ( "log/slog" "os" "path/filepath" + "regexp" "strings" "time" @@ -211,25 +212,28 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool var deletionCount int if replaceAll { - newContent = strings.ReplaceAll(oldContent, oldString, "") - deletionCount = strings.Count(oldContent, oldString) - if deletionCount == 0 { + // For replaceAll, try fuzzy match if exact match fails. + replaced, found := replaceAllWithBestMatch(oldContent, oldString, "") + if !found { return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil } + newContent = replaced + deletionCount = 1 } else { - index := strings.Index(oldContent, oldString) - if index == -1 { + // Try exact match first, then fuzzy match. + matchedString, found, isMultiple := findBestMatch(oldContent, oldString) + if !found { return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil } - - lastIndex := strings.LastIndex(oldContent, oldString) - if index != lastIndex { + if isMultiple { return fantasy.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil } - newContent = oldContent[:index] + oldContent[index+len(oldString):] + index := strings.Index(oldContent, matchedString) + newContent = oldContent[:index] + oldContent[index+len(matchedString):] deletionCount = 1 } + _ = deletionCount sessionID := GetSessionFromContext(edit.ctx) @@ -341,27 +345,26 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep oldContent, isCrlf := fsext.ToUnixLineEndings(string(content)) var newContent string - var replacementCount int if replaceAll { - newContent = strings.ReplaceAll(oldContent, oldString, newString) - replacementCount = strings.Count(oldContent, oldString) - if replacementCount == 0 { + // For replaceAll, try fuzzy match if exact match fails. + replaced, found := replaceAllWithBestMatch(oldContent, oldString, newString) + if !found { return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil } + newContent = replaced } else { - index := strings.Index(oldContent, oldString) - if index == -1 { + // Try exact match first, then fuzzy match. + matchedString, found, isMultiple := findBestMatch(oldContent, oldString) + if !found { return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil } - - lastIndex := strings.LastIndex(oldContent, oldString) - if index != lastIndex { + if isMultiple { return fantasy.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil } - newContent = oldContent[:index] + newString + oldContent[index+len(oldString):] - replacementCount = 1 + index := strings.Index(oldContent, matchedString) + newContent = oldContent[:index] + newString + oldContent[index+len(matchedString):] } if oldContent == newContent { @@ -440,3 +443,265 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep Removals: removals, }), nil } + +// findBestMatch attempts to find a match for oldString in content. If an exact +// match is found, it returns the oldString unchanged. Otherwise, it tries +// several normalization strategies to find a fuzzy match. +// +// Returns: (matchedString, found, isMultiple) +// - matchedString: the actual string found in content that should be used +// - found: whether any match was found +// - isMultiple: whether multiple matches were found (ambiguous) +func findBestMatch(content, oldString string) (string, bool, bool) { + oldString = normalizeOldStringForMatching(oldString) + + // Strategy 1: Exact match. + index := strings.Index(content, oldString) + if index != -1 { + lastIndex := strings.LastIndex(content, oldString) + return oldString, true, index != lastIndex + } + + // Strategy 2: Try trimming surrounding blank lines. + trimmedSurrounding := trimSurroundingBlankLines(oldString) + if trimmedSurrounding != "" && trimmedSurrounding != oldString { + index := strings.Index(content, trimmedSurrounding) + if index != -1 { + lastIndex := strings.LastIndex(content, trimmedSurrounding) + return trimmedSurrounding, true, index != lastIndex + } + } + + // Strategy 3: Try trimming trailing whitespace from each line of oldString. + trimmedLines := trimTrailingWhitespacePerLine(oldString) + if trimmedLines != oldString { + index := strings.Index(content, trimmedLines) + if index != -1 { + lastIndex := strings.LastIndex(content, trimmedLines) + return trimmedLines, true, index != lastIndex + } + } + + // Strategy 4: Try with/without trailing newline. + if strings.HasSuffix(oldString, "\n") { + withoutTrailing := strings.TrimSuffix(oldString, "\n") + index := strings.Index(content, withoutTrailing) + if index != -1 { + lastIndex := strings.LastIndex(content, withoutTrailing) + return withoutTrailing, true, index != lastIndex + } + } else { + withTrailing := oldString + "\n" + index := strings.Index(content, withTrailing) + if index != -1 { + lastIndex := strings.LastIndex(content, withTrailing) + return withTrailing, true, index != lastIndex + } + } + + // Strategy 5: Try matching with flexible blank lines (collapse multiple + // blank lines to single). + collapsedOld := collapseBlankLines(oldString) + if collapsedOld != oldString { + index := strings.Index(content, collapsedOld) + if index != -1 { + lastIndex := strings.LastIndex(content, collapsedOld) + return collapsedOld, true, index != lastIndex + } + } + + // Strategy 6: Try normalizing indentation (find content with same structure + // but different leading whitespace). + matched, found, isMultiple := tryNormalizeIndentation(content, oldString) + if found { + return matched, true, isMultiple + } + + if collapsedOld != oldString { + matched, found, isMultiple := tryNormalizeIndentation(content, collapsedOld) + if found { + return matched, true, isMultiple + } + } + + return "", false, false +} + +var viewLinePrefixRE = regexp.MustCompile(`^\s*\d+\|\s?`) + +func normalizeOldStringForMatching(oldString string) string { + oldString, _ = fsext.ToUnixLineEndings(oldString) + oldString = stripZeroWidthCharacters(oldString) + oldString = stripMarkdownCodeFences(oldString) + oldString = stripViewLineNumbers(oldString) + return oldString +} + +func stripZeroWidthCharacters(s string) string { + s = strings.ReplaceAll(s, "\ufeff", "") + s = strings.ReplaceAll(s, "\u200b", "") + s = strings.ReplaceAll(s, "\u200c", "") + s = strings.ReplaceAll(s, "\u200d", "") + s = strings.ReplaceAll(s, "\u2060", "") + return s +} + +func stripMarkdownCodeFences(s string) string { + re := regexp.MustCompile("(?s)^\\s*```[^\\n]*\\n(.*)\\n```\\s*$") + m := re.FindStringSubmatch(s) + if len(m) != 2 { + return s + } + return m[1] +} + +func stripViewLineNumbers(s string) string { + lines := strings.Split(s, "\n") + if len(lines) < 2 { + return s + } + + var withPrefix int + for _, line := range lines { + if viewLinePrefixRE.MatchString(line) { + withPrefix++ + } + } + + if withPrefix < (len(lines)+1)/2 { + return s + } + + for i, line := range lines { + lines[i] = viewLinePrefixRE.ReplaceAllString(line, "") + } + + return strings.Join(lines, "\n") +} + +func trimSurroundingBlankLines(s string) string { + lines := strings.Split(s, "\n") + start := 0 + for start < len(lines) && strings.TrimSpace(lines[start]) == "" { + start++ + } + + end := len(lines) + for end > start && strings.TrimSpace(lines[end-1]) == "" { + end-- + } + + return strings.Join(lines[start:end], "\n") +} + +func replaceAllWithBestMatch(content, oldString, newString string) (string, bool) { + oldString = normalizeOldStringForMatching(oldString) + if oldString == "" { + return "", false + } + + if strings.Contains(content, oldString) { + return strings.ReplaceAll(content, oldString, newString), true + } + + newContent, ok := tryReplaceAllWithFlexibleMultilineRegexp(content, oldString, newString) + if ok { + return newContent, true + } + + collapsedOld := collapseBlankLines(oldString) + if collapsedOld != oldString { + newContent, ok := tryReplaceAllWithFlexibleMultilineRegexp(content, collapsedOld, newString) + if ok { + return newContent, true + } + } + + matchedString, found, _ := findBestMatch(content, oldString) + if !found || matchedString == "" { + return "", false + } + return strings.ReplaceAll(content, matchedString, newString), true +} + +func tryReplaceAllWithFlexibleMultilineRegexp(content, oldString, newString string) (string, bool) { + re := buildFlexibleMultilineRegexp(oldString) + if re == nil { + return "", false + } + + if !re.MatchString(content) { + return "", false + } + + newContent := re.ReplaceAllStringFunc(content, func(string) string { + return newString + }) + return newContent, true +} + +func buildFlexibleMultilineRegexp(oldString string) *regexp.Regexp { + oldString = normalizeOldStringForMatching(oldString) + lines := strings.Split(oldString, "\n") + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + if len(lines) < 2 { + return nil + } + + patternParts := make([]string, 0, len(lines)) + for _, line := range lines { + trimmedLeft := strings.TrimLeft(line, " \t") + trimmed := strings.TrimRight(trimmedLeft, " \t") + if trimmed == "" { + patternParts = append(patternParts, `^[ \t]*$`) + continue + } + escaped := regexp.QuoteMeta(trimmed) + patternParts = append(patternParts, `^[ \t]*`+escaped+`[ \t]*$`) + } + + pattern := "(?m)" + strings.Join(patternParts, "\n") + re, err := regexp.Compile(pattern) + if err != nil { + return nil + } + return re +} + +// trimTrailingWhitespacePerLine removes trailing spaces/tabs from each line. +func trimTrailingWhitespacePerLine(s string) string { + lines := strings.Split(s, "\n") + for i, line := range lines { + lines[i] = strings.TrimRight(line, " \t") + } + return strings.Join(lines, "\n") +} + +// collapseBlankLines replaces multiple consecutive blank lines with a single +// blank line. +func collapseBlankLines(s string) string { + re := regexp.MustCompile(`\n{3,}`) + return re.ReplaceAllString(s, "\n\n") +} + +// tryNormalizeIndentation attempts to find a match by adjusting indentation. +// It extracts the "shape" of the code (non-whitespace content per line) and +// looks for that pattern in the content with potentially different +// indentation. +func tryNormalizeIndentation(content, oldString string) (string, bool, bool) { + re := buildFlexibleMultilineRegexp(oldString) + if re == nil { + return "", false, false + } + + matches := re.FindAllStringIndex(content, 2) + if len(matches) == 0 { + return "", false, false + } + if len(matches) > 1 { + return content[matches[0][0]:matches[0][1]], true, true + } + return content[matches[0][0]:matches[0][1]], true, false +} diff --git a/internal/agent/tools/edit_fuzzy_test.go b/internal/agent/tools/edit_fuzzy_test.go new file mode 100644 index 0000000000000000000000000000000000000000..10004a68c7d99490816f5af86478468bd51eae0f --- /dev/null +++ b/internal/agent/tools/edit_fuzzy_test.go @@ -0,0 +1,542 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "testing" + + "charm.land/fantasy" + "github.com/charmbracelet/crush/internal/filetracker" + "github.com/charmbracelet/crush/internal/history" + "github.com/charmbracelet/crush/internal/permission" + "github.com/charmbracelet/crush/internal/pubsub" + "github.com/stretchr/testify/require" +) + +func TestFindBestMatch_ExactMatch(t *testing.T) { + t.Parallel() + + content := "func foo() {\n\treturn 1\n}\n" + oldString := "func foo() {\n\treturn 1\n}" + + matched, found, isMultiple := findBestMatch(content, oldString) + require.True(t, found) + require.False(t, isMultiple) + require.Equal(t, oldString, matched) +} + +func TestFindBestMatch_TrailingWhitespacePerLine(t *testing.T) { + t.Parallel() + + // Content has no trailing spaces, but oldString has trailing spaces. + content := "func foo() {\n\treturn 1\n}\n" + oldString := "func foo() { \n\treturn 1 \n}" + + matched, found, isMultiple := findBestMatch(content, oldString) + require.True(t, found) + require.False(t, isMultiple) + require.Equal(t, "func foo() {\n\treturn 1\n}", matched) +} + +func TestFindBestMatch_TrailingNewline(t *testing.T) { + t.Parallel() + + // Content has trailing newline, oldString doesn't. + content := "line1\nline2\n" + oldString := "line1\nline2" + + matched, found, isMultiple := findBestMatch(content, oldString) + require.True(t, found) + require.False(t, isMultiple) + require.Equal(t, "line1\nline2", matched) +} + +func TestFindBestMatch_MissingTrailingNewline(t *testing.T) { + t.Parallel() + + // Content doesn't have trailing newline after match, but oldString does. + content := "line1\nline2" + oldString := "line1\nline2\n" + + matched, found, isMultiple := findBestMatch(content, oldString) + require.True(t, found) + require.False(t, isMultiple) + require.Equal(t, "line1\nline2", matched) +} + +func TestFindBestMatch_IndentationDifference(t *testing.T) { + t.Parallel() + + // Content uses tabs, oldString uses spaces. + content := "func foo() {\n\treturn 1\n}\n" + oldString := "func foo() {\n return 1\n}" + + matched, found, isMultiple := findBestMatch(content, oldString) + require.True(t, found) + require.False(t, isMultiple) + require.Equal(t, "func foo() {\n\treturn 1\n}", matched) +} + +func TestFindBestMatch_DifferentIndentLevel(t *testing.T) { + t.Parallel() + + // Content has 4-space indent, oldString has 2-space indent. + content := "func foo() {\n return 1\n}\n" + oldString := "func foo() {\n return 1\n}" + + matched, found, isMultiple := findBestMatch(content, oldString) + require.True(t, found) + require.False(t, isMultiple) + require.Equal(t, "func foo() {\n return 1\n}", matched) +} + +func TestFindBestMatch_CollapseBlankLines(t *testing.T) { + t.Parallel() + + // Content has single blank line, oldString has multiple. + content := "line1\n\nline2\n" + oldString := "line1\n\n\n\nline2" + + matched, found, isMultiple := findBestMatch(content, oldString) + require.True(t, found) + require.False(t, isMultiple) + require.Equal(t, "line1\n\nline2", matched) +} + +func TestFindBestMatch_MultipleMatches(t *testing.T) { + t.Parallel() + + content := "foo\nbar\nfoo\n" + oldString := "foo" + + matched, found, isMultiple := findBestMatch(content, oldString) + require.True(t, found) + require.True(t, isMultiple) + require.Equal(t, "foo", matched) +} + +func TestFindBestMatch_NoMatch(t *testing.T) { + t.Parallel() + + content := "func foo() {\n\treturn 1\n}\n" + oldString := "func bar() {\n\treturn 2\n}" + + _, found, _ := findBestMatch(content, oldString) + require.False(t, found) +} + +func TestFindBestMatch_StripsViewLineNumbers(t *testing.T) { + t.Parallel() + + content := "line1\nline2\nline3\n" + oldString := " 1|line1\n 2|line2\n 3|line3" + + matched, found, isMultiple := findBestMatch(content, oldString) + require.True(t, found) + require.False(t, isMultiple) + require.Equal(t, "line1\nline2\nline3", matched) +} + +func TestFindBestMatch_StripsMarkdownCodeFences(t *testing.T) { + t.Parallel() + + content := "line1\nline2\n" + oldString := "```go\nline1\nline2\n```" + + matched, found, isMultiple := findBestMatch(content, oldString) + require.True(t, found) + require.False(t, isMultiple) + require.Equal(t, "line1\nline2", matched) +} + +func TestFindBestMatch_TrimsSurroundingBlankLines(t *testing.T) { + t.Parallel() + + content := "line1\nline2\n" + oldString := "\n\nline1\nline2\n\n" + + matched, found, isMultiple := findBestMatch(content, oldString) + require.True(t, found) + require.False(t, isMultiple) + require.Equal(t, "line1\nline2", matched) +} + +func TestFindBestMatch_StripsZeroWidthCharacters(t *testing.T) { + t.Parallel() + + content := "line1\nline2\n" + oldString := "line\u200b1\nline2" + + matched, found, isMultiple := findBestMatch(content, oldString) + require.True(t, found) + require.False(t, isMultiple) + require.Equal(t, "line1\nline2", matched) +} + +func TestFindBestMatch_ComplexIndentation(t *testing.T) { + t.Parallel() + + content := `func example() { + if true { + doSomething() + } +} +` + // Model provided with wrong indentation (2 spaces instead of tabs). + oldString := `func example() { + if true { + doSomething() + } +}` + + matched, found, isMultiple := findBestMatch(content, oldString) + require.True(t, found) + require.False(t, isMultiple) + require.Contains(t, matched, "\t") +} + +func TestApplyEditToContent_FuzzyMatch(t *testing.T) { + t.Parallel() + + // Content uses tabs, edit uses spaces - should still match. + content := "func foo() {\n\treturn 1\n}\n" + + newContent, err := applyEditToContent(content, MultiEditOperation{ + OldString: "func foo() {\n return 1\n}", + NewString: "func foo() {\n\treturn 2\n}", + }) + require.NoError(t, err) + require.Contains(t, newContent, "return 2") +} + +func TestApplyEditToContent_FuzzyMatchTrailingSpaces(t *testing.T) { + t.Parallel() + + content := "line 1\nline 2\nline 3\n" + + // Edit has trailing spaces that don't exist in content. + newContent, err := applyEditToContent(content, MultiEditOperation{ + OldString: "line 1 \nline 2 ", + NewString: "LINE 1\nLINE 2", + }) + require.NoError(t, err) + require.Contains(t, newContent, "LINE 1") + require.Contains(t, newContent, "LINE 2") +} + +func TestApplyEditToContent_FuzzyMatchReplaceAll(t *testing.T) { + t.Parallel() + + content := "foo bar\nfoo baz\n" + + // With replaceAll and fuzzy match (trailing space). + newContent, err := applyEditToContent(content, MultiEditOperation{ + OldString: "foo ", + NewString: "FOO ", + ReplaceAll: true, + }) + require.NoError(t, err) + require.Contains(t, newContent, "FOO bar") + require.Contains(t, newContent, "FOO baz") +} + +func TestTrimTrailingWhitespacePerLine(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected string + }{ + { + name: "trailing spaces", + input: "line1 \nline2 \nline3", + expected: "line1\nline2\nline3", + }, + { + name: "trailing tabs", + input: "line1\t\nline2\t\t\nline3", + expected: "line1\nline2\nline3", + }, + { + name: "mixed trailing whitespace", + input: "line1 \t \nline2\t \nline3 ", + expected: "line1\nline2\nline3", + }, + { + name: "no trailing whitespace", + input: "line1\nline2\nline3", + expected: "line1\nline2\nline3", + }, + { + name: "preserves leading whitespace", + input: " line1 \n\tline2\t\n line3 ", + expected: " line1\n\tline2\n line3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := trimTrailingWhitespacePerLine(tt.input) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestCollapseBlankLines(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected string + }{ + { + name: "multiple blank lines", + input: "line1\n\n\n\nline2", + expected: "line1\n\nline2", + }, + { + name: "single blank line unchanged", + input: "line1\n\nline2", + expected: "line1\n\nline2", + }, + { + name: "no blank lines", + input: "line1\nline2", + expected: "line1\nline2", + }, + { + name: "many blank lines", + input: "line1\n\n\n\n\n\n\nline2", + expected: "line1\n\nline2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := collapseBlankLines(tt.input) + require.Equal(t, tt.expected, result) + }) + } +} + +// Integration tests that test the actual replaceContent and deleteContent +// functions with real files. + +func newTestEditContext(t *testing.T) (editContext, string) { + t.Helper() + tmpDir := t.TempDir() + permissions := &mockPermissionService{Broker: pubsub.NewBroker[permission.PermissionRequest]()} + files := &mockHistoryService{Broker: pubsub.NewBroker[history.File]()} + ctx := context.WithValue(context.Background(), SessionIDContextKey, "test-session") + return editContext{ctx, permissions, files, tmpDir}, tmpDir +} + +func TestEditTool_ReplaceContent_FuzzyIndentation(t *testing.T) { + t.Parallel() + + edit, tmpDir := newTestEditContext(t) + testFile := filepath.Join(tmpDir, "test.go") + + // File uses tabs for indentation. + content := "func foo() {\n\treturn 1\n}\n" + err := os.WriteFile(testFile, []byte(content), 0o644) + require.NoError(t, err) + + // Simulate reading the file first. + filetracker.RecordRead(testFile) + + // Model provides spaces instead of tabs. + oldString := "func foo() {\n return 1\n}" + newString := "func foo() {\n\treturn 2\n}" + + resp, err := replaceContent(edit, testFile, oldString, newString, false, fantasy.ToolCall{ID: "test"}) + require.NoError(t, err) + require.False(t, resp.IsError, "expected no error, got: %s", resp.Content) + + // Verify the file was updated. + result, err := os.ReadFile(testFile) + require.NoError(t, err) + require.Contains(t, string(result), "return 2") +} + +func TestEditTool_ReplaceContent_FuzzyTrailingWhitespace(t *testing.T) { + t.Parallel() + + edit, tmpDir := newTestEditContext(t) + testFile := filepath.Join(tmpDir, "test.txt") + + // File has no trailing whitespace. + content := "line 1\nline 2\nline 3\n" + err := os.WriteFile(testFile, []byte(content), 0o644) + require.NoError(t, err) + + filetracker.RecordRead(testFile) + + // Model provides trailing spaces. + oldString := "line 1 \nline 2 " + newString := "LINE 1\nLINE 2" + + resp, err := replaceContent(edit, testFile, oldString, newString, false, fantasy.ToolCall{ID: "test"}) + require.NoError(t, err) + require.False(t, resp.IsError, "expected no error, got: %s", resp.Content) + + result, err := os.ReadFile(testFile) + require.NoError(t, err) + require.Contains(t, string(result), "LINE 1") + require.Contains(t, string(result), "LINE 2") +} + +func TestEditTool_ReplaceContent_FuzzyTrailingNewline(t *testing.T) { + t.Parallel() + + edit, tmpDir := newTestEditContext(t) + testFile := filepath.Join(tmpDir, "test.txt") + + // File content. + content := "hello\nworld\n" + err := os.WriteFile(testFile, []byte(content), 0o644) + require.NoError(t, err) + + filetracker.RecordRead(testFile) + + // Model omits trailing newline. + oldString := "hello\nworld" + newString := "HELLO\nWORLD" + + resp, err := replaceContent(edit, testFile, oldString, newString, false, fantasy.ToolCall{ID: "test"}) + require.NoError(t, err) + require.False(t, resp.IsError, "expected no error, got: %s", resp.Content) + + result, err := os.ReadFile(testFile) + require.NoError(t, err) + require.Contains(t, string(result), "HELLO") + require.Contains(t, string(result), "WORLD") +} + +func TestEditTool_ReplaceContent_ExactMatchStillWorks(t *testing.T) { + t.Parallel() + + edit, tmpDir := newTestEditContext(t) + testFile := filepath.Join(tmpDir, "test.txt") + + content := "func foo() {\n\treturn 1\n}\n" + err := os.WriteFile(testFile, []byte(content), 0o644) + require.NoError(t, err) + + filetracker.RecordRead(testFile) + + // Exact match should still work. + oldString := "func foo() {\n\treturn 1\n}" + newString := "func foo() {\n\treturn 2\n}" + + resp, err := replaceContent(edit, testFile, oldString, newString, false, fantasy.ToolCall{ID: "test"}) + require.NoError(t, err) + require.False(t, resp.IsError, "expected no error, got: %s", resp.Content) + + result, err := os.ReadFile(testFile) + require.NoError(t, err) + require.Contains(t, string(result), "return 2") +} + +func TestEditTool_ReplaceContent_NoMatchStillFails(t *testing.T) { + t.Parallel() + + edit, tmpDir := newTestEditContext(t) + testFile := filepath.Join(tmpDir, "test.txt") + + content := "func foo() {\n\treturn 1\n}\n" + err := os.WriteFile(testFile, []byte(content), 0o644) + require.NoError(t, err) + + filetracker.RecordRead(testFile) + + // Completely wrong content should still fail. + oldString := "func bar() {\n\treturn 999\n}" + newString := "func baz() {\n\treturn 0\n}" + + resp, err := replaceContent(edit, testFile, oldString, newString, false, fantasy.ToolCall{ID: "test"}) + require.NoError(t, err) + require.True(t, resp.IsError, "expected error for no match") + require.Contains(t, resp.Content, "not found") +} + +func TestEditTool_DeleteContent_FuzzyIndentation(t *testing.T) { + t.Parallel() + + edit, tmpDir := newTestEditContext(t) + testFile := filepath.Join(tmpDir, "test.go") + + // File uses tabs. + content := "func foo() {\n\treturn 1\n}\n\nfunc bar() {\n\treturn 2\n}\n" + err := os.WriteFile(testFile, []byte(content), 0o644) + require.NoError(t, err) + + filetracker.RecordRead(testFile) + + // Model provides spaces instead of tabs. + oldString := "func foo() {\n return 1\n}\n\n" + + resp, err := deleteContent(edit, testFile, oldString, false, fantasy.ToolCall{ID: "test"}) + require.NoError(t, err) + require.False(t, resp.IsError, "expected no error, got: %s", resp.Content) + + result, err := os.ReadFile(testFile) + require.NoError(t, err) + require.NotContains(t, string(result), "return 1") + require.Contains(t, string(result), "return 2") +} + +func TestEditTool_ReplaceContent_ReplaceAllFuzzy(t *testing.T) { + t.Parallel() + + edit, tmpDir := newTestEditContext(t) + testFile := filepath.Join(tmpDir, "test.txt") + + content := "foo bar\nfoo baz\nfoo qux\n" + err := os.WriteFile(testFile, []byte(content), 0o644) + require.NoError(t, err) + + filetracker.RecordRead(testFile) + + // ReplaceAll with exact match. + oldString := "foo" + newString := "FOO" + + resp, err := replaceContent(edit, testFile, oldString, newString, true, fantasy.ToolCall{ID: "test"}) + require.NoError(t, err) + require.False(t, resp.IsError, "expected no error, got: %s", resp.Content) + + result, err := os.ReadFile(testFile) + require.NoError(t, err) + require.Contains(t, string(result), "FOO bar") + require.Contains(t, string(result), "FOO baz") + require.Contains(t, string(result), "FOO qux") + require.NotContains(t, string(result), "foo") +} + +func TestEditTool_ReplaceContent_MultipleMatchesFails(t *testing.T) { + t.Parallel() + + edit, tmpDir := newTestEditContext(t) + testFile := filepath.Join(tmpDir, "test.txt") + + content := "foo\nbar\nfoo\n" + err := os.WriteFile(testFile, []byte(content), 0o644) + require.NoError(t, err) + + filetracker.RecordRead(testFile) + + // Should fail because "foo" appears multiple times. + oldString := "foo" + newString := "FOO" + + resp, err := replaceContent(edit, testFile, oldString, newString, false, fantasy.ToolCall{ID: "test"}) + require.NoError(t, err) + require.True(t, resp.IsError, "expected error for multiple matches") + require.Contains(t, resp.Content, "multiple times") +} diff --git a/internal/agent/tools/multiedit.go b/internal/agent/tools/multiedit.go index 9136c37fadb914cb1c560e3fa5f2b6208fc3ead5..ceff3e095348144010b39b60333258b6f5b7fd92 100644 --- a/internal/agent/tools/multiedit.go +++ b/internal/agent/tools/multiedit.go @@ -396,27 +396,26 @@ func applyEditToContent(content string, edit MultiEditOperation) (string, error) } var newContent string - var replacementCount int if edit.ReplaceAll { - newContent = strings.ReplaceAll(content, edit.OldString, edit.NewString) - replacementCount = strings.Count(content, edit.OldString) - if replacementCount == 0 { + // For replaceAll, try fuzzy match if exact match fails. + replaced, found := replaceAllWithBestMatch(content, edit.OldString, edit.NewString) + if !found { return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks") } + newContent = replaced } else { - index := strings.Index(content, edit.OldString) - if index == -1 { + // Try exact match first, then fuzzy match. + matchedString, found, isMultiple := findBestMatch(content, edit.OldString) + if !found { return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks") } - - lastIndex := strings.LastIndex(content, edit.OldString) - if index != lastIndex { + if isMultiple { return "", fmt.Errorf("old_string appears multiple times in the content. Please provide more context to ensure a unique match, or set replace_all to true") } - newContent = content[:index] + edit.NewString + content[index+len(edit.OldString):] - replacementCount = 1 + index := strings.Index(content, matchedString) + newContent = content[:index] + edit.NewString + content[index+len(matchedString):] } return newContent, nil