Skip to content

Commit ab05648

Browse files
committed
fix: locate sequence matches by position, not by searching
Signed-off-by: Joseph Kato <joseph@jdkato.io>
1 parent 6e87b3e commit ab05648

5 files changed

Lines changed: 244 additions & 43 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ require (
1414
github.com/expr-lang/expr v1.17.7
1515
github.com/gobwas/glob v0.2.3
1616
github.com/jdkato/go-tree-sitter-julia v0.1.0
17-
github.com/jdkato/prose/v3 v3.0.0
17+
github.com/jdkato/prose/v3 v3.0.1
1818
github.com/litao91/goldmark-mathjax v0.0.0-20210217064022-a43cf739a50f
1919
github.com/mitchellh/mapstructure v1.5.0
2020
github.com/niklasfasching/go-org v1.7.0

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI
6363
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
6464
github.com/jdkato/go-tree-sitter-julia v0.1.0 h1:z+6zTbd6PHMKAge7GJx9QIwPQX2NOKb4Pj5jteJvaYY=
6565
github.com/jdkato/go-tree-sitter-julia v0.1.0/go.mod h1:lXNEZorcvU63DcANEklLMbDRjwam4VQ44MIV1Cck0w8=
66-
github.com/jdkato/prose/v3 v3.0.0 h1:MomxPb0GtTWXboechFvI429uBZoalYMgQ1hpTfI6Tbg=
67-
github.com/jdkato/prose/v3 v3.0.0/go.mod h1:VRQyR/EyA6bX457JTCIF67n04ycHek4v6v0veGiJvUk=
66+
github.com/jdkato/prose/v3 v3.0.1 h1:tIRDKg5i8HLBtrj3SdVHD948cDlz7b4mDupZI8206Uw=
67+
github.com/jdkato/prose/v3 v3.0.1/go.mod h1:VRQyR/EyA6bX457JTCIF67n04ycHek4v6v0veGiJvUk=
6868
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
6969
github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
7070
github.com/klauspost/cpuid/v2 v2.0.12 h1:p9dKCg8i4gmOxtv35DvrYoWqYzQrvEVdjQ762Y0OqZE=

internal/check/sequence.go

Lines changed: 132 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,18 @@ import (
1414

1515
// NLPToken represents a token of text with NLP-related attributes.
1616
type NLPToken struct {
17-
Pattern string
18-
Tag string
19-
Skip int
17+
Pattern string
18+
Tag string
19+
Skip int
20+
21+
// Target narrows the alert to this token alone.
22+
//
23+
// Without it a match spans every token in the sequence. Marking one lets a
24+
// rule require surrounding context while pointing at only the part the
25+
// writer should change -- "flag the space, but only between these two
26+
// words".
27+
Target bool
28+
2029
re *rx.Regexp
2130
Negate bool
2231
optional bool
@@ -133,14 +142,36 @@ func tokensMatch(token NLPToken, word tag.Token) bool {
133142
return true
134143
}
135144

136-
func sequenceMatches(idx int, chk Sequence, target NLPToken, words []tag.Token, history []int) ([]string, int) {
145+
// match describes one sequence hit: the matched words' text, the index of the
146+
// anchor word, and the range of word indices the match covers.
147+
//
148+
// The index range is what lets Run report where the match actually is. The
149+
// text alone is not enough: the same sequence can occur more than once, and
150+
// re-joining the words does not reproduce the source when the spacing is
151+
// irregular.
152+
type match struct {
153+
text []string
154+
index int
155+
lo int
156+
hi int
157+
158+
// wordAt maps a position in the expanded token slice to the word it
159+
// matched, so a targeted token can be resolved back to its span.
160+
wordAt map[int]int
161+
}
162+
163+
func (m match) ok() bool { return len(m.text) > 0 && m.lo >= 0 && m.hi >= m.lo }
164+
165+
func sequenceMatches(idx int, chk Sequence, target NLPToken, words []tag.Token, history []int) match {
137166
var text []string
138167

139168
toks := chk.Tokens
140169

141170
sizeT := len(toks)
142171
sizeW := len(words)
143172
index := 0
173+
lo, hi := -1, -1
174+
wordAt := map[int]int{}
144175

145176
for jdx, tok := range words {
146177
if tokensMatch(target, tok) && !core.IntInSlice(jdx, history) {
@@ -157,12 +188,14 @@ func sequenceMatches(idx int, chk Sequence, target NLPToken, words []tag.Token,
157188
// side to check -- hence, `idx > 0`.
158189
for i := 1; idx-i >= 0; i++ {
159190
if jdx-i < 0 {
160-
return []string{}, index
191+
return match{index: index, lo: -1, hi: -1}
161192
}
162193
tok := toks[idx-i]
163194

164195
word := words[jdx-i]
165196
text = append([]string{word.Text}, text...)
197+
lo = jdx - i
198+
wordAt[idx-i] = jdx - i
166199

167200
// NOTE: We have to perform this conversion because the token slice is made
168201
// with the right-hand orientation in mind. For example,
@@ -176,7 +209,7 @@ func sequenceMatches(idx int, chk Sequence, target NLPToken, words []tag.Token,
176209

177210
mat := tokensMatch(tok, word)
178211
if !mat && !tok.optional {
179-
return []string{}, index
212+
return match{index: index, lo: -1, hi: -1}
180213
} else if mat && tok.optional {
181214
break
182215
}
@@ -189,16 +222,21 @@ func sequenceMatches(idx int, chk Sequence, target NLPToken, words []tag.Token,
189222
// side to check.
190223
for i := 0; idx+i < sizeT; i++ {
191224
if jdx+i >= sizeW {
192-
return []string{}, index
225+
return match{index: index, lo: -1, hi: -1}
193226
}
194227
tok := toks[idx+i]
195228

196229
word := words[jdx+i]
197230
text = append(text, word.Text)
231+
if lo < 0 || jdx+i < lo {
232+
lo = jdx + i
233+
}
234+
hi = jdx + i
235+
wordAt[idx+i] = jdx + i
198236

199237
mat := tokensMatch(tok, word)
200238
if !mat && !tok.optional {
201-
return []string{}, index
239+
return match{index: index, lo: -1, hi: -1}
202240
} else if mat && tok.optional {
203241
break
204242
}
@@ -208,7 +246,7 @@ func sequenceMatches(idx int, chk Sequence, target NLPToken, words []tag.Token,
208246
}
209247
}
210248

211-
return text, index
249+
return match{text: text, index: index, lo: lo, hi: hi, wordAt: wordAt}
212250
}
213251

214252
func stepsToString(steps []string) string {
@@ -243,6 +281,67 @@ func stepsToString(steps []string) string {
243281
return strings.TrimSpace(sb.String())
244282
}
245283

284+
// locate returns the span of a match within txt, plus the matched text.
285+
//
286+
// When the tokens carry offsets, the span is taken straight from them, so it
287+
// is exact even when the sequence occurs more than once or the source spacing
288+
// is irregular. Without offsets we fall back to rebuilding the text and
289+
// searching for it, which is what Vale did before tokens had positions; that
290+
// path returns nil rather than a negative span when the search fails.
291+
func (s Sequence) locate(txt string, words []tag.Token, m match, positioned bool) ([]int, string) {
292+
if positioned && m.hi < len(words) {
293+
lo, hi := m.lo, m.hi
294+
if tlo, thi, ok := s.targetRange(m); ok {
295+
lo, hi = tlo, thi
296+
}
297+
298+
start := words[lo].Start
299+
end := words[hi].Start + len(words[hi].Text)
300+
if start >= 0 && end <= len(txt) && start < end {
301+
return []int{start, end}, txt[start:end]
302+
}
303+
}
304+
305+
seq := stepsToString(m.text)
306+
if ssp := strings.Index(txt, seq); ssp >= 0 {
307+
return []int{ssp, ssp + len(seq)}, seq
308+
}
309+
return nil, seq
310+
}
311+
312+
// targetRange returns the span of words covered by the rule's `target`
313+
// tokens.
314+
//
315+
// Several tokens may be marked, in which case the range runs from the first to
316+
// the last -- a target of two words reports both, not just one. Unmarked
317+
// tokens between them are included, since the result has to be a single
318+
// contiguous span.
319+
func (s Sequence) targetRange(m match) (int, int, bool) {
320+
lo, hi := -1, -1
321+
for i := range s.Tokens {
322+
if !s.Tokens[i].Target {
323+
continue
324+
}
325+
w, ok := m.wordAt[i]
326+
if !ok {
327+
// A targeted token that matched nothing -- an optional or skipped
328+
// one. Narrowing to a range we cannot fully resolve would report
329+
// the wrong span, so fall back to the whole match.
330+
return 0, 0, false
331+
}
332+
if lo < 0 || w < lo {
333+
lo = w
334+
}
335+
if w > hi {
336+
hi = w
337+
}
338+
}
339+
if lo < 0 {
340+
return 0, 0, false
341+
}
342+
return lo, hi, true
343+
}
344+
246345
// Run looks for the user-defined sequence of tokens.
247346
func (s Sequence) Run(blk nlp.Block, f *core.File, _ *core.Config) ([]core.Alert, error) {
248347
var alerts []core.Alert
@@ -252,26 +351,43 @@ func (s Sequence) Run(blk nlp.Block, f *core.File, _ *core.Config) ([]core.Alert
252351
// This is *always* sentence-scoped.
253352
words := nlp.TextToTokens(blk.Text, &f.NLP)
254353

354+
// A remote NLP endpoint returns text and tags only, so we have no offsets
355+
// to work from and have to fall back to locating the match by its text.
356+
positioned := f.NLP.Endpoint == ""
357+
255358
txt := blk.Text
256359
for idx, tok := range s.Tokens {
257360
if !tok.Negate && tok.Pattern != "" {
258361
// We're looking for our "anchor" ...
259362
for _, loc := range tok.re.FindAllStringIndex(txt, -1) {
260363
// These are all possible violations in `txt`:
261-
steps, index := sequenceMatches(idx, s, tok, words, history)
262-
history = append(history, index)
364+
m := sequenceMatches(idx, s, tok, words, history)
365+
history = append(history, m.index)
366+
367+
if m.ok() {
368+
span, seq := s.locate(txt, words, m, positioned)
369+
if span == nil {
370+
// We matched but cannot say where; reporting a bogus
371+
// span is worse than reporting nothing.
372+
continue
373+
}
263374

264-
if len(steps) > 0 {
265-
seq := stepsToString(steps)
266-
ssp := strings.Index(txt, seq)
375+
// When the block knows where it sits in the document, hand
376+
// back an absolute offset. Otherwise the span is
377+
// block-relative and has to be located by searching, which
378+
// resolves every repeat of a sentence to the first one.
379+
absolute := blk.Offset >= 0
380+
if absolute {
381+
span = []int{blk.Offset + span[0], blk.Offset + span[1]}
382+
}
267383

268384
a := core.Alert{
269385
Check: s.Name, Severity: s.Level, Link: s.Link,
270-
Span: []int{ssp, ssp + len(seq)}, Hide: false,
386+
Span: span, Hide: false, HasByteOffsets: absolute,
271387
Match: seq, Action: s.Action}
272388

273389
a.Message, a.Description = formatMessages(s.Message,
274-
s.Description, steps...)
390+
s.Description, m.text...)
275391
a.Offset = offset
276392

277393
alerts = append(alerts, a)

internal/nlp/prose.go

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package nlp
22

33
import (
4-
"strings"
54
"sync"
65

76
"github.com/jdkato/prose/v3/segment"
@@ -47,38 +46,55 @@ var SentenceTokenizer sentenceTokenizer
4746
// a plain check-then-assign here is a data race.
4847
var tagger = sync.OnceValues(tag.New)
4948

50-
// wordTokenizer splits a sentence into words for tagging.
51-
var wordTokenizer = sync.OnceValue(tokenize.NewTreebankWordTokenizer)
49+
// wordTokenizer splits a sentence into positioned words for tagging.
50+
//
51+
// prose's tokenizer rather than the Treebank one: Treebank rewrites the text
52+
// as it splits (quotes become “ and ”), so its tokens are not substrings of
53+
// the source and cannot carry offsets. Callers such as the `sequence` check
54+
// need to know where a token actually is.
55+
var wordTokenizer = sync.OnceValue(func() *tokenize.Tokenizer {
56+
return tokenize.New()
57+
})
5258

53-
// doTag assigns part-of-speech tags to `words`.
54-
func doTag(words []string) []tag.Token {
59+
// tagText splits text into sentences, tags each one, and returns the tokens
60+
// with offsets relative to text.
61+
//
62+
// Tagging is per sentence because the tagger conditions on the previous two
63+
// tags; letting that context run across a sentence boundary would condition
64+
// the first word of each sentence on the last word of the one before it.
65+
func tagText(text string) []tag.Token {
5566
t, err := tagger()
5667
if err != nil {
5768
panic("nlp: loading the part-of-speech model: " + err.Error())
5869
}
59-
return t.Tag(words)
60-
}
6170

62-
// textToWords convert raw text into a slice of words.
63-
func textToWords(text string, nlp bool) []string {
64-
words := []string{}
65-
for _, s := range SentenceTokenizer.Segment(text) {
66-
if nlp {
67-
words = append(words, wordTokenizer().Tokenize(s)...)
68-
} else {
69-
words = append(words, strings.Fields(s)...)
71+
var tokens []tag.Token
72+
for _, sent := range punktSegmenter().Segment(text) {
73+
found := wordTokenizer().Tokenize(sent.Text)
74+
t.TagTokens(found)
75+
76+
// Tokenize reported offsets within the sentence; shift them so they
77+
// address the text the caller passed in.
78+
for i := range found {
79+
found[i].Start += sent.Start
7080
}
81+
tokens = append(tokens, found...)
7182
}
7283

73-
return words
84+
return tokens
7485
}
7586

76-
// TextToTokens converts a string to a slice of tokens.
87+
// TextToTokens converts a string to a slice of tagged tokens.
88+
//
89+
// Tokens from the built-in tagger carry their byte offset within text, so
90+
// text[tok.Start:tok.Start+len(tok.Text)] == tok.Text. Tokens from a remote
91+
// NLP endpoint do not: that API returns text and tags only, so Start is zero
92+
// throughout and callers needing positions must locate the tokens themselves.
7793
func TextToTokens(text string, nlp *Info) []tag.Token {
7894
// Determine if (and how) we need to do POS tagging.
7995
if nlp == nil || nlp.Endpoint == "" {
8096
// Fall back to our internal library (English-only).
81-
return doTag(textToWords(text, true))
97+
return tagText(text)
8298
}
8399
result, err := pos(text, nlp.Lang, nlp.Endpoint)
84100
if err != nil {

0 commit comments

Comments
 (0)