Files
AutoZero/tools/edit_match.go
T
Sakurasan d5dac5a6fc feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)
llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、
builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、
编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。

产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt)
留在 AutoGo,宿主接入方式见 MIGRATION.md。
2026-10-26 00:54:46 +08:00

200 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tools
import "strings"
// editMatch 是 old_string 在文件内容里的一个字节区间 [start, end)。
type editMatch struct {
start int
end int
}
// findEditMatches 按三级降级查找 old_string 的出现位置:
//
// 1. 精确匹配:字节完全一致,命中后不再降级;
// 2. 字形归一:把弯引号、破折号、不间断空格等"看起来一样"的字符归一后匹配
// (模型从网页/文档复制的文本经常带这些字符,精确匹配会失败);
// 3. 逐行降级:忽略每行行尾空白后再匹配,用来兜住"多了几个空格/少了几个空格"。
//
// 返回的区间始终指向**原文**,调用方据此替换,不会把归一化后的字符写回文件。
func findEditMatches(content, old string) []editMatch {
if old == "" {
return nil
}
if matches := findExactMatches(content, old); len(matches) > 0 {
return matches
}
if matches := findNormalizedMatches(content, old); len(matches) > 0 {
return matches
}
return findLineMatches(content, old)
}
// findExactMatches 返回不重叠的精确出现位置(已是字节区间)。
func findExactMatches(content, old string) []editMatch {
var out []editMatch
offset := 0
for {
i := strings.Index(content[offset:], old)
if i < 0 {
return out
}
start := offset + i
out = append(out, editMatch{start: start, end: start + len(old)})
offset = start + len(old)
}
}
// findNormalizedMatches 在字形归一后的 rune 序列上匹配,再把 rune 下标映射回
// 原始字节区间。归一化是逐 rune 的一对一替换,所以下标可以直接对应。
func findNormalizedMatches(content, old string) []editMatch {
contentRunes := []rune(content)
oldRunes := []rune(old)
if len(oldRunes) == 0 || len(oldRunes) > len(contentRunes) {
return nil
}
prefix := runeByteOffsets(contentRunes)
var out []editMatch
for i := 0; i+len(oldRunes) <= len(contentRunes); i++ {
match := true
for j := range oldRunes {
if normalizeEditRune(contentRunes[i+j]) != normalizeEditRune(oldRunes[j]) {
match = false
break
}
}
if !match {
continue
}
out = append(out, editMatch{start: prefix[i], end: prefix[i+len(oldRunes)]})
i += len(oldRunes) - 1
}
return out
}
// findLineMatches 逐行比较(忽略行尾空白与字形差异),返回整行窗口的字节区间。
// old 带结尾换行时连同最后一行的换行一起替换;不带时保留原文换行。
func findLineMatches(content, old string) []editMatch {
trailingNewline := strings.HasSuffix(old, "\n")
expected := strings.Split(old, "\n")
if trailingNewline {
expected = expected[:len(expected)-1]
}
if len(expected) == 0 {
return nil
}
lines := splitLines(content)
var out []editMatch
for i := 0; i+len(expected) <= len(lines); i++ {
matched := true
for j := range expected {
if normalizeEditLine(lines[i+j].text) != normalizeEditLine(expected[j]) {
matched = false
break
}
}
if !matched {
continue
}
last := lines[i+len(expected)-1]
end := last.end
if !trailingNewline {
end = last.contentEnd
}
if len(out) > 0 && out[len(out)-1].end > lines[i].start {
continue // 与上一个命中重叠:同一个位置只算一次
}
out = append(out, editMatch{start: lines[i].start, end: end})
i += len(expected) - 1
}
return out
}
type lineSpan struct {
start int
end int // 含换行
contentEnd int // 不含 \r\n / \n
text string
}
// splitLines 切出每一行及其字节区间。空内容返回空切片;末尾换行不产生空行。
func splitLines(s string) []lineSpan {
var out []lineSpan
for start := 0; start < len(s); {
i := strings.IndexByte(s[start:], '\n')
if i < 0 {
contentEnd := len(s)
if contentEnd > start && s[contentEnd-1] == '\r' {
contentEnd--
}
out = append(out, lineSpan{start: start, end: len(s), contentEnd: contentEnd, text: s[start:contentEnd]})
break
}
end := start + i + 1
contentEnd := end - 1
if contentEnd > start && s[contentEnd-1] == '\r' {
contentEnd--
}
out = append(out, lineSpan{start: start, end: end, contentEnd: contentEnd, text: s[start:contentEnd]})
start = end
}
return out
}
// runeByteOffsets 返回每个 rune 起始处的字节偏移,外加末尾哨兵。
func runeByteOffsets(runes []rune) []int {
out := make([]int, len(runes)+1)
offset := 0
for i, r := range runes {
out[i] = offset
offset += len(string(r))
}
out[len(runes)] = offset
return out
}
// normalizeEditRune 把视觉上等价、字节上不同的字符归一。
func normalizeEditRune(r rune) rune {
switch r {
case '‘', '’', '‚', '‛':
return '\''
case '“', '”', '„', '‟':
return '"'
case '‐', '‑', '‒', '–', '—', '―', '−', '-':
return '-'
case '\u00A0', '\u2002', '\u2003', '\u2004', '\u2005', '\u2006', '\u2007', '\u2008',
'\u2009', '\u200A', '\u202F', '\u205F', '\u3000':
return ' '
}
return r
}
func normalizeEditLine(line string) string {
line = strings.TrimRight(line, " \t\r")
return string([]rune(normalizeEditTextRunes(line)))
}
func normalizeEditTextRunes(s string) []rune {
out := []rune(s)
for i := range out {
out[i] = normalizeEditRune(out[i])
}
return out
}
// applyEditMatches 从后往前替换,前面的偏移因此不受影响。
// 替换文本沿用命中区域的换行风格:命中区是 CRLF 而 new 用 LF 时,自动转成 CRLF,
// 避免一次编辑把整个文件的行尾改掉。
func applyEditMatches(content string, matches []editMatch, newText string) string {
updated := content
for i := len(matches) - 1; i >= 0; i-- {
m := matches[i]
region := content[m.start:m.end]
replacement := newText
if strings.Contains(region, "\r\n") && !strings.Contains(replacement, "\r\n") {
replacement = strings.ReplaceAll(replacement, "\n", "\r\n")
}
updated = updated[:m.start] + replacement + updated[m.end:]
}
return updated
}