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 }