all repos

clerk @ ab4f608

missing tooling for ledger/hledger
64 files changed, 1494 insertions(+), 439 deletions(-)
feat: config (#6)
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.mod
M go.sum
M internal/cli/cli.go
M internal/cli/cmd_format.go
M internal/cli/cmd_lint.go
M internal/cli/cmd_lsp.go
M internal/cli/helpers.go
M internal/linter/linter.go
M internal/linter/linter_test.go
M internal/linter/report.go
M internal/linter/rule_account_depth.go
M internal/linter/rule_duplicated_account.go
M internal/linter/rule_duplicated_commodity.go
M internal/linter/rule_duplicated_tag.go
M internal/linter/rule_duplicated_transaction.go
M internal/linter/rule_empty_postings.go
M internal/linter/rule_invalid_date_tag.go
M internal/linter/rule_invalid_include.go
M internal/linter/rule_invalid_type_tag.go
M internal/linter/rule_missing_commodity.go
M internal/linter/rule_missing_payee.go
M internal/linter/rule_missing_status.go
M internal/linter/rule_multiple_omitted_amounts.go
M internal/linter/rule_omitted_precision.go
M internal/linter/rule_orderdate.go
M internal/linter/rule_parse_error.go
M internal/linter/rule_unbalanced_transaction.go
M internal/linter/rule_undeclared_account.go
M internal/linter/rule_undeclared_commodity.go
M internal/linter/rule_undeclared_payee.go
M internal/linter/rule_undeclared_tag.go
M internal/linter/rule_unused_account.go
M internal/linter/rule_unused_tag.go
M internal/linter/rules.go
D internal/lsp/config.go
D internal/lsp/config_test.go
M internal/lsp/diagnostics.go
M internal/lsp/diagnostics_test.go
M internal/lsp/lsp.go
M internal/lsp/server.go
M internal/lsp/server_test.go
M internal/lsp/textdocument_completion_test.go
M internal/lsp/textdocument_definition_test.go
M internal/lsp/textdocument_document_symbol_test.go
M internal/lsp/textdocument_format.go
M internal/lsp/textdocument_format_test.go
M internal/lsp/textdocument_hover_test.go
M internal/lsp/textdocument_references_test.go
M internal/lsp/textdocument_rename_test.go
M internal/lsp/textdocument_semantic_tokens_test.go
M internal/lsp/workspace_symbol_test.go
A internal/settings/format.go
A internal/settings/linter.go
A internal/settings/settings.go
A internal/settings/settings_test.go
A internal/settings/testdata/bench.toml
A internal/settings/testdata/different-cases.txtar
A internal/settings/testdata/full.txtar
A internal/settings/testdata/invalid-values.txtar
A internal/settings/testdata/missing.txtar
A internal/settings/testdata/non-table.txtar
A internal/settings/testdata/unknown-keys.txtar
M journal/ast/ast_test.go
M journal/printer/printer.go
M go.mod
···
        6
        6
         

      
        7
        7
         require (

      
        8
        8
         	github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68

      
        
        9
        +	github.com/pelletier/go-toml/v2 v2.3.1

      
        9
        10
         	github.com/urfave/cli/v3 v3.10.0

      
        10
        11
         	go.lsp.dev/jsonrpc2 v1.0.1

      
        11
        12
         	go.lsp.dev/protocol v1.0.1

      
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/bench.toml
···
        
        1
        +[format]

      
        
        2
        +tab-indent = true

      
        
        3
        +indent-width = 4

      
        
        4
        +

      
        
        5
        +[lint]

      
        
        6
        +unbalanced-transaction = "info"

      
        
        7
        +missing-payee = false
      
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/non-table.txtar
···
        
        1
        +-- config.toml --

      
        
        2
        +42

      
        
        3
        +

      
        
        4
        +-- expect --

      
        
        5
        +error: toml: expected character =

      
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())