Skip to content

Commit 9694566

Browse files
committed
✨ edit match
1 parent 0acff9c commit 9694566

3 files changed

Lines changed: 277 additions & 23 deletions

File tree

tools/edit_file.go

Lines changed: 160 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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 处理、写回时还原。
1739
func 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+
}

tools/edit_file_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package tools
2+
3+
import (
4+
"os"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestResolveEditTarget(t *testing.T) {
10+
content := "func f() {\n\tx := 1\n\treturn x\n}\n"
11+
12+
cases := []struct {
13+
name string
14+
search string
15+
wantCount int
16+
wantText string // 命中文件里的真实文本(唯一时)
17+
}{
18+
{"精确", "\tx := 1", 1, "\tx := 1"},
19+
{"行尾多空格", "\tx := 1 ", 1, "\tx := 1"}, // 模型尾部多敲了空格
20+
{"缩进漂移(空格代Tab)", " x := 1", 1, "\tx := 1"}, // 模型用空格、文件是 Tab
21+
{"多行+缩进漂移", " x := 1\n return x", 1, "\tx := 1\n\treturn x"},
22+
{"不存在", "y := 2", 0, ""},
23+
}
24+
for _, c := range cases {
25+
got, n, _ := resolveEditTarget(content, c.search)
26+
if n != c.wantCount {
27+
t.Errorf("[%s] count=%d want %d", c.name, n, c.wantCount)
28+
continue
29+
}
30+
if c.wantCount == 1 && got != c.wantText {
31+
t.Errorf("[%s] actual=%q want %q", c.name, got, c.wantText)
32+
}
33+
}
34+
}
35+
36+
func TestResolveEditTarget_Ambiguous(t *testing.T) {
37+
content := "a := 1\nb := 2\na := 1\n"
38+
// 精确出现 2 次 → count=2(上层据此要求 replace_all 或补上下文)
39+
if _, n, _ := resolveEditTarget(content, "a := 1"); n != 2 {
40+
t.Fatalf("精确多处应 count=2, got %d", n)
41+
}
42+
// 空白容差下也多处 → count>1,不返回真实文本
43+
got, n, _ := resolveEditTarget(content, "a := 1 ")
44+
if n <= 1 || got != "" {
45+
t.Fatalf("容差多处应 count>1 且不返回文本, got count=%d text=%q", n, got)
46+
}
47+
}
48+
49+
func TestUnescapeLiteralControls(t *testing.T) {
50+
if got := unescapeLiteralControls(`a\nb\tc`); got != "a\nb\tc" {
51+
t.Fatalf("got %q", got)
52+
}
53+
}
54+
55+
func TestToLF(t *testing.T) {
56+
if lf, crlf := toLF("a\r\nb\r\n"); !crlf || lf != "a\nb\n" {
57+
t.Fatalf("toLF crlf=%v lf=%q", crlf, lf)
58+
}
59+
if _, crlf := toLF("a\nb\n"); crlf {
60+
t.Fatal("纯 LF 不应判 crlf")
61+
}
62+
}
63+
64+
func TestLocateEditTargetLine(t *testing.T) {
65+
content := "package x\n\nfunc f() {\n\tx := 1\n\treturn x\n}\n"
66+
// 精确:第 4 行
67+
if got := LocateEditTargetLine(content, "\tx := 1"); got != 4 {
68+
t.Fatalf("精确 行号 got %d want 4", got)
69+
}
70+
// 缩进漂移(空格代 Tab):仍应定位到第 4 行
71+
if got := LocateEditTargetLine(content, " x := 1"); got != 4 {
72+
t.Fatalf("容差 行号 got %d want 4", got)
73+
}
74+
// CRLF 文件 + LF needle:仍应定位
75+
crlf := strings.ReplaceAll(content, "\n", "\r\n")
76+
if got := LocateEditTargetLine(crlf, "\tx := 1"); got != 4 {
77+
t.Fatalf("CRLF 行号 got %d want 4", got)
78+
}
79+
// 找不到 → 0
80+
if got := LocateEditTargetLine(content, "nope"); got != 0 {
81+
t.Fatalf("找不到应 0, got %d", got)
82+
}
83+
}
84+
85+
func TestEditFile_RecordsLine(t *testing.T) {
86+
dir := t.TempDir()
87+
// workspace 未注入时 confineToWorkspace 放行,临时绝对路径直接可写。
88+
p := dir + "/f.txt"
89+
if err := os.WriteFile(p, []byte("l1\nl2\ntarget\nl4\n"), 0o644); err != nil {
90+
t.Fatal(err)
91+
}
92+
res := EditFile(map[string]any{"path": p, "old_string": "target", "new_string": "TARGET"})
93+
if !res.Success {
94+
t.Fatalf("edit 失败: %s", res.Output)
95+
}
96+
// 编辑后再取:行号应是命中段的真实起始行(第 3 行),与"改后文件已无 target"无关
97+
if ln, ok := RecordedEditLine(p, "target"); !ok || ln != 3 {
98+
t.Fatalf("RecordedEditLine got (%d,%v) want (3,true)", ln, ok)
99+
}
100+
}

tui/tool_display.go

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package tui
22

33
import (
4+
"deepx/tools"
45
"encoding/json"
56
"os"
67
"strings"
@@ -62,12 +63,19 @@ func formatUpdatePreview(argsJSON string) string {
6263
if oldS == "" && newS == "" {
6364
return header
6465
}
65-
// 字符串模式没有显式行号,grep 文件定位 old_string 的起始行,这样 -/+ 行前面
66-
// 也能渲染行号列。ToolCallStartMsg 在 Executor 之前 fire,文件还是 pre-edit 状态,
67-
// 此时 old_string 仍能在文件里精确匹配到。读不到 / 没匹配 → startLine=0,退化成无行号。
66+
// diff 行号:
67+
// ① 编辑已执行过 → 直接取 EditFile 在编辑时(文件还是改前)记下的精确行号,最准、与当前文件无关;
68+
// ② 还没执行(实时预览,ToolCallStartMsg)→ 缓存里没有,grep 改前文件里的 old_string 兜底。
69+
// ③ 实在没有 → 再试 new_string(极端兜底)。
70+
path := strVal(args["path"])
6871
startLine := 0
69-
if oldS != "" {
70-
startLine = locateLineInFile(strVal(args["path"]), oldS)
72+
if v, ok := tools.RecordedEditLine(path, oldS); ok {
73+
startLine = v
74+
} else if oldS != "" {
75+
startLine = locateLineInFile(path, oldS)
76+
}
77+
if startLine == 0 && newS != "" {
78+
startLine = locateLineInFile(path, newS)
7179
}
7280

7381
var sb strings.Builder
@@ -79,9 +87,9 @@ func formatUpdatePreview(argsJSON string) string {
7987
return sb.String()
8088
}
8189

82-
// locateLineInFile 读 path,在文件内容里精确定位 needle,返回它的首行行号(1-indexed)。
83-
// 读不到 / 没匹配返回 0。多次匹配时取第一个 —— EditFile 字符串模式"必须唯一"约束一致。
84-
// 用于字符串模式的 patch 预览:有了行号就能在 -/+ 前面渲染行号列
90+
// locateLineInFile 读 path,定位 needle 的首行行号(1-indexed),读不到 / 没匹配返回 0
91+
// 复用 tools.LocateEditTargetLine —— EditFile 同一套多级容差匹配(精确→unescape→空白/缩进容差),
92+
// 所以模型 old_string 有空白/缩进/CRLF 漂移、但 EditFile 容差匹配成功时,预览也照样能渲染行号
8593
func locateLineInFile(path, needle string) int {
8694
if path == "" || needle == "" {
8795
return 0
@@ -90,11 +98,7 @@ func locateLineInFile(path, needle string) int {
9098
if err != nil {
9199
return 0
92100
}
93-
idx := strings.Index(string(data), needle)
94-
if idx < 0 {
95-
return 0
96-
}
97-
return strings.Count(string(data[:idx]), "\n") + 1
101+
return tools.LocateEditTargetLine(string(data), needle)
98102
}
99103

100104
const (

0 commit comments

Comments
 (0)