@@ -14,9 +14,18 @@ import (
1414
1515// NLPToken represents a token of text with NLP-related attributes.
1616type 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
214252func 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.
247346func (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 )
0 commit comments