Skip to content

Commit e60b5bc

Browse files
committed
fix: keep a rule's level in the section that set it
Fixes #965 Signed-off-by: Joseph Kato <joseph@jdkato.io>
1 parent c1e2fa0 commit e60b5bc

6 files changed

Lines changed: 258 additions & 30 deletions

File tree

‎internal/core/config.go‎

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -189,28 +189,29 @@ type CLIFlags struct {
189189
// Config holds the configuration values from both the CLI and `.vale.ini`.
190190
type Config struct {
191191
// General configuration
192-
BlockIgnores map[string][]string // A list of blocks to ignore
193-
Checks []string // All checks to load
194-
Formats map[string]string // A map of unknown -> known formats
195-
Asciidoctor map[string]string // A map of asciidoctor attributes
196-
FormatToLang map[string]string // A map of format to lang ID
197-
GBaseStyles []string // Global base style
198-
GChecks map[string]bool // Global checks
199-
IgnoredClasses []string // A list of HTML classes to ignore
200-
IgnoredScopes []string // A list of HTML tags to ignore
201-
MinAlertLevel int // Lowest alert level to display
202-
Vocab []string // The active project
203-
RuleToLevel map[string]string // Single-rule level changes
204-
SBaseStyles map[string][]string // Syntax-specific base styles
205-
SChecks map[string]map[string]bool // Syntax-specific checks
206-
SkippedScopes []string // A list of HTML blocks to ignore
207-
Stylesheets map[string]string // XSLT stylesheet
208-
TokenIgnores map[string][]string // A list of tokens to ignore
209-
CommentDelimiters map[string][2]string // Strings to treat as comment delimiters. Indicates the start and end delimiters.
210-
WordTemplate string // The template used in YAML -> regexp list conversions
211-
RootINI string // the path to the project's .vale.ini file
212-
Paths []string // A list of paths to search for styles
213-
ConfigFiles []string // A list of configuration files to load
192+
BlockIgnores map[string][]string // A list of blocks to ignore
193+
Checks []string // All checks to load
194+
Formats map[string]string // A map of unknown -> known formats
195+
Asciidoctor map[string]string // A map of asciidoctor attributes
196+
FormatToLang map[string]string // A map of format to lang ID
197+
GBaseStyles []string // Global base style
198+
GChecks map[string]bool // Global checks
199+
IgnoredClasses []string // A list of HTML classes to ignore
200+
IgnoredScopes []string // A list of HTML tags to ignore
201+
MinAlertLevel int // Lowest alert level to display
202+
Vocab []string // The active project
203+
RuleToLevel map[string]string // Single-rule level changes
204+
SBaseStyles map[string][]string // Syntax-specific base styles
205+
SChecks map[string]map[string]bool // Syntax-specific checks
206+
SLevels map[string]map[string]string // Syntax-specific level changes
207+
SkippedScopes []string // A list of HTML blocks to ignore
208+
Stylesheets map[string]string // XSLT stylesheet
209+
TokenIgnores map[string][]string // A list of tokens to ignore
210+
CommentDelimiters map[string][2]string // Strings to treat as comment delimiters. Indicates the start and end delimiters.
211+
WordTemplate string // The template used in YAML -> regexp list conversions
212+
RootINI string // the path to the project's .vale.ini file
213+
Paths []string // A list of paths to search for styles
214+
ConfigFiles []string // A list of configuration files to load
214215

215216
AcceptedTokens []string `json:"-"` // Project-specific vocabulary (okay)
216217
RejectedTokens []string `json:"-"` // Project-specific vocabulary (avoid)
@@ -242,6 +243,7 @@ func NewConfig(flags *CLIFlags) (*Config, error) {
242243
cfg.RuleToLevel = make(map[string]string)
243244
cfg.SBaseStyles = make(map[string][]string)
244245
cfg.SChecks = make(map[string]map[string]bool)
246+
cfg.SLevels = make(map[string]map[string]string)
245247
cfg.SecToPat = make(map[string]glob.Glob)
246248
cfg.Stylesheets = make(map[string]string)
247249
cfg.TokenIgnores = make(map[string][]string)

‎internal/core/file.go‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ type File struct {
4242
Checks map[string]bool // syntax-specific checks assigned in .vale
4343
ChkToCtx map[string]string // maps a temporary context to a particular check
4444

45+
// Levels holds the level each rule was given by the sections matching this
46+
// file, which is not the same as the level it was compiled with: the same
47+
// rule can be an error in one format and a warning in another. See #965.
48+
Levels map[string]string
49+
4550
// chkMasked counts, per check and match text, the occurrences already
4651
// masked into ChkToCtx this block. An occurrence a prior alert consumed
4752
// is gone from the context, so it must not widen a later alert's
@@ -123,6 +128,7 @@ func NewFile(src string, config *Config) (*File, error) {
123128

124129
baseStyles := config.GBaseStyles
125130
checks := make(map[string]bool)
131+
levels := make(map[string]string)
126132

127133
for _, fp := range filepaths {
128134
for _, sec := range config.StyleKeys {
@@ -136,6 +142,11 @@ func NewFile(src string, config *Config) (*File, error) {
136142
for k, v := range config.SChecks[sec] {
137143
checks[k] = v
138144
}
145+
// Sections are visited in the order they were written, so a
146+
// later one wins -- for this file, and no other. See #965.
147+
for k, v := range config.SLevels[sec] {
148+
levels[k] = v
149+
}
139150
}
140151
}
141152
}
@@ -171,7 +182,8 @@ func NewFile(src string, config *Config) (*File, error) {
171182

172183
file := File{
173184
NormedExt: ext, Format: format, RealExt: filepath.Ext(path),
174-
BaseStyles: baseStyles, Checks: checks, Lines: lines, Content: content,
185+
BaseStyles: baseStyles, Checks: checks, Levels: levels,
186+
Lines: lines, Content: content,
175187
Comments: make(map[string]bool), history: make(map[string]int),
176188
simple: config.Flags.Simple, Transform: transform,
177189
limits: make(map[string]int), Path: path, Metrics: make(map[string]int),
@@ -572,3 +584,17 @@ func (f *File) SetMetaScope(scope string) {
572584
f.MetaScope = ""
573585
}
574586
}
587+
588+
// Level returns the level `name` should report at in this file, falling back
589+
// to the level it was compiled with.
590+
//
591+
// A rule's own level covers it; a level set for its style covers the rest of
592+
// that style, mirroring how the two are resolved at compile time.
593+
func (f *File) Level(name, compiled string) string {
594+
if level, ok := f.Levels[name]; ok {
595+
return level
596+
} else if level, ok = f.Levels[StyleName(name)]; ok {
597+
return level
598+
}
599+
return compiled
600+
}

‎internal/core/ini.go‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,19 @@ func loadVocab(root string, cfg *Config) error {
8585
return err
8686
}
8787

88-
func validateLevel(key, val string, cfg *Config) bool {
88+
// validateLevel reports whether `key` names a rule that should run, recording
89+
// any level it was given in `levels`.
90+
//
91+
// A level set under a section belongs to that section. Writing every one into
92+
// a single map made the last section in the file decide the level everywhere,
93+
// so `Vale.Spelling = warning` for Markdown quietly downgraded HTML too. See
94+
// #965.
95+
func validateLevel(key, val string, levels map[string]string) bool {
8996
options := []string{"YES", "suggestion", "warning", "error"}
9097
if val == "NO" || !StringInSlice(val, options) {
9198
return false
9299
} else if val != "YES" {
93-
cfg.RuleToLevel[key] = val
100+
levels[key] = val
94101
}
95102
return true
96103
}
@@ -419,7 +426,7 @@ func processConfig(uCfg *ini.File, cfg *Config, dry bool) (*ini.File, error) {
419426
msg := fmt.Sprintf("'%s' is a syntax-specific option", k)
420427
return nil, NewE201FromTarget(msg, k, cfg.RootINI)
421428
} else {
422-
cfg.GChecks[k] = validateLevel(k, global.Key(k).String(), cfg)
429+
cfg.GChecks[k] = validateLevel(k, global.Key(k).String(), cfg.RuleToLevel)
423430
cfg.Checks = append(cfg.Checks, k)
424431
}
425432
}
@@ -437,6 +444,7 @@ func processConfig(uCfg *ini.File, cfg *Config, dry bool) (*ini.File, error) {
437444
cfg.SecToPat[sec] = pat
438445

439446
syntaxMap := make(map[string]bool)
447+
levelMap := make(map[string]string)
440448
for _, k := range uCfg.Section(sec).KeyStrings() {
441449
if _, option := coreOpts[k]; option {
442450
return nil, NewE201FromTarget(fmt.Sprintf(coreError, k), k, cfg.RootINI)
@@ -445,12 +453,13 @@ func processConfig(uCfg *ini.File, cfg *Config, dry bool) (*ini.File, error) {
445453
return nil, err
446454
}
447455
} else {
448-
syntaxMap[k] = validateLevel(k, uCfg.Section(sec).Key(k).String(), cfg)
456+
syntaxMap[k] = validateLevel(k, uCfg.Section(sec).Key(k).String(), levelMap)
449457
cfg.Checks = append(cfg.Checks, k)
450458
}
451459
}
452460
cfg.RuleKeys = append(cfg.RuleKeys, sec)
453461
cfg.SChecks[sec] = syntaxMap
462+
cfg.SLevels[sec] = levelMap
454463
}
455464

456465
return uCfg, nil

‎internal/core/ini_test.go‎

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,3 +219,136 @@ func Test_shadowLoad_sectionsDoNotInherit(t *testing.T) {
219219
t.Errorf("[*.txt] should hold only its own styles; got %v", got)
220220
}
221221
}
222+
223+
// A level set under a section belongs to that section. Collecting them all in
224+
// one map let the last section in the file decide the level everywhere. See
225+
// #965.
226+
func Test_processConfig_sectionLevels(t *testing.T) {
227+
cases := []struct {
228+
description string
229+
body string
230+
levels map[string]map[string]string
231+
global map[string]string
232+
}{
233+
{
234+
description: "one section overrides a shared style",
235+
body: `[*.{html,md}]
236+
BasedOnStyles = Vale
237+
238+
[*.md]
239+
Vale.Spelling = warning
240+
`,
241+
levels: map[string]map[string]string{
242+
"*.{html,md}": {},
243+
"*.md": {"Vale.Spelling": "warning"},
244+
},
245+
global: map[string]string{},
246+
},
247+
{
248+
description: "each section keeps its own level",
249+
body: `[*.html]
250+
Vale.Spelling = error
251+
252+
[*.md]
253+
Vale.Spelling = warning
254+
`,
255+
levels: map[string]map[string]string{
256+
"*.html": {"Vale.Spelling": "error"},
257+
"*.md": {"Vale.Spelling": "warning"},
258+
},
259+
global: map[string]string{},
260+
},
261+
{
262+
description: "a level under [*] stays global",
263+
body: `[*]
264+
Vale.Spelling = suggestion
265+
266+
[*.md]
267+
BasedOnStyles = Vale
268+
`,
269+
levels: map[string]map[string]string{
270+
"*.md": {},
271+
},
272+
global: map[string]string{"Vale.Spelling": "suggestion"},
273+
},
274+
{
275+
description: "YES and NO carry no level",
276+
body: `[*.md]
277+
Vale.Spelling = YES
278+
Vale.Repetition = NO
279+
`,
280+
levels: map[string]map[string]string{
281+
"*.md": {},
282+
},
283+
global: map[string]string{},
284+
},
285+
}
286+
287+
for _, c := range cases {
288+
t.Run(c.description, func(t *testing.T) {
289+
uCfg, err := shadowLoad([]byte(c.body))
290+
if err != nil {
291+
t.Fatal(err)
292+
}
293+
294+
conf, err := NewConfig(&CLIFlags{})
295+
if err != nil {
296+
t.Fatal(err)
297+
}
298+
299+
if _, err = processConfig(uCfg, conf, false); err != nil {
300+
t.Fatal(err)
301+
}
302+
303+
for sec, want := range c.levels {
304+
got := conf.SLevels[sec]
305+
if len(got) != len(want) {
306+
t.Fatalf("SLevels[%q] = %v, want %v", sec, got, want)
307+
}
308+
for k, v := range want {
309+
if got[k] != v {
310+
t.Errorf("SLevels[%q][%q] = %q, want %q", sec, k, got[k], v)
311+
}
312+
}
313+
}
314+
315+
if len(conf.RuleToLevel) != len(c.global) {
316+
t.Fatalf("RuleToLevel = %v, want %v", conf.RuleToLevel, c.global)
317+
}
318+
for k, v := range c.global {
319+
if conf.RuleToLevel[k] != v {
320+
t.Errorf("RuleToLevel[%q] = %q, want %q", k, conf.RuleToLevel[k], v)
321+
}
322+
}
323+
})
324+
}
325+
}
326+
327+
// A file reports a rule at the level its own sections gave it, falling back to
328+
// the level the rule was compiled with.
329+
func TestFileLevel(t *testing.T) {
330+
f := File{Levels: map[string]string{
331+
"Vale.Spelling": "warning",
332+
"proselint": "suggestion",
333+
}}
334+
335+
tests := []struct {
336+
name string
337+
rule string
338+
compiled string
339+
want string
340+
}{
341+
{"the rule itself", "Vale.Spelling", "error", "warning"},
342+
{"its style", "proselint.Typography", "error", "suggestion"},
343+
{"neither", "Microsoft.Wordiness", "error", "error"},
344+
}
345+
346+
for _, tt := range tests {
347+
t.Run(tt.name, func(t *testing.T) {
348+
if got := f.Level(tt.rule, tt.compiled); got != tt.want {
349+
t.Errorf("Level(%q, %q) = %q, want %q",
350+
tt.rule, tt.compiled, got, tt.want)
351+
}
352+
})
353+
}
354+
}

‎internal/lint/lint.go‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -427,14 +427,28 @@ func (l *Linter) lintBlock(f *core.File, blk nlp.Block, lines, pad int, lookup b
427427
if f.QueryComments(r.name + "[" + found[i][j].Match + "]") {
428428
continue
429429
}
430-
core.FormatAlert(&found[i][j], info.Limit, info.Level, r.name)
430+
setLevel(&found[i][j], f, info, r.name)
431431
f.AddAlert(found[i][j], blk, lines, pad, lookup)
432432
}
433433
}
434434

435435
return nil
436436
}
437437

438+
// setLevel finishes an alert, reporting it at the level this file gives its
439+
// rule.
440+
//
441+
// Assigning the severity rather than leaving it to FormatAlert is what makes a
442+
// per-format level take effect: a check builds its alerts with the level it was
443+
// compiled with already set, and FormatAlert only fills a severity that is
444+
// still empty. See #965.
445+
func setLevel(a *core.Alert, f *core.File, info check.Definition, name string) {
446+
level := f.Level(name, info.Level)
447+
448+
core.FormatAlert(a, info.Limit, level, name)
449+
a.Severity = level
450+
}
451+
438452
// lintBlockSerial is lintBlock without the concurrency, and without what it
439453
// costs to set up.
440454
func (l *Linter) lintBlockSerial(f *core.File, blk nlp.Block, rules []scopedRule, lines, pad int, lookup bool) error {
@@ -454,7 +468,7 @@ func (l *Linter) lintBlockSerial(f *core.File, blk nlp.Block, rules []scopedRule
454468
if f.QueryComments(name + "[" + alerts[i].Match + "]") {
455469
continue
456470
}
457-
core.FormatAlert(&alerts[i], info.Limit, info.Level, name)
471+
setLevel(&alerts[i], f, info, name)
458472
f.AddAlert(alerts[i], blk, lines, pad, lookup)
459473
}
460474
}
@@ -561,7 +575,9 @@ func (l *Linter) shouldRun(name string, f *core.File, chk check.Rule) bool {
561575
if f.QueryComments(name) {
562576
// It has been disabled via an in-text comment.
563577
return false
564-
} else if core.LevelToInt[details.Level] < minLevel {
578+
} else if core.LevelToInt[f.Level(name, details.Level)] < minLevel {
579+
// The level this file gives the rule, which a section may have changed
580+
// for this format alone. See #965.
565581
return false
566582
}
567583

0 commit comments

Comments
��(0)