all repos

clerk @ 56a4631

missing tooling for ledger/hledger
14 files changed, 406 insertions(+), 0 deletions(-)
implement basic linter
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
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.golden

Not showing binary file.

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
         }