fuzzyReplace attempts to find `search` inside `content` and replace it with `replace`. It uses a cascading match strategy inspired by openai/codex's apply_patch: 1. Exact substring match (byte-for-byte). 2. Line-by-line match ignoring trailing whitespace on each line. 3. Line-by-line match ignoring
(content string, edit workspacesdk.FileEdit)
| 1098 | // lines) while letting the caller drive deliberate rewrites of |
| 1099 | // leading whitespace or endings. |
| 1100 | func fuzzyReplace(content string, edit workspacesdk.FileEdit) (string, error) { |
| 1101 | search := edit.Search |
| 1102 | replace := edit.Replace |
| 1103 | |
| 1104 | // An empty search string has no meaningful interpretation: it |
| 1105 | // matches at every byte position, which means the caller has not |
| 1106 | // told us what they want to replace. Reject explicitly so |
| 1107 | // replace_all=true can't silently inject the replacement between |
| 1108 | // every byte. |
| 1109 | if search == "" { |
| 1110 | return "", xerrors.New("search string must not be empty; include the " + |
| 1111 | "text you want to match") |
| 1112 | } |
| 1113 | |
| 1114 | // Split up front so the ending-normalization rule can inspect |
| 1115 | // all three before any matching pass. |
| 1116 | contentLines := strings.SplitAfter(content, "\n") |
| 1117 | searchLines := strings.SplitAfter(search, "\n") |
| 1118 | // A trailing newline in the search produces an empty final element |
| 1119 | // from SplitAfter. Drop it so it doesn't interfere with line |
| 1120 | // matching. |
| 1121 | if len(searchLines) > 0 && searchLines[len(searchLines)-1] == "" { |
| 1122 | searchLines = searchLines[:len(searchLines)-1] |
| 1123 | } |
| 1124 | replaceLines := strings.SplitAfter(replace, "\n") |
| 1125 | if len(replaceLines) > 0 && replaceLines[len(replaceLines)-1] == "" { |
| 1126 | replaceLines = replaceLines[:len(replaceLines)-1] |
| 1127 | } |
| 1128 | |
| 1129 | // Ending normalization. If replace has a consistent internal |
| 1130 | // ending, force every spliced interior line to the file's |
| 1131 | // dominant ending. If search also has a consistent internal |
| 1132 | // ending and it disagrees with replace's, the caller signaled |
| 1133 | // intent to rewrite endings; restrict the match to pass 1 so |
| 1134 | // CRLF/LF interchange at pass 2 can't silently bridge a search |
| 1135 | // that doesn't actually occur in the file. |
| 1136 | var forcedEnding string |
| 1137 | searchInternal, searchOK := internalLineEnding(searchLines) |
| 1138 | replaceInternal, replaceOK := internalLineEnding(replaceLines) |
| 1139 | if replaceOK { |
| 1140 | forcedEnding = dominantFileEnding(contentLines) |
| 1141 | } |
| 1142 | callerEndingIntent := searchOK && replaceOK && searchInternal != replaceInternal |
| 1143 | |
| 1144 | // Pass 1 - exact substring match. Normalize replace's interior |
| 1145 | // endings to the file's style unless the caller's search/replace |
| 1146 | // disagreement signaled intent to rewrite endings. |
| 1147 | pass1Replace := replace |
| 1148 | if forcedEnding != "" && !callerEndingIntent && replaceInternal != forcedEnding { |
| 1149 | pass1Replace = rewriteInternalEnding(replaceLines, forcedEnding) |
| 1150 | } |
| 1151 | if strings.Contains(content, search) { |
| 1152 | if edit.ReplaceAll { |
| 1153 | return strings.ReplaceAll(content, search, pass1Replace), nil |
| 1154 | } |
| 1155 | count := strings.Count(content, search) |
| 1156 | if count > 1 { |
| 1157 | return "", xerrors.Errorf("search string matches %d occurrences "+ |
no test coverage detected