diff --git a/internal/links/mentions.go b/internal/links/mentions.go index b9674be35..485e54538 100644 --- a/internal/links/mentions.go +++ b/internal/links/mentions.go @@ -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) { diff --git a/internal/links/mentions_test.go b/internal/links/mentions_test.go index 7338a0976..fbd930936 100644 --- a/internal/links/mentions_test.go +++ b/internal/links/mentions_test.go @@ -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) + } +}