all repos

clerk @ ed6cfe6

missing tooling for ledger/hledger

clerk/internal/linter/linter.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
feat: config (#6), 1 month ago
1
package linter
2
3
import (
4
	"fmt"
5
	"slices"
6
7
	"olexsmir.xyz/clerk/internal/analyzer"
8
	"olexsmir.xyz/clerk/journal/token"
9
)
10
11
// A Find represents a single lint finding.
12
type Find struct {
13
	Code     RuleID
14
	Severity Severity // set during reporting
15
	Message  string
16
	Span     token.Span
17
}
18
19
// Config configures linter.
20
type Config struct {
21
	Rules map[RuleID]RuleConfig
22
}
23
24
var DefaultConfig Config
25
26
func init() {
27
	rules := make(map[RuleID]RuleConfig, len(Rules))
28
	for id, br := range Rules {
29
		rules[id] = RuleConfig{Severity: br.Severity, Disabled: br.Severity == severityNone}
30
	}
31
	DefaultConfig = Config{Rules: rules}
32
}
33
34
// SeverityFor returns the severity for a rule. Returns config override if set,
35
// otherwise the rule's default from [Rules]
36
func (c Config) SeverityFor(rule RuleID) Severity {
37
	if rs, ok := c.Rules[rule]; ok && rs.Severity != severityNone {
38
		return rs.Severity
39
	}
40
	return Rules[rule].Severity
41
}
42
43
// Linter runs lint rules against a parsed journal.
44
type Linter struct {
45
	rules []Rule
46
}
47
48
// NewLinter creates a [Linter] with all built-in [Rules] configured by cfg.
49
// Rules with [RuleConfig.Disabled] set are omitted; options are applied to
50
// rule copies. Rules run in ID order for determinism.
51
func NewLinter(cfg Config) (*Linter, error) {
52
	ids := make([]RuleID, 0, len(Rules))
53
	for id := range Rules {
54
		rc, ok := cfg.Rules[id]
55
		if !ok {
56
			rc = DefaultConfig.Rules[id]
57
		}
58
		if rc.Disabled {
59
			continue
60
		}
61
		ids = append(ids, id)
62
	}
63
	slices.Sort(ids)
64
65
	var rules []Rule
66
	for _, id := range ids {
67
		rule := Rules[id].Rule
68
		rc := cfg.Rules[id]
69
		if len(rc.Options) > 0 {
70
			o, ok := rule.(RuleOptioner)
71
			if !ok {
72
				return nil, fmt.Errorf("rule %q does not accept options", rule.ID())
73
			}
74
			clone := o.Clone()
75
			if err := clone.(RuleOptioner).UnmarshalOptions(rc.Options); err != nil {
76
				return nil, fmt.Errorf("configuring rule %q: %w", rule.ID(), err)
77
			}
78
			rule = clone
79
		}
80
		rules = append(rules, rule)
81
	}
82
	return &Linter{rules: rules}, nil
83
}
84
85
// Run runs all rules against the analysis context.
86
func (l *Linter) Run(a *analyzer.Analysis) []Find {
87
	var finds []Find
88
	for _, rule := range l.rules {
89
		finds = append(finds, rule.CheckJournal(a)...)
90
	}
91
	return finds
92
}
93
94
// Severity represents severity of the litning find.
95
type Severity int
96
97
const (
98
	severityNone Severity = iota
99
	SeverityError
100
	SeverityWarning
101
	SeverityInfo
102
	SeverityHint
103
)
104
105
func (s Severity) String() string {
106
	switch s {
107
	case SeverityError:
108
		return "error"
109
	case SeverityWarning:
110
		return "warning"
111
	case SeverityInfo:
112
		return "info"
113
	case SeverityHint:
114
		return "hint"
115
	case severityNone:
116
		return "off"
117
	}
118
	panic("impossible severity state")
119
}
120
121
func ParseSeverity(s string) (sev Severity, ok bool) {
122
	switch s {
123
	case "error":
124
		return SeverityError, true
125
	case "warn", "warning":
126
		return SeverityWarning, true
127
	case "info":
128
		return SeverityInfo, true
129
	case "hint":
130
		return SeverityHint, true
131
	}
132
	return severityNone, false
133
}