Files
2026-07-13 13:00:08 +08:00

394 lines
11 KiB
Go

package builtin
import (
"fmt"
"os"
"strings"
fileenc "reasonix/internal/fileutil/encoding"
)
// readFileEncoded reads a file and decodes its encoding to UTF-8.
// Returns the decoded content and the detected encoding kind so callers
// can re-encode on write to preserve the original charset.
func readFileEncoded(path string) (content string, enc fileenc.Kind, err error) {
b, err := os.ReadFile(path)
if err != nil {
return "", 0, err
}
enc, _ = fileenc.Detect(b)
return string(fileenc.Decode(b, enc)), enc, nil
}
// writeFileEncoded encodes content back to the given encoding and writes it.
func writeFileEncoded(path string, content string, enc fileenc.Kind) error {
return os.WriteFile(path, fileenc.Encode(content, enc), 0o644)
}
// matchLineEndings adapts an edit's old/new text to a CRLF file when the literal
// old_string isn't present but its CRLF form is. read_file strips '\r' (bufio
// ScanLines), so a model's multi-line old_string arrives LF-only while a
// Windows/CJK source stores '\r\n'; rewriting search and replacement to the
// file's ending fixes the match without rewriting the file's other line endings.
func matchLineEndings(content, old, new string) (string, string) {
if strings.Contains(content, old) || !strings.Contains(content, "\r\n") {
return old, new
}
if strings.Contains(content, toCRLF(old)) {
return toCRLF(old), toCRLF(new)
}
return old, new
}
func toCRLF(s string) string {
return strings.ReplaceAll(strings.ReplaceAll(s, "\r\n", "\n"), "\n", "\r\n")
}
func matchReplacementLineEndings(content, replacement string) string {
if strings.Contains(content, "\r\n") {
return toCRLF(replacement)
}
return replacement
}
type editApplyResult struct {
updated string
applied int
matches int
fuzzy bool
}
type editRange struct {
start int
end int
}
// applyOldStringEdit is the shared edit_file/multi_edit/Preview contract. It
// preserves the exact-match rule first, then falls back to a narrow fuzzy match
// for the mismatches read_file commonly introduces or hides: trailing
// whitespace, tab-vs-spaces indentation, and copied read_file line prefixes.
// Non-replace_all edits still require exactly one match, including fuzzy
// matches.
func applyOldStringEdit(content, oldString, newString string, replaceAll bool) editApplyResult {
old, newStr := matchLineEndings(content, oldString, newString)
if replaceAll {
if count := strings.Count(content, old); count > 0 {
return editApplyResult{
updated: strings.ReplaceAll(content, old, newStr),
applied: count,
matches: count,
}
}
ranges := fuzzyEditRanges(content, old)
if len(ranges) == 0 {
return editApplyResult{updated: content}
}
return editApplyResult{
updated: replaceEditRanges(content, ranges, matchReplacementLineEndings(content, newStr)),
applied: len(ranges),
matches: len(ranges),
fuzzy: true,
}
}
switch count := strings.Count(content, old); count {
case 0:
ranges := fuzzyEditRanges(content, old)
if len(ranges) != 1 {
return editApplyResult{updated: content, matches: len(ranges)}
}
return editApplyResult{
updated: replaceEditRanges(content, ranges, matchReplacementLineEndings(content, newStr)),
applied: 1,
matches: 1,
fuzzy: true,
}
case 1:
return editApplyResult{
updated: strings.Replace(content, old, newStr, 1),
applied: 1,
matches: 1,
}
default:
return editApplyResult{updated: content, matches: count}
}
}
func oldStringNotFoundError(path, oldString, content string) error {
hint := oldStringNotFoundHint(oldString, content)
if line, text, ok := nearestContentLine(oldString, content); ok {
return fmt.Errorf("old_string not found in %s (nearest line %d: %q).%s", path, line, text, hint)
}
return fmt.Errorf("old_string not found in %s.%s", path, hint)
}
func oldStringNotFoundHint(oldString, content string) string {
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."
if !strings.Contains(content, "\r\n") {
return base
}
normalizedContent := strings.ReplaceAll(content, "\r\n", "\n")
normalizedOld := strings.ReplaceAll(oldString, "\r\n", "\n")
if strings.Contains(normalizedContent, normalizedOld) {
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."
}
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."
}
func oldStringNotUniqueError(path, oldString, content string, matches int, replaceAllHint bool) error {
lineHint := oldStringMatchLineSummary(oldString, content, 5)
if replaceAllHint {
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)
}
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)
}
type lineSegment struct {
raw string
start int
end int
}
type fuzzyMode struct {
stripOldReadPrefixes bool
trimTrailing bool
expandTabs bool
trimLeading bool
}
func fuzzyEditRanges(content, old string) []editRange {
if old == "" || content == "" {
return nil
}
contentLines := splitLineSegments(content)
oldLines := splitLineSegments(old)
if len(oldLines) == 0 || len(oldLines) > len(contentLines) {
return nil
}
oldHasReadPrefixes := allLinesHaveReadFilePrefix(oldLines)
modes := []fuzzyMode{
{trimTrailing: true},
{trimTrailing: true, expandTabs: true},
}
if oldHasReadPrefixes {
modes = append(modes,
fuzzyMode{stripOldReadPrefixes: true, trimTrailing: true},
fuzzyMode{stripOldReadPrefixes: true, trimTrailing: true, expandTabs: true},
)
}
for _, mode := range modes {
normOld := make([]string, len(oldLines))
for i, line := range oldLines {
normOld[i] = normalizeFuzzyLine(line.raw, lineHasNewline(line.raw), mode, mode.stripOldReadPrefixes)
}
var ranges []editRange
for i := 0; i <= len(contentLines)-len(oldLines); {
if fuzzyWindowMatches(contentLines[i:i+len(oldLines)], oldLines, normOld, mode) {
ranges = append(ranges, editRange{
start: contentLines[i].start,
end: fuzzyWindowEnd(contentLines[i+len(oldLines)-1], oldLines[len(oldLines)-1]),
})
i += len(oldLines)
continue
}
i++
}
if len(ranges) > 0 {
return ranges
}
}
return nil
}
func fuzzyWindowMatches(contentWindow, oldLines []lineSegment, normOld []string, mode fuzzyMode) bool {
for i, contentLine := range contentWindow {
oldHasNewline := lineHasNewline(oldLines[i].raw)
if oldHasNewline && !lineHasNewline(contentLine.raw) {
return false
}
got := normalizeFuzzyLine(contentLine.raw, oldHasNewline, mode, false)
if got != normOld[i] {
return false
}
}
return true
}
func splitLineSegments(s string) []lineSegment {
if s == "" {
return nil
}
var lines []lineSegment
start := 0
for i, r := range s {
if r == '\n' {
end := i + 1
lines = append(lines, lineSegment{raw: s[start:end], start: start, end: end})
start = end
}
}
if start < len(s) {
lines = append(lines, lineSegment{raw: s[start:], start: start, end: len(s)})
}
return lines
}
func lineHasNewline(line string) bool {
return strings.HasSuffix(line, "\n")
}
func fuzzyWindowEnd(contentLast, oldLast lineSegment) int {
if lineHasNewline(oldLast.raw) || !lineHasNewline(contentLast.raw) {
return contentLast.end
}
end := contentLast.end - 1
if end > contentLast.start && contentLast.raw[len(contentLast.raw)-2] == '\r' {
end--
}
return end
}
func normalizeFuzzyLine(line string, includeNewline bool, mode fuzzyMode, stripReadPrefix bool) string {
body := strings.TrimSuffix(line, "\n")
if stripReadPrefix {
body, _ = stripReadFileLinePrefix(body)
}
if mode.trimTrailing {
body = strings.TrimRight(body, " \t\r")
}
if mode.expandTabs {
body = strings.ReplaceAll(body, "\t", " ")
}
if mode.trimLeading {
body = strings.TrimLeft(body, " \t")
}
if includeNewline {
return body + "\n"
}
return body
}
func allLinesHaveReadFilePrefix(lines []lineSegment) bool {
if len(lines) == 0 {
return false
}
for _, line := range lines {
body := strings.TrimSuffix(line.raw, "\n")
if _, ok := stripReadFileLinePrefix(body); !ok {
return false
}
}
return true
}
func stripReadFileLinePrefix(line string) (string, bool) {
i := 0
for i < len(line) && (line[i] == ' ' || line[i] == '\t') {
i++
}
j := i
for j < len(line) && line[j] >= '0' && line[j] <= '9' {
j++
}
if j == i || !strings.HasPrefix(line[j:], "\u2192") {
return line, false
}
return line[j+len("\u2192"):], true
}
func replaceEditRanges(content string, ranges []editRange, replacement string) string {
updated := content
for i := len(ranges) - 1; i >= 0; i-- {
r := ranges[i]
updated = updated[:r.start] + replacement + updated[r.end:]
}
return updated
}
func nearestContentLine(oldString, content string) (int, string, bool) {
oldLines := splitLineSegments(oldString)
if len(oldLines) == 0 {
return 0, "", false
}
target := strings.TrimSpace(normalizeFuzzyLine(oldLines[0].raw, false, fuzzyMode{trimTrailing: true, expandTabs: true}, true))
if target == "" {
return 0, "", false
}
bestLine := 0
bestScore := 0
bestText := ""
for i, line := range splitLineSegments(content) {
text := strings.TrimSuffix(line.raw, "\n")
score := commonPrefixLen(strings.TrimSpace(strings.ReplaceAll(text, "\t", " ")), target)
if score > bestScore {
bestLine = i + 1
bestScore = score
bestText = text
}
}
if bestScore < 3 {
return 0, "", false
}
return bestLine, bestText, true
}
func oldStringMatchLineSummary(oldString, content string, limit int) string {
if limit <= 0 {
return ""
}
target := firstNonEmptyLine(oldString)
if target == "" {
return ""
}
var matches []int
for i, line := range splitLineSegments(content) {
text := strings.TrimSuffix(line.raw, "\n")
text = strings.TrimSuffix(text, "\r")
if strings.Contains(text, target) {
matches = append(matches, i+1)
}
}
if len(matches) == 0 {
return ""
}
var b strings.Builder
b.WriteString("; matching lines include ")
for i, line := range matches {
if i >= limit {
b.WriteString(", ...")
break
}
if i > 0 {
b.WriteString(", ")
}
fmt.Fprint(&b, line)
}
return b.String()
}
func firstNonEmptyLine(s string) string {
for _, line := range splitLineSegments(s) {
text := strings.TrimSpace(strings.TrimSuffix(line.raw, "\n"))
text = strings.TrimSuffix(text, "\r")
if text != "" {
return text
}
}
return ""
}
func commonPrefixLen(a, b string) int {
n := len(a)
if len(b) < n {
n = len(b)
}
for i := 0; i < n; i++ {
if a[i] != b[i] {
return i
}
}
return n
}