From 1c0442c247c08e1968d75b4908328f478bad6549 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 24 Mar 2026 14:41:45 +0200 Subject: [PATCH] fix(agent/agentfiles): fix replace_all in fuzzy matching mode (#23480) replace_all in fuzzy mode (passes 2 and 3 of fuzzyReplace) only replaced the first match. seekLines returned the first match, spliceLines replaced one range, and there was no loop. Extract fuzzy pass logic into fuzzyReplaceLines which: - Returns a 3-tuple (result, matched, error) for clean caller flow - When replaceAll is true, collects all non-overlapping matches then applies replacements from last to first to preserve indices - When replaceAll is false with multiple matches, returns an error Add test cases for replace_all with fuzzy trailing whitespace and fuzzy indent matching. --- agent/agentfiles/files.go | 96 ++++++++++++++++++++++++++-------- agent/agentfiles/files_test.go | 37 +++++++++++++ 2 files changed, 112 insertions(+), 21 deletions(-) diff --git a/agent/agentfiles/files.go b/agent/agentfiles/files.go index 6144771601..3f3bea27c2 100644 --- a/agent/agentfiles/files.go +++ b/agent/agentfiles/files.go @@ -626,30 +626,15 @@ func fuzzyReplace(content string, edit workspacesdk.FileEdit) (string, error) { } // Pass 2 – trim trailing whitespace on each line. - if start, end, ok := seekLines(contentLines, searchLines, trimRight); ok { - if !edit.ReplaceAll { - if count := countLineMatches(contentLines, searchLines, trimRight); count > 1 { - return "", xerrors.Errorf("search string matches %d occurrences "+ - "(expected exactly 1). Include more surrounding "+ - "context to make the match unique, or set "+ - "replace_all to true", count) - } - } - return spliceLines(contentLines, start, end, replace), nil + if result, matched, err := fuzzyReplaceLines(contentLines, searchLines, replace, trimRight, edit.ReplaceAll); matched { + return result, err } // Pass 3 – trim all leading and trailing whitespace - // (indentation-tolerant). - if start, end, ok := seekLines(contentLines, searchLines, trimAll); ok { - if !edit.ReplaceAll { - if count := countLineMatches(contentLines, searchLines, trimAll); count > 1 { - return "", xerrors.Errorf("search string matches %d occurrences "+ - "(expected exactly 1). Include more surrounding "+ - "context to make the match unique, or set "+ - "replace_all to true", count) - } - } - return spliceLines(contentLines, start, end, replace), nil + // (indentation-tolerant). The replacement is inserted verbatim; + // callers must provide correctly indented replacement text. + if result, matched, err := fuzzyReplaceLines(contentLines, searchLines, replace, trimAll, edit.ReplaceAll); matched { + return result, err } return "", xerrors.New("search string not found in file. Verify the search " + @@ -712,3 +697,72 @@ func spliceLines(contentLines []string, start, end int, replacement string) stri } return b.String() } + +// fuzzyReplaceLines handles fuzzy matching passes (2 and 3) for +// fuzzyReplace. When replaceAll is false and there are multiple +// matches, an error is returned. When replaceAll is true, all +// non-overlapping matches are replaced. +// +// Returns (result, true, nil) on success, ("", false, nil) when +// searchLines don't match at all, or ("", true, err) when the match +// is ambiguous. +// +//nolint:revive // replaceAll is a direct pass-through of the user's flag, not a control coupling. +func fuzzyReplaceLines( + contentLines, searchLines []string, + replace string, + eq func(a, b string) bool, + replaceAll bool, +) (string, bool, error) { + start, end, ok := seekLines(contentLines, searchLines, eq) + if !ok { + return "", false, nil + } + + if !replaceAll { + if count := countLineMatches(contentLines, searchLines, eq); count > 1 { + return "", true, xerrors.Errorf("search string matches %d occurrences "+ + "(expected exactly 1). Include more surrounding "+ + "context to make the match unique, or set "+ + "replace_all to true", count) + } + return spliceLines(contentLines, start, end, replace), true, nil + } + + // Replace all: collect all match positions, then apply from last + // to first to preserve indices. + type lineMatch struct{ start, end int } + var matches []lineMatch + for i := 0; i <= len(contentLines)-len(searchLines); { + found := true + for j, sLine := range searchLines { + if !eq(contentLines[i+j], sLine) { + found = false + break + } + } + if found { + matches = append(matches, lineMatch{i, i + len(searchLines)}) + i += len(searchLines) // skip past this match + } else { + i++ + } + } + + // Apply replacements from last to first. + repLines := strings.SplitAfter(replace, "\n") + for i := len(matches) - 1; i >= 0; i-- { + m := matches[i] + newLines := make([]string, 0, m.start+len(repLines)+(len(contentLines)-m.end)) + newLines = append(newLines, contentLines[:m.start]...) + newLines = append(newLines, repLines...) + newLines = append(newLines, contentLines[m.end:]...) + contentLines = newLines + } + + var b strings.Builder + for _, l := range contentLines { + _, _ = b.WriteString(l) + } + return b.String(), true, nil +} diff --git a/agent/agentfiles/files_test.go b/agent/agentfiles/files_test.go index cf4a7aa7ee..f30bb740f1 100644 --- a/agent/agentfiles/files_test.go +++ b/agent/agentfiles/files_test.go @@ -881,6 +881,43 @@ func TestEditFiles(t *testing.T) { }, expected: map[string]string{filepath.Join(tmpdir, "ra-exact"): "qux bar qux baz qux"}, }, + { + // replace_all with fuzzy trailing-whitespace match. + name: "ReplaceAllFuzzyTrailing", + contents: map[string]string{filepath.Join(tmpdir, "ra-fuzzy-trail"): "hello \nworld\nhello \nagain"}, + edits: []workspacesdk.FileEdits{ + { + Path: filepath.Join(tmpdir, "ra-fuzzy-trail"), + Edits: []workspacesdk.FileEdit{ + { + Search: "hello\n", + Replace: "bye\n", + ReplaceAll: true, + }, + }, + }, + }, + expected: map[string]string{filepath.Join(tmpdir, "ra-fuzzy-trail"): "bye\nworld\nbye\nagain"}, + }, + { + // replace_all with fuzzy indent match (pass 3). + name: "ReplaceAllFuzzyIndent", + contents: map[string]string{filepath.Join(tmpdir, "ra-fuzzy-indent"): "\t\talpha\n\t\tbeta\n\t\talpha\n\t\tgamma"}, + edits: []workspacesdk.FileEdits{ + { + Path: filepath.Join(tmpdir, "ra-fuzzy-indent"), + Edits: []workspacesdk.FileEdit{ + { + // Search uses different indentation (spaces instead of tabs). + Search: " alpha\n", + Replace: "\t\tREPLACED\n", + ReplaceAll: true, + }, + }, + }, + }, + expected: map[string]string{filepath.Join(tmpdir, "ra-fuzzy-indent"): "\t\tREPLACED\n\t\tbeta\n\t\tREPLACED\n\t\tgamma"}, + }, { name: "MixedWhitespaceMultiline", contents: map[string]string{filepath.Join(tmpdir, "mixed-ws"): "func main() {\n\tresult := compute()\n\tfmt.Println(result)\n}"},