@@ -4,16 +4,38 @@ import (
44 "fmt"
55 "os"
66 "strings"
7+ "sync"
78)
89
10+ // editLineCache:编辑时(文件还是改前)记下「(path, old_string) → 命中段精确起始行」,
11+ // 供 UI 渲染 diff 行号时直接取——这样不论改前/改后、内容多常见,都有准确行号,
12+ // 不再依赖渲染时回读当前文件 grep(改后 old_string 没了 / 短串歧义都会丢行号)。
13+ var editLineCache sync.Map // key: path\x00old_string → int(1-indexed)
14+
15+ func editLineKey (path , old string ) string { return path + "\x00 " + old }
16+
17+ // RecordedEditLine 取出某次编辑在文件里的真实起始行(编辑时记的);没有返回 (0,false)。
18+ func RecordedEditLine (path , old string ) (int , bool ) {
19+ if v , ok := editLineCache .Load (editLineKey (path , old )); ok {
20+ return v .(int ), true
21+ }
22+ return 0 , false
23+ }
24+
925// EditFile 字符串模式替换:
1026//
11- // old_string (string) 要替换的内容(需逐字符精确匹配)
27+ // old_string (string) 要替换的内容
1228// new_string (string) 替换为
1329// replace_all (bool) 是否替换所有匹配,默认 false
1430//
15- // 跟 Claude Code 的 Edit 一致。UI 预览侧用 locateLineInFile 反推出 old_string
16- // 在文件里的起始行号,patch -/+ 前面会带上行号列。
31+ // 匹配采用多级回退(对齐 codex/aider 等业界做法),把模型常见的空白漂移吸收掉,
32+ // 大幅降低「old_string 找不到」:
33+ // 1. 精确匹配;
34+ // 2. 控制符 unescape 回退(模型把 \n/\t 当字面量发过来);
35+ // 3. 行对齐 + 每行空白容差(先容行尾空白、再容首尾缩进),命中文件里【真实那段】文本后替换。
36+ //
37+ // 关键:始终靠【内容】定位、替换文件真实文本(不靠行号),所以不会错改无关行;命中必须唯一,
38+ // 否则报错要求补上下文。CRLF 文件先归一为 LF 处理、写回时还原。
1739func EditFile (args map [string ]any ) ToolResult {
1840 path , _ := args ["path" ].(string )
1941 if path == "" {
@@ -38,10 +60,14 @@ func EditFile(args map[string]any) ToolResult {
3860 return ToolResult {Output : fmt .Sprintf ("读取失败: %v" , err ), Success : false }
3961 }
4062
41- content := string (data )
42- count := strings .Count (content , oldStr )
63+ // CRLF 文件归一为 LF 处理,写回时还原;search/replace 同样归一,口径一致。
64+ content , crlf := toLF (string (data ))
65+ search := strings .ReplaceAll (oldStr , "\r \n " , "\n " )
66+ replace := strings .ReplaceAll (newStr , "\r \n " , "\n " )
67+
68+ actual , count , note := resolveEditTarget (content , search )
4369 if count == 0 {
44- return ToolResult {Output : "错误: 在文件中未找到 old_string" , Success : false }
70+ return ToolResult {Output : "错误: 在文件中未找到 old_string" + editDivergenceHint ( content , search ) , Success : false }
4571 }
4672 if count > 1 && ! replaceAll {
4773 return ToolResult {
@@ -50,18 +76,142 @@ func EditFile(args map[string]any) ToolResult {
5076 }
5177 }
5278
79+ // 编辑时(content 还是改前)记下命中段的精确起始行,供 UI 渲染 diff 行号直接取。
80+ if idx := strings .Index (content , actual ); idx >= 0 {
81+ editLineCache .Store (editLineKey (path , oldStr ), strings .Count (content [:idx ], "\n " )+ 1 )
82+ }
83+
5384 var updated string
5485 if replaceAll {
55- updated = strings .ReplaceAll (content , oldStr , newStr )
86+ updated = strings .ReplaceAll (content , actual , replace )
5687 } else {
57- updated = strings .Replace (content , oldStr , newStr , 1 )
88+ updated = strings .Replace (content , actual , replace , 1 )
5889 }
59- if err := os .WriteFile (absPath , []byte (updated ), 0o644 ); err != nil {
90+ out := updated
91+ if crlf {
92+ out = strings .ReplaceAll (updated , "\n " , "\r \n " )
93+ }
94+ if err := os .WriteFile (absPath , []byte (out ), 0o644 ); err != nil {
6095 return ToolResult {Output : fmt .Sprintf ("写入失败: %v" , err ), Success : false }
6196 }
6297 CodeGraphInvalidate () // 文件变了,代码图谱缓存失效,下次查询重建
6398 return ToolResult {
64- Output : fmt .Sprintf ("已替换 %d 处 -> %s" , count , absPath ),
99+ Output : fmt .Sprintf ("已替换 %d 处%s -> %s" , count , note , absPath ),
65100 Success : true ,
66101 }
67102}
103+
104+ // LocateEditTargetLine 返回 search 在 content 里(经与 EditFile 同一套多级容差匹配)命中段的
105+ // 首行行号(1-indexed);找不到 / 多处歧义返回 0。供 UI 预览渲染 diff 行号,口径与 EditFile 一致
106+ // ——容差匹配成功时也能给出行号,不再因空白/缩进/CRLF 漂移而退化成无行号。
107+ func LocateEditTargetLine (content , search string ) int {
108+ lf , _ := toLF (content )
109+ s := strings .ReplaceAll (search , "\r \n " , "\n " )
110+ actual , count , _ := resolveEditTarget (lf , s )
111+ if count == 0 || actual == "" { // 0=没找到;actual==""=容差档下多处歧义,不给行号
112+ return 0
113+ }
114+ idx := strings .Index (lf , actual )
115+ if idx < 0 {
116+ return 0
117+ }
118+ return strings .Count (lf [:idx ], "\n " ) + 1
119+ }
120+
121+ // resolveEditTarget 多级回退定位:返回文件里【真实匹配到的文本】、匹配次数、以及一段说明(用了哪级回退)。
122+ // count==0 表示没找到;count>1 表示多处(行对齐回退只在唯一时返回真实文本,多处时只回计数)。
123+ func resolveEditTarget (content , search string ) (actual string , count int , note string ) {
124+ // 1. 精确匹配
125+ if c := strings .Count (content , search ); c > 0 {
126+ return search , c , ""
127+ }
128+ // 2. 控制符 unescape 回退:模型把 \n / \t 当字面量(双反斜杠)发过来时还原再试
129+ if u := unescapeLiteralControls (search ); u != search {
130+ if c := strings .Count (content , u ); c > 0 {
131+ return u , c , "(已还原字面量转义)"
132+ }
133+ }
134+ // 3. 行对齐 + 空白容差:先只容行尾空白,再容首尾缩进。命中唯一才用(避免误改)。
135+ for _ , m := range []struct {
136+ eq func (a , b string ) bool
137+ note string
138+ }{
139+ {eqLineRStrip , "(行尾空白容差匹配)" },
140+ {eqLineTrim , "(空白/缩进容差匹配)" },
141+ } {
142+ if a , c := locateLineAligned (content , search , m .eq ); c == 1 {
143+ return a , 1 , m .note
144+ } else if c > 1 {
145+ // 该容差级别下多处命中:不返回真实文本(replace 无法区分),交由上层按"多处"处理。
146+ return "" , c , ""
147+ }
148+ }
149+ return "" , 0 , ""
150+ }
151+
152+ // locateLineAligned 在 content 里按行查找与 search 各行(在 eq 容差下)逐行匹配的连续窗口。
153+ // 唯一命中时返回文件里真实那段文本(原样,保留真实缩进);否则返回("", 命中数)。
154+ func locateLineAligned (content , search string , eq func (a , b string ) bool ) (string , int ) {
155+ sLines := strings .Split (strings .TrimSuffix (search , "\n " ), "\n " )
156+ cLines := strings .Split (content , "\n " )
157+ m := len (sLines )
158+ if m == 0 {
159+ return "" , 0
160+ }
161+ var hits []string
162+ for i := 0 ; i + m <= len (cLines ); i ++ {
163+ ok := true
164+ for k := 0 ; k < m ; k ++ {
165+ if ! eq (cLines [i + k ], sLines [k ]) {
166+ ok = false
167+ break
168+ }
169+ }
170+ if ok {
171+ hits = append (hits , strings .Join (cLines [i :i + m ], "\n " ))
172+ i += m - 1 // 跳过本窗口,避免重叠重复计数
173+ }
174+ }
175+ if len (hits ) == 1 {
176+ return hits [0 ], 1
177+ }
178+ return "" , len (hits )
179+ }
180+
181+ func eqLineRStrip (a , b string ) bool {
182+ return strings .TrimRight (a , " \t " ) == strings .TrimRight (b , " \t " )
183+ }
184+ func eqLineTrim (a , b string ) bool { return strings .TrimSpace (a ) == strings .TrimSpace (b ) }
185+
186+ // unescapeLiteralControls 把字面量的 \n \t \r(反斜杠+字母)还原成真实控制符。仅作回退用。
187+ func unescapeLiteralControls (s string ) string {
188+ return strings .NewReplacer (`\n` , "\n " , `\t` , "\t " , `\r` , "\r " ).Replace (s )
189+ }
190+
191+ // toLF 把内容归一为 LF;返回是否原本是 CRLF(用于写回还原)。
192+ func toLF (s string ) (string , bool ) {
193+ if strings .Contains (s , "\r \n " ) {
194+ return strings .ReplaceAll (s , "\r \n " , "\n " ), true
195+ }
196+ return s , false
197+ }
198+
199+ // editDivergenceHint 找不到时给一句有用的提示:若 search 的某些行能在文件里单独找到,
200+ // 多半是整体空白/缩进/相邻行对不上 —— 提示重新 Read 后逐字复制。
201+ func editDivergenceHint (content , search string ) string {
202+ sLines := strings .Split (strings .TrimSuffix (search , "\n " ), "\n " )
203+ found := 0
204+ for _ , ln := range sLines {
205+ t := strings .TrimSpace (ln )
206+ if t == "" {
207+ continue
208+ }
209+ if strings .Contains (content , t ) {
210+ found ++
211+ }
212+ }
213+ if found > 0 {
214+ return "(部分行能单独找到,可能是缩进/空白/相邻行对不上 —— 请重新 Read 该文件并逐字复制 old_string,注意保留原始缩进)"
215+ }
216+ return "(该文本不在文件中 —— 请先 Read 确认文件当前内容)"
217+ }
0 commit comments