64 files changed,
1494 insertions(+),
439 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed by:
GitHub
noreply@github.com
Committed at:
2026-09-02 16:11:25 +0300
Parent:
beddd81
jump to
M
go.sum
··· 4 4 github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= 5 5 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 6 6 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 7 +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= 8 +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 7 9 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 10 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 11 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
M
internal/cli/cli.go
··· 22 22 Usage: "missing pta tooling", 23 23 Version: c.version, 24 24 EnableShellCompletion: true, 25 + Flags: []cli.Flag{ 26 + &cli.StringFlag{ 27 + Name: "config", 28 + Usage: "path to config file (defaults to clerk.toml in the current directory)", 29 + }, 30 + }, 25 31 Commands: []*cli.Command{ 26 32 { 27 33 Name: "lsp",
M
internal/cli/cmd_format.go
··· 19 19 write := cmd.Bool("write") 20 20 paths := cmd.StringArgs("journals") 21 21 22 + sets, warns, err := loadConfig(cmd) 23 + if err != nil { 24 + return err 25 + } 26 + for i := range warns { 27 + fmt.Fprintf(os.Stderr, "warning: %s\n", warns[i]) 28 + } 29 + 22 30 loader := journal.NewLoader() 23 31 if len(paths) == 0 { 24 - src, err := readStdin() 25 - if err != nil { 26 - return err 32 + src, rerr := readStdin() 33 + if rerr != nil { 34 + return rerr 27 35 } 28 36 rj := loader.ResolveBytes("stdin", src) 29 37 pf := rj.Occurrences[0] ··· 36 44 } 37 45 return cli.Exit("", 1) 38 46 } 39 - return c.formatFile("stdin", pf, check, diff, list, write) 47 + return c.formatFile("stdin", pf, sets.Format, check, diff, list, write) 40 48 } 41 49 42 50 files, err := resolvePaths(paths) ··· 64 72 continue 65 73 } 66 74 67 - if err := c.formatFile(path, pf, check, diff, list, write); err != nil { 75 + if err := c.formatFile(path, pf, sets.Format, check, diff, list, write); err != nil { 68 76 fmt.Fprintf(os.Stderr, "error: %s: %v\n", path, err) 69 77 hasErrors = true 70 78 } ··· 75 83 return nil 76 84 } 77 85 78 -func (c *Cli) formatFile(path string, pf *journal.ParsedFile, check, wantDiff, list, write bool) error { 86 +func (c *Cli) formatFile(path string, pf *journal.ParsedFile, cfg printer.Config, check, wantDiff, list, write bool) error { 79 87 var buf bytes.Buffer 80 - if err := printer.Fprint(&buf, pf.Ast); err != nil { 88 + if err := cfg.Fprint(&buf, pf.Ast); err != nil { 81 89 return fmt.Errorf("format: %w", err) 82 90 } 83 91 formatted := buf.Bytes()
M
internal/cli/cmd_lint.go
··· 17 17 format := cmd.String("format") 18 18 pathStyle := cmd.String("path-style") 19 19 20 - lint := linter.NewLinter(linter.Rules) 21 - reporter := linter.NewReporter(os.Stdout, parsePathStyle(pathStyle)) 20 + set, warns, err := loadConfig(cmd) 21 + if err != nil { 22 + return err 23 + } 24 + 25 + for _, w := range warns { 26 + fmt.Fprintln(os.Stderr, "warning:", w) 27 + } 28 + 29 + lint, err := linter.NewLinter(set.Linter) 30 + if err != nil { 31 + return err 32 + } 33 + reporter := linter.NewReporter(os.Stdout, parsePathStyle(pathStyle), set.Linter) 22 34 23 35 journals := cmd.StringArgs("journals") 24 36 if len(journals) == 0 { ··· 35 47 36 48 loader := journal.NewLoader() 37 49 38 - var hasIssues bool 50 + var hasFailures bool 39 51 for _, f := range journalFiles { 40 52 rj, err := loader.Resolve(f) 41 53 if err != nil { 42 54 fmt.Fprintf(os.Stderr, "error: %v\n", err) 43 - hasIssues = true 55 + hasFailures = true 44 56 continue 45 57 } 46 58 reporter.Collect(lint.Run(analyzer.Build(rj))) ··· 50 62 return fmt.Errorf("flushing report: %w", err) 51 63 } 52 64 53 - if reporter.HasIssues() || hasIssues { 65 + if reporter.HasFailures() || hasFailures { 54 66 return cli.Exit("", 1) 55 67 } 56 68 return nil ··· 68 80 if err := reporter.Flush(format); err != nil { 69 81 return fmt.Errorf("flushing report: %w", err) 70 82 } 71 - if reporter.HasIssues() { 83 + if reporter.HasFailures() { 72 84 return cli.Exit("", 1) 73 85 } 74 86 return nil
M
internal/cli/cmd_lsp.go
··· 10 10 ) 11 11 12 12 func (c *Cli) lspAction(ctx context.Context, cmd *cli.Command) error { 13 - server := lsp.NewServer(c.version) 13 + configPath, err := findConfigFilePath(cmd) 14 + if err != nil { 15 + return err 16 + } 17 + 18 + server, err := lsp.NewServer(c.version, configPath) 19 + if err != nil { 20 + return err 21 + } 14 22 return server.Run(ctx, os.Stdin, os.Stdout) 15 23 }
M
internal/cli/helpers.go
··· 6 6 "os" 7 7 "path/filepath" 8 8 9 + "github.com/urfave/cli/v3" 10 + 11 + "olexsmir.xyz/clerk/internal/settings" 9 12 "olexsmir.xyz/clerk/journal" 10 13 ) 11 14 ··· 53 56 } 54 57 return src, nil 55 58 } 59 + 60 +func findConfigFilePath(cmd *cli.Command) (string, error) { 61 + if p := cmd.String("config"); p != "" { 62 + if _, err := os.Stat(p); err != nil { 63 + return "", fmt.Errorf("config %q: %w", p, err) 64 + } 65 + return p, nil 66 + } 67 + 68 + cwd, err := os.Getwd() 69 + if err != nil { 70 + return "", err 71 + } 72 + return filepath.Join(cwd, "clerk.toml"), nil 73 +} 74 + 75 +func loadConfig(cmd *cli.Command) (settings.Settings, []string, error) { 76 + configPath, err := findConfigFilePath(cmd) 77 + if err != nil { 78 + return settings.Settings{}, nil, err 79 + } 80 + return settings.Load(configPath) 81 +}
M
internal/linter/linter.go
··· 1 1 package linter 2 2 3 3 import ( 4 + "fmt" 5 + "slices" 6 + 4 7 "olexsmir.xyz/clerk/internal/analyzer" 5 8 "olexsmir.xyz/clerk/journal/token" 6 9 ) ··· 8 11 // A Find represents a single lint finding. 9 12 type Find struct { 10 13 Code RuleID 11 - Severity Severity 14 + Severity Severity // set during reporting 12 15 Message string 13 16 Span token.Span 14 17 } 15 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 + 16 43 // Linter runs lint rules against a parsed journal. 17 44 type Linter struct { 18 45 rules []Rule 19 46 } 20 47 21 -// NewLinter creates a [Linter] with the given rules. 22 -func NewLinter(rules []Rule) *Linter { 23 - return &Linter{rules: rules} 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 24 83 } 25 84 26 85 // Run runs all rules against the analysis context. ··· 36 95 type Severity int 37 96 38 97 const ( 39 - SeverityError Severity = 1 40 - SeverityWarning Severity = 2 41 - SeverityInfo Severity = 3 42 - SeverityHint Severity = 4 98 + severityNone Severity = iota 99 + SeverityError 100 + SeverityWarning 101 + SeverityInfo 102 + SeverityHint 43 103 ) 44 104 45 105 func (s Severity) String() string { ··· 52 112 return "info" 53 113 case SeverityHint: 54 114 return "hint" 115 + case severityNone: 116 + return "off" 55 117 } 56 118 panic("impossible severity state") 57 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 +}
M
internal/linter/linter_test.go
··· 1 1 package linter 2 2 3 3 import ( 4 + "encoding/json" 5 + "slices" 4 6 "strings" 5 7 "testing" 6 8 ··· 10 12 ) 11 13 12 14 var tests = map[string][]Rule{ 13 - "correct": Rules, 15 + "correct": allRules(), 14 16 "invalid-include": {&InvalidInclude{}}, 15 17 "empty-postings": {&EmptyPostings{}}, 16 18 "parse-error": {&ParseError{}}, ··· 52 54 } 53 55 54 56 ctx := analyzer.Build(rj) 55 - finds := NewLinter(trules).Run(ctx) 57 + finds := (&Linter{rules: trules}).Run(ctx) 56 58 57 59 var b strings.Builder 58 - Fprint(&b, PathBasename, finds) 60 + fprint(&b, PathBasename, finds) 59 61 golden.Assert(t, a, b.String()) 60 62 }) 61 63 } ··· 69 71 } 70 72 71 73 ctx := analyzer.Build(rj) 72 - l := NewLinter(Rules) 74 + l, err := NewLinter(Config{}) 75 + if err != nil { 76 + b.Fatal(err) 77 + } 73 78 74 79 b.ResetTimer() 75 80 b.ReportAllocs() ··· 77 82 l.Run(ctx) 78 83 } 79 84 } 85 + 86 +func allRules() []Rule { 87 + out := make([]Rule, 0, len(Rules)) 88 + for _, b := range Rules { 89 + out = append(out, b.Rule) 90 + } 91 + return out 92 +} 93 + 94 +func TestNewLinter(t *testing.T) { 95 + offByDefault := []RuleID{UndeclaredPayeeID, UndeclaredTagID} 96 + t.Run("all-default", func(t *testing.T) { 97 + l, err := NewLinter(Config{}) 98 + if err != nil { 99 + t.Fatal(err) 100 + } 101 + var got []RuleID 102 + for _, r := range l.rules { 103 + got = append(got, r.ID()) 104 + } 105 + if want := without(builtinIDs(), offByDefault...); !slices.Equal(got, want) { 106 + t.Errorf("got %v, want %v", got, want) 107 + } 108 + }) 109 + 110 + t.Run("disabled-omitted", func(t *testing.T) { 111 + l, err := NewLinter(Config{Rules: map[RuleID]RuleConfig{ 112 + OrderDateID: {Disabled: true}, 113 + MissingPayeeID: {Disabled: true}, 114 + }}) 115 + if err != nil { 116 + t.Fatal(err) 117 + } 118 + var got []RuleID 119 + for _, r := range l.rules { 120 + got = append(got, r.ID()) 121 + } 122 + want := without(builtinIDs(), OrderDateID, MissingPayeeID) 123 + want = without(want, offByDefault...) 124 + if !slices.Equal(got, want) { 125 + t.Errorf("got %v, want %v", got, want) 126 + } 127 + }) 128 + 129 + t.Run("severity-none-enabled-by-config", func(t *testing.T) { 130 + l, err := NewLinter(Config{Rules: map[RuleID]RuleConfig{UndeclaredPayeeID: {Severity: SeverityWarning}}}) 131 + if err != nil { 132 + t.Fatal(err) 133 + } 134 + var got []RuleID 135 + for _, r := range l.rules { 136 + got = append(got, r.ID()) 137 + } 138 + want := without(builtinIDs(), UndeclaredTagID) 139 + if !slices.Equal(got, want) { 140 + t.Errorf("got %v, want %v", got, want) 141 + } 142 + }) 143 + 144 + t.Run("options-applied", func(t *testing.T) { 145 + l, err := NewLinter(Config{Rules: map[RuleID]RuleConfig{ 146 + AccountDepthLimitID: {Options: json.RawMessage(`{"max-depth": 2}`)}, 147 + }}) 148 + if err != nil { 149 + t.Fatal(err) 150 + } 151 + for _, r := range l.rules { 152 + if r.ID() != AccountDepthLimitID { 153 + continue 154 + } 155 + if got := r.(*AccountDepthLimit).MaxDepth; got != 2 { 156 + t.Errorf("MaxDepth = %d, want 2", got) 157 + } 158 + if def := Rules[AccountDepthLimitID].Rule.(*AccountDepthLimit).MaxDepth; def != 4 { 159 + t.Errorf("shared built-in mutated: MaxDepth = %d, want 4", def) 160 + } 161 + return 162 + } 163 + t.Error("account-depth rule not found") 164 + }) 165 + 166 + t.Run("options-on-non-optionable-rule", func(t *testing.T) { 167 + cfg := Config{Rules: map[RuleID]RuleConfig{ 168 + ParseErrorID: {Options: json.RawMessage(`{"x":1}`)}, 169 + }} 170 + if _, err := NewLinter(cfg); err == nil { 171 + t.Error("expected error") 172 + } 173 + }) 174 + 175 + t.Run("unknown-option-key", func(t *testing.T) { 176 + cfg := Config{Rules: map[RuleID]RuleConfig{ 177 + AccountDepthLimitID: {Options: json.RawMessage(`{"nope": 1}`)}, 178 + }} 179 + if _, err := NewLinter(cfg); err == nil { 180 + t.Error("expected error") 181 + } 182 + }) 183 +} 184 + 185 +func builtinIDs() []RuleID { 186 + ids := make([]RuleID, 0, len(Rules)) 187 + for id := range Rules { 188 + ids = append(ids, id) 189 + } 190 + slices.Sort(ids) 191 + return ids 192 +} 193 + 194 +func without(ids []RuleID, drop ...RuleID) []RuleID { 195 + out := ids[:0:0] 196 + for _, id := range ids { 197 + if !slices.Contains(drop, id) { 198 + out = append(out, id) 199 + } 200 + } 201 + return out 202 +}
M
internal/linter/report.go
··· 19 19 PathRelative // relative to current directory 20 20 ) 21 21 22 -// Fprint writes finds in text format: file:line:col code: message. 23 -func Fprint(w io.Writer, style PathStyle, finds []Find) { 22 +// Reporter collects lint findings across files and flushes them in the desired format. 23 +type Reporter struct { 24 + w io.Writer 25 + finds []Find 26 + style PathStyle 27 + cfg Config 28 +} 29 + 30 +func NewReporter(w io.Writer, style PathStyle, cfg Config) *Reporter { 31 + return &Reporter{w: w, style: style, cfg: cfg} 32 +} 33 + 34 +func (r *Reporter) Collect(finds []Find) { 35 + for i := range finds { 36 + finds[i].Severity = r.cfg.SeverityFor(finds[i].Code) 37 + } 38 + r.finds = append(r.finds, finds...) 39 +} 40 + 41 +func (r *Reporter) HasFailures() bool { 42 + for i := range r.finds { 43 + if r.finds[i].Severity <= SeverityWarning { 44 + return true 45 + } 46 + } 47 + return false 48 +} 49 + 50 +func (r *Reporter) Flush(format string) error { 51 + switch format { 52 + case "json": 53 + return fprintJSON(r.w, r.style, r.finds) 54 + case "text": 55 + fprint(r.w, r.style, r.finds) 56 + return nil 57 + default: 58 + return errors.New("unsupported format") 59 + } 60 +} 61 + 62 +// fprint writes finds in text format: file:line:col code: message. 63 +func fprint(w io.Writer, style PathStyle, finds []Find) { 24 64 sortFinds(finds) 25 65 for _, find := range finds { 26 66 _, _ = fmt.Fprintf(w, "%s:%d:%d: %s: %s\n", ··· 30 70 } 31 71 } 32 72 33 -type FindJSON struct { 73 +type findJSON struct { 34 74 Message string `json:"message"` 35 75 Severity string `json:"severity"` 36 76 Code string `json:"code"` ··· 39 79 Column int `json:"column"` 40 80 } 41 81 42 -// FprintJSON writes finds as [FindJSON] array. 43 -func FprintJSON(w io.Writer, style PathStyle, finds []Find) error { 82 +// fprintJSON writes finds as [findJSON] array. 83 +func fprintJSON(w io.Writer, style PathStyle, finds []Find) error { 44 84 sortFinds(finds) 45 - jsonFinds := make([]FindJSON, len(finds)) 85 + jsonFinds := make([]findJSON, len(finds)) 46 86 for i, find := range finds { 47 - jsonFinds[i] = FindJSON{ 87 + jsonFinds[i] = findJSON{ 48 88 Message: find.Message, 49 - Severity: find.Severity.String(), 89 + Severity: find.Severity.String(), // TODO: it's unset 50 90 Code: string(find.Code), 51 91 File: formatPath(style, find.Span.File), 52 92 Line: find.Span.Start.Line, ··· 54 94 } 55 95 } 56 96 return json.NewEncoder(w).Encode(jsonFinds) 57 -} 58 - 59 -// Reporter collects lint findings across files and flushes them in the desired format. 60 -type Reporter struct { 61 - w io.Writer 62 - finds []Find 63 - style PathStyle 64 -} 65 - 66 -func NewReporter(w io.Writer, style PathStyle) *Reporter { 67 - return &Reporter{w: w, style: style} 68 -} 69 - 70 -func (r *Reporter) Collect(finds []Find) { 71 - r.finds = append(r.finds, finds...) 72 -} 73 - 74 -func (r *Reporter) HasIssues() bool { 75 - return len(r.finds) > 0 76 -} 77 - 78 -func (r *Reporter) Flush(format string) error { 79 - switch format { 80 - case "json": 81 - return FprintJSON(r.w, r.style, r.finds) 82 - case "text": 83 - Fprint(r.w, r.style, r.finds) 84 - return nil 85 - default: 86 - return errors.New("unsupported format") 87 - } 88 97 } 89 98 90 99 func formatPath(style PathStyle, p string) string {
M
internal/linter/rule_account_depth.go
··· 1 1 package linter 2 2 3 3 import ( 4 + "bytes" 5 + "encoding/json" 4 6 "fmt" 5 7 6 8 "olexsmir.xyz/clerk/internal/analyzer" 7 9 "olexsmir.xyz/clerk/journal/ast" 8 10 ) 9 11 12 +const AccountDepthLimitID = "account-depth" 13 + 10 14 // AccountDepthLimit checks that account names don't exceed MaxDepth 11 15 type AccountDepthLimit struct { 12 - MaxDepth int 16 + MaxDepth int `json:"max-depth"` 13 17 } 14 18 15 -func (AccountDepthLimit) ID() RuleID { return "account-depth" } 16 -func (AccountDepthLimit) Severity() Severity { return SeverityWarning } 19 +func (AccountDepthLimit) ID() RuleID { return AccountDepthLimitID } 17 20 func (a *AccountDepthLimit) CheckJournal(an *analyzer.Analysis) []Find { 18 21 var finds []Find 19 22 ··· 46 49 return finds 47 50 } 48 51 52 +func (a *AccountDepthLimit) Clone() Rule { cpy := *a; return &cpy } 53 +func (a *AccountDepthLimit) UnmarshalOptions(data json.RawMessage) error { 54 + d := json.NewDecoder(bytes.NewReader(data)) 55 + d.DisallowUnknownFields() 56 + return d.Decode(a) 57 +} 58 + 49 59 func (a *AccountDepthLimit) check(finds *[]Find, acc ast.Account) { 50 60 if depth := len(acc.Name); depth > a.MaxDepth { 51 61 *finds = append(*finds, Find{ 52 - Code: a.ID(), 53 - Severity: a.Severity(), 62 + Code: a.ID(), 54 63 Message: fmt.Sprintf("account %q depth (%d) exceeds max allowed depth (%d)", 55 64 acc.String(), depth, a.MaxDepth), 56 65 Span: acc.Span,
M
internal/linter/rule_duplicated_account.go
··· 6 6 "olexsmir.xyz/clerk/internal/analyzer" 7 7 ) 8 8 9 +const DuplicatedAccountID RuleID = "duplicated-account" 10 + 9 11 // DuplicatedAccount flags account declarations that appear more than once. 10 12 type DuplicatedAccount struct{} 11 13 12 -func (DuplicatedAccount) ID() RuleID { return "duplicated-account" } 13 -func (DuplicatedAccount) Severity() Severity { return SeverityWarning } 14 +func (DuplicatedAccount) ID() RuleID { return DuplicatedAccountID } 14 15 func (d *DuplicatedAccount) CheckJournal(an *analyzer.Analysis) []Find { 15 16 var finds []Find 16 17 for _, info := range an.Accounts { ··· 19 20 } 20 21 for _, ad := range info.Directives { 21 22 finds = append(finds, Find{ 22 - Code: d.ID(), 23 - Severity: d.Severity(), 24 - Message: fmt.Sprintf("duplicated account declaration: %s", ad.Account.String()), 25 - Span: ad.Account.Span, 23 + Code: d.ID(), 24 + Message: fmt.Sprintf("duplicated account declaration: %s", ad.Account.String()), 25 + Span: ad.Account.Span, 26 26 }) 27 27 } 28 28 }
M
internal/linter/rule_duplicated_commodity.go
··· 6 6 "olexsmir.xyz/clerk/internal/analyzer" 7 7 ) 8 8 9 +const DuplicatedCommodityID RuleID = "duplicated-commodity" 10 + 9 11 // DuplicatedCommodity flags commodity declarations that appear more than once. 10 12 type DuplicatedCommodity struct{} 11 13 12 -func (DuplicatedCommodity) ID() RuleID { return "duplicated-commodity" } 13 -func (DuplicatedCommodity) Severity() Severity { return SeverityWarning } 14 +func (DuplicatedCommodity) ID() RuleID { return DuplicatedCommodityID } 14 15 func (d *DuplicatedCommodity) CheckJournal(an *analyzer.Analysis) []Find { 15 16 var finds []Find 16 17 for sym, info := range an.Commodities { ··· 19 20 } 20 21 for _, cd := range info.Directives { 21 22 finds = append(finds, Find{ 22 - Code: d.ID(), 23 - Severity: d.Severity(), 24 - Message: fmt.Sprintf("duplicated commodity declaration: %s", sym), 25 - Span: cd.Span, 23 + Code: d.ID(), 24 + Message: fmt.Sprintf("duplicated commodity declaration: %s", sym), 25 + Span: cd.Span, 26 26 }) 27 27 } 28 28 }
M
internal/linter/rule_duplicated_tag.go
··· 6 6 "olexsmir.xyz/clerk/internal/analyzer" 7 7 ) 8 8 9 +const DuplicatedTagID = "duplicated-tag" 10 + 9 11 // DuplicatedTag flags tag declarations that appear more than once. 10 12 type DuplicatedTag struct{} 11 13 12 -func (DuplicatedTag) ID() RuleID { return "duplicated-tag" } 13 -func (DuplicatedTag) Severity() Severity { return SeverityWarning } 14 +func (DuplicatedTag) ID() RuleID { return DuplicatedTagID } 14 15 func (d *DuplicatedTag) CheckJournal(an *analyzer.Analysis) []Find { 15 16 var finds []Find 16 17 for name, info := range an.Tags { ··· 19 20 } 20 21 for _, td := range info.Directives { 21 22 finds = append(finds, Find{ 22 - Code: d.ID(), 23 - Severity: d.Severity(), 24 - Message: fmt.Sprintf("duplicated tag declaration: %s", name), 25 - Span: td.Span, 23 + Code: d.ID(), 24 + Message: fmt.Sprintf("duplicated tag declaration: %s", name), 25 + Span: td.Span, 26 26 }) 27 27 } 28 28 }
M
internal/linter/rule_duplicated_transaction.go
··· 6 6 "olexsmir.xyz/clerk/internal/analyzer" 7 7 ) 8 8 9 +const DuplicatedTransactionID = "duplicated-transaction" 10 + 9 11 // DuplicatedTransaction flags idnetical transactions. 10 12 type DuplicatedTransaction struct{} 11 13 12 -func (DuplicatedTransaction) ID() RuleID { return "duplicated-transaction" } 13 -func (DuplicatedTransaction) Severity() Severity { return SeverityWarning } 14 +func (DuplicatedTransaction) ID() RuleID { return DuplicatedTransactionID } 14 15 func (d *DuplicatedTransaction) CheckJournal(an *analyzer.Analysis) []Find { 15 16 var finds []Find 16 17 for _, txs := range an.TransactionsByKey { ··· 19 20 } 20 21 for _, tx := range txs[1:] { 21 22 finds = append(finds, Find{ 22 - Code: d.ID(), 23 - Severity: d.Severity(), 24 - Message: fmt.Sprintf("duplicate of transaction at line %d", txs[0].Span.Start.Line), 25 - Span: tx.Span, 23 + Code: d.ID(), 24 + Message: fmt.Sprintf("duplicate of transaction at line %d", txs[0].Span.Start.Line), 25 + Span: tx.Span, 26 26 }) 27 27 } 28 28 }
M
internal/linter/rule_empty_postings.go
··· 2 2 3 3 import "olexsmir.xyz/clerk/internal/analyzer" 4 4 5 +const EmptyPostingsID RuleID = "empty-postings" 6 + 5 7 // EmptyPostings flags transactions that have no postings. 6 8 type EmptyPostings struct{} 7 9 8 -func (EmptyPostings) ID() RuleID { return "empty-postings" } 9 -func (EmptyPostings) Severity() Severity { return SeverityError } 10 +func (EmptyPostings) ID() RuleID { return EmptyPostingsID } 10 11 func (e *EmptyPostings) CheckJournal(an *analyzer.Analysis) []Find { 11 12 var finds []Find 12 13 for _, txn := range an.Transactions { 13 14 if len(txn.Postings) == 0 { 14 15 finds = append(finds, Find{ 15 - Code: e.ID(), 16 - Severity: e.Severity(), 17 - Message: "transaction has no postings", 18 - Span: txn.Span, 16 + Code: e.ID(), 17 + Message: "transaction has no postings", 18 + Span: txn.Span, 19 19 }) 20 20 } 21 21 }
M
internal/linter/rule_invalid_date_tag.go
··· 7 7 "olexsmir.xyz/clerk/journal/parser" 8 8 ) 9 9 10 +const InvalidDateTagID RuleID = "invalid-date-tag" 11 + 10 12 // InvalidDateTag flags date: and date2: tag values that are not valid dates. 11 13 type InvalidDateTag struct{} 12 14 13 -func (InvalidDateTag) ID() RuleID { return "invalid-date-tag" } 14 -func (InvalidDateTag) Severity() Severity { return SeverityError } 15 +func (InvalidDateTag) ID() RuleID { return InvalidDateTagID } 15 16 func (i *InvalidDateTag) CheckJournal(an *analyzer.Analysis) []Find { 16 17 var finds []Find 17 18 for _, name := range []string{"date", "date2"} { ··· 22 23 for _, usage := range info.Usage { 23 24 if _, _, _, _, err := parser.ParseDateLiteral(usage.Tag.Value); err != nil { 24 25 finds = append(finds, Find{ 25 - Code: i.ID(), 26 - Severity: i.Severity(), 27 - Span: usage.Tag.Span, 28 - Message: fmt.Sprintf("invalid %s: tag value %q", name, usage.Tag.Value), 26 + Code: i.ID(), 27 + Span: usage.Tag.Span, 28 + Message: fmt.Sprintf("invalid %s: tag value %q", name, usage.Tag.Value), 29 29 }) 30 30 } 31 31 }
M
internal/linter/rule_invalid_include.go
··· 9 9 "olexsmir.xyz/clerk/journal/ast" 10 10 ) 11 11 12 +const InvalidIncludeID RuleID = "invalid-include" 13 + 12 14 // InvalidInclude flags include directives that don't point to an existing journal file. 13 15 type InvalidInclude struct{} 14 16 15 -func (InvalidInclude) ID() RuleID { return "invalid-include" } 16 -func (InvalidInclude) Severity() Severity { return SeverityError } 17 +func (InvalidInclude) ID() RuleID { return InvalidIncludeID } 17 18 func (i *InvalidInclude) CheckJournal(an *analyzer.Analysis) []Find { 18 19 var finds []Find 19 20 for _, pf := range an.Files { ··· 26 27 27 28 if !i.resolved(target, an.Files) { 28 29 finds = append(finds, Find{ 29 - Code: i.ID(), 30 - Severity: i.Severity(), 31 - Message: fmt.Sprintf("include not found: %s", inc.Path), 32 - Span: inc.Span, 30 + Code: i.ID(), 31 + Message: fmt.Sprintf("include not found: %s", inc.Path), 32 + Span: inc.Span, 33 33 }) 34 34 continue 35 35 } 36 36 if !journal.IsJournalFile(target) { 37 37 finds = append(finds, Find{ 38 - Code: i.ID(), 39 - Severity: i.Severity(), 40 - Message: fmt.Sprintf("include is not a journal file: %s", inc.Path), 41 - Span: inc.Span, 38 + Code: i.ID(), 39 + Message: fmt.Sprintf("include is not a journal file: %s", inc.Path), 40 + Span: inc.Span, 42 41 }) 43 42 } 44 43 }
M
internal/linter/rule_invalid_type_tag.go
··· 8 8 "olexsmir.xyz/clerk/journal/ast" 9 9 ) 10 10 11 +const InvalidTypeTagID RuleID = "invalid-type-tag" 12 + 11 13 // InvalidTypeTag flags account type declarations (the type: tag and the type 12 14 // subdirective) whose value is not a valid account type code. 13 15 type InvalidTypeTag struct{} 14 16 15 -func (InvalidTypeTag) ID() RuleID { return "invalid-type-tag" } 16 -func (InvalidTypeTag) Severity() Severity { return SeverityError } 17 +func (InvalidTypeTag) ID() RuleID { return InvalidTypeTagID } 17 18 func (i *InvalidTypeTag) CheckJournal(an *analyzer.Analysis) []Find { 18 19 var finds []Find 19 20 for _, entry := range an.Directives { ··· 28 29 } 29 30 if err := i.parseAccountTypeCode(tag.Value); err != nil { 30 31 finds = append(finds, Find{ 31 - Code: i.ID(), 32 - Severity: i.Severity(), 33 - Span: tag.Span, 34 - Message: fmt.Sprintf("invalid type: tag value %q", tag.Value), 32 + Code: i.ID(), 33 + Span: tag.Span, 34 + Message: fmt.Sprintf("invalid type: tag value %q", tag.Value), 35 35 }) 36 36 } 37 37 } ··· 43 43 } 44 44 if err := i.parseAccountTypeCode(sd.Value); err != nil { 45 45 finds = append(finds, Find{ 46 - Code: i.ID(), 47 - Severity: i.Severity(), 48 - Span: sd.ValueSpan, 49 - Message: fmt.Sprintf("invalid type subdirective value %q", sd.Value), 46 + Code: i.ID(), 47 + Span: sd.ValueSpan, 48 + Message: fmt.Sprintf("invalid type subdirective value %q", sd.Value), 50 49 }) 51 50 } 52 51 }
M
internal/linter/rule_missing_commodity.go
··· 5 5 "olexsmir.xyz/clerk/journal/ast" 6 6 ) 7 7 8 +const MissingCommodityID RuleID = "missing-commodity" 9 + 8 10 // MissingCommodity flags amounts with a missing commodity. 9 11 type MissingCommodity struct{} 10 12 11 -func (MissingCommodity) ID() RuleID { return "missing-commodity" } 12 -func (MissingCommodity) Severity() Severity { return SeverityWarning } 13 +func (MissingCommodity) ID() RuleID { return MissingCommodityID } 13 14 func (m *MissingCommodity) CheckJournal(an *analyzer.Analysis) []Find { 14 15 var finds []Find 15 16 ··· 63 64 func (m *MissingCommodity) check(finds *[]Find, am ast.Amount) { 64 65 if am.Commodity == "" { 65 66 *finds = append(*finds, Find{ 66 - Code: m.ID(), 67 - Severity: m.Severity(), 68 - Message: "amount missing commodity", 69 - Span: am.Span, 67 + Code: m.ID(), 68 + Message: "amount missing commodity", 69 + Span: am.Span, 70 70 }) 71 71 } 72 72 }
M
internal/linter/rule_missing_payee.go
··· 2 2 3 3 import "olexsmir.xyz/clerk/internal/analyzer" 4 4 5 +const MissingPayeeID RuleID = "missing-payee" 6 + 5 7 // MissingPayee flags transactions with missing payee. 6 8 type MissingPayee struct{} 7 9 8 -func (MissingPayee) ID() RuleID { return "missing-payee" } 9 -func (MissingPayee) Severity() Severity { return SeverityWarning } 10 +func (MissingPayee) ID() RuleID { return MissingPayeeID } 10 11 func (m *MissingPayee) CheckJournal(an *analyzer.Analysis) []Find { 11 12 var finds []Find 12 13 for _, txn := range an.Transactions { 13 14 if txn.Payee == nil { 14 15 finds = append(finds, Find{ 15 - Code: m.ID(), 16 - Severity: m.Severity(), 17 - Message: "transaction has no payee", 18 - Span: txn.Date.Span, 16 + Code: m.ID(), 17 + Message: "transaction has no payee", 18 + Span: txn.Date.Span, 19 19 }) 20 20 } 21 21 }
M
internal/linter/rule_missing_status.go
··· 5 5 "olexsmir.xyz/clerk/journal/ast" 6 6 ) 7 7 8 +const MissingStatusID RuleID = "missing-status" 9 + 8 10 // MissingStatus flags transactions with missing status. 9 11 type MissingStatus struct{} 10 12 11 -func (MissingStatus) ID() RuleID { return "missing-status" } 12 -func (MissingStatus) Severity() Severity { return SeverityWarning } 13 +func (MissingStatus) ID() RuleID { return MissingStatusID } 13 14 func (m *MissingStatus) CheckJournal(an *analyzer.Analysis) []Find { 14 15 var finds []Find 15 16 for _, txn := range an.Transactions { 16 17 if txn.Status.Value == ast.StatusNone { 17 18 finds = append(finds, Find{ 18 - Code: m.ID(), 19 - Severity: m.Severity(), 20 - Message: "transaction has no status", 21 - Span: txn.Status.Span, 19 + Code: m.ID(), 20 + Message: "transaction has no status", 21 + Span: txn.Status.Span, 22 22 }) 23 23 } 24 24 }
M
internal/linter/rule_multiple_omitted_amounts.go
··· 5 5 "olexsmir.xyz/clerk/journal/ast" 6 6 ) 7 7 8 +const MultipleOmittedAmountsID RuleID = "multiple-omitted-amounts" 9 + 8 10 // MultipleOmittedAmounts flags entries where more than one posting has an ommited amount. 9 11 type MultipleOmittedAmounts struct{} 10 12 11 -func (MultipleOmittedAmounts) ID() RuleID { return "multiple-omitted-amounts" } 12 -func (MultipleOmittedAmounts) Severity() Severity { return SeverityError } 13 +func (MultipleOmittedAmounts) ID() RuleID { return MultipleOmittedAmountsID } 13 14 func (m *MultipleOmittedAmounts) CheckJournal(an *analyzer.Analysis) []Find { 14 15 var finds []Find 15 16 for _, txn := range an.Transactions { ··· 29 30 for _, p := range postings { 30 31 if p.Amount == nil && p.Balance == nil { 31 32 finds = append(finds, Find{ 32 - Code: m.ID(), 33 - Severity: m.Severity(), 34 - Message: "more than one posting has omitted amount", 35 - Span: p.Span, 33 + Code: m.ID(), 34 + Message: "more than one posting has omitted amount", 35 + Span: p.Span, 36 36 }) 37 37 } 38 38 }
M
internal/linter/rule_omitted_precision.go
··· 2 2 3 3 import "olexsmir.xyz/clerk/internal/analyzer" 4 4 5 +const OmittedPrecisionID RuleID = "omitted-precision" 6 + 5 7 // OmittedPrecision flags amounts with insufficient decimal precision (<2 digits). 6 8 type OmittedPrecision struct{} 7 9 8 -func (OmittedPrecision) ID() RuleID { return "omitted-precision" } 9 -func (OmittedPrecision) Severity() Severity { return SeverityWarning } 10 +func (OmittedPrecision) ID() RuleID { return OmittedPrecisionID } 10 11 func (o *OmittedPrecision) CheckJournal(an *analyzer.Analysis) []Find { 11 12 var finds []Find 12 13 for _, txn := range an.Transactions { ··· 16 17 } 17 18 if posting.Amount.QuantityFmt.Precision < 2 { 18 19 finds = append(finds, Find{ 19 - Code: o.ID(), 20 - Severity: o.Severity(), 21 - Message: "amount has insufficient precision", 22 - Span: posting.Amount.Span, 20 + Code: o.ID(), 21 + Message: "amount has insufficient precision", 22 + Span: posting.Amount.Span, 23 23 }) 24 24 } 25 25 }
M
internal/linter/rule_orderdate.go
··· 7 7 "olexsmir.xyz/clerk/journal/ast" 8 8 ) 9 9 10 +const OrderDateID RuleID = "orderdate" 11 + 10 12 // OrderDate checks that transactions are in chronological order by date. 11 13 type OrderDate struct{} 12 14 13 -func (OrderDate) ID() RuleID { return "orderdate" } 14 -func (OrderDate) Severity() Severity { return SeverityWarning } 15 +func (OrderDate) ID() RuleID { return OrderDateID } 15 16 func (o *OrderDate) CheckJournal(an *analyzer.Analysis) []Find { 16 17 var finds []Find 17 18 var anchor *ast.Date ··· 23 24 } 24 25 if anchor != nil && txn.Date.Compare(*anchor) < 0 { 25 26 finds = append(finds, Find{ 26 - Code: o.ID(), 27 - Severity: o.Severity(), 28 - Message: fmt.Sprintf("transaction is out of chronological order (date %s before %s)", txn.Date, *anchor), 29 - Span: txn.Date.Span, 27 + Code: o.ID(), 28 + Message: fmt.Sprintf("transaction is out of chronological order (date %s before %s)", txn.Date, *anchor), 29 + Span: txn.Date.Span, 30 30 }) 31 31 continue 32 32 }
M
internal/linter/rule_parse_error.go
··· 2 2 3 3 import "olexsmir.xyz/clerk/internal/analyzer" 4 4 5 +const ParseErrorID RuleID = "parse-error" 6 + 5 7 // ParseError wraps parser errors into lint findings. 6 8 type ParseError struct{} 7 9 8 -func (ParseError) ID() RuleID { return "parse-error" } 9 -func (ParseError) Severity() Severity { return SeverityError } 10 +func (ParseError) ID() RuleID { return ParseErrorID } 10 11 func (p *ParseError) CheckJournal(an *analyzer.Analysis) []Find { 11 12 var finds []Find 12 13 for _, pf := range an.Files { 13 14 for _, err := range pf.Errors { 14 15 finds = append(finds, Find{ 15 - Code: p.ID(), 16 - Severity: p.Severity(), 17 - Message: err.Message, 18 - Span: err.Span, 16 + Code: p.ID(), 17 + Message: err.Message, 18 + Span: err.Span, 19 19 }) 20 20 } 21 21 }
M
internal/linter/rule_unbalanced_transaction.go
··· 9 9 "olexsmir.xyz/clerk/journal/token" 10 10 ) 11 11 12 +const UnbalancedTransactionID RuleID = "unbalanced-transaction" 13 + 12 14 // UnbalancedTransaction flags transactions whose postings don't balance to zero. 13 15 type UnbalancedTransaction struct{} 14 16 15 -func (UnbalancedTransaction) ID() RuleID { return "unbalanced-transaction" } 16 -func (UnbalancedTransaction) Severity() Severity { return SeverityError } 17 +func (UnbalancedTransaction) ID() RuleID { return UnbalancedTransactionID } 17 18 func (u *UnbalancedTransaction) CheckJournal(an *analyzer.Analysis) []Find { 18 19 var finds []Find 19 20 for _, txn := range an.Transactions { ··· 75 76 msg = fmt.Sprintf("transaction is unbalanced; net balance is %s", sum.String()) 76 77 } 77 78 finds = append(finds, Find{ 78 - Code: u.ID(), 79 - Severity: u.Severity(), 80 - Span: span, 81 - Message: msg, 79 + Code: u.ID(), 80 + Span: span, 81 + Message: msg, 82 82 }) 83 83 } 84 84 }
M
internal/linter/rule_undeclared_account.go
··· 1 1 package linter 2 2 3 -import ( 4 - "olexsmir.xyz/clerk/internal/analyzer" 5 -) 3 +import "olexsmir.xyz/clerk/internal/analyzer" 4 + 5 +const UndeclaredAccountID RuleID = "undeclared-account" 6 6 7 7 // UndeclaredAccount flags postings that reference an account not declared via `account` directive. 8 8 type UndeclaredAccount struct{} 9 9 10 -func (UndeclaredAccount) ID() RuleID { return "undeclared-account" } 11 -func (UndeclaredAccount) Severity() Severity { return SeverityWarning } 10 +func (UndeclaredAccount) ID() RuleID { return UndeclaredAccountID } 12 11 func (u *UndeclaredAccount) CheckJournal(an *analyzer.Analysis) []Find { 13 12 var finds []Find 14 13 for name, info := range an.Accounts { ··· 20 19 } 21 20 for _, usage := range info.Usages { 22 21 finds = append(finds, Find{ 23 - Code: u.ID(), 24 - Severity: u.Severity(), 25 - Span: usage.Posting.Account.Span, 26 - Message: "undeclared account: " + name, 22 + Code: u.ID(), 23 + Span: usage.Posting.Account.Span, 24 + Message: "undeclared account: " + name, 27 25 }) 28 26 } 29 27 }
M
internal/linter/rule_undeclared_commodity.go
··· 1 1 package linter 2 2 3 -import ( 4 - "olexsmir.xyz/clerk/internal/analyzer" 5 -) 3 +import "olexsmir.xyz/clerk/internal/analyzer" 4 + 5 +const UndeclaredCommodityID RuleID = "undeclared-commodity" 6 6 7 7 // UndeclaredCommodity flags amounts that reference a commodity not declared via `commodity` directive. 8 8 type UndeclaredCommodity struct{} 9 9 10 -func (UndeclaredCommodity) ID() RuleID { return "undeclared-commodity" } 11 -func (UndeclaredCommodity) Severity() Severity { return SeverityWarning } 10 +func (UndeclaredCommodity) ID() RuleID { return UndeclaredCommodityID } 12 11 func (u *UndeclaredCommodity) CheckJournal(an *analyzer.Analysis) []Find { 13 12 var finds []Find 14 13 for name, info := range an.Commodities { ··· 17 16 } 18 17 for _, usage := range info.Usages { 19 18 finds = append(finds, Find{ 20 - Code: u.ID(), 21 - Severity: u.Severity(), 22 - Span: usage.Amount.Span, 23 - Message: "undeclared commodity: " + name, 19 + Code: u.ID(), 20 + Span: usage.Amount.Span, 21 + Message: "undeclared commodity: " + name, 24 22 }) 25 23 } 26 24 }
M
internal/linter/rule_undeclared_payee.go
··· 1 1 package linter 2 2 3 -import ( 4 - "olexsmir.xyz/clerk/internal/analyzer" 5 -) 3 +import "olexsmir.xyz/clerk/internal/analyzer" 4 + 5 +const UndeclaredPayeeID RuleID = "undeclared-payee" 6 6 7 7 // UndeclaredPayee flags transactions that reference a payee not declared via `payee` directive. 8 8 type UndeclaredPayee struct{} 9 9 10 -func (UndeclaredPayee) ID() RuleID { return "undeclared-payee" } 11 -func (UndeclaredPayee) Severity() Severity { return SeverityWarning } 10 +func (UndeclaredPayee) ID() RuleID { return UndeclaredPayeeID } 12 11 func (u *UndeclaredPayee) CheckJournal(an *analyzer.Analysis) []Find { 13 12 var finds []Find 14 13 for name, info := range an.Payees { ··· 20 19 } 21 20 for _, usage := range info.Usage { 22 21 finds = append(finds, Find{ 23 - Code: u.ID(), 24 - Severity: u.Severity(), 25 - Span: usage.Payee.Span, 26 - Message: "undeclared payee: " + name, 22 + Code: u.ID(), 23 + Span: usage.Payee.Span, 24 + Message: "undeclared payee: " + name, 27 25 }) 28 26 } 29 27 }
M
internal/linter/rule_undeclared_tag.go
··· 6 6 "olexsmir.xyz/clerk/internal/analyzer" 7 7 ) 8 8 9 +const UndeclaredTagID RuleID = "undeclared-tag" 10 + 9 11 // UndeclaredTag flags used tag that's not declared via `tag` directive. 10 12 type UndeclaredTag struct{} 11 13 12 -func (UndeclaredTag) ID() RuleID { return "undeclared-tag" } 13 -func (UndeclaredTag) Severity() Severity { return SeverityWarning } 14 +func (UndeclaredTag) ID() RuleID { return UndeclaredTagID } 14 15 func (u *UndeclaredTag) CheckJournal(an *analyzer.Analysis) []Find { 15 16 var finds []Find 16 17 for name, info := range an.Tags { ··· 23 24 } 24 25 for _, usage := range info.Usage { 25 26 finds = append(finds, Find{ 26 - Code: u.ID(), 27 - Severity: u.Severity(), 28 - Span: usage.Tag.Span, 29 - Message: fmt.Sprintf("undeclared tag: %s", name), 27 + Code: u.ID(), 28 + Span: usage.Tag.Span, 29 + Message: fmt.Sprintf("undeclared tag: %s", name), 30 30 }) 31 31 } 32 32 }
M
internal/linter/rule_unused_account.go
··· 6 6 "olexsmir.xyz/clerk/internal/analyzer" 7 7 ) 8 8 9 +const UnusedAccountID RuleID = "unused-account" 10 + 9 11 // UnusedAccount flags declared accounts that are not used. 10 12 type UnusedAccount struct{} 11 13 12 -func (UnusedAccount) ID() RuleID { return "unused-account" } 13 -func (UnusedAccount) Severity() Severity { return SeverityWarning } 14 +func (UnusedAccount) ID() RuleID { return UnusedAccountID } 14 15 func (u *UnusedAccount) CheckJournal(an *analyzer.Analysis) []Find { 15 16 var finds []Find 16 17 for name, info := range an.Accounts { ··· 25 26 } 26 27 for _, d := range info.Directives { 27 28 finds = append(finds, Find{ 28 - Code: u.ID(), 29 - Severity: u.Severity(), 30 - Span: d.Account.Span, 31 - Message: fmt.Sprintf("unused account: %s", name), 29 + Code: u.ID(), 30 + Span: d.Account.Span, 31 + Message: fmt.Sprintf("unused account: %s", name), 32 32 }) 33 33 } 34 34 }
M
internal/linter/rule_unused_tag.go
··· 6 6 "olexsmir.xyz/clerk/internal/analyzer" 7 7 ) 8 8 9 +const UnusedTagID RuleID = "unused-tag" 10 + 9 11 // UnusedTag flags declared tags that are not used. 10 12 type UnusedTag struct{} 11 13 12 -func (UnusedTag) ID() RuleID { return "unused-tag" } 13 -func (UnusedTag) Severity() Severity { return SeverityWarning } 14 +func (UnusedTag) ID() RuleID { return UnusedTagID } 14 15 func (u *UnusedTag) CheckJournal(an *analyzer.Analysis) []Find { 15 16 var finds []Find 16 17 for name, info := range an.Tags { ··· 22 23 } 23 24 for _, d := range info.Directives { 24 25 finds = append(finds, Find{ 25 - Code: u.ID(), 26 - Severity: u.Severity(), 27 - Span: d.Span, 28 - Message: fmt.Sprintf("unused tag: %s", name), 26 + Code: u.ID(), 27 + Span: d.Span, 28 + Message: fmt.Sprintf("unused tag: %s", name), 29 29 }) 30 30 } 31 31 }
M
internal/linter/rules.go
··· 1 1 package linter 2 2 3 -import "olexsmir.xyz/clerk/internal/analyzer" 3 +import ( 4 + "encoding/json" 5 + 6 + "olexsmir.xyz/clerk/internal/analyzer" 7 +) 4 8 5 9 type RuleID string 6 10 7 11 // Rule is the best interface that every rule must implement. 8 12 type Rule interface { 9 13 ID() RuleID 10 - Severity() Severity 11 14 CheckJournal(an *analyzer.Analysis) []Find 12 15 } 13 16 14 -// Rules is list of all available rules. 15 -var Rules = []Rule{ 16 - &ParseError{}, 17 - &InvalidInclude{}, 18 - &EmptyPostings{}, 19 - &OmittedPrecision{}, 20 - &MissingCommodity{}, 21 - &MissingStatus{}, 22 - &MissingPayee{}, 23 - &AccountDepthLimit{MaxDepth: 4}, 24 - &MultipleOmittedAmounts{}, 25 - &OrderDate{}, 26 - &DuplicatedAccount{}, 27 - &DuplicatedCommodity{}, 28 - &DuplicatedTransaction{}, 29 - &DuplicatedTag{}, 30 - &UndeclaredCommodity{}, 31 - &UndeclaredAccount{}, 32 - &UndeclaredPayee{}, 33 - &UnbalancedTransaction{}, 34 - &UndeclaredTag{}, 35 - &InvalidDateTag{}, 36 - &InvalidTypeTag{}, 37 - &UnusedAccount{}, 38 - &UnusedTag{}, 17 +type RuleConfig struct { 18 + // Disabled turns the rule off entirely. 19 + Disabled bool 20 + 21 + // Severity overrides the rule's default severity level. 22 + Severity Severity // [SeverityNone] if [Disabled] is true 23 + 24 + // Options holds JSON-encoded options, already validated against the rule. 25 + Options json.RawMessage // TODO: check if there's better options than json 26 +} 27 + 28 +type RuleOptioner interface { 29 + UnmarshalOptions(data json.RawMessage) error 30 + Clone() Rule 31 +} 32 + 33 +type builtinRule struct { 34 + Rule Rule 35 + Severity Severity 36 +} 37 + 38 +// Rules maps every rule ID to its implementation and default severity. 39 +var Rules = map[RuleID]builtinRule{ 40 + AccountDepthLimitID: {&AccountDepthLimit{MaxDepth: 4}, SeverityWarning}, 41 + DuplicatedAccountID: {&DuplicatedAccount{}, SeverityWarning}, 42 + DuplicatedCommodityID: {&DuplicatedCommodity{}, SeverityWarning}, 43 + DuplicatedTagID: {&DuplicatedTag{}, SeverityWarning}, 44 + DuplicatedTransactionID: {&DuplicatedTransaction{}, SeverityWarning}, 45 + EmptyPostingsID: {&EmptyPostings{}, SeverityError}, 46 + InvalidDateTagID: {&InvalidDateTag{}, SeverityError}, 47 + InvalidIncludeID: {&InvalidInclude{}, SeverityError}, 48 + InvalidTypeTagID: {&InvalidTypeTag{}, SeverityError}, 49 + MissingCommodityID: {&MissingCommodity{}, SeverityWarning}, 50 + MissingPayeeID: {&MissingPayee{}, SeverityWarning}, 51 + MissingStatusID: {&MissingStatus{}, SeverityWarning}, 52 + MultipleOmittedAmountsID: {&MultipleOmittedAmounts{}, SeverityError}, 53 + OmittedPrecisionID: {&OmittedPrecision{}, SeverityWarning}, 54 + OrderDateID: {&OrderDate{}, SeverityWarning}, 55 + ParseErrorID: {&ParseError{}, SeverityError}, 56 + UnbalancedTransactionID: {&UnbalancedTransaction{}, SeverityError}, 57 + UndeclaredAccountID: {&UndeclaredAccount{}, SeverityWarning}, 58 + UndeclaredCommodityID: {&UndeclaredCommodity{}, SeverityWarning}, 59 + UndeclaredPayeeID: {&UndeclaredPayee{}, severityNone}, 60 + UndeclaredTagID: {&UndeclaredTag{}, severityNone}, 61 + UnusedAccountID: {&UnusedAccount{}, SeverityWarning}, 62 + UnusedTagID: {&UnusedTag{}, SeverityWarning}, 39 63 }
D
internal/lsp/config.go
··· 1 -package lsp 2 - 3 -import ( 4 - "github.com/go-json-experiment/json" 5 - "go.lsp.dev/protocol" 6 -) 7 - 8 -type Config struct { 9 - SemanticHighlighting bool 10 - 11 - // LatinToCyrillicCompletion matches Latin input against Cyrillic labels. 12 - LatinToCyrillicCompletion bool 13 - 14 - // TODO: Formatter 15 - // TODO: Linter 16 -} 17 - 18 -var DefaultConfig = Config{ 19 - SemanticHighlighting: true, 20 - LatinToCyrillicCompletion: false, 21 -} 22 - 23 -func (c *Config) merge(v protocol.LSPAny) error { 24 - if len(v) == 0 { // no settings provided 25 - return nil 26 - } 27 - 28 - var patch struct { 29 - SemanticHighlighting *bool `json:"semantic_highlighting,case:ignore"` 30 - LatinToCyrillicCompletion *bool `json:"latin_to_cyrillic_completion,case:ignore"` 31 - } 32 - 33 - if err := json.Unmarshal(v, &patch); err != nil { 34 - return err 35 - } 36 - if patch.SemanticHighlighting != nil { 37 - c.SemanticHighlighting = *patch.SemanticHighlighting 38 - } 39 - if patch.LatinToCyrillicCompletion != nil { 40 - c.LatinToCyrillicCompletion = *patch.LatinToCyrillicCompletion 41 - } 42 - return nil 43 -} 44 - 45 -func (s *server) semanticHighlightingEnabled() bool { 46 - s.mu.RLock() 47 - defer s.mu.RUnlock() 48 - return s.config.SemanticHighlighting 49 -} 50 - 51 -func (s *server) latinToCyrillicCompletionEnabled() bool { 52 - s.mu.RLock() 53 - defer s.mu.RUnlock() 54 - return s.config.LatinToCyrillicCompletion 55 -}
D
internal/lsp/config_test.go
··· 1 -package lsp 2 - 3 -import ( 4 - "testing" 5 - 6 - "go.lsp.dev/protocol" 7 -) 8 - 9 -func TestConfig_Merge(t *testing.T) { 10 - tests := map[string]struct { 11 - in string 12 - cfg, want Config 13 - }{ 14 - "if not set, uses default": {`{"semanticHighlighting": true}`, Config{}, Config{SemanticHighlighting: true}}, 15 - "empty object keeps defaults": {`{}`, DefaultConfig, Config{SemanticHighlighting: true}}, 16 - "nil settings keep defaults": {"", DefaultConfig, Config{SemanticHighlighting: true}}, 17 - 18 - "disables provided option": {`{"semanticHighlighting": false}`, DefaultConfig, Config{SemanticHighlighting: false}}, 19 - "snake_case key": {`{"latin_to_cyrillic_completion": true}`, DefaultConfig, Config{LatinToCyrillicCompletion: true, SemanticHighlighting: true}}, 20 - "kebab-case key": {`{"semantic-highlighting": false}`, DefaultConfig, Config{SemanticHighlighting: false}}, 21 - 22 - "malformed settings ignored": {`{`, DefaultConfig, DefaultConfig}, 23 - "non-object settings ignored": {`"clerk"`, DefaultConfig, DefaultConfig}, 24 - "unknown fields ignored": {`{"lint": true}`, DefaultConfig, DefaultConfig}, 25 - } 26 - for name, tt := range tests { 27 - t.Run(name, func(t *testing.T) { 28 - tt.cfg.merge(protocol.LSPAny(tt.in)) 29 - if tt.cfg != tt.want { 30 - t.Errorf("merge(%q) = %+v, want %+v", tt.in, tt.cfg, tt.want) 31 - } 32 - }) 33 - } 34 -} 35 - 36 -func TestServer_Intialize_config(t *testing.T) { 37 - srv := NewServer("test") 38 - res, err := srv.server.Initialize(t.Context(), &protocol.InitializeParams{ 39 - InitializationOptions: protocol.LSPAny(`{"semanticHighlighting": false}`), 40 - }) 41 - if err != nil { 42 - t.Fatal(err) 43 - } 44 - if srv.server.semanticHighlightingEnabled() { 45 - t.Error("semanticHighlighting=false in initializationOptions not applied") 46 - } 47 - if res.Capabilities.SemanticTokensProvider == nil { 48 - t.Error("SemanticTokensProvider must always be advertised") 49 - } 50 -}
M
internal/lsp/diagnostics.go
··· 36 36 func (s *server) publishDiagnostics(ctx context.Context) { 37 37 s.log.Debug("publishing diagnostics") 38 38 39 - if ctx.Err() != nil { 40 - return 41 - } 42 - 43 39 s.mu.RLock() 44 40 var dirtyURIs []uri.URI 45 41 for u, state := range s.openDocs { ··· 55 51 56 52 // Rebuild every dirty doc and publish the union of their files; 57 53 // the same included file may appear in several trees and must be published once. 54 + s.mu.RLock() 55 + lintCfg := s.settings.Linter 56 + s.mu.RUnlock() 57 + lint, err := linter.NewLinter(lintCfg) 58 + if err != nil { 59 + s.log.Warn("building linter failed", "err", err) 60 + return 61 + } 62 + 58 63 var finds []linter.Find 59 64 paths := make(map[string]bool) 60 65 for _, u := range dirtyURIs { ··· 65 70 for _, pf := range a.Files { 66 71 paths[pf.Path] = true 67 72 } 68 - finds = append(finds, s.linter.Run(a)...) 73 + finds = append(finds, lint.Run(a)...) 69 74 } 70 75 71 76 if ctx.Err() != nil { 72 77 return 73 78 } 74 79 80 + s.assignSeverities(finds) 75 81 diagsByFile := s.groupFindsByFile(dedupFinds(finds)) 76 82 for fpath := range paths { 77 - if ctx.Err() != nil { 78 - return 79 - } 80 83 if err := s.client.PublishDiagnostics(ctx, &protocol.PublishDiagnosticsParams{ 81 84 URI: uri.File(fpath), 82 85 Diagnostics: diagsByFile[fpath], ··· 135 138 return dedup 136 139 } 137 140 138 -// findKey identifies a find by its position and rule; a struct key avoids a 139 -// per-find fmt.Sprintf. 140 141 type findKey struct { 141 142 file string 142 143 line, col int ··· 153 154 Line: max(0, uint32(span.End.Line-1)), 154 155 Character: uint32(max(0, span.End.Col-1)), 155 156 }, 157 + } 158 +} 159 + 160 +func (s *server) assignSeverities(finds []linter.Find) { 161 + s.mu.RLock() 162 + l := s.settings.Linter 163 + s.mu.RUnlock() 164 + for i := range finds { 165 + finds[i].Severity = l.SeverityFor(finds[i].Code) 156 166 } 157 167 } 158 168
M
internal/lsp/diagnostics_test.go
··· 4 4 "testing" 5 5 6 6 "olexsmir.xyz/clerk/internal/analyzer" 7 + "olexsmir.xyz/clerk/internal/linter" 7 8 "olexsmir.xyz/clerk/journal" 8 9 ) 9 10 10 11 func BenchmarkDiagnostics(b *testing.B) { 11 12 content := openJournal(b, "../../journal/testdata/journals/actual-1ktxns-100accts.journal") 12 - srv := NewServer("test") 13 + srv := newServer(b) 14 + 15 + lint, err := linter.NewLinter(srv.server.settings.Linter) 16 + if err != nil { 17 + b.Fatal(err) 18 + } 13 19 14 20 // Per-edit cost: a fresh loader skips the parse cache, so each iteration re-parses, then lints and groups findings. 15 21 b.ReportAllocs() 16 22 b.ResetTimer() 17 23 for b.Loop() { 18 24 an := analyzer.Build(journal.NewLoader().ResolveBytes("/test.journal", []byte(content))) 19 - finds := dedupFinds(srv.server.linter.Run(an)) 25 + finds := dedupFinds(lint.Run(an)) 26 + srv.server.assignSeverities(finds) 20 27 _ = srv.server.groupFindsByFile(finds) 21 28 } 22 29 }
M
internal/lsp/lsp.go
··· 11 11 "go.lsp.dev/protocol" 12 12 "go.lsp.dev/uri" 13 13 14 - "olexsmir.xyz/clerk/internal/linter" 14 + "olexsmir.xyz/clerk/internal/settings" 15 15 "olexsmir.xyz/clerk/internal/xdg" 16 16 "olexsmir.xyz/clerk/journal" 17 - "olexsmir.xyz/clerk/journal/printer" 18 17 ) 19 18 20 19 type Server struct{ server *server } 21 20 22 -func NewServer(version string) Server { 21 +func NewServer(version string, configPath string) (Server, error) { 23 22 logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) 24 23 if logFile, err := openLogFile(); err == nil { 25 24 logger = slog.New(slog.NewTextHandler(logFile, nil)) ··· 31 30 32 31 openDocs: make(map[uri.URI]docState), 33 32 34 - config: DefaultConfig, 35 - linter: linter.NewLinter(linter.Rules), 36 - loader: journal.NewLoader(), 37 - printer: printer.DefaultConfig, 33 + settings: settings.DefaultConfig, 34 + configPath: configPath, 35 + loader: journal.NewLoader(), 38 36 39 37 log: logger, 40 38 } 41 39 srv.loader.ContentProvider = srv.bufferContent 42 - return Server{srv} 40 + return Server{srv}, nil 43 41 } 44 42 45 43 func (s *Server) Run(ctx context.Context, stdin io.ReadCloser, stdout io.WriteCloser) error {
M
internal/lsp/server.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "errors" 6 + "fmt" 5 7 "log/slog" 8 + "os" 6 9 "sync" 7 10 11 + "github.com/go-json-experiment/json" 12 + "github.com/pelletier/go-toml/v2" 8 13 "go.lsp.dev/protocol" 9 14 "go.lsp.dev/uri" 10 15 11 16 "olexsmir.xyz/clerk/internal/analyzer" 12 - "olexsmir.xyz/clerk/internal/linter" 17 + "olexsmir.xyz/clerk/internal/settings" 13 18 "olexsmir.xyz/clerk/journal" 14 - "olexsmir.xyz/clerk/journal/printer" 15 19 ) 16 20 17 21 type server struct { ··· 22 26 23 27 version, name string 24 28 25 - linter *linter.Linter 26 - loader *journal.Loader 27 - printer *printer.Config 29 + settings settings.Settings 30 + loader *journal.Loader 28 31 29 32 mu sync.RWMutex 30 - config Config 31 33 openDocs map[uri.URI]docState 32 34 diagCancel context.CancelFunc 33 35 dynFileWather bool 36 + configPath string 34 37 } 35 38 36 39 // analysisFor returns the cached analysis for an open doc, rebuilds when the doc or a file it inclues changed. ··· 77 80 } 78 81 } 79 82 80 - s.applySettings(params.InitializationOptions) 83 + if err := s.applySettings(ctx, params.InitializationOptions); err != nil { 84 + return nil, err 85 + } 81 86 full := protocol.SemanticTokensOptionsFull(protocol.Boolean(true)) 82 87 if td := params.Capabilities.TextDocument; td != nil { 83 88 if fd, ok := td.SemanticTokens.Requests.Full.(*protocol.ClientSemanticTokensRequestFullDelta); ok && fd.Delta != nil && *fd.Delta { ··· 120 125 if s.dynFileWather { 121 126 go s.registerFileWatchers(context.Background()) 122 127 } 128 + s.applyConfigFile(ctx) 123 129 s.scheduleDiagnostics(ctx) 124 130 return nil 125 131 } ··· 142 148 } 143 149 144 150 func (s *server) DidChangeConfiguration(ctx context.Context, params *protocol.DidChangeConfigurationParams) error { 145 - s.applySettings(params.Settings) 146 - return nil 151 + return s.applySettings(ctx, params.Settings) 147 152 } 148 153 149 154 func (s *server) Shutdown(ctx context.Context) error { ··· 177 182 } 178 183 } 179 184 180 -func (s *server) applySettings(v protocol.LSPAny) { 185 +func (s *server) applySettings(ctx context.Context, v protocol.LSPAny) error { 186 + if len(v) == 0 { 187 + return nil 188 + } 189 + var raw map[string]any 190 + if err := json.Unmarshal(v, &raw); err != nil { 191 + return fmt.Errorf("invalid settings: %w", err) 192 + } 181 193 s.mu.Lock() 182 - if err := s.config.merge(v); err != nil { 183 - s.log.Error("failed to merge config", "err", err) 194 + warns, err := s.settings.ApplyLSP(raw) 195 + s.mu.Unlock() 196 + for _, w := range warns { 197 + s.reportConfigProblem(ctx, protocol.MessageTypeWarning, w) 198 + } 199 + return err 200 +} 201 + 202 +func (s *server) applyConfigFile(ctx context.Context) { 203 + data, err := os.ReadFile(s.configPath) 204 + if err != nil { 205 + if errors.Is(err, os.ErrNotExist) { 206 + return 207 + } 208 + s.reportConfigError(ctx, err) 209 + return 184 210 } 211 + var raw map[string]any 212 + if err := toml.Unmarshal(data, &raw); err != nil { 213 + s.reportConfigError(ctx, err) 214 + return 215 + } 216 + s.mu.Lock() 217 + warns, err := s.settings.Apply(raw) 185 218 s.mu.Unlock() 219 + if err != nil { 220 + s.reportConfigError(ctx, err) 221 + } 222 + for _, w := range warns { 223 + s.reportConfigProblem(ctx, protocol.MessageTypeWarning, w) 224 + } 225 +} 226 + 227 +func (s *server) reportConfigError(ctx context.Context, err error) { 228 + s.reportConfigProblem(ctx, protocol.MessageTypeError, "config "+s.configPath+": "+err.Error()) 229 +} 230 + 231 +func (s *server) reportConfigProblem(ctx context.Context, typ protocol.MessageType, msg string) { 232 + lvl := slog.LevelWarn 233 + if typ == protocol.MessageTypeError { 234 + lvl = slog.LevelError 235 + } 236 + s.log.Log(ctx, lvl, "config", "message", msg) 237 + if s.client == nil { 238 + return 239 + } 240 + if err := s.client.ShowMessage(ctx, &protocol.ShowMessageParams{Type: typ, Message: msg}); err != nil { 241 + s.log.Warn("window/showMessage failed", "err", err) 242 + } 243 +} 244 + 245 +func (s *server) semanticHighlightingEnabled() bool { 246 + s.mu.RLock() 247 + defer s.mu.RUnlock() 248 + return s.settings.SemanticHighlighting 249 +} 250 + 251 +func (s *server) latinToCyrillicCompletionEnabled() bool { 252 + s.mu.RLock() 253 + defer s.mu.RUnlock() 254 + return s.settings.LatinToCyrillicCompletion 186 255 }
M
internal/lsp/server_test.go
··· 4 4 "context" 5 5 "path/filepath" 6 6 "slices" 7 + "strings" 7 8 "sync" 8 9 "testing" 9 10 "time" ··· 11 12 "go.lsp.dev/protocol" 12 13 "go.lsp.dev/uri" 13 14 15 + "olexsmir.xyz/clerk/internal/linter" 14 16 "olexsmir.xyz/clerk/internal/testutil" 15 17 ) 16 18 17 19 func TestAnalysisFor_CachedAndRebuilt(t *testing.T) { 18 20 u := uri.File(filepath.Join(t.TempDir(), "a.journal")) 19 - srv := NewServer("test") 21 + srv := newServer(t) 20 22 srv.server.openDoc(u, "2024-01-01 t\n expenses:food $10\n assets:cash\n", 1, "journal") 21 23 22 24 a1 := srv.server.analysisFor(u) ··· 46 48 testutil.WriteFile(t, base, []byte("2024-01-01 t\n expenses:food $10\n assets:cash\n")) 47 49 testutil.WriteFile(t, main, []byte("include base.journal\n")) 48 50 49 - srv := NewServer("test") 51 + srv := newServer(t) 50 52 uMain, uBase := uri.File(main), uri.File(base) 51 53 srv.server.openDoc(uMain, "include base.journal\n", 1, "journal") 52 54 srv.server.openDoc(uBase, "2024-01-01 t\n expenses:food $10\n assets:cash\n", 1, "journal") ··· 75 77 testutil.WriteFile(t, a.Path(), []byte(aContent)) 76 78 testutil.WriteFile(t, b.Path(), []byte(bContent)) 77 79 78 - srv := NewServer("test") 80 + srv := newServer(t) 79 81 capture := &captureClient{} 80 82 srv.server.client = capture 81 83 ··· 128 130 base := filepath.Join(dir, "base.journal") 129 131 testutil.WriteFile(t, base, []byte("2024-01-01 t\n expenses:food $10\n assets:cash\n")) 130 132 131 - srv := NewServer("test") 133 + srv := newServer(t) 132 134 uBase := uri.File(base) 133 135 srv.server.openDoc(uBase, "2024-01-01 t\n expenses:food $10\n assets:cash\n", 1, "journal") 134 136 ··· 153 155 testutil.WriteFile(t, base, []byte("2024-01-01 t\n expenses:food $10\n assets:cash\n")) 154 156 testutil.WriteFile(t, main, []byte("include base.journal\n")) 155 157 156 - srv := NewServer("test") 158 + srv := newServer(t) 157 159 srv.server.client = &captureClient{} 158 160 uMain := uri.File(main) 159 161 if err := srv.server.DidOpen(context.Background(), &protocol.DidOpenTextDocumentParams{ ··· 184 186 } 185 187 } 186 188 189 +func TestServer_ReportsConfigProblems(t *testing.T) { 190 + for name, tt := range map[string]struct { 191 + config, inline string 192 + typ protocol.MessageType 193 + want string 194 + }{ 195 + "unknown setting": {config: "bogus = 1\n", typ: protocol.MessageTypeWarning, want: `unknown setting "bogus"`}, 196 + "unknown lint rule": {config: "[lint]\nnot-a-rule = \"error\"\n", typ: protocol.MessageTypeWarning, want: `unknown lint rule "not-a-rule"`}, 197 + "unparseable": {config: "not toml [[[\n", typ: protocol.MessageTypeError, want: "toml:"}, 198 + "settings unknown lint rule": {inline: `{"lint":{"unused_accountt":"off"}}`, typ: protocol.MessageTypeWarning, want: `unknown lint rule "unused_accountt"`}, 199 + } { 200 + t.Run(name, func(t *testing.T) { 201 + capture := &captureClient{} 202 + var srv *server 203 + if tt.config != "" { 204 + cfgPath := filepath.Join(t.TempDir(), "clerk.toml") 205 + testutil.WriteFile(t, cfgPath, []byte(tt.config)) 206 + s, err := NewServer("test", cfgPath) 207 + if err != nil { 208 + t.Fatal(err) 209 + } 210 + srv = s.server 211 + srv.client = capture 212 + if err := srv.Initialized(t.Context(), &protocol.InitializedParams{}); err != nil { 213 + t.Fatalf("initialized: %v", err) 214 + } 215 + } else { 216 + srv = newServer(t).server 217 + srv.client = capture 218 + if err := srv.DidChangeConfiguration(t.Context(), &protocol.DidChangeConfigurationParams{ 219 + Settings: protocol.LSPAny(tt.inline), 220 + }); err != nil { 221 + t.Fatalf("didChangeConfiguration: %v", err) 222 + } 223 + } 224 + msgs := capture.shownMessages() 225 + if len(msgs) != 1 || msgs[0].Type != tt.typ || !strings.Contains(msgs[0].Message, tt.want) { 226 + t.Errorf("unexpected messages: %+v", msgs) 227 + } 228 + }) 229 + } 230 +} 231 + 232 +func TestServer_Initialized_mergesConfigWithLSPSettings(t *testing.T) { 233 + cfgPath := filepath.Join(t.TempDir(), "clerk.toml") 234 + testutil.WriteFile(t, cfgPath, []byte("[lint]\nunbalanced-transaction = \"off\"\n")) 235 + s, err := NewServer("test", cfgPath) 236 + if err != nil { 237 + t.Fatal(err) 238 + } 239 + if _, err := s.server.Initialize(t.Context(), &protocol.InitializeParams{ 240 + InitializationOptions: protocol.LSPAny(`{"lint": {"missing-payee": "warn"}}`), 241 + }); err != nil { 242 + t.Fatalf("initialize: %v", err) 243 + } 244 + if err := s.server.Initialized(t.Context(), &protocol.InitializedParams{}); err != nil { 245 + t.Fatalf("initialized: %v", err) 246 + } 247 + s.server.mu.RLock() 248 + got := s.server.settings 249 + s.server.mu.RUnlock() 250 + if !got.Linter.Rules[linter.UnbalancedTransactionID].Disabled { 251 + t.Error("file setting unbalanced-transaction=off not applied") 252 + } 253 + if rc := got.Linter.Rules[linter.MissingPayeeID]; rc.Disabled || rc.Severity != linter.SeverityWarning { 254 + t.Errorf("init option missing-payee=warn clobbered by file: %+v", rc) 255 + } 256 +} 257 + 187 258 type captureClient struct { 188 259 protocol.Client 189 - mu sync.Mutex 190 - diag []protocol.PublishDiagnosticsParams 260 + mu sync.Mutex 261 + diag []protocol.PublishDiagnosticsParams 262 + shown []protocol.ShowMessageParams 191 263 } 192 264 193 265 func (c *captureClient) PublishDiagnostics(_ context.Context, params *protocol.PublishDiagnosticsParams) error { ··· 197 269 return nil 198 270 } 199 271 272 +func (c *captureClient) ShowMessage(_ context.Context, params *protocol.ShowMessageParams) error { 273 + c.mu.Lock() 274 + c.shown = append(c.shown, *params) 275 + c.mu.Unlock() 276 + return nil 277 +} 278 + 279 +func (c *captureClient) shownMessages() []protocol.ShowMessageParams { 280 + c.mu.Lock() 281 + defer c.mu.Unlock() 282 + return slices.Clone(c.shown) 283 +} 284 + 200 285 func (c *captureClient) lastDiags(u uri.URI) ([]protocol.Diagnostic, bool) { 201 286 c.mu.Lock() 202 287 defer c.mu.Unlock() ··· 219 304 } 220 305 t.Fatalf("timed out waiting for %s", what) 221 306 } 307 + 308 +func newServer(tb testing.TB) Server { 309 + tb.Helper() 310 + s, err := NewServer("test", filepath.Join(tb.TempDir(), "clerk.toml")) 311 + if err != nil { 312 + tb.Fatal(err) 313 + } 314 + return s 315 +}
M
internal/lsp/textdocument_completion_test.go
··· 96 96 t.Run(tt, func(t *testing.T) { 97 97 ar := golden.Read(t, tt) 98 98 h := newTxtarHarness(t, ar) 99 - h.srv.applySettings([]byte(`{"latin_to_cyrillic_completion": true}`)) 99 + if err := h.srv.applySettings(t.Context(), []byte(`{"latin_to_cyrillic_completion": true}`)); err != nil { 100 + t.Fatal(err) 101 + } 100 102 101 103 var b strings.Builder 102 104 for i, c := range h.cursors { ··· 172 174 } 173 175 content := string(rj.Occurrences[0].Src) 174 176 175 - srv := NewServer("test") 177 + srv := newServer(b) 176 178 srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal") 177 179 srv.server.analysisFor(uri.URI("file:///test.journal")) // warm the per-doc cache 178 180 ··· 222 224 sb.WriteString("account vyt") 223 225 content := sb.String() 224 226 225 - srv := NewServer("test") 227 + srv := newServer(b) 226 228 srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal") 227 229 srv.server.analysisFor(uri.URI("file:///test.journal")) // warm the per-doc cache 228 - srv.server.applySettings([]byte(`{"latin_to_cyrillic_completion": true}`)) 230 + if err := srv.server.applySettings(b.Context(), []byte(`{"latin_to_cyrillic_completion": true}`)); err != nil { 231 + b.Fatal(err) 232 + } 229 233 230 234 line, col := lsputil.LineCol(content, len(content)) 231 235 params := &protocol.CompletionParams{
M
internal/lsp/textdocument_definition_test.go
··· 15 15 ) 16 16 17 17 func TestServer_Definition_DocumentNotFound(t *testing.T) { 18 - srv := NewServer("test") 18 + srv := newServer(t) 19 19 res, err := srv.server.Definition(t.Context(), &protocol.DefinitionParams{ 20 20 TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///nonexistent.journal")}, 21 21 Position: protocol.Position{Line: 0, Character: 0}, ··· 70 70 } 71 71 content := string(rj.Occurrences[0].Src) 72 72 73 - srv := NewServer("test") 73 + srv := newServer(b) 74 74 u := uri.File(abs) 75 75 srv.server.openDoc(u, content, 1, "journal") 76 76 srv.server.analysisFor(u) // warm the per-doc cache
M
internal/lsp/textdocument_document_symbol_test.go
··· 39 39 func BenchmarkDocumentSymbol(b *testing.B) { 40 40 content := openJournal(b, "../../journal/testdata/journals/actual-1ktxns-100accts.journal") 41 41 42 - srv := NewServer("test") 42 + srv := newServer(b) 43 43 u := uri.URI("file:///test.journal") 44 44 srv.server.openDoc(u, content, 1, "journal") 45 45 srv.server.analysisFor(u) // warm the per-doc cache
M
internal/lsp/textdocument_format.go
··· 24 24 return nil, fmt.Errorf("can't format file with errors: %v", jnrl.Errors[0].Message) // TODO: report all errors 25 25 } 26 26 27 + s.mu.RLock() 28 + cfg := s.settings.Format 29 + s.mu.RUnlock() 30 + 27 31 var buf strings.Builder 28 - if err := s.printer.Fprint(&buf, jnrl); err != nil { 32 + if err := cfg.Fprint(&buf, jnrl); err != nil { 29 33 return nil, fmt.Errorf("format: %w", err) 30 34 } 31 35
M
internal/lsp/textdocument_format_test.go
··· 10 10 func BenchmarkFormatting(b *testing.B) { 11 11 content := openJournal(b, "../../journal/testdata/journals/actual-1ktxns-100accts.journal") 12 12 13 - srv := NewServer("test") 13 + srv := newServer(b) 14 14 srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal") 15 15 16 16 params := &protocol.DocumentFormattingParams{
M
internal/lsp/textdocument_hover_test.go
··· 44 44 } 45 45 46 46 func TestServer_Hover_DocumentNotFound(t *testing.T) { 47 - srv := NewServer("test") 47 + srv := newServer(t) 48 48 res, err := srv.server.Hover(context.Background(), &protocol.HoverParams{ 49 49 TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///nonexistent.journal")}, 50 50 Position: protocol.Position{Line: 0, Character: 0}, ··· 94 94 func BenchmarkHover(b *testing.B) { 95 95 content := openJournal(b, "../../journal/testdata/journals/actual-1ktxns-100accts.journal") 96 96 97 - srv := NewServer("test") 97 + srv := newServer(b) 98 98 srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal") 99 99 srv.server.analysisFor(uri.URI("file:///test.journal")) // warm the per-doc cache 100 100
M
internal/lsp/textdocument_references_test.go
··· 16 16 ) 17 17 18 18 func TestServer_References_DocumentNotFound(t *testing.T) { 19 - srv := NewServer("test") 19 + srv := newServer(t) 20 20 res, err := srv.server.References(context.Background(), &protocol.ReferenceParams{ 21 21 TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///nonexistent.journal")}, 22 22 Position: protocol.Position{Line: 0, Character: 0}, ··· 67 67 func BenchmarkReferences(b *testing.B) { 68 68 content := openJournal(b, "../../journal/testdata/journals/actual-1ktxns-100accts.journal") 69 69 70 - srv := NewServer("test") 70 + srv := newServer(b) 71 71 u := uri.URI("file:///test.journal") 72 72 srv.server.openDoc(u, content, 1, "journal") 73 73 srv.server.analysisFor(u) // warm per-doc cache
M
internal/lsp/textdocument_rename_test.go
··· 138 138 } 139 139 content := string(rj.Occurrences[0].Src) 140 140 141 - srv := NewServer("test") 141 + srv := newServer(b) 142 142 u := uri.File(abs) 143 143 srv.server.openDoc(u, content, 1, "journal") 144 144 srv.server.analysisFor(u) // warm the per-doc cache ··· 203 203 } 204 204 u := uri.File(filepath.Join(dir, "in.journal")) 205 205 206 - srv := NewServer("test") 206 + srv := newServer(t) 207 207 srv.server.openDoc(u, content, 1, "journal") 208 208 209 209 return &txtarHarness{srv: srv.server, uri: u, content: content, cursors: cursors}
M
internal/lsp/textdocument_semantic_tokens_test.go
··· 87 87 } 88 88 89 89 func TestServer_Semantic_EmptyDocument(t *testing.T) { 90 - srv := NewServer("test") 90 + srv := newServer(t) 91 91 srv.server.openDoc(uri.URI("file:///empty.journal"), "", 1, "journal") 92 92 result, err := srv.server.SemanticTokensFull(t.Context(), &protocol.SemanticTokensParams{ 93 93 TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///empty.journal")}, ··· 101 101 } 102 102 103 103 func TestServer_Semantic_DocumentNotFound(t *testing.T) { 104 - result, err := NewServer("test").server.SemanticTokensFull(t.Context(), &protocol.SemanticTokensParams{ 104 + result, err := newServer(t).server.SemanticTokensFull(t.Context(), &protocol.SemanticTokensParams{ 105 105 TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///unknown.journal")}, 106 106 }) 107 107 if err != nil { ··· 140 140 141 141 t.Run("golden", func(t *testing.T) { 142 142 u := uri.URI("file:///test.journal") 143 - srv := NewServer("test") 143 + srv := newServer(t) 144 144 srv.server.openDoc(u, string(in), 1, "journal") 145 145 146 146 var out strings.Builder ··· 189 189 in := ar.Get("in.journal") 190 190 191 191 u := uri.URI("file:///test.journal") 192 - srv := NewServer("test") 192 + srv := newServer(t) 193 193 srv.server.openDoc(u, string(in), 1, "journal") 194 194 195 195 full, err := srv.server.SemanticTokensFull(t.Context(), &protocol.SemanticTokensParams{
M
internal/lsp/workspace_symbol_test.go
··· 33 33 } 34 34 35 35 func TestServer_Symbols_EmptyQuery(t *testing.T) { 36 - srv := NewServer("test") 36 + srv := newServer(t) 37 37 u := uri.URI("file:///test.journal") 38 38 srv.server.openDoc(u, "account expenses:food\n", 1, "journal") 39 39 ··· 47 47 } 48 48 49 49 func TestServer_Symbols_SearchAllOpenDocs(t *testing.T) { 50 - srv := NewServer("test") 50 + srv := newServer(t) 51 51 srv.server.openDoc(uri.URI("file:///a.journal"), "account assets:only\n", 1, "journal") 52 52 srv.server.openDoc(uri.URI("file:///b.journal"), "2024-01-15 Grocery Store\n expenses:food $50\n assets:cash $-50\n", 1, "journal") 53 53 ··· 101 101 func BenchmarkSymbols(b *testing.B) { 102 102 content := openJournal(b, "../../journal/testdata/journals/actual-1ktxns-100accts.journal") 103 103 104 - srv := NewServer("test") 104 + srv := newServer(b) 105 105 u := uri.URI("file:///test.journal") 106 106 srv.server.openDoc(u, content, 1, "journal") 107 107 srv.server.analysisFor(u) // warm the per-doc cache
A
internal/settings/format.go
··· 1 +package settings 2 + 3 +import ( 4 + "fmt" 5 + "strings" 6 + 7 + "olexsmir.xyz/clerk/journal/printer" 8 +) 9 + 10 +func (s *Settings) setFormat(v any) ([]string, error) { 11 + return applyTable(v, s.setFormatField) 12 +} 13 + 14 +func (s *Settings) setFormatField(name string, val any) ([]string, error) { 15 + switch normalizeKey(name) { 16 + case "tab_indent": 17 + return nil, setBool(&s.Format.TabIndent, val) 18 + case "indent_width": 19 + return nil, setInt(&s.Format.IndentWidth, val, 1, 32) 20 + case "preserve_blank_lines": 21 + return nil, setBool(&s.Format.PreserveBlankLines, val) 22 + case "align_style": 23 + as, ok := val.(string) 24 + if !ok { 25 + return nil, fmt.Errorf("invalid value %v (want string)", val) 26 + } 27 + switch { 28 + case strings.EqualFold(as, "two-spaces"): 29 + s.Format.AlignStyle = printer.AlignTwoSpaces 30 + case strings.EqualFold(as, "right"): 31 + s.Format.AlignStyle = printer.AlignRight 32 + case strings.EqualFold(as, "tab"): 33 + s.Format.AlignStyle = printer.AlignTab 34 + default: 35 + return nil, fmt.Errorf("invalid value %q (want %q, %q, or %q)", as, "two-spaces", "right", "tab") 36 + } 37 + case "align_column": 38 + return nil, setInt(&s.Format.AlignColumn, val, 1, 240) 39 + case "commodity_pos": 40 + c, ok := val.(string) 41 + if !ok { 42 + return nil, fmt.Errorf("invalid value %v (want string)", val) 43 + } 44 + switch { 45 + case strings.EqualFold(c, "after"): 46 + s.Format.CommodityPos = printer.CommodityAfter 47 + case strings.EqualFold(c, "before"): 48 + s.Format.CommodityPos = printer.CommodityBefore 49 + default: 50 + return nil, fmt.Errorf("invalid value %q (want %q or %q)", c, "after", "before") 51 + } 52 + default: 53 + return []string{fmt.Sprintf("unknown format option %q", name)}, nil 54 + } 55 + return nil, nil 56 +} 57 + 58 +func setInt(n *int, v any, lo, hi int) error { 59 + var x int 60 + switch t := v.(type) { 61 + case int: 62 + x = t 63 + case int64: 64 + x = int(t) 65 + case float64: 66 + if t != float64(int(t)) { 67 + return fmt.Errorf("invalid value %v (want int)", v) 68 + } 69 + x = int(t) 70 + default: 71 + return fmt.Errorf("invalid value %v (want int)", v) 72 + } 73 + if x < lo || x > hi { 74 + return fmt.Errorf("%d out of range (want %d..%d)", x, lo, hi) 75 + } 76 + *n = x 77 + return nil 78 +} 79 + 80 +func setBool(b *bool, v any) error { 81 + x, ok := v.(bool) 82 + if !ok { 83 + return fmt.Errorf("invalid value %v (want bool)", v) 84 + } 85 + *b = x 86 + return nil 87 +}
A
internal/settings/linter.go
··· 1 +package settings 2 + 3 +import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "maps" 8 + "strings" 9 + 10 + "olexsmir.xyz/clerk/internal/linter" 11 +) 12 + 13 +var lintRuleLookup = func() map[string]linter.RuleID { 14 + idx := make(map[string]linter.RuleID, 2*len(linter.Rules)) 15 + for id := range linter.Rules { 16 + idx[string(id)] = id 17 + idx[normalizeKey(string(id))] = id 18 + } 19 + return idx 20 +}() 21 + 22 +func (s *Settings) setLint(v any) ([]string, error) { 23 + rules := make(map[linter.RuleID]linter.RuleConfig, len(s.Linter.Rules)) 24 + maps.Copy(rules, s.Linter.Rules) 25 + s.Linter.Rules = rules 26 + return applyTable(v, func(name string, val any) ([]string, error) { 27 + id, ok := lintRuleLookup[name] 28 + if !ok { 29 + id, ok = lintRuleLookup[normalizeKey(name)] 30 + } 31 + if !ok { 32 + return []string{fmt.Sprintf("unknown lint rule %q", name)}, nil 33 + } 34 + rc, err := applyLintRule(s.Linter.Rules[id], val) 35 + if err != nil { 36 + return nil, err 37 + } 38 + s.Linter.Rules[id] = rc 39 + return nil, nil 40 + }) 41 +} 42 + 43 +func applyLintRule(rc linter.RuleConfig, v any) (linter.RuleConfig, error) { 44 + switch v := v.(type) { 45 + case bool: 46 + if v { 47 + return rc, errors.New(`true is not supported (want false, "off", a severity, or an options table)`) 48 + } 49 + rc.Disabled = true 50 + case string: 51 + return applySeverity(rc, v) 52 + case map[string]any: 53 + opts := make(map[string]any, len(v)) 54 + for k, val := range v { 55 + if normalizeKey(k) == "severity" { 56 + s, ok := val.(string) 57 + if !ok { 58 + return rc, fmt.Errorf("invalid severity %v (want string)", val) 59 + } 60 + 61 + var err error 62 + rc, err = applySeverity(rc, s) 63 + if err != nil { 64 + return rc, err 65 + } 66 + continue 67 + } 68 + opts[k] = val 69 + } 70 + if len(opts) > 0 { 71 + raw, err := json.Marshal(opts) 72 + if err != nil { 73 + return rc, err 74 + } 75 + rc.Options = raw 76 + } 77 + default: 78 + return rc, fmt.Errorf("invalid type %T (want bool, string, or table)", v) 79 + } 80 + return rc, nil 81 +} 82 + 83 +func applySeverity(rc linter.RuleConfig, s string) (linter.RuleConfig, error) { 84 + if strings.EqualFold(s, "off") { 85 + rc.Disabled = true 86 + return rc, nil 87 + } 88 + sev, ok := linter.ParseSeverity(s) 89 + if !ok { 90 + return rc, fmt.Errorf("invalid severity %q (want %q, %q, %q, %q, or %q)", 91 + s, "off", "error", "warn", "info", "hint") 92 + } 93 + rc.Disabled = false 94 + rc.Severity = sev 95 + return rc, nil 96 +}
A
internal/settings/settings.go
··· 1 +package settings 2 + 3 +import ( 4 + "errors" 5 + "fmt" 6 + "io/fs" 7 + "os" 8 + "strings" 9 + 10 + "github.com/pelletier/go-toml/v2" 11 + "olexsmir.xyz/clerk/internal/linter" 12 + "olexsmir.xyz/clerk/journal/printer" 13 +) 14 + 15 +type Settings struct { 16 + // SemanticHighlighting enables LSP semantic tokens. 17 + SemanticHighlighting bool 18 + 19 + // LatinToCyrillicCompletion matches Latin input against Cyrillic labels. 20 + LatinToCyrillicCompletion bool 21 + 22 + Linter linter.Config 23 + Format printer.Config 24 +} 25 + 26 +var DefaultConfig = Settings{ 27 + SemanticHighlighting: true, 28 + LatinToCyrillicCompletion: false, 29 + Linter: linter.DefaultConfig, 30 + Format: printer.DefaultConfig, 31 +} 32 + 33 +// Load reads and parses the TOML config file at path. 34 +// A missing file yields defaults without error. 35 +func Load(fpath string) (Settings, []string, error) { 36 + data, err := os.ReadFile(fpath) 37 + if err != nil { 38 + if errors.Is(err, fs.ErrNotExist) { 39 + return DefaultConfig, nil, nil 40 + } 41 + return DefaultConfig, nil, err 42 + } 43 + var raw map[string]any 44 + if err := toml.Unmarshal(data, &raw); err != nil { 45 + return DefaultConfig, nil, err 46 + } 47 + return parse(raw) 48 +} 49 + 50 +func parse(raw map[string]any) (settings Settings, warns []string, err error) { 51 + s := DefaultConfig 52 + warns, err = s.Apply(raw) 53 + return s, warns, err 54 +} 55 + 56 +// Apply merges raw setting from a config file into Settings object. 57 +func (s *Settings) Apply(raw map[string]any) ([]string, error) { 58 + return applyMap(raw, s.applyFileField) 59 +} 60 + 61 +func (s *Settings) applyFileField(name string, val any) ([]string, error) { 62 + switch normalizeKey(name) { 63 + case "lint": 64 + return s.setLint(val) 65 + case "format": 66 + return s.setFormat(val) 67 + default: 68 + return []string{fmt.Sprintf("unknown setting %q", name)}, nil 69 + } 70 +} 71 + 72 +// ApplyLSP merges raw settings from lsp server config into Settings object. 73 +func (s *Settings) ApplyLSP(raw map[string]any) ([]string, error) { 74 + return applyMap(raw, s.applyLSPField) 75 +} 76 + 77 +func (s *Settings) applyLSPField(name string, val any) ([]string, error) { 78 + switch normalizeKey(name) { 79 + case "semantic_highlighting": 80 + return nil, setBool(&s.SemanticHighlighting, val) 81 + case "latin_to_cyrillic_completion": 82 + return nil, setBool(&s.LatinToCyrillicCompletion, val) 83 + default: 84 + return s.applyFileField(name, val) 85 + } 86 +} 87 + 88 +func applyMap(m map[string]any, fn func(k string, val any) ([]string, error)) ([]string, error) { 89 + count := make(map[string]int, len(m)) 90 + for k := range m { 91 + count[normalizeKey(k)]++ 92 + } 93 + var ( 94 + warns []string 95 + errs []error 96 + ) 97 + for k, val := range m { 98 + norm := normalizeKey(k) 99 + if count[norm] > 1 { 100 + errs = append(errs, fmt.Errorf("%s: duplicate setting %q not applied", k, norm)) 101 + continue 102 + } 103 + ws, err := fn(k, val) 104 + warns = append(warns, ws...) 105 + if err != nil { 106 + errs = append(errs, prefixLines(k, err)) 107 + } 108 + } 109 + return warns, errors.Join(errs...) 110 +} 111 + 112 +func applyTable(v any, fn func(k string, val any) ([]string, error)) ([]string, error) { 113 + m, ok := v.(map[string]any) 114 + if !ok { 115 + return nil, fmt.Errorf("invalid value %v (want table)", v) 116 + } 117 + return applyMap(m, fn) 118 +} 119 + 120 +func normalizeKey(s string) string { 121 + // already canonical(lowercase, digits, underscores) 122 + canonical := true 123 + for i := 0; i < len(s); i++ { 124 + c := s[i] 125 + if c != '_' && (c < 'a' || c > 'z') && (c < '0' || c > '9') { 126 + canonical = false 127 + break 128 + } 129 + } 130 + if canonical { 131 + return s 132 + } 133 + 134 + var b strings.Builder 135 + b.Grow(len(s)) 136 + prevLower := false 137 + for i := 0; i < len(s); i++ { 138 + c := s[i] 139 + switch { 140 + case c == '-' || c == '_': 141 + b.WriteByte('_') 142 + prevLower = false 143 + case c >= 'A' && c <= 'Z': 144 + if prevLower { 145 + b.WriteByte('_') 146 + } 147 + b.WriteByte(c + 'a' - 'A') 148 + prevLower = false 149 + case c >= 'a' && c <= 'z': 150 + b.WriteByte(c) 151 + prevLower = true 152 + default: 153 + b.WriteByte(c) 154 + prevLower = false 155 + } 156 + } 157 + return b.String() 158 +} 159 + 160 +func prefixLines(prefix string, err error) error { 161 + lines := strings.Split(err.Error(), "\n") 162 + for i, line := range lines { 163 + if line != "" { 164 + lines[i] = prefix + ": " + line 165 + } 166 + } 167 + return errors.New(strings.Join(lines, "\n")) 168 +}
A
internal/settings/settings_test.go
··· 1 +package settings 2 + 3 +import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "slices" 8 + "strings" 9 + "testing" 10 + 11 + "olexsmir.xyz/clerk/internal/linter" 12 + "olexsmir.xyz/clerk/internal/testutil/golden" 13 + "olexsmir.xyz/clerk/journal/printer" 14 +) 15 + 16 +func TestNormKey(t *testing.T) { 17 + cases := map[string]string{ 18 + "semantic_highlighting": "semantic_highlighting", 19 + "semantic-highlighting": "semantic_highlighting", 20 + "semanticHighlighting": "semantic_highlighting", 21 + "latin_to_cyrillic_completion": "latin_to_cyrillic_completion", 22 + "latinToCyrillicCompletion": "latin_to_cyrillic_completion", 23 + "format": "format", 24 + "ALIGN_STYLE": "align_style", 25 + "indent-width": "indent_width", 26 + } 27 + for in, want := range cases { 28 + if got := normalizeKey(in); got != want { 29 + t.Errorf("normKey(%q) = %q, want %q", in, got, want) 30 + } 31 + } 32 +} 33 + 34 +func TestApplyLSP(t *testing.T) { 35 + s := DefaultConfig 36 + _, err := s.ApplyLSP(map[string]any{ 37 + "semanticHighlighting": false, 38 + "latinToCyrillicCompletion": true, 39 + "format": map[string]any{"indent-width": int64(4)}, 40 + }) 41 + if err != nil { 42 + t.Fatalf("ApplyLSP: %v", err) 43 + } 44 + if s.SemanticHighlighting { 45 + t.Error("SemanticHighlighting = true, want false") 46 + } 47 + if !s.LatinToCyrillicCompletion { 48 + t.Error("LatinToCyrillicCompletion = false, want true") 49 + } 50 + if s.Format.IndentWidth != 4 { 51 + t.Errorf("IndentWidth = %d, want 4", s.Format.IndentWidth) 52 + } 53 +} 54 + 55 +func TestApply_duplicateKey(t *testing.T) { 56 + s := DefaultConfig 57 + _, err := s.Apply(map[string]any{ 58 + "format": map[string]any{ 59 + "indent-width": 4, 60 + "indent_width": 8, 61 + }, 62 + }) 63 + if err == nil { 64 + t.Fatal("expected duplicate-key error, got nil") 65 + } 66 + // Neither conflicting value is applied; default prevails. 67 + if s.Format.IndentWidth != DefaultConfig.Format.IndentWidth { 68 + t.Errorf("IndentWidth = %d, want default %d", s.Format.IndentWidth, DefaultConfig.Format.IndentWidth) 69 + } 70 +} 71 + 72 +// Benchmarks 73 + 74 +func BenchmarkParseMap(b *testing.B) { 75 + benchRawMap := map[string]any{ 76 + "semantic_highlighting": true, 77 + "format": map[string]any{ 78 + "tab-indent": true, 79 + "indent-width": int64(4), 80 + "align-style": "right", 81 + "commodity-pos": "before", 82 + "align-column": int64(80), 83 + "preserve-blank-lines": true, 84 + }, 85 + "lint": map[string]any{ 86 + "unbalanced-transaction": "error", 87 + "missing-payee": false, 88 + "empty-postings": "warn", 89 + "account-depth": map[string]any{"severity": "error", "max-depth": int64(8)}, 90 + }, 91 + } 92 + 93 + b.ReportAllocs() 94 + for b.Loop() { 95 + if _, _, err := parse(benchRawMap); err != nil { 96 + b.Fatal(err) 97 + } 98 + } 99 +} 100 + 101 +func BenchmarkParseLargeLint(b *testing.B) { 102 + raw := map[string]any{"lint": map[string]any{}} 103 + for id := range linter.Rules { 104 + raw["lint"].(map[string]any)[string(id)] = "error" 105 + } 106 + b.ReportAllocs() 107 + for b.Loop() { 108 + if _, _, err := parse(raw); err != nil { 109 + b.Fatal(err) 110 + } 111 + } 112 +} 113 + 114 +func BenchmarkLoad(b *testing.B) { 115 + path := filepath.Join("testdata", "bench.toml") 116 + b.ReportAllocs() 117 + for b.Loop() { 118 + if _, _, err := Load(path); err != nil { 119 + b.Fatal(err) 120 + } 121 + } 122 +} 123 + 124 +func BenchmarkNormKey(b *testing.B) { 125 + tests := []string{"latinToCyrillicCompletion", "semantic_highlighting", "unbalanced-transaction"} 126 + for _, test := range tests { 127 + b.ReportAllocs() 128 + b.Run(test, func(b *testing.B) { 129 + for b.Loop() { 130 + _ = normalizeKey(test) 131 + } 132 + }) 133 + } 134 +} 135 + 136 +// Golden tests 137 + 138 +func TestGolden_Load(t *testing.T) { 139 + for _, tt := range []string{"different-cases", "full", "invalid-values", "missing", "non-table", "unknown-keys"} { 140 + t.Run(tt, func(t *testing.T) { 141 + ar := golden.Read(t, tt) 142 + path := filepath.Join(t.TempDir(), "clerk.toml") 143 + if cfg := ar.Get("config.toml"); cfg != nil { 144 + if err := os.WriteFile(path, cfg, 0o644); err != nil { 145 + t.Fatal(err) 146 + } 147 + } 148 + s, warns, err := Load(path) 149 + golden.Assert(t, ar, renderTOML(s, warns, err)) 150 + }) 151 + } 152 +} 153 + 154 +func renderTOML(s Settings, warns []string, err error) string { 155 + var lines []string 156 + for _, w := range warns { 157 + lines = append(lines, "warning: "+w) 158 + } 159 + if err != nil { 160 + for line := range strings.SplitSeq(err.Error(), "\n") { 161 + if line != "" { 162 + lines = append(lines, "error: "+line) 163 + } 164 + } 165 + } 166 + slices.Sort(lines) 167 + if err == nil { 168 + lines = append(lines, changedLines(configLines(s), configLines(DefaultConfig))...) 169 + } 170 + if len(lines) == 0 { 171 + return "" 172 + } 173 + return strings.Join(lines, "\n") + "\n" 174 +} 175 + 176 +func configLines(s Settings) []string { 177 + var lines []string 178 + for _, line := range renderFormat(s.Format) { 179 + lines = append(lines, "format."+line) 180 + } 181 + for _, id := range sortedRuleIDs() { 182 + rc := s.Linter.Rules[id] 183 + line := "lint." + string(id) + ": " 184 + if rc.Disabled { 185 + line += "disabled" 186 + } else { 187 + line += rc.Severity.String() 188 + if len(rc.Options) > 0 { 189 + line += " options=" + string(rc.Options) 190 + } 191 + } 192 + lines = append(lines, line) 193 + } 194 + return lines 195 +} 196 + 197 +func changedLines(got, want []string) []string { 198 + var out []string 199 + for i, line := range got { 200 + if line != want[i] { 201 + out = append(out, line) 202 + } 203 + } 204 + return out 205 +} 206 + 207 +func renderFormat(c printer.Config) []string { 208 + return []string{ 209 + "tab-indent: " + fmt.Sprint(c.TabIndent), 210 + "indent-width: " + fmt.Sprint(c.IndentWidth), 211 + "preserve-blank-lines: " + fmt.Sprint(c.PreserveBlankLines), 212 + "align-style: " + c.AlignStyle.String(), 213 + "align-column: " + fmt.Sprint(c.AlignColumn), 214 + "commodity-pos: " + c.CommodityPos.String(), 215 + } 216 +} 217 + 218 +func sortedRuleIDs() []linter.RuleID { 219 + ids := make([]linter.RuleID, 0, len(linter.Rules)) 220 + for id := range linter.Rules { 221 + ids = append(ids, id) 222 + } 223 + slices.Sort(ids) 224 + return ids 225 +}
A
internal/settings/testdata/different-cases.txtar
··· 1 +-- config.toml -- 2 +[format] 3 +tabIndent = true 4 +indentWidth = 4 5 +preserveBlankLines = true 6 + 7 +[lint] 8 +missingPayee = "off" 9 +accountDepth = { severity = "info" } 10 +account_pepth = "info" 11 + 12 +-- expect -- 13 +warning: unknown lint rule "account_pepth" 14 +format.tab-indent: true 15 +format.indent-width: 4 16 +format.preserve-blank-lines: true 17 +lint.account-depth: info 18 +lint.missing-payee: disabled
A
internal/settings/testdata/full.txtar
··· 1 +-- config.toml -- 2 +[format] 3 +tab-indent = true 4 +indent-width = 4 5 +align-style = "right" 6 +commodity-pos = "before" 7 +align-column = 80 8 +preserve-blank-lines = true 9 + 10 +[lint] 11 +unbalanced-transaction = "info" 12 +missing-payee = false 13 +empty-postings = "warn" 14 +orderdate = "hint" 15 +account-depth = { severity = "error", max-depth = 8 } 16 + 17 +-- expect -- 18 +format.tab-indent: true 19 +format.indent-width: 4 20 +format.preserve-blank-lines: true 21 +format.align-style: right 22 +format.align-column: 80 23 +format.commodity-pos: before 24 +lint.account-depth: error options={"max-depth":8} 25 +lint.empty-postings: warning 26 +lint.missing-payee: disabled 27 +lint.orderdate: hint 28 +lint.unbalanced-transaction: info
A
internal/settings/testdata/invalid-values.txtar
··· 1 +-- config.toml -- 2 +[format] 3 +indent-width = 17 4 +align-column = 0 5 +align-style = "none" 6 +commodity-pos = "left" 7 + 8 +[lint] 9 +unbalanced-transaction = true 10 +account-depth = { severity = "critical" } 11 + 12 +-- expect -- 13 +error: format: align-column: 0 out of range (want 1..240) 14 +error: format: align-style: invalid value "none" (want "two-spaces", "right", or "tab") 15 +error: format: commodity-pos: invalid value "left" (want "after" or "before") 16 +error: lint: account-depth: invalid severity "critical" (want "off", "error", "warn", "info", or "hint") 17 +error: lint: unbalanced-transaction: true is not supported (want false, "off", a severity, or an options table)
A
internal/settings/testdata/unknown-keys.txtar
··· 1 +-- config.toml -- 2 +semantic_highlighting = true 3 +latin_to_cyrillic_completion = true 4 +bogus-setting = true 5 + 6 +[format] 7 +bogus-option = 1 8 + 9 +[lint] 10 +not-a-rule = "error" 11 + 12 +-- expect -- 13 +warning: unknown format option "bogus-option" 14 +warning: unknown lint rule "not-a-rule" 15 +warning: unknown setting "bogus-setting" 16 +warning: unknown setting "latin_to_cyrillic_completion" 17 +warning: unknown setting "semantic_highlighting"
M
journal/ast/ast_test.go
··· 8 8 feb1 := Date{Year: 2024, Month: 2, Day: 1} 9 9 nextYear := Date{Year: 2025, Month: 1, Day: 1} 10 10 11 - tests := []struct { 12 - a, b Date 13 - want int 14 - }{ 15 - {jan1, jan1, 0}, 16 - {jan2, jan1, 1}, 17 - {jan1, jan2, -1}, 18 - {feb1, jan2, 1}, 19 - {jan2, feb1, -1}, 20 - {nextYear, feb1, 1}, 21 - {feb1, nextYear, -1}, 11 + tests := map[[2]Date]int{ 12 + {jan1, jan1}: 0, 13 + {jan2, jan1}: 1, 14 + {jan1, jan2}: -1, 15 + {feb1, jan2}: 1, 16 + {jan2, feb1}: -1, 17 + {nextYear, feb1}: 1, 18 + {feb1, nextYear}: -1, 22 19 } 23 - for _, c := range tests { 24 - if got := c.a.Compare(c.b); got != c.want { 25 - t.Errorf("Compare(%v, %v) = %d, want %d", c.a, c.b, got, c.want) 20 + for dates, want := range tests { 21 + if got := dates[0].Compare(dates[1]); got != want { 22 + t.Errorf("Compare(%v, %v) = %d, want %d", dates[0], dates[1], got, want) 26 23 } 27 24 } 28 25 }
M
journal/printer/printer.go
··· 17 17 AlignTab // elastic tabstops 18 18 ) 19 19 20 +func (a AlignStyle) String() string { 21 + switch a { 22 + case AlignTwoSpaces: 23 + return "two-spaces" 24 + case AlignRight: 25 + return "right" 26 + case AlignTab: 27 + return "tab" 28 + default: 29 + panic("invalid align style value") 30 + } 31 +} 32 + 20 33 // CommodityPos controls where the commodity marker is placed 21 34 type CommodityPos int 22 35 ··· 25 38 CommodityBefore // "$10.00" 26 39 ) 27 40 41 +func (c CommodityPos) String() string { 42 + switch c { 43 + case CommodityAfter: 44 + return "after" 45 + case CommodityBefore: 46 + return "before" 47 + default: 48 + panic("invalid commodity pos value") 49 + } 50 +} 51 + 28 52 type Config struct { 29 53 TabIndent bool // true = tabs, false = spaces 30 54 IndentWidth int // spaces per indent level (default: 2) ··· 34 58 CommodityPos CommodityPos // where to place commodity 35 59 } 36 60 37 -var DefaultConfig = &Config{ 61 +var DefaultConfig = Config{ 38 62 TabIndent: false, 39 63 IndentWidth: 2, 40 64 PreserveBlankLines: false, ··· 61 85 indent string 62 86 prevWasBlank bool 63 87 } 64 - 65 -// Fprint formats using the default config. 66 -func Fprint(w io.Writer, j *ast.Journal) error { return DefaultConfig.Fprint(w, j) } 67 88 68 89 // Fprint formats a parsed journal. 69 90 func (c *Config) Fprint(w io.Writer, j *ast.Journal) error { 70 - if c == nil { 71 - c = DefaultConfig 72 - } 73 91 p := printer{cfg: c, indent: c.indent()} 74 92 75 93 for _, e := range j.Entries { ··· 82 100 return err 83 101 } 84 102 85 -// FprintEntry formats a single ast entry using the default config. 86 -func FprintEntry(w io.Writer, e ast.Entry) error { return DefaultConfig.FprintEntry(w, e) } 87 - 88 103 // FprintEntry formats a single journal entry. 89 104 func (c *Config) FprintEntry(w io.Writer, e ast.Entry) error { 90 - if c == nil { 91 - c = DefaultConfig 92 - } 93 105 p := printer{cfg: c, indent: c.indent()} 94 106 p.formatEntry(e) 95 107 _, err := io.WriteString(w, p.buf.String())