package linter import ( "os" "path/filepath" "strings" "testing" "olexsmir.xyz/clerk/internal/testutil/golden" "olexsmir.xyz/clerk/journal" "olexsmir.xyz/clerk/journal/semantic" ) var tests = map[string][]Rule{ "correct": Rules, "empty-postings": {&EmptyPostings{}}, "parse-error": {&ParseError{}}, "omitted-precision": {&OmittedPrecision{}}, "missing-commodity": {&MissingCommodity{}}, "missing-status": {&MissingStatus{}}, "missing-payee": {&MissingPayee{}}, "account-depth": {&AccountDepthLimit{MaxDepth: 3}}, "multiple-omitted-amounts": {&MultipleOmittedAmounts{}}, "orderdate": {&OrderDate{}}, "duplicated-account": {&DuplicatedAccount{}}, "duplicated-commodity": {&DuplicatedCommodity{}}, "undeclared-commodity": {&UndeclaredCommodity{}}, "undeclared-account": {&UndeclaredAccount{}}, "unbalanced-transaction": {&UnbalancedTransaction{}}, } func TestLinter(t *testing.T) { for tname, trules := range tests { t.Run(tname, func(t *testing.T) { inp := golden.Load(t, tname) pf, err := journal.NewLoader().LoadBytes(tname+".journal", inp) if err != nil { t.Fatalf("failed to load test journal: %v\n", err) } ctx := semantic.Build([]*journal.ParsedFile{pf}) finds := NewLinter(trules).Run(ctx) var b strings.Builder Fprint(&b, PathBasename, finds) golden.Assert(t, tname, b.String()) }) } } func TestLinter_CrossFileScoping(t *testing.T) { t.Run("undeclared across sibling trees", func(t *testing.T) { loader := loadTestFiles(t, map[string]string{ "root_a.journal": "2026/01/01 * \"A\"\n Expenses:Food $10\n Assets:Cash\n", "root_b.journal": "account Expenses:Food\n", }) finds := runLint(t, loader) n := 0 for _, f := range finds { if f.Code == "undeclared-account" { n++ } } if n != 2 { t.Fatalf("expected 2 undeclared (Expenses:Food + Assets:Cash in root_a), got %d", n) } }) t.Run("declaration in included file visible", func(t *testing.T) { loader := loadTestFiles(t, map[string]string{ "child.journal": "account Expenses:Food\n", "parent.journal": "include child.journal\n\n2026/01/01 * \"Lunch\"\n Expenses:Food $10\n Assets:Cash\n", }) finds := runLint(t, loader) n := 0 for _, f := range finds { if f.Code == "undeclared-account" { n++ } } if n != 1 { t.Fatalf("expected 1 undeclared (Assets:Cash), got %d", n) } }) t.Run("duplicate across sibling trees not flagged", func(t *testing.T) { loader := loadTestFiles(t, map[string]string{ "a.journal": "account Expenses:Food\n", "b.journal": "account Expenses:Food\n", }) finds := runLint(t, loader) for _, f := range finds { if f.Code == "duplicated-account" { t.Fatalf("unexpected duplicate across trees: %v", f) } } }) } func BenchmarkLinter(b *testing.B) { ldr := journal.NewLoader() _, err := ldr.Load( "../../journal/testdata/journals/actual-1ktxns-100accts.journal", ) if err != nil { b.Fatalf("failed to load benchmark journal: %v", err) } ctx := semantic.Build(ldr.Ordered()) l := NewLinter(Rules) b.ResetTimer() b.ReportAllocs() for b.Loop() { l.Run(ctx) } } func loadTestFiles(t testing.TB, files map[string]string) *journal.Loader { t.Helper() dir := t.TempDir() loader := journal.NewLoader() for name, content := range files { if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { t.Fatal(err) } if _, err := loader.Load(filepath.Join(dir, name)); err != nil { t.Fatal(err) } } return loader } func runLint(t testing.TB, loader *journal.Loader) []Find { t.Helper() lint := NewLinter(Rules) var finds []Find for _, root := range loader.Roots() { finds = append(finds, lint.Run(semantic.Build(journal.CollectFiles(root)))...) } return finds }