Skip to content

Commit 9bd0cf9

Browse files
committed
feat: match universal POS tags in sequence
Signed-off-by: Joseph Kato <joseph@jdkato.io>
1 parent ab05648 commit 9bd0cf9

3 files changed

Lines changed: 196 additions & 8 deletions

File tree

internal/check/sequence.go

Lines changed: 93 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

internal/check/upos.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package check
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
"sort"
7+
"strings"
8+
)
9+
10+
// Universal POS tags, expressed in terms of the Penn Treebank tags Vale's
11+
// tagger actually produces.
12+
//
13+
// Penn is the finer tagset, so most of this is a straight widening: NOUN is
14+
// "NN or NNS", ADJ is "JJ, JJR or JJS". Three categories are not expressible
15+
// that way, because the distinction Penn omits is syntactic rather than
16+
// morphological:
17+
//
18+
// - Penn has no AUX at all. "have" is VBP whether it is the main verb of
19+
// "I have a car" or the auxiliary of "I have eaten".
20+
// - IN covers both ADP and SCONJ: "after dinner" and "after we ate".
21+
// - TO is PART in "to eat" but ADP in "to the store".
22+
//
23+
// Those are approximated with a word list alongside the tag; see uposWords.
24+
// The approximation is stated in the docs rather than hidden, because a rule
25+
// author needs to know that `upos: AUX` means "an auxiliary-looking verb", not
26+
// "an auxiliary".
27+
var uposTags = map[string]string{
28+
"ADJ": `JJ|JJR|JJS`,
29+
"ADP": `IN|RP`,
30+
"ADV": `RB|RBR|RBS|WRB`,
31+
"AUX": `MD|VB|VBD|VBG|VBN|VBP|VBZ`,
32+
"CCONJ": `CC`,
33+
"DET": `DT|PDT|WDT|WP\$`,
34+
"INTJ": `UH`,
35+
"NOUN": `NN|NNS`,
36+
"NUM": `CD`,
37+
"PART": `TO|RP|POS`,
38+
"PRON": `PRP|PRP\$|WP|EX`,
39+
"PROPN": `NNP|NNPS`,
40+
"PUNCT": `[.,:]|''|` + "``" + `|-LRB-|-RRB-|\(|\)`,
41+
"SCONJ": `IN|WRB`,
42+
"SYM": `SYM|\$|#`,
43+
"VERB": `VB|VBD|VBG|VBN|VBP|VBZ`,
44+
"X": `FW|LS`,
45+
}
46+
47+
// uposWords narrows the categories Penn cannot distinguish on its own.
48+
//
49+
// A token must carry both the tag and one of these words to match. Without
50+
// this, `upos: AUX` would match every verb in the document.
51+
var uposWords = map[string][]string{
52+
"AUX": {
53+
"am", "is", "are", "was", "were", "be", "been", "being",
54+
"have", "has", "had", "having",
55+
"do", "does", "did", "doing",
56+
"will", "would", "shall", "should", "can", "could",
57+
"may", "might", "must", "ought",
58+
"'s", "'re", "'m", "'ve", "'ll", "'d",
59+
},
60+
"SCONJ": {
61+
"after", "although", "as", "because", "before", "even", "if",
62+
"since", "so", "than", "that", "though", "unless", "until",
63+
"when", "whenever", "where", "whereas", "wherever", "whether",
64+
"while", "why", "how",
65+
},
66+
}
67+
68+
// uposTagPattern returns the Penn-tag regex for a universal tag.
69+
func uposTagPattern(name string) (string, error) {
70+
pattern, ok := uposTags[strings.ToUpper(name)]
71+
if !ok {
72+
return "", fmt.Errorf("unknown universal POS tag %q; expected one of %s",
73+
name, strings.Join(uposNames(), ", "))
74+
}
75+
return "^(?:" + pattern + ")$", nil
76+
}
77+
78+
// uposWordPattern returns the word-level constraint for a universal tag, or
79+
// "" when the tag needs none.
80+
func uposWordPattern(name string) string {
81+
words, ok := uposWords[strings.ToUpper(name)]
82+
if !ok {
83+
return ""
84+
}
85+
86+
escaped := make([]string, 0, len(words))
87+
for _, w := range words {
88+
escaped = append(escaped, regexp.QuoteMeta(w))
89+
}
90+
91+
return "^(?:" + strings.Join(escaped, "|") + ")$"
92+
}
93+
94+
// uposNames lists the supported tags, sorted, for error messages.
95+
func uposNames() []string {
96+
names := make([]string, 0, len(uposTags))
97+
for name := range uposTags {
98+
names = append(names, name)
99+
}
100+
sort.Strings(names)
101+
return names
102+
}

testdata/features/checks.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ Feature: Checks
178178
test.md:25:1:LanguageTool.Metadata:Use data and metadata as plural nouns.
179179
test.md:29:1:LanguageTool.Metadata:Use data and metadata as plural nouns.
180180
test.md:31:17:LanguageTool.ARE_USING:Use 'by using' instead of 'using' when it follows a noun for clarity and grammatical correctness.
181+
test.txt:1:16:LanguageTool.AMBIG:Avoid ambiguous pronouns
181182
test.txt:3:4:LanguageTool.WOULD_BE_JJ_VB:The infinitive 'write' after 'be' requries 'to'. Did you mean 'be great *to* write'?
182183
test.txt:9:88:LanguageTool.WOULD_BE_JJ_VB:The infinitive 'come' after 'be' requries 'to'. Did you mean 'be available *to* come'?
183184
test.txt:11:7:LanguageTool.AMBIG:Avoid ambiguous pronouns

0 commit comments

Comments
 (0)