返回 DeepSeek-Reasonix
encoding_helpers.go
根目录 / internal / tool / builtin / encoding_helpers.go
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
447 lines GO