@@ -18,6 +18,15 @@ type NLPToken struct {
1818 Tag string
1919 Skip int
2020
21+ // UPOS matches a universal part-of-speech tag -- NOUN, VERB, ADJ and so
22+ // on -- rather than a Penn Treebank one.
23+ //
24+ // It compiles down to the equivalent Penn tags, plus a word constraint for
25+ // the few categories Penn cannot express (see upos.go). Rules written
26+ // against universal tags are portable; rules written against Penn tags are
27+ // more precise.
28+ UPOS string
29+
2130 // Target narrows the alert to this token alone.
2231 //
2332 // Without it a match spans every token in the sequence. Marking one lets a
@@ -26,7 +35,15 @@ type NLPToken struct {
2635 // words".
2736 Target bool
2837
29- re * rx.Regexp
38+ re * rx.Regexp
39+
40+ // wordRe narrows a universal tag to the words it can apply to.
41+ //
42+ // Kept apart from `re` because that one doubles as the anchor and is run
43+ // against the whole sentence; this is only ever tested against a single
44+ // token's text.
45+ wordRe * rx.Regexp
46+
3047 Negate bool
3148 optional bool
3249 start bool
@@ -61,6 +78,33 @@ func NewSequence(cfg *core.Config, generic baseCheck, path string) (Sequence, er
6178 }
6279
6380 for i , token := range rule .Tokens {
81+ if token .UPOS != "" {
82+ if token .Tag != "" {
83+ return rule , core .NewE201FromPosition (
84+ "a token cannot set both `tag` and `upos`" , path , 1 )
85+ }
86+
87+ pattern , uerr := uposTagPattern (token .UPOS )
88+ if uerr != nil {
89+ return rule , core .NewE201FromPosition (uerr .Error (), path , 1 )
90+ }
91+ rule .Tokens [i ].Tag = pattern
92+ token .Tag = pattern
93+
94+ // A category Penn cannot express on its own also constrains the
95+ // word. Only applied when the rule did not ask for a pattern of
96+ // its own, which is the more specific request.
97+ if token .Pattern == "" {
98+ if words := uposWordPattern (token .UPOS ); words != "" {
99+ wre , werr := rx .Compile (words )
100+ if werr != nil {
101+ return rule , core .NewE201FromPosition (werr .Error (), path , 1 )
102+ }
103+ rule .Tokens [i ].wordRe = wre
104+ }
105+ }
106+ }
107+
64108 if ! rule .needsTagging && token .Tag != "" {
65109 rule .needsTagging = true
66110 }
@@ -112,7 +156,7 @@ func makeTokens(s *Sequence, generic baseCheck) error {
112156 s .Tokens = append (s .Tokens , tok )
113157 }
114158
115- if tok .Pattern != "" || tok .Tag != "" {
159+ if tok .Pattern != "" || tok .Tag != "" || tok . UPOS != "" {
116160 tok .optional = false
117161 tok .end = true
118162 s .Tokens = append (s .Tokens , tok )
@@ -133,6 +177,12 @@ func tokensMatch(token NLPToken, word tag.Token) bool {
133177 failedTag = failedTag == token .Negate
134178 failedTok := token .re != nil && token .re .MatchStringStd (word .Text ) == token .Negate
135179
180+ // A universal tag that Penn cannot express also restricts which words
181+ // qualify -- `upos: AUX` is "a verb, and one of these words".
182+ if token .wordRe != nil && token .wordRe .MatchStringStd (word .Text ) == token .Negate {
183+ return false
184+ }
185+
136186 if (token .Pattern == "" && failedTag ) ||
137187 (token .Tag == "" && failedTok ) ||
138188 (token .Tag != "" && token .Pattern != "" ) && (failedTag || failedTok ) {
@@ -356,10 +406,14 @@ func (s Sequence) Run(blk nlp.Block, f *core.File, _ *core.Config) ([]core.Alert
356406 positioned := f .NLP .Endpoint == ""
357407
358408 txt := blk .Text
359- for idx , tok := range s .Tokens {
360- if ! tok .Negate && tok .Pattern != "" {
361- // We're looking for our "anchor" ...
362- for _ , loc := range tok .re .FindAllStringIndex (txt , - 1 ) {
409+ idx , tok , ok := s .anchor ()
410+ if ok {
411+ {
412+ // Each candidate position for the anchor is one possible
413+ // violation. A `pattern` anchor enumerates them by searching the
414+ // text; a tag-only anchor has nothing to search for, so we let
415+ // sequenceMatches walk the words and stop when it runs out.
416+ for _ , loc := range s .candidates (txt , tok , len (words )) {
363417 // These are all possible violations in `txt`:
364418 m := sequenceMatches (idx , s , tok , words , history )
365419 history = append (history , m .index )
@@ -392,17 +446,48 @@ func (s Sequence) Run(blk nlp.Block, f *core.File, _ *core.Config) ([]core.Alert
392446
393447 alerts = append (alerts , a )
394448 offset = []string {}
395- } else {
449+ } else if loc != nil {
396450 converted , err := re2Loc (txt , loc )
397451 if err != nil {
398452 return alerts , err
399453 }
400454 offset = append (offset , converted )
401455 }
402456 }
403- break
404457 }
405458 }
406459
407460 return alerts , nil
408461}
462+
463+ // anchor picks the token the search starts from.
464+ //
465+ // A `pattern` token is preferred because it can be located in the text
466+ // directly. Failing that any tagged token will do -- without this, a rule made
467+ // only of tags matched nothing at all, silently.
468+ func (s Sequence ) anchor () (int , NLPToken , bool ) {
469+ for i , tok := range s .Tokens {
470+ if ! tok .Negate && tok .Pattern != "" {
471+ return i , tok , true
472+ }
473+ }
474+ for i , tok := range s .Tokens {
475+ if ! tok .Negate && tok .Tag != "" {
476+ return i , tok , true
477+ }
478+ }
479+ return 0 , NLPToken {}, false
480+ }
481+
482+ // candidates returns one entry per position the anchor might occupy.
483+ //
484+ // For a `pattern` anchor each entry is the match's location in the text, which
485+ // the caller reports as an offset when the surrounding sequence does not pan
486+ // out. A tag-only anchor has no such location, so the entries are nil and the
487+ // count simply bounds how many times to try.
488+ func (s Sequence ) candidates (txt string , tok NLPToken , words int ) [][]int {
489+ if tok .re != nil {
490+ return tok .re .FindAllStringIndex (txt , - 1 )
491+ }
492+ return make ([][]int , words )
493+ }
0 commit comments