14 files changed,
406 insertions(+),
0 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-06-10 21:18:36 +0300
Authored at:
2026-06-10 14:02:13 +0300
Change ID:
qptnkrnkynsrrqkuzyruukusqtqmuuvu
Parent:
232cfab
jump to
A
internal/linter/linter.go
··· 1 +package linter 2 + 3 +import ( 4 + "olexsmir.xyz/clerk/journal/ast" 5 + "olexsmir.xyz/clerk/journal/token" 6 +) 7 + 8 +// A Find represents a single lint finding. 9 +type Find struct { 10 + Code RuleID 11 + Severity Severity 12 + Message string 13 + Span token.Span 14 +} 15 + 16 +// Linter runs lint rules against a parsed journal. 17 +type Linter struct { 18 + rules []Rule 19 +} 20 + 21 +// NewLinter creates a [Linter] with the given rules. 22 +func NewLinter(rules []Rule) *Linter { 23 + return &Linter{rules: rules} 24 +} 25 + 26 +// Run runs all rules against the journal. 27 +func (l *Linter) Run(j *ast.Journal) []Find { 28 + var finds []Find 29 + 30 + // single traversal 31 + for _, entry := range j.Entries { 32 + for _, rule := range l.rules { 33 + if ec, ok := rule.(EntryChecker); ok { 34 + finds = append(finds, ec.CheckEntry(entry)...) 35 + } 36 + } 37 + } 38 + 39 + // post-traversal 40 + for _, rule := range l.rules { 41 + if jc, ok := rule.(JournalChecker); ok { 42 + finds = append(finds, jc.CheckJournal(j)...) 43 + } 44 + } 45 + 46 + return finds 47 +} 48 + 49 +// Severity represents severity of the litning find. 50 +type Severity int 51 + 52 +const ( 53 + SeverityError Severity = 1 54 + SeverityWarning Severity = 2 55 + SeverityInfo Severity = 3 56 + SeverityHint Severity = 4 57 +) 58 + 59 +func (s Severity) String() string { 60 + switch s { 61 + case SeverityError: 62 + return "error" 63 + case SeverityWarning: 64 + return "warning" 65 + case SeverityInfo: 66 + return "info" 67 + case SeverityHint: 68 + return "hint" 69 + } 70 + panic("impossible severity state") 71 +}
A
internal/linter/linter_test.go
··· 1 +package linter 2 + 3 +import ( 4 + "strings" 5 + "testing" 6 + 7 + "olexsmir.xyz/clerk/internal/testutil/golden" 8 + "olexsmir.xyz/clerk/journal" 9 +) 10 + 11 +var tests = map[string][]Rule{ 12 + "correct": Rules, 13 + "empty-postings": {&EmptyPostings{}}, 14 + "parse-error": {&ParseError{}}, 15 +} 16 + 17 +func TestLinter(t *testing.T) { 18 + for tname, trules := range tests { 19 + t.Run(tname, func(t *testing.T) { 20 + inp := golden.Load(t, tname) 21 + pf, err := journal.NewLoader().LoadBytes(tname+".journal", inp) 22 + if err != nil { 23 + t.Fatalf("failed to load test journal: %v\n", err) 24 + } 25 + 26 + finds := NewLinter(trules).Run(pf.Ast) 27 + 28 + var b strings.Builder 29 + Fprint(&b, PathBasename, finds) 30 + golden.Assert(t, tname, b.String()) 31 + }) 32 + } 33 +} 34 + 35 +func BenchmarkLinter(b *testing.B) { 36 + pf, err := journal.NewLoader().Load( 37 + "../../journal/testdata/journals/actual-1ktxns-100accts.journal", 38 + ) 39 + if err != nil { 40 + b.Fatalf("failed to load benchmark journal: %v", err) 41 + } 42 + l := NewLinter(Rules) 43 + 44 + b.ResetTimer() 45 + b.ReportAllocs() 46 + for b.Loop() { 47 + l.Run(pf.Ast) 48 + } 49 +}
A
internal/linter/report.go
··· 1 +package linter 2 + 3 +import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "os" 8 + "path/filepath" 9 +) 10 + 11 +// PathStyle controls how file paths are shown in output. 12 +type PathStyle int 13 + 14 +const ( 15 + PathBasename PathStyle = iota // just the filename (e.g. "entry.journal") 16 + PathAbsolute // full absolute path 17 + PathRelative // relative to current directory 18 +) 19 + 20 +// Fprint writes finds in text format: file:line:col code: message. 21 +func Fprint(w io.Writer, style PathStyle, finds []Find) { 22 + for _, find := range finds { 23 + fmt.Fprintf(w, "%s:%d:%d: %s: %s\n", 24 + formatPath(style, find.Span.Start.File), 25 + find.Span.Start.Line, find.Span.Start.Col, 26 + find.Code, find.Message) 27 + } 28 +} 29 + 30 +type FindJSON struct { 31 + Message string `json:"message"` 32 + Severity string `json:"severity"` 33 + Code string `json:"code"` 34 + File string `json:"file"` 35 + Line int `json:"line"` 36 + Column int `json:"column"` 37 +} 38 + 39 +// FprintJSON writes finds as [FindJSON] array. 40 +func FprintJSON(w io.Writer, style PathStyle, finds []Find) error { 41 + jsonFinds := make([]FindJSON, len(finds)) 42 + for i, find := range finds { 43 + jsonFinds[i] = FindJSON{ 44 + Message: find.Message, 45 + Severity: find.Severity.String(), 46 + Code: string(find.Code), 47 + File: formatPath(style, find.Span.Start.File), 48 + Line: find.Span.Start.Line, 49 + Column: find.Span.Start.Col, 50 + } 51 + } 52 + return json.NewEncoder(w).Encode(jsonFinds) 53 +} 54 + 55 +func formatPath(style PathStyle, p string) string { 56 + switch style { 57 + case PathBasename: 58 + return filepath.Base(p) 59 + case PathAbsolute: 60 + return p 61 + case PathRelative: 62 + wd, err := os.Getwd() 63 + if err == nil { 64 + if rel, err := filepath.Rel(wd, p); err == nil { 65 + return rel 66 + } 67 + } 68 + return p 69 + default: 70 + panic("impossible PathStyle value") 71 + } 72 +}
A
internal/linter/rule_empty_postings.go
··· 1 +package linter 2 + 3 +import "olexsmir.xyz/clerk/journal/ast" 4 + 5 +// EmptyPostings flags transactions that have no postings. 6 +type EmptyPostings struct{} 7 + 8 +func (EmptyPostings) ID() RuleID { return "empty-postings" } 9 +func (EmptyPostings) Severity() Severity { return SeverityError } 10 +func (EmptyPostings) Description() string { return "transaction has no postings" } 11 +func (e *EmptyPostings) CheckEntry(entry ast.Entry) []Find { 12 + txn, ok := entry.(*ast.Transaction) 13 + if !ok { 14 + return nil 15 + } 16 + if len(txn.Postings) == 0 { 17 + return []Find{{ 18 + Code: e.ID(), 19 + Severity: e.Severity(), 20 + Message: e.Description(), 21 + Span: txn.Span, 22 + }} 23 + } 24 + return nil 25 +}
A
internal/linter/rule_parse_error.go
··· 1 +package linter 2 + 3 +import "olexsmir.xyz/clerk/journal/ast" 4 + 5 +// ParseError wraps parser errors into lint findings. 6 +type ParseError struct{} 7 + 8 +func (ParseError) ID() RuleID { return "parse-error" } 9 +func (ParseError) Severity() Severity { return SeverityError } 10 +func (ParseError) Description() string { return "parse error" } 11 +func (e *ParseError) CheckJournal(j *ast.Journal) []Find { 12 + var finds []Find 13 + for _, err := range j.Errors { 14 + finds = append(finds, Find{ 15 + Code: e.ID(), 16 + Severity: e.Severity(), 17 + Message: err.Message, 18 + Span: err.Span, 19 + }) 20 + } 21 + return finds 22 +}
A
internal/linter/rules.go
··· 1 +package linter 2 + 3 +import "olexsmir.xyz/clerk/journal/ast" 4 + 5 +type RuleID string 6 + 7 +// Rule is the best interface that every rule must implement. 8 +type Rule interface { 9 + ID() RuleID 10 + Severity() Severity 11 + Description() string 12 +} 13 + 14 +// EntryChecker implements pre entry linting during. 15 +type EntryChecker interface { 16 + CheckEntry(entry ast.Entry) []Find 17 + Rule 18 +} 19 + 20 +// JournalChecker implements whole journal linting. 21 +type JournalChecker interface { 22 + CheckJournal(journal *ast.Journal) []Find 23 + Rule 24 +} 25 + 26 +// Rules is list of all available rules. 27 +var Rules = []Rule{ 28 + &ParseError{}, 29 + &EmptyPostings{}, 30 +}
A
internal/linter/testdata/correct.input
··· 1 +2024-05-01 "groceries" 2 + expenses:food 50.00 USD 3 + assets:cash
A
internal/linter/testdata/empty-postings.golden
··· 1 +empty-postings.journal:1:1: empty-postings: transaction has no postings
A
internal/linter/testdata/empty-postings.input
··· 1 +2024-05-01 empty txn 2 + 3 +2024-05-01 "not empty txn" 4 + my:account
A
internal/linter/testdata/parse-error.golden
··· 1 +parse-error.journal:1:1: parse-error: unexpected token AT
A
lint.go
··· 1 +package main 2 + 3 +import ( 4 + "flag" 5 + "fmt" 6 + "io" 7 + "os" 8 + "path/filepath" 9 + "strings" 10 + 11 + "olexsmir.xyz/clerk/internal/linter" 12 + "olexsmir.xyz/clerk/journal" 13 +) 14 + 15 +func runLint(args []string) { 16 + fs := flag.NewFlagSet("lint", flag.ExitOnError) 17 + format := fs.String("format", "text", "output format: text, json") 18 + pathStyle := fs.String("path-style", "relative", "file path style: basename, relative, absolute") 19 + fs.Usage = func() { 20 + fmt.Fprintf(os.Stderr, "Usage: clerk lint [flags] [path...]\n") 21 + fs.PrintDefaults() 22 + } 23 + fs.Parse(args) 24 + 25 + style := parsePathStyle(*pathStyle) 26 + l := linter.NewLinter(linter.Rules) 27 + paths := fs.Args() 28 + 29 + if len(paths) == 0 { 30 + finds := lintStdin(l) 31 + report(finds, *format, style) 32 + if hasFatal(finds) { 33 + os.Exit(1) 34 + } 35 + os.Exit(0) 36 + } 37 + 38 + exitCode := 0 39 + for _, path := range paths { 40 + info, err := os.Stat(path) 41 + if err != nil { 42 + fmt.Fprintf(os.Stderr, "error: %s: %v\n", path, err) 43 + exitCode = 1 44 + continue 45 + } 46 + if info.IsDir() { 47 + filepath.Walk(path, func(fpath string, finfo os.FileInfo, err error) error { 48 + if err != nil || finfo.IsDir() || !journal.IsJournalFile(fpath) { 49 + return nil 50 + } 51 + fatal := lintFileAndReport(l, fpath, *format, style) 52 + if fatal { 53 + exitCode = 1 54 + } 55 + return nil 56 + }) 57 + continue 58 + } 59 + if lintFileAndReport(l, path, *format, style) { 60 + exitCode = 1 61 + } 62 + } 63 + os.Exit(exitCode) 64 +} 65 + 66 +// lintFileAndReport reads, lints, reports, and returns whether a fatal finding was found. 67 +func lintFileAndReport(l *linter.Linter, path, format string, style linter.PathStyle) bool { 68 + src, err := os.ReadFile(path) 69 + if err != nil { 70 + fmt.Fprintf(os.Stderr, "error: %s: %v\n", path, err) 71 + return true 72 + } 73 + pf, err := journal.NewLoader().LoadBytes(path, src) 74 + if err != nil { 75 + fmt.Fprintf(os.Stderr, "error: %s: %v\n", path, err) 76 + return true 77 + } 78 + finds := l.Run(pf.Ast) 79 + report(finds, format, style) 80 + return hasFatal(finds) 81 +} 82 + 83 +func lintStdin(l *linter.Linter) []linter.Find { 84 + src, err := io.ReadAll(os.Stdin) 85 + if err != nil { 86 + fmt.Fprintf(os.Stderr, "error reading stdin: %v\n", err) 87 + os.Exit(1) 88 + } 89 + pf, err := journal.NewLoader().LoadBytes("stdin", src) 90 + if err != nil { 91 + fmt.Fprintf(os.Stderr, "error: %v\n", err) 92 + os.Exit(1) 93 + } 94 + return l.Run(pf.Ast) 95 +} 96 + 97 +func report(finds []linter.Find, format string, style linter.PathStyle) { 98 + switch format { 99 + case "json": 100 + linter.FprintJSON(os.Stdout, style, finds) 101 + default: 102 + linter.Fprint(os.Stdout, style, finds) 103 + } 104 +} 105 + 106 +func hasFatal(finds []linter.Find) bool { 107 + for _, f := range finds { 108 + if f.Severity <= 2 { 109 + return true 110 + } 111 + } 112 + return false 113 +} 114 + 115 +func parsePathStyle(s string) linter.PathStyle { 116 + switch strings.ToLower(s) { 117 + default: 118 + return linter.PathRelative 119 + case "basename": 120 + return linter.PathBasename 121 + case "absolute": 122 + return linter.PathAbsolute 123 + } 124 +}
M
main.go
··· 16 16 runFormat(os.Args[2:]) 17 17 case "tags": 18 18 runTags(os.Args[2:]) 19 + case "lint": 20 + runLint(os.Args[2:]) 19 21 default: 20 22 usage() 21 23 os.Exit(1) ··· 28 30 Commands: 29 31 tags Generate ctags-compatible tag file 30 32 format Format journal files 33 + lint Lint journal files 31 34 `) 32 35 }