Skip to content

Commit 948bd96

Browse files
committed
feat: support presence checks in conditional rules
Signed-off-by: Joseph Kato <joseph@jdkato.io>
1 parent 4d281c3 commit 948bd96

6 files changed

Lines changed: 79 additions & 0 deletions

File tree

internal/check/conditional.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package check
22

33
import (
4+
"strings"
5+
46
"github.com/errata-ai/regexp2"
57

68
"github.com/errata-ai/vale/v3/internal/core"
@@ -18,6 +20,21 @@ type Conditional struct {
1820
phraseRe *regexp2.Regexp
1921
Ignorecase bool
2022
Vocab bool
23+
24+
// secondHasGroup records whether `Second` has a capture group. When it
25+
// does, a `First` match is allowed only if its value was captured by a
26+
// `Second` match (e.g. an acronym defined as `... (WHO)`). When it doesn't,
27+
// the rule is a plain presence check: any `First` requires `Second` to
28+
// appear somewhere in the same block. See #1048.
29+
secondHasGroup bool
30+
}
31+
32+
// hasCaptureGroup reports whether `pattern` contains a capturing group -- an
33+
// unescaped `(` that doesn't begin a non-capturing/extension group `(?...)`.
34+
func hasCaptureGroup(pattern string) bool {
35+
opens := strings.Count(pattern, "(")
36+
noncap := strings.Count(pattern, "(?") + strings.Count(pattern, `\(`)
37+
return opens > noncap
2138
}
2239

2340
// NewConditional creates a new `conditional`-based rule.
@@ -47,6 +64,7 @@ func NewConditional(cfg *core.Config, generic baseCheck, path string) (Condition
4764
return rule, core.NewE201FromPosition(err.Error(), path, 1)
4865
}
4966
expression = append(expression, re)
67+
rule.secondHasGroup = hasCaptureGroup(rule.Second)
5068

5169
re, err = regexp2.CompileStd(rule.First)
5270
if err != nil {
@@ -64,6 +82,18 @@ func (c Conditional) Run(blk nlp.Block, f *core.File, cfg *core.Config) ([]core.
6482
alerts := []core.Alert{}
6583

6684
txt := blk.Text
85+
86+
// When `Second` has no capture group, the rule is a plain presence check:
87+
// if `First` appears, `Second` must appear somewhere in the same block. If
88+
// it does, there's nothing to flag; otherwise every `First` match is a
89+
// violation. See #1048.
90+
if !c.secondHasGroup {
91+
if c.patterns[0].MatchStringStd(txt) {
92+
return alerts, nil
93+
}
94+
return c.flagAntecedents(txt, cfg)
95+
}
96+
6797
// We first look for the consequent of the conditional statement.
6898
// For example, if we're ensuring that abbreviations have been defined
6999
// parenthetically, we'd have something like:
@@ -107,6 +137,29 @@ func (c Conditional) Run(blk nlp.Block, f *core.File, cfg *core.Config) ([]core.
107137
return alerts, nil
108138
}
109139

140+
// flagAntecedents reports every `First` match as a violation (used by the
141+
// presence check when `Second` is absent), honoring the rule's exceptions and
142+
// accepted phrases.
143+
func (c Conditional) flagAntecedents(txt string, cfg *core.Config) ([]core.Alert, error) {
144+
alerts := []core.Alert{}
145+
for _, loc := range c.patterns[1].FindAllStringIndex(txt, -1) {
146+
s, err := re2Loc(txt, loc)
147+
if err != nil {
148+
return alerts, err
149+
}
150+
if isMatch(c.exceptRe, s) || withinPhrase(c.phraseRe, txt, loc) {
151+
continue
152+
}
153+
154+
a, erra := makeAlert(c.Definition, loc, txt, cfg)
155+
if erra != nil {
156+
return alerts, erra
157+
}
158+
alerts = append(alerts, a)
159+
}
160+
return alerts, nil
161+
}
162+
110163
// Fields provides access to the internal rule definition.
111164
func (c Conditional) Fields() Definition {
112165
return c.Definition

testdata/features/checks.feature

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ Feature: Checks
2222
test.md:9:5:Checks.MultiCapture:'NFL' has no definition
2323
"""
2424

25+
Scenario: Conditional presence check (#1048)
26+
# When `second` has no capture group, the rule just requires `second` to
27+
# be present whenever `first` is -- so only the file missing it flags.
28+
When I test "checks/ConditionalPresence"
29+
Then the output should contain exactly:
30+
"""
31+
missing.md:1:3:Test.Presence:A 'Section' requires a 'Summary:' line.
32+
"""
33+
2534
Scenario: Occurrence
2635
When I test "checks/Occurrence"
2736
Then the output should contain exactly:
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
StylesPath = styles
2+
MinAlertLevel = suggestion
3+
4+
[*.md]
5+
BasedOnStyles = Test
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Section
2+
3+
Summary: this is present.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Section
2+
3+
No summary line here.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
extends: conditional
2+
message: "A 'Section' requires a 'Summary:' line."
3+
level: error
4+
scope: raw
5+
first: '\bSection\b'
6+
second: 'Summary:'

0 commit comments

Comments
 (0)