| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "os" |
| 6 | "strings" |
| 7 | |
| 8 | fileenc "reasonix/internal/fileutil/encoding" |
| 9 | ) |
| 10 | |
| 11 | // readFileEncoded reads a file and decodes its encoding to UTF-8. |
| 12 | // Returns the decoded content and the detected encoding kind so callers |
| 13 | // can re-encode on write to preserve the original charset. |
| 14 | func readFileEncoded(path string) (content string, enc fileenc.Kind, err error) { |
| 15 | b, err := os.ReadFile(path) |
| 16 | if err != nil { |
| 17 | return "", 0, err |
| 18 | } |
| 19 | enc, _ = fileenc.Detect(b) |
| 20 | return string(fileenc.Decode(b, enc)), enc, nil |
| 21 | } |
| 22 | |
| 23 | // writeFileEncoded encodes content back to the given encoding and writes it. |
| 24 | func writeFileEncoded(path string, content string, enc fileenc.Kind) error { |
| 25 | return os.WriteFile(path, fileenc.Encode(content, enc), 0o644) |
| 26 | } |
| 27 | |
| 28 | // matchLineEndings adapts an edit's old/new text to a CRLF file when the literal |
| 29 | // old_string isn't present but its CRLF form is. read_file strips '\r' (bufio |
| 30 | // ScanLines), so a model's multi-line old_string arrives LF-only while a |
| 31 | // Windows/CJK source stores '\r\n'; rewriting search and replacement to the |
| 32 | // file's ending fixes the match without rewriting the file's other line endings. |
| 33 | func matchLineEndings(content, old, new string) (string, string) { |
| 34 | if strings.Contains(content, old) || !strings.Contains(content, "\r\n") { |
| 35 | return old, new |
| 36 | } |
| 37 | if strings.Contains(content, toCRLF(old)) { |
| 38 | return toCRLF(old), toCRLF(new) |
| 39 | } |
| 40 | return old, new |
| 41 | } |
| 42 | |
| 43 | func toCRLF(s string) string { |
| 44 | return strings.ReplaceAll(strings.ReplaceAll(s, "\r\n", "\n"), "\n", "\r\n") |
| 45 | } |
| 46 | |
| 47 | func matchReplacementLineEndings(content, replacement string) string { |
| 48 | if strings.Contains(content, "\r\n") { |
| 49 | return toCRLF(replacement) |
| 50 | } |
| 51 | return replacement |
| 52 | } |
| 53 | |
| 54 | type editApplyResult struct { |
| 55 | updated string |
| 56 | applied int |
| 57 | matches int |
| 58 | fuzzy bool |
| 59 | receipt editReplacementReceipt |
| 60 | } |
| 61 | |
| 62 | type editRange struct { |
| 63 | start int |
| 64 | end int |
| 65 | } |
| 66 | |
| 67 | // editReplacementReceipt records only the span the tool actually matched and |
| 68 | // the span it wrote in its place. It deliberately excludes surrounding file |
| 69 | // content so a successful edit can ground the next model turn without widening |
| 70 | // provider-visible workspace data. |
| 71 | type editReplacementReceipt struct { |
| 72 | matched string |
| 73 | replacement string |
| 74 | occurrences int |
| 75 | fuzzy bool |
| 76 | } |
| 77 | |
| 78 | // applyOldStringEdit is the shared edit_file/multi_edit/Preview contract. It |
| 79 | // preserves the exact-match rule first, then falls back to a narrow fuzzy match |
| 80 | // for the mismatches read_file commonly introduces or hides: trailing |
| 81 | // whitespace, tab-vs-spaces indentation, and copied read_file line prefixes. |
| 82 | // Non-replace_all edits still require exactly one match, including fuzzy |
| 83 | // matches. |
| 84 | func applyOldStringEdit(content, oldString, newString string, replaceAll bool) editApplyResult { |
| 85 | old, newStr := matchLineEndings(content, oldString, newString) |
| 86 | if replaceAll { |
| 87 | if count := strings.Count(content, old); count > 0 { |
| 88 | return editApplyResult{ |
| 89 | updated: strings.ReplaceAll(content, old, newStr), |
| 90 | applied: count, |
| 91 | matches: count, |
| 92 | receipt: editReplacementReceipt{ |
| 93 | matched: old, |
| 94 | replacement: newStr, |
| 95 | occurrences: count, |
| 96 | }, |
| 97 | } |
| 98 | } |
| 99 | ranges := fuzzyEditRanges(content, old) |
| 100 | if len(ranges) == 0 { |
| 101 | return editApplyResult{updated: content} |
| 102 | } |
| 103 | replacement := matchReplacementLineEndings(content, newStr) |
| 104 | return editApplyResult{ |
| 105 | updated: replaceEditRanges(content, ranges, replacement), |
| 106 | applied: len(ranges), |
| 107 | matches: len(ranges), |
| 108 | fuzzy: true, |
| 109 | receipt: editReplacementReceipt{ |
| 110 | matched: matchedRangeSample(content, old, ranges), |
| 111 | replacement: replacement, |
| 112 | occurrences: len(ranges), |
| 113 | fuzzy: true, |
| 114 | }, |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | switch count := strings.Count(content, old); count { |
| 119 | case 0: |
| 120 | ranges := fuzzyEditRanges(content, old) |
| 121 | if len(ranges) != 1 { |
| 122 | return editApplyResult{updated: content, matches: len(ranges)} |
| 123 | } |
| 124 | return editApplyResult{ |
| 125 | updated: replaceEditRanges(content, ranges, matchReplacementLineEndings(content, newStr)), |
| 126 | applied: 1, |
| 127 | matches: 1, |
| 128 | fuzzy: true, |
| 129 | receipt: editReplacementReceipt{ |
| 130 | matched: matchedRangeSample(content, old, ranges), |
| 131 | replacement: matchReplacementLineEndings(content, newStr), |
| 132 | occurrences: 1, |
| 133 | fuzzy: true, |
| 134 | }, |
| 135 | } |
| 136 | case 1: |
| 137 | return editApplyResult{ |
| 138 | updated: strings.Replace(content, old, newStr, 1), |
| 139 | applied: 1, |
| 140 | matches: 1, |
| 141 | receipt: editReplacementReceipt{ |
| 142 | matched: old, |
| 143 | replacement: newStr, |
| 144 | occurrences: 1, |
| 145 | }, |
| 146 | } |
| 147 | default: |
| 148 | return editApplyResult{updated: content, matches: count} |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | func matchedRangeSample(content, fallback string, ranges []editRange) string { |
| 153 | if len(ranges) == 0 { |
| 154 | return fallback |
| 155 | } |
| 156 | r := ranges[0] |
| 157 | if r.start < 0 || r.end < r.start || r.end > len(content) { |
| 158 | return fallback |
| 159 | } |
| 160 | actual := content[r.start:r.end] |
| 161 | sample := clipPostWriteSpan(actual, maxCapturedReceiptSpanBytes) |
| 162 | if len(sample) == len(actual) { |
| 163 | // Do not let a short substring keep an otherwise-dead large intermediate |
| 164 | // multi_edit buffer alive until all later steps finish. |
| 165 | return strings.Clone(sample) |
| 166 | } |
| 167 | return sample |
| 168 | } |
| 169 | |
| 170 | func oldStringNotFoundError(path, oldString, content string) error { |
| 171 | hint := oldStringNotFoundHint(oldString, content) |
| 172 | if line, text, ok := nearestContentLine(oldString, content); ok { |
| 173 | return fmt.Errorf("old_string not found in %s (nearest line %d: %q).%s", path, line, text, hint) |
| 174 | } |
| 175 | return fmt.Errorf("old_string not found in %s.%s", path, hint) |
| 176 | } |
| 177 | |
| 178 | func oldStringNotFoundHint(oldString, content string) string { |
| 179 | base := " Re-read the current file before retrying; if several related edits target the same area, combine the final replacements in one multi_edit call." |
| 180 | if !strings.Contains(content, "\r\n") { |
| 181 | return base |
| 182 | } |
| 183 | normalizedContent := strings.ReplaceAll(content, "\r\n", "\n") |
| 184 | normalizedOld := strings.ReplaceAll(oldString, "\r\n", "\n") |
| 185 | if strings.Contains(normalizedContent, normalizedOld) { |
| 186 | return " The target file uses CRLF line endings; edit_file/multi_edit normally normalize LF-only old_string for CRLF files, so this is likely stale context. Re-read the current file before retrying." |
| 187 | } |
| 188 | return " The target file uses CRLF line endings, but edit_file/multi_edit already tolerate LF-only old_string for CRLF files; check for stale, incomplete, or non-unique context before retrying." |
| 189 | } |
| 190 | |
| 191 | func oldStringNotUniqueError(path, oldString, content string, matches int, replaceAllHint bool) error { |
| 192 | lineHint := oldStringMatchLineSummary(oldString, content, 5) |
| 193 | if replaceAllHint { |
| 194 | return fmt.Errorf("old_string is not unique in %s (%d matches)%s; add nearby unique code, not just repeated separator lines, or set replace_all if every match should change", path, matches, lineHint) |
| 195 | } |
| 196 | return fmt.Errorf("old_string is not unique in %s (%d matches)%s; add nearby unique code, not just repeated separator lines", path, matches, lineHint) |
| 197 | } |
| 198 | |
| 199 | type lineSegment struct { |
| 200 | raw string |
| 201 | start int |
| 202 | end int |
| 203 | } |
| 204 | |
| 205 | type fuzzyMode struct { |
| 206 | stripOldReadPrefixes bool |
| 207 | trimTrailing bool |
| 208 | expandTabs bool |
| 209 | trimLeading bool |
| 210 | } |
| 211 | |
| 212 | func fuzzyEditRanges(content, old string) []editRange { |
| 213 | if old == "" || content == "" { |
| 214 | return nil |
| 215 | } |
| 216 | contentLines := splitLineSegments(content) |
| 217 | oldLines := splitLineSegments(old) |
| 218 | if len(oldLines) == 0 || len(oldLines) > len(contentLines) { |
| 219 | return nil |
| 220 | } |
| 221 | |
| 222 | oldHasReadPrefixes := allLinesHaveReadFilePrefix(oldLines) |
| 223 | modes := []fuzzyMode{ |
| 224 | {trimTrailing: true}, |
| 225 | {trimTrailing: true, expandTabs: true}, |
| 226 | } |
| 227 | if oldHasReadPrefixes { |
| 228 | modes = append(modes, |
| 229 | fuzzyMode{stripOldReadPrefixes: true, trimTrailing: true}, |
| 230 | fuzzyMode{stripOldReadPrefixes: true, trimTrailing: true, expandTabs: true}, |
| 231 | ) |
| 232 | } |
| 233 | |
| 234 | for _, mode := range modes { |
| 235 | normOld := make([]string, len(oldLines)) |
| 236 | for i, line := range oldLines { |
| 237 | normOld[i] = normalizeFuzzyLine(line.raw, lineHasNewline(line.raw), mode, mode.stripOldReadPrefixes) |
| 238 | } |
| 239 | var ranges []editRange |
| 240 | for i := 0; i <= len(contentLines)-len(oldLines); { |
| 241 | if fuzzyWindowMatches(contentLines[i:i+len(oldLines)], oldLines, normOld, mode) { |
| 242 | ranges = append(ranges, editRange{ |
| 243 | start: contentLines[i].start, |
| 244 | end: fuzzyWindowEnd(contentLines[i+len(oldLines)-1], oldLines[len(oldLines)-1]), |
| 245 | }) |
| 246 | i += len(oldLines) |
| 247 | continue |
| 248 | } |
| 249 | i++ |
| 250 | } |
| 251 | if len(ranges) > 0 { |
| 252 | return ranges |
| 253 | } |
| 254 | } |
| 255 | return nil |
| 256 | } |
| 257 | |
| 258 | func fuzzyWindowMatches(contentWindow, oldLines []lineSegment, normOld []string, mode fuzzyMode) bool { |
| 259 | for i, contentLine := range contentWindow { |
| 260 | oldHasNewline := lineHasNewline(oldLines[i].raw) |
| 261 | if oldHasNewline && !lineHasNewline(contentLine.raw) { |
| 262 | return false |
| 263 | } |
| 264 | got := normalizeFuzzyLine(contentLine.raw, oldHasNewline, mode, false) |
| 265 | if got != normOld[i] { |
| 266 | return false |
| 267 | } |
| 268 | } |
| 269 | return true |
| 270 | } |
| 271 | |
| 272 | func splitLineSegments(s string) []lineSegment { |
| 273 | if s == "" { |
| 274 | return nil |
| 275 | } |
| 276 | var lines []lineSegment |
| 277 | start := 0 |
| 278 | for i, r := range s { |
| 279 | if r == '\n' { |
| 280 | end := i + 1 |
| 281 | lines = append(lines, lineSegment{raw: s[start:end], start: start, end: end}) |
| 282 | start = end |
| 283 | } |
| 284 | } |
| 285 | if start < len(s) { |
| 286 | lines = append(lines, lineSegment{raw: s[start:], start: start, end: len(s)}) |
| 287 | } |
| 288 | return lines |
| 289 | } |
| 290 | |
| 291 | func lineHasNewline(line string) bool { |
| 292 | return strings.HasSuffix(line, "\n") |
| 293 | } |
| 294 | |
| 295 | func fuzzyWindowEnd(contentLast, oldLast lineSegment) int { |
| 296 | if lineHasNewline(oldLast.raw) || !lineHasNewline(contentLast.raw) { |
| 297 | return contentLast.end |
| 298 | } |
| 299 | end := contentLast.end - 1 |
| 300 | if end > contentLast.start && contentLast.raw[len(contentLast.raw)-2] == '\r' { |
| 301 | end-- |
| 302 | } |
| 303 | return end |
| 304 | } |
| 305 | |
| 306 | func normalizeFuzzyLine(line string, includeNewline bool, mode fuzzyMode, stripReadPrefix bool) string { |
| 307 | body := strings.TrimSuffix(line, "\n") |
| 308 | if stripReadPrefix { |
| 309 | body, _ = stripReadFileLinePrefix(body) |
| 310 | } |
| 311 | if mode.trimTrailing { |
| 312 | body = strings.TrimRight(body, " \t\r") |
| 313 | } |
| 314 | if mode.expandTabs { |
| 315 | body = strings.ReplaceAll(body, "\t", " ") |
| 316 | } |
| 317 | if mode.trimLeading { |
| 318 | body = strings.TrimLeft(body, " \t") |
| 319 | } |
| 320 | if includeNewline { |
| 321 | return body + "\n" |
| 322 | } |
| 323 | return body |
| 324 | } |
| 325 | |
| 326 | func allLinesHaveReadFilePrefix(lines []lineSegment) bool { |
| 327 | if len(lines) == 0 { |
| 328 | return false |
| 329 | } |
| 330 | for _, line := range lines { |
| 331 | body := strings.TrimSuffix(line.raw, "\n") |
| 332 | if _, ok := stripReadFileLinePrefix(body); !ok { |
| 333 | return false |
| 334 | } |
| 335 | } |
| 336 | return true |
| 337 | } |
| 338 | |
| 339 | func stripReadFileLinePrefix(line string) (string, bool) { |
| 340 | i := 0 |
| 341 | for i < len(line) && (line[i] == ' ' || line[i] == '\t') { |
| 342 | i++ |
| 343 | } |
| 344 | j := i |
| 345 | for j < len(line) && line[j] >= '0' && line[j] <= '9' { |
| 346 | j++ |
| 347 | } |
| 348 | if j == i || !strings.HasPrefix(line[j:], "\u2192") { |
| 349 | return line, false |
| 350 | } |
| 351 | return line[j+len("\u2192"):], true |
| 352 | } |
| 353 | |
| 354 | func replaceEditRanges(content string, ranges []editRange, replacement string) string { |
| 355 | updated := content |
| 356 | for i := len(ranges) - 1; i >= 0; i-- { |
| 357 | r := ranges[i] |
| 358 | updated = updated[:r.start] + replacement + updated[r.end:] |
| 359 | } |
| 360 | return updated |
| 361 | } |
| 362 | |
| 363 | func nearestContentLine(oldString, content string) (int, string, bool) { |
| 364 | oldLines := splitLineSegments(oldString) |
| 365 | if len(oldLines) == 0 { |
| 366 | return 0, "", false |
| 367 | } |
| 368 | target := strings.TrimSpace(normalizeFuzzyLine(oldLines[0].raw, false, fuzzyMode{trimTrailing: true, expandTabs: true}, true)) |
| 369 | if target == "" { |
| 370 | return 0, "", false |
| 371 | } |
| 372 | bestLine := 0 |
| 373 | bestScore := 0 |
| 374 | bestText := "" |
| 375 | for i, line := range splitLineSegments(content) { |
| 376 | text := strings.TrimSuffix(line.raw, "\n") |
| 377 | score := commonPrefixLen(strings.TrimSpace(strings.ReplaceAll(text, "\t", " ")), target) |
| 378 | if score > bestScore { |
| 379 | bestLine = i + 1 |
| 380 | bestScore = score |
| 381 | bestText = text |
| 382 | } |
| 383 | } |
| 384 | if bestScore < 3 { |
| 385 | return 0, "", false |
| 386 | } |
| 387 | return bestLine, bestText, true |
| 388 | } |
| 389 | |
| 390 | func oldStringMatchLineSummary(oldString, content string, limit int) string { |
| 391 | if limit <= 0 { |
| 392 | return "" |
| 393 | } |
| 394 | target := firstNonEmptyLine(oldString) |
| 395 | if target == "" { |
| 396 | return "" |
| 397 | } |
| 398 | var matches []int |
| 399 | for i, line := range splitLineSegments(content) { |
| 400 | text := strings.TrimSuffix(line.raw, "\n") |
| 401 | text = strings.TrimSuffix(text, "\r") |
| 402 | if strings.Contains(text, target) { |
| 403 | matches = append(matches, i+1) |
| 404 | } |
| 405 | } |
| 406 | if len(matches) == 0 { |
| 407 | return "" |
| 408 | } |
| 409 | var b strings.Builder |
| 410 | b.WriteString("; matching lines include ") |
| 411 | for i, line := range matches { |
| 412 | if i >= limit { |
| 413 | b.WriteString(", ...") |
| 414 | break |
| 415 | } |
| 416 | if i > 0 { |
| 417 | b.WriteString(", ") |
| 418 | } |
| 419 | fmt.Fprint(&b, line) |
| 420 | } |
| 421 | return b.String() |
| 422 | } |
| 423 | |
| 424 | func firstNonEmptyLine(s string) string { |
| 425 | for _, line := range splitLineSegments(s) { |
| 426 | text := strings.TrimSpace(strings.TrimSuffix(line.raw, "\n")) |
| 427 | text = strings.TrimSuffix(text, "\r") |
| 428 | if text != "" { |
| 429 | return text |
| 430 | } |
| 431 | } |
| 432 | return "" |
| 433 | } |
| 434 | |
| 435 | func commonPrefixLen(a, b string) int { |
| 436 | n := len(a) |
| 437 | if len(b) < n { |
| 438 | n = len(b) |
| 439 | } |
| 440 | for i := 0; i < n; i++ { |
| 441 | if a[i] != b[i] { |
| 442 | return i |
| 443 | } |
| 444 | } |
| 445 | return n |
| 446 | } |
| 447 |