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