11package check
22
33import (
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.
111164func (c Conditional ) Fields () Definition {
112165 return c .Definition
0 commit comments