all repos

clerk @ 9369172

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
linter/lsp: scope checks to include tree, 20 days ago
1
package linter
2
3
import (
4
	"os"
5
	"path/filepath"
6
	"strings"
7
	"testing"
8
9
	"olexsmir.xyz/clerk/internal/testutil/golden"
10
	"olexsmir.xyz/clerk/journal"
11
	"olexsmir.xyz/clerk/journal/semantic"
12
)
13
14
var tests = map[string][]Rule{
15
	"correct":                  Rules,
16
	"empty-postings":           {&EmptyPostings{}},
17
	"parse-error":              {&ParseError{}},
18
	"omitted-precision":        {&OmittedPrecision{}},
19
	"missing-commodity":        {&MissingCommodity{}},
20
	"missing-status":           {&MissingStatus{}},
21
	"missing-payee":            {&MissingPayee{}},
22
	"account-depth":            {&AccountDepthLimit{MaxDepth: 3}},
23
	"multiple-omitted-amounts": {&MultipleOmittedAmounts{}},
24
	"orderdate":                {&OrderDate{}},
25
	"duplicated-account":       {&DuplicatedAccount{}},
26
	"duplicated-commodity":     {&DuplicatedCommodity{}},
27
	"undeclared-commodity":     {&UndeclaredCommodity{}},
28
	"undeclared-account":       {&UndeclaredAccount{}},
29
	"unbalanced-transaction":   {&UnbalancedTransaction{}},
30
}
31
32
func TestLinter(t *testing.T) {
33
	for tname, trules := range tests {
34
		t.Run(tname, func(t *testing.T) {
35
			inp := golden.Load(t, tname)
36
			pf, err := journal.NewLoader().LoadBytes(tname+".journal", inp)
37
			if err != nil {
38
				t.Fatalf("failed to load test journal: %v\n", err)
39
			}
40
41
			ctx := semantic.Build([]*journal.ParsedFile{pf})
42
			finds := NewLinter(trules).Run(ctx)
43
44
			var b strings.Builder
45
			Fprint(&b, PathBasename, finds)
46
			golden.Assert(t, tname, b.String())
47
		})
48
	}
49
}
50
51
func TestLinter_CrossFileScoping(t *testing.T) {
52
	t.Run("undeclared across sibling trees", func(t *testing.T) {
53
		loader := loadTestFiles(t, map[string]string{
54
			"root_a.journal": "2026/01/01 * \"A\"\n  Expenses:Food  $10\n  Assets:Cash\n",
55
			"root_b.journal": "account Expenses:Food\n",
56
		})
57
		finds := runLint(t, loader)
58
59
		n := 0
60
		for _, f := range finds {
61
			if f.Code == "undeclared-account" {
62
				n++
63
			}
64
		}
65
		if n != 2 {
66
			t.Fatalf("expected 2 undeclared (Expenses:Food + Assets:Cash in root_a), got %d", n)
67
		}
68
	})
69
70
	t.Run("declaration in included file visible", func(t *testing.T) {
71
		loader := loadTestFiles(t, map[string]string{
72
			"child.journal":  "account Expenses:Food\n",
73
			"parent.journal": "include child.journal\n\n2026/01/01 * \"Lunch\"\n  Expenses:Food  $10\n  Assets:Cash\n",
74
		})
75
		finds := runLint(t, loader)
76
77
		n := 0
78
		for _, f := range finds {
79
			if f.Code == "undeclared-account" {
80
				n++
81
			}
82
		}
83
		if n != 1 {
84
			t.Fatalf("expected 1 undeclared (Assets:Cash), got %d", n)
85
		}
86
	})
87
88
	t.Run("duplicate across sibling trees not flagged", func(t *testing.T) {
89
		loader := loadTestFiles(t, map[string]string{
90
			"a.journal": "account Expenses:Food\n",
91
			"b.journal": "account Expenses:Food\n",
92
		})
93
		finds := runLint(t, loader)
94
95
		for _, f := range finds {
96
			if f.Code == "duplicated-account" {
97
				t.Fatalf("unexpected duplicate across trees: %v", f)
98
			}
99
		}
100
	})
101
}
102
103
func BenchmarkLinter(b *testing.B) {
104
	ldr := journal.NewLoader()
105
	_, err := ldr.Load(
106
		"../../journal/testdata/journals/actual-1ktxns-100accts.journal",
107
	)
108
	if err != nil {
109
		b.Fatalf("failed to load benchmark journal: %v", err)
110
	}
111
112
	ctx := semantic.Build(ldr.Ordered())
113
	l := NewLinter(Rules)
114
115
	b.ResetTimer()
116
	b.ReportAllocs()
117
	for b.Loop() {
118
		l.Run(ctx)
119
	}
120
}
121
122
func loadTestFiles(t testing.TB, files map[string]string) *journal.Loader {
123
	t.Helper()
124
	dir := t.TempDir()
125
	loader := journal.NewLoader()
126
	for name, content := range files {
127
		if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
128
			t.Fatal(err)
129
		}
130
		if _, err := loader.Load(filepath.Join(dir, name)); err != nil {
131
			t.Fatal(err)
132
		}
133
	}
134
	return loader
135
}
136
137
func runLint(t testing.TB, loader *journal.Loader) []Find {
138
	t.Helper()
139
	lint := NewLinter(Rules)
140
	var finds []Find
141
	for _, root := range loader.Roots() {
142
		finds = append(finds, lint.Run(semantic.Build(journal.CollectFiles(root)))...)
143
	}
144
	return finds
145
}