fix(links): mention code-span check walks the spans once per call (TASK-3917 r4)

isInRanges scanned every code range for every match, so many inline code
spans followed by many mentions was quadratic (360 KB took ~1.1 s). A cursor
over the sorted, disjoint ranges advances with the increasing match position.
ExtractWikiLinks has the same shape and is left for its own item.

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
This commit is contained in:
xarmian
2026-10-19 18:56:34 +00:00
parent 7826436b9f
commit f4af9889ee
2 changed files with 32 additions and 1 deletions
+18 -1
View File
@@ -92,6 +92,22 @@ func (u *urlScan) inURL(content string, at int) bool {
strings.HasPrefix(content[u.runStart:at], "www.")
}
// rangeCursor answers "is pos inside one of these ranges" for positions that
// arrive in increasing order, walking the sorted, disjoint ranges once per
// call rather than once per match (TASK-3917 r4: isInRanges per match made a
// body of many code spans and then many mentions quadratic).
type rangeCursor struct {
ranges [][2]int
i int
}
func (c *rangeCursor) contains(pos int) bool {
for c.i < len(c.ranges) && c.ranges[c.i][1] <= pos {
c.i++
}
return c.i < len(c.ranges) && pos >= c.ranges[c.i][0]
}
// badMentionPrefix reports whether the byte before the "@" at content[at]
// makes it a non-mention (an email local part, a path, a URL).
func badMentionPrefix(content string, at int, u *urlScan) bool {
@@ -161,10 +177,11 @@ func ExtractMentions(content string) []string {
seen := map[string]bool{}
var out []string
u := urlScan{scheme: -1}
fc, ic := rangeCursor{ranges: fenced}, rangeCursor{ranges: inline}
for _, m := range mentionPattern.FindAllStringSubmatchIndex(content, -1) {
at := m[0]
u.advance(content, at)
if isInRanges(at, fenced) || isInRanges(at, inline) {
if fc.contains(at) || ic.contains(at) {
continue
}
if at > 0 && badMentionPrefix(content, at, &u) {
+14
View File
@@ -57,6 +57,8 @@ func TestParseMentions(t *testing.T) {
{"url then space then mention", "see https://x.social/a @alice", []string{"alice"}},
{"www in an earlier run does not taint", "www.x.social z/@alice", []string{"alice"}},
{"scheme earlier in the same run", "go:x://h/p/@alice", nil},
{"mentions between and after code spans", "`a` @alice `@bob` @carol\n```\n@dave\n```\n@erin", []string{"alice", "carol", "erin"}},
{"mention at a code span edge", "`x`@alice", []string{"alice"}},
{"hyphen prefix rejected (documented limit)", "-@alice", nil},
{"period at end", "thanks @alice.", []string{"alice"}},
{"apostrophe", "@alice's turn", []string{"alice"}},
@@ -83,3 +85,15 @@ func TestExtractMentionsLinearOnSlashRuns(t *testing.T) {
t.Fatalf("ExtractMentions on %d bytes took %v; a per-match backward scan is back", len(body), d)
}
}
// The code-range check walks the spans once per call, not once per match: a
// body of many inline code spans followed by many mentions was quadratic
// (TASK-3917 r4; 360 KB took ~1.1 s, so a 2 MiB comment body ~40 s).
func TestExtractMentionsLinearAfterCodeSpans(t *testing.T) {
body := strings.Repeat("`a` ", 160000) + strings.Repeat("@aaa ", 160000)
start := time.Now()
ExtractMentions(body)
if d := time.Since(start); d > 3*time.Second {
t.Fatalf("ExtractMentions on %d bytes took %v; a per-match range scan is back", len(body), d)
}
}