Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

180 lines
6.2 KiB
Go

package mcp
import (
"fmt"
"sort"
"strings"
)
// EOL-tolerant fragment matching for the string-replacement edit tools
// (edit_file, edit_symbol, and the batch_edit ops). Agents routinely author
// old_string / old_source with LF line terminators while the on-disk file
// uses CRLF (or vice versa) — exact byte matching would report "not found"
// even though the text is visually identical. findEOLMatches keeps the
// byte-exact match as the primary path and falls back to a CRLF<->LF
// normalized comparison, mapping every match span back onto the original
// bytes so callers always splice real file offsets.
// eolSpan is a byte range [start, end) in the original (unnormalized) string.
type eolSpan struct {
start, end int
}
// eolMatches reports where a needle occurs in a haystack.
type eolMatches struct {
count int
// spans holds every non-overlapping match in original byte offsets.
// Empty for the degenerate empty-needle case (count still mirrors
// strings.Count semantics there).
spans []eolSpan
// normalized is true when the spans were produced by the CRLF<->LF
// fallback rather than byte-exact matching. Callers must rewrite the
// replacement's line terminators (spliceSpansEOL / adaptToDominantEOL)
// before splicing so the edit never introduces mixed endings.
normalized bool
}
// findEOLMatches locates needle in hay: byte-exact first, then — when the
// exact pass finds nothing and either side carries CRLF — with both sides'
// CRLF terminators normalized to LF. Fallback spans are mapped back onto
// hay's original bytes; a match that starts at a normalized LF swallows the
// CR that preceded it, so replacing the span never strands a bare CR.
func findEOLMatches(hay, needle string) eolMatches {
if needle == "" {
// Mirror strings.Count: an empty needle "matches" between every
// byte pair. No spans — no caller splices on an empty needle.
return eolMatches{count: len(hay) + 1}
}
if spans := exactSpans(hay, needle); len(spans) > 0 {
return eolMatches{count: len(spans), spans: spans}
}
if !strings.Contains(hay, "\r\n") && !strings.Contains(needle, "\r\n") {
return eolMatches{}
}
normHay, removals := normalizeCRLF(hay)
normNeedle, _ := normalizeCRLF(needle)
nspans := exactSpans(normHay, normNeedle)
if len(nspans) == 0 {
return eolMatches{}
}
spans := make([]eolSpan, len(nspans))
for i, sp := range nspans {
spans[i] = eolSpan{
start: origOffset(removals, sp.start),
end: origOffset(removals, sp.end),
}
}
return eolMatches{count: len(spans), spans: spans, normalized: true}
}
// exactSpans returns every non-overlapping byte-exact match of needle in
// hay, scanning left to right (strings.Count semantics, non-empty needle).
func exactSpans(hay, needle string) []eolSpan {
var spans []eolSpan
from := 0
for {
idx := strings.Index(hay[from:], needle)
if idx < 0 {
return spans
}
start := from + idx
spans = append(spans, eolSpan{start: start, end: start + len(needle)})
from = start + len(needle)
}
}
// normalizeCRLF rewrites every CRLF in s to a bare LF. For each removed CR
// it records the index its LF lands on in the normalized output. Lone CRs
// are not line terminators and pass through untouched.
func normalizeCRLF(s string) (string, []int) {
if !strings.Contains(s, "\r\n") {
return s, nil
}
var b strings.Builder
b.Grow(len(s))
var removals []int
for i := 0; i < len(s); i++ {
if s[i] == '\r' && i+1 < len(s) && s[i+1] == '\n' {
// The LF is written on the next iteration at this index.
removals = append(removals, b.Len())
continue
}
b.WriteByte(s[i])
}
return b.String(), removals
}
// origOffset maps an offset in the normalized string back onto the
// original: every CR removed strictly before the offset shifts it right by
// one. An offset sitting exactly on a recorded LF resolves to its removed
// CR, which is what span semantics need — a span starting there swallows
// the CR, and a span ending there leaves the CR to the remainder.
func origOffset(removals []int, off int) int {
return off + sort.SearchInts(removals, off)
}
// spliceSpansEOL replaces up to n of the given spans (sorted,
// non-overlapping — as produced by findEOLMatches) in hay with replacement,
// rewriting the replacement's line terminators per span to match the bytes
// that span carries. A CRLF region stays CRLF and an LF region stays LF
// even in a mixed-EOL file. n < 0 replaces all spans.
func spliceSpansEOL(hay string, spans []eolSpan, replacement string, n int) string {
if n >= 0 && n < len(spans) {
spans = spans[:n]
}
var b strings.Builder
last := 0
for _, sp := range spans {
b.WriteString(hay[last:sp.start])
b.WriteString(adaptToDominantEOL(replacement, hay[sp.start:sp.end]))
last = sp.end
}
b.WriteString(hay[last:])
return b.String()
}
// dominantEOL reports the line terminator s predominantly uses: CRLF when
// CRLF terminators outnumber bare-LF ones, LF otherwise (including the tie
// and the no-newline cases).
func dominantEOL(s string) string {
crlf := strings.Count(s, "\r\n")
if crlf > strings.Count(s, "\n")-crlf {
return "\r\n"
}
return "\n"
}
// adaptToDominantEOL rewrites replacement's line terminators to the
// dominant terminator of context so a normalized-match splice never
// introduces mixed line endings. Lone CRs in the replacement pass through
// untouched.
func adaptToDominantEOL(replacement, context string) string {
norm := strings.ReplaceAll(replacement, "\r\n", "\n")
if dominantEOL(context) == "\r\n" {
return strings.ReplaceAll(norm, "\n", "\r\n")
}
return norm
}
// matchSpansHint returns a brief " (first match lines X, Y, Z)" hint for up
// to three match spans. Empty when there are none. Helps an agent choose a
// more unique fragment without re-reading the file.
func matchSpansHint(fileStr string, spans []eolSpan) string {
if len(spans) == 0 {
return ""
}
const maxHits = 3
n := min(len(spans), maxHits)
parts := make([]string, n)
for i := 0; i < n; i++ {
// Line number = 1 + count of '\n' before the span. Counting LFs
// works for CRLF content too — each terminator carries one LF.
parts[i] = fmt.Sprintf("%d", 1+strings.Count(fileStr[:spans[i].start], "\n"))
}
suffix := ""
if len(spans) > maxHits {
suffix = ", ..."
}
return fmt.Sprintf(" (first match lines %s%s)", strings.Join(parts, ", "), suffix)
}