all repos

clerk @ e5cc255

missing tooling for ledger/hledger
19 files changed, 740 insertions(+), 0 deletions(-)
generate tags
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-06-04 13:42:16 +0300
Authored at: 2026-06-02 14:03:23 +0300
Change ID: yxmkuqvuvorysywtyntwxmkqxxovwony
Parent: 66c8add
A internal/tags/tags.go
···
        
        1
        +package tags

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"fmt"

      
        
        5
        +	"io"

      
        
        6
        +	"slices"

      
        
        7
        +	"sort"

      
        
        8
        +	"strings"

      
        
        9
        +)

      
        
        10
        +

      
        
        11
        +type Entry struct {

      
        
        12
        +	Name     string

      
        
        13
        +	Kind     byte

      
        
        14
        +	File     string // file path (relative or absolute)

      
        
        15
        +	Line     int    // line number

      
        
        16
        +	Pattern  string // the full line content (without /^ $/ wrappers)

      
        
        17
        +	Language string // language field, emitted per-entry when non-empty

      
        
        18
        +}

      
        
        19
        +

      
        
        20
        +type Writer struct {

      
        
        21
        +	entries []Entry

      
        
        22
        +	kinds   map[byte]string // kind: description

      
        
        23
        +}

      
        
        24
        +

      
        
        25
        +func NewWriter() *Writer {

      
        
        26
        +	return &Writer{kinds: make(map[byte]string)}

      
        
        27
        +}

      
        
        28
        +

      
        
        29
        +func (w *Writer) DescribeKind(kind byte, description string) {

      
        
        30
        +	w.kinds[kind] = description

      
        
        31
        +}

      
        
        32
        +

      
        
        33
        +func (w *Writer) Add(entry Entry) {

      
        
        34
        +	w.entries = append(w.entries, entry)

      
        
        35
        +}

      
        
        36
        +

      
        
        37
        +func (w *Writer) Write(to io.Writer) error {

      
        
        38
        +	// Pseudo-tags

      
        
        39
        +	_, _ = fmt.Fprintf(to, "!_TAG_FILE_FORMAT\t2\t/extended format/\n")

      
        
        40
        +	_, _ = fmt.Fprintf(to, "!_TAG_FILE_SORTED\t1\t/1=sorted/\n")

      
        
        41
        +	_, _ = fmt.Fprintf(to, "!_TAG_PROGRAM_NAME\tclerk\t//\n")

      
        
        42
        +	_, _ = fmt.Fprintf(to, "!_TAG_PROGRAM_URL\thttps://olexsmir.xyz/clerk\t//\n") // TODO:

      
        
        43
        +	_, _ = fmt.Fprintf(to, "!_TAG_PROGRAM_VERSION\t0.1.0\t//\n")

      
        
        44
        +

      
        
        45
        +	// Kind descriptions

      
        
        46
        +	var kindLetters []byte

      
        
        47
        +	for k := range w.kinds {

      
        
        48
        +		kindLetters = append(kindLetters, k)

      
        
        49
        +	}

      
        
        50
        +

      
        
        51
        +	slices.Sort(kindLetters)

      
        
        52
        +	for _, k := range kindLetters {

      
        
        53
        +		_, _ = fmt.Fprintf(to, "!_TAG_KIND_DESCRIPTION!%c\t%c,%s\t/%s/\n", k, k, w.kinds[k], w.kinds[k])

      
        
        54
        +	}

      
        
        55
        +

      
        
        56
        +	// Tags

      
        
        57
        +	sort.Slice(w.entries, func(i, j int) bool {

      
        
        58
        +		if w.entries[i].Name != w.entries[j].Name {

      
        
        59
        +			return w.entries[i].Name < w.entries[j].Name

      
        
        60
        +		}

      
        
        61
        +		if w.entries[i].Kind != w.entries[j].Kind {

      
        
        62
        +			return w.entries[i].Kind < w.entries[j].Kind

      
        
        63
        +		}

      
        
        64
        +		if w.entries[i].File != w.entries[j].File {

      
        
        65
        +			return w.entries[i].File < w.entries[j].File

      
        
        66
        +		}

      
        
        67
        +		return w.entries[i].Line < w.entries[j].Line

      
        
        68
        +	})

      
        
        69
        +

      
        
        70
        +	for _, e := range w.entries {

      
        
        71
        +		_, _ = fmt.Fprintf(to, "%s\t%s\t/^%s$/;\"\tkind:%c\tline:%d", e.Name, e.File, escapePattern(e.Pattern), e.Kind, e.Line)

      
        
        72
        +		if e.Language != "" {

      
        
        73
        +			_, _ = fmt.Fprintf(to, "\tlanguage:%s", e.Language)

      
        
        74
        +		}

      
        
        75
        +		_, _ = fmt.Fprintf(to, "\n")

      
        
        76
        +	}

      
        
        77
        +

      
        
        78
        +	return nil

      
        
        79
        +}

      
        
        80
        +

      
        
        81
        +func escapePattern(s string) string {

      
        
        82
        +	s = strings.ReplaceAll(s, `\`, `\\`)

      
        
        83
        +	s = strings.ReplaceAll(s, `/`, `\/`)

      
        
        84
        +	return s

      
        
        85
        +}

      
A internal/tags/tags_test.go
···
        
        1
        +package tags

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"bytes"

      
        
        5
        +	"strings"

      
        
        6
        +	"testing"

      
        
        7
        +)

      
        
        8
        +

      
        
        9
        +// ctags extended format: https://ctags.sourceforge.net/FORMAT

      
        
        10
        +

      
        
        11
        +func TestWriterRoundTrip(t *testing.T) {

      
        
        12
        +	w := NewWriter()

      
        
        13
        +	w.DescribeKind('a', "account")

      
        
        14
        +	w.DescribeKind('c', "commodity")

      
        
        15
        +	w.Add(Entry{Name: "expenses:food", Kind: 'a', File: "test.journal", Line: 3, Pattern: "account expenses:food", Language: "hledger"})

      
        
        16
        +	w.Add(Entry{Name: "$", Kind: 'c', File: "test.journal", Line: 9, Pattern: "    expenses:food   $50.00", Language: "hledger"})

      
        
        17
        +	w.Add(Entry{Name: "assets:bank", Kind: 'a', File: "test.journal", Line: 5, Pattern: "account assets:bank", Language: "hledger"})

      
        
        18
        +

      
        
        19
        +	var buf bytes.Buffer

      
        
        20
        +	if err := w.Write(&buf); err != nil {

      
        
        21
        +		t.Fatal(err)

      
        
        22
        +	}

      
        
        23
        +	out := buf.String()

      
        
        24
        +

      
        
        25
        +	// pseudo-tags

      
        
        26
        +	for _, tag := range []string{

      
        
        27
        +		"!_TAG_FILE_FORMAT\t2",

      
        
        28
        +		"!_TAG_FILE_SORTED\t1",

      
        
        29
        +		"!_TAG_PROGRAM_NAME\tclerk",

      
        
        30
        +		"!_TAG_PROGRAM_URL\t",

      
        
        31
        +		"!_TAG_PROGRAM_VERSION\t",

      
        
        32
        +	} {

      
        
        33
        +		if !strings.Contains(out, tag) {

      
        
        34
        +			t.Errorf("missing: %q", tag)

      
        
        35
        +		}

      
        
        36
        +	}

      
        
        37
        +

      
        
        38
        +	// Verify language field on each tag entry

      
        
        39
        +	for line := range strings.SplitSeq(out, "\n") {

      
        
        40
        +		if strings.HasPrefix(line, "!_TAG_") || line == "" {

      
        
        41
        +			continue

      
        
        42
        +		}

      
        
        43
        +		if !strings.Contains(line, "\tlanguage:hledger") {

      
        
        44
        +			t.Errorf("tag entry missing language:hledger: %q", line)

      
        
        45
        +		}

      
        
        46
        +	}

      
        
        47
        +

      
        
        48
        +	var firstTag string

      
        
        49
        +	for line := range strings.SplitSeq(out, "\n") {

      
        
        50
        +		if !strings.HasPrefix(line, "!_TAG_") && line != "" {

      
        
        51
        +			firstTag = line

      
        
        52
        +			break

      
        
        53
        +		}

      
        
        54
        +	}

      
        
        55
        +

      
        
        56
        +	if !strings.HasPrefix(firstTag, "$\ttest.journal\t/") {

      
        
        57
        +		t.Errorf("unexpected first entry: %q", firstTag)

      
        
        58
        +	}

      
        
        59
        +	if !strings.Contains(firstTag, "kind:c") {

      
        
        60
        +		t.Errorf("missing kind:c: %q", firstTag)

      
        
        61
        +	}

      
        
        62
        +	if !strings.Contains(firstTag, "line:9") {

      
        
        63
        +		t.Errorf("missing line:9: %q", firstTag)

      
        
        64
        +	}

      
        
        65
        +}

      
        
        66
        +

      
        
        67
        +func TestNoLanguageNoFieldDescription(t *testing.T) {

      
        
        68
        +	w := NewWriter()

      
        
        69
        +	w.DescribeKind('a', "account")

      
        
        70
        +	w.Add(Entry{Name: "test", Kind: 'a', File: "f", Line: 1, Pattern: "account test"})

      
        
        71
        +

      
        
        72
        +	var buf bytes.Buffer

      
        
        73
        +	if err := w.Write(&buf); err != nil {

      
        
        74
        +		t.Fatal(err)

      
        
        75
        +	}

      
        
        76
        +	out := buf.String()

      
        
        77
        +

      
        
        78
        +	if strings.Contains(out, "\tlanguage:") {

      
        
        79
        +		t.Error("language: field should not appear when no entries have Language set")

      
        
        80
        +	}

      
        
        81
        +}

      
A internal/testutil/testutil.go
···
        
        1
        +package testutil

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"os"

      
        
        5
        +	"testing"

      
        
        6
        +)

      
        
        7
        +

      
        
        8
        +func WriteFile(t testing.TB, fpath string, src []byte) {

      
        
        9
        +	t.Helper()

      
        
        10
        +	if err := os.WriteFile(fpath, src, 0o644); err != nil {

      
        
        11
        +		t.Fatalf("failed to write '%s': %v", fpath, err)

      
        
        12
        +	}

      
        
        13
        +}

      
A journal/journal.go
···
        
        1
        +package journal

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"path/filepath"

      
        
        5
        +	"strings"

      
        
        6
        +)

      
        
        7
        +

      
        
        8
        +func IsJournalFile(name string) bool {

      
        
        9
        +	ext := strings.ToLower(filepath.Ext(name))

      
        
        10
        +	return extensionSet[ext]

      
        
        11
        +}

      
        
        12
        +

      
        
        13
        +var (

      
        
        14
        +	extensionSet        map[string]bool

      
        
        15
        +	SupportedExtensions = [...]string{

      
        
        16
        +		".journal", ".hledger",

      
        
        17
        +		".dat", ".ledger",

      
        
        18
        +		".jrnl",

      
        
        19
        +	}

      
        
        20
        +)

      
        
        21
        +

      
        
        22
        +func init() {

      
        
        23
        +	extensionSet = make(map[string]bool, len(SupportedExtensions))

      
        
        24
        +	for _, ext := range SupportedExtensions {

      
        
        25
        +		extensionSet[ext] = true

      
        
        26
        +	}

      
        
        27
        +}

      
A journal/tags/tag.go
···
        
        1
        +package tags

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"io"

      
        
        5
        +	"path/filepath"

      
        
        6
        +	"sort"

      
        
        7
        +

      
        
        8
        +	"olexsmir.xyz/clerk/internal/tags"

      
        
        9
        +	"olexsmir.xyz/clerk/journal"

      
        
        10
        +	"olexsmir.xyz/clerk/journal/ast"

      
        
        11
        +	"olexsmir.xyz/clerk/journal/token"

      
        
        12
        +)

      
        
        13
        +

      
        
        14
        +const (

      
        
        15
        +	KindAccount     = 'a'

      
        
        16
        +	KindAccountDesc = "account"

      
        
        17
        +

      
        
        18
        +	KindCommodity     = 'c'

      
        
        19
        +	KindCommodityDesc = "commodity"

      
        
        20
        +

      
        
        21
        +	KindPayee     = 'p'

      
        
        22
        +	KindPayeeDesc = "payee"

      
        
        23
        +

      
        
        24
        +	KindTag     = 't'

      
        
        25
        +	KindTagDesc = "tag"

      
        
        26
        +)

      
        
        27
        +

      
        
        28
        +// priority constants for source types - lower wins.

      
        
        29
        +const (

      
        
        30
        +	prioTagDirective = 0

      
        
        31
        +

      
        
        32
        +	prioAccountDirective = 0

      
        
        33
        +	prioAliasDirective   = 1

      
        
        34
        +	prioAccountPosting   = 2

      
        
        35
        +

      
        
        36
        +	prioCommodityDirective = 0

      
        
        37
        +	prioMarketPrice        = 1

      
        
        38
        +	prioAmountCommodity    = 2

      
        
        39
        +

      
        
        40
        +	prioPayeeDirective   = 0

      
        
        41
        +	prioPayeeTransaction = 1

      
        
        42
        +)

      
        
        43
        +

      
        
        44
        +// candidate is a potential tag before sorting and dedup.

      
        
        45
        +type candidate struct {

      
        
        46
        +	name     string

      
        
        47
        +	kind     byte

      
        
        48
        +	file     string

      
        
        49
        +	line     int

      
        
        50
        +	priority int

      
        
        51
        +	src      []byte

      
        
        52
        +	offset   int

      
        
        53
        +}

      
        
        54
        +

      
        
        55
        +type Tagger struct {

      
        
        56
        +	loader *journal.Loader

      
        
        57
        +	relDir string

      
        
        58
        +

      
        
        59
        +	candidates []candidate

      
        
        60
        +}

      
        
        61
        +

      
        
        62
        +// New creates a [Tagger]. If relDir is not empty, file paths in the tag output are made relative to relDir.

      
        
        63
        +func New(loader *journal.Loader, relDir string) *Tagger {

      
        
        64
        +	return &Tagger{loader: loader, relDir: relDir}

      
        
        65
        +}

      
        
        66
        +

      
        
        67
        +// Write generate tags file and write it in tags format.

      
        
        68
        +func (t *Tagger) Write(w io.Writer) error {

      
        
        69
        +	tw := tags.NewWriter()

      
        
        70
        +	tw.DescribeKind(KindAccount, KindAccountDesc)

      
        
        71
        +	tw.DescribeKind(KindCommodity, KindCommodityDesc)

      
        
        72
        +	tw.DescribeKind(KindPayee, KindPayeeDesc)

      
        
        73
        +	tw.DescribeKind(KindTag, KindTagDesc)

      
        
        74
        +	for _, e := range t.collect() {

      
        
        75
        +		tw.Add(e)

      
        
        76
        +	}

      
        
        77
        +	return tw.Write(w)

      
        
        78
        +}

      
        
        79
        +

      
        
        80
        +func (t *Tagger) collect() []tags.Entry {

      
        
        81
        +	t.candidates = nil

      
        
        82
        +

      
        
        83
        +	for _, pf := range t.loader.Ordered() {

      
        
        84
        +		filePath := pf.Path

      
        
        85
        +		if t.relDir != "" {

      
        
        86
        +			filePath = relativePath(filePath, t.relDir)

      
        
        87
        +		}

      
        
        88
        +		for _, entry := range pf.Ast.Entries {

      
        
        89
        +			t.collectFromEntry(pf, filePath, entry)

      
        
        90
        +		}

      
        
        91
        +	}

      
        
        92
        +

      
        
        93
        +	// sort by priority

      
        
        94
        +	sort.Slice(t.candidates, func(i, j int) bool {

      
        
        95
        +		if t.candidates[i].name != t.candidates[j].name {

      
        
        96
        +			return t.candidates[i].name < t.candidates[j].name

      
        
        97
        +		}

      
        
        98
        +		if t.candidates[i].kind != t.candidates[j].kind {

      
        
        99
        +			return t.candidates[i].kind < t.candidates[j].kind

      
        
        100
        +		}

      
        
        101
        +		if t.candidates[i].priority != t.candidates[j].priority {

      
        
        102
        +			return t.candidates[i].priority < t.candidates[j].priority

      
        
        103
        +		}

      
        
        104
        +		if t.candidates[i].file != t.candidates[j].file {

      
        
        105
        +			return t.candidates[i].file < t.candidates[j].file

      
        
        106
        +		}

      
        
        107
        +		return t.candidates[i].line < t.candidates[j].line

      
        
        108
        +	})

      
        
        109
        +

      
        
        110
        +	// deduplicate

      
        
        111
        +	var entries []tags.Entry

      
        
        112
        +	for i, c := range t.candidates {

      
        
        113
        +		if i > 0 && t.candidates[i-1].name == c.name && t.candidates[i-1].kind == c.kind {

      
        
        114
        +			continue

      
        
        115
        +		}

      
        
        116
        +		entries = append(entries, tags.Entry{

      
        
        117
        +			Name:     c.name,

      
        
        118
        +			File:     c.file,

      
        
        119
        +			Line:     c.line,

      
        
        120
        +			Pattern:  extractLine(c.src, c.offset),

      
        
        121
        +			Kind:     c.kind,

      
        
        122
        +			Language: "hledger",

      
        
        123
        +		})

      
        
        124
        +	}

      
        
        125
        +

      
        
        126
        +	return entries

      
        
        127
        +}

      
        
        128
        +

      
        
        129
        +func (t *Tagger) collectFromEntry(pf *journal.ParsedFile, fpath string, entry ast.Entry) {

      
        
        130
        +	switch e := entry.(type) {

      
        
        131
        +	case *ast.AccountDirective:

      
        
        132
        +		t.addCandidate(e.Account.Name, fpath, e.Account.Span, pf.Src, KindAccount, prioAccountDirective)

      
        
        133
        +

      
        
        134
        +	case *ast.CommodityDirective:

      
        
        135
        +		t.addCandidate(e.Commodity, fpath, e.Span, pf.Src, KindCommodity, prioCommodityDirective)

      
        
        136
        +

      
        
        137
        +	case *ast.PayeeDirective:

      
        
        138
        +		t.addCandidate(e.Name, fpath, e.Span, pf.Src, KindPayee, prioPayeeDirective)

      
        
        139
        +

      
        
        140
        +	case *ast.TagDirective:

      
        
        141
        +		t.addCandidate(e.Name, fpath, e.Span, pf.Src, KindTag, prioTagDirective)

      
        
        142
        +

      
        
        143
        +	case *ast.Transaction:

      
        
        144
        +		if e.Payee != nil {

      
        
        145
        +			t.addCandidate(e.Payee.Name, fpath, e.Payee.Span, pf.Src, KindPayee, prioPayeeTransaction)

      
        
        146
        +		}

      
        
        147
        +		for _, p := range e.Postings {

      
        
        148
        +			t.collectFromPosting(p, fpath, pf.Src)

      
        
        149
        +		}

      
        
        150
        +

      
        
        151
        +	case *ast.PeriodicTransaction:

      
        
        152
        +		for _, p := range e.Postings {

      
        
        153
        +			t.collectFromPosting(p, fpath, pf.Src)

      
        
        154
        +		}

      
        
        155
        +

      
        
        156
        +	case *ast.AutomatedTransaction:

      
        
        157
        +		for _, p := range e.Postings {

      
        
        158
        +			t.collectFromPosting(p, fpath, pf.Src)

      
        
        159
        +		}

      
        
        160
        +

      
        
        161
        +	case *ast.MarketPriceDirective:

      
        
        162
        +		t.addCandidate(e.Commodity, fpath, e.Span, pf.Src, KindCommodity, prioMarketPrice)

      
        
        163
        +		if e.Amount.Commodity != "" {

      
        
        164
        +			t.addCandidate(e.Amount.Commodity, fpath, e.Amount.Span, pf.Src, KindCommodity, prioMarketPrice)

      
        
        165
        +		}

      
        
        166
        +

      
        
        167
        +	case *ast.DefaultCommodityDirective:

      
        
        168
        +		if e.Amount.Commodity != "" {

      
        
        169
        +			t.addCandidate(e.Amount.Commodity, fpath, e.Amount.Span, pf.Src, KindCommodity, prioAmountCommodity)

      
        
        170
        +		}

      
        
        171
        +

      
        
        172
        +	case *ast.ConversionDirective:

      
        
        173
        +		if e.From.Commodity != "" {

      
        
        174
        +			t.addCandidate(e.From.Commodity, fpath, e.From.Span, pf.Src, KindCommodity, prioAmountCommodity)

      
        
        175
        +		}

      
        
        176
        +		if e.To.Commodity != "" {

      
        
        177
        +			t.addCandidate(e.To.Commodity, fpath, e.To.Span, pf.Src, KindCommodity, prioAmountCommodity)

      
        
        178
        +		}

      
        
        179
        +

      
        
        180
        +	case *ast.AliasDirective:

      
        
        181
        +		t.addCandidate(e.From, fpath, e.Span, pf.Src, KindAccount, prioAliasDirective)

      
        
        182
        +		t.addCandidate(e.To, fpath, e.Span, pf.Src, KindAccount, prioAliasDirective)

      
        
        183
        +	}

      
        
        184
        +}

      
        
        185
        +

      
        
        186
        +func (t *Tagger) collectFromPosting(p *ast.Posting, filePath string, src []byte) {

      
        
        187
        +	t.addCandidate(p.Account.Name, filePath, p.Account.Span, src, KindAccount, prioAccountPosting)

      
        
        188
        +	if p.Amount != nil && p.Amount.Commodity != "" {

      
        
        189
        +		t.addCandidate(p.Amount.Commodity, filePath, p.Amount.Span, src, KindCommodity, prioAmountCommodity)

      
        
        190
        +	}

      
        
        191
        +	if p.Cost != nil && p.Cost.Amount.Commodity != "" {

      
        
        192
        +		t.addCandidate(p.Cost.Amount.Commodity, filePath, p.Cost.Amount.Span, src, KindCommodity, prioAmountCommodity)

      
        
        193
        +	}

      
        
        194
        +	if p.Balance != nil && p.Balance.Amount.Commodity != "" {

      
        
        195
        +		t.addCandidate(p.Balance.Amount.Commodity, filePath, p.Balance.Amount.Span, src, KindCommodity, prioAmountCommodity)

      
        
        196
        +	}

      
        
        197
        +}

      
        
        198
        +

      
        
        199
        +func (t *Tagger) addCandidate(name, fpath string, span token.Span, src []byte, kind byte, priority int) {

      
        
        200
        +	if name == "" {

      
        
        201
        +		return

      
        
        202
        +	}

      
        
        203
        +	t.candidates = append(t.candidates, candidate{

      
        
        204
        +		name:     name,

      
        
        205
        +		kind:     kind,

      
        
        206
        +		file:     fpath,

      
        
        207
        +		line:     span.Start.Line,

      
        
        208
        +		priority: priority,

      
        
        209
        +		src:      src,

      
        
        210
        +		offset:   span.Start.Offset,

      
        
        211
        +	})

      
        
        212
        +}

      
        
        213
        +

      
        
        214
        +func relativePath(target, base string) string {

      
        
        215
        +	rel, err := filepath.Rel(base, target)

      
        
        216
        +	if err != nil {

      
        
        217
        +		return target

      
        
        218
        +	}

      
        
        219
        +	return rel

      
        
        220
        +}

      
        
        221
        +

      
        
        222
        +// extractLine returns the source line containing offset.

      
        
        223
        +// Caller guarantees src is non-nil and 0 <= offset < len(src).

      
        
        224
        +func extractLine(src []byte, offset int) string {

      
        
        225
        +	start := offset

      
        
        226
        +	for start > 0 && src[start-1] != '\n' {

      
        
        227
        +		start--

      
        
        228
        +	}

      
        
        229
        +	end := offset

      
        
        230
        +	for end < len(src) && src[end] != '\n' {

      
        
        231
        +		end++

      
        
        232
        +	}

      
        
        233
        +	return string(src[start:end])

      
        
        234
        +}

      
A journal/tags/tag_test.go
···
        
        1
        +package tags

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"bytes"

      
        
        5
        +	"path/filepath"

      
        
        6
        +	"testing"

      
        
        7
        +

      
        
        8
        +	"olexsmir.xyz/clerk/internal/testutil"

      
        
        9
        +	"olexsmir.xyz/clerk/internal/testutil/golden"

      
        
        10
        +	"olexsmir.xyz/clerk/journal"

      
        
        11
        +)

      
        
        12
        +

      
        
        13
        +func TestTagsGeneration(t *testing.T) {

      
        
        14
        +	tests := []string{"basic", "kinds", "duplicates"}

      
        
        15
        +	for _, tt := range tests {

      
        
        16
        +		t.Run(tt, func(t *testing.T) {

      
        
        17
        +			inp := golden.Load(t, tt)

      
        
        18
        +			out := generateTags(t, inp, tt+".journal")

      
        
        19
        +			golden.AssertInput(t, out, tt)

      
        
        20
        +		})

      
        
        21
        +	}

      
        
        22
        +}

      
        
        23
        +

      
        
        24
        +func TestCrossFileResolution(t *testing.T) {

      
        
        25
        +	t.Parallel()

      
        
        26
        +	tmp := t.TempDir()

      
        
        27
        +

      
        
        28
        +	declSrc := golden.Load(t, "crossfile1")

      
        
        29
        +	jrnlSrc := golden.Load(t, "crossfile2")

      
        
        30
        +	testutil.WriteFile(t, filepath.Join(tmp, "decls.journal"), declSrc)

      
        
        31
        +	testutil.WriteFile(t, filepath.Join(tmp, "2026.journal"), jrnlSrc)

      
        
        32
        +

      
        
        33
        +	loader := journal.NewLoader()

      
        
        34
        +	if _, err := loader.Load(filepath.Join(tmp, "2026.journal")); err != nil {

      
        
        35
        +		t.Fatal(err)

      
        
        36
        +	}

      
        
        37
        +

      
        
        38
        +	tagger := New(loader, tmp)

      
        
        39
        +	var buf bytes.Buffer

      
        
        40
        +	if err := tagger.Write(&buf); err != nil {

      
        
        41
        +		t.Fatal(err)

      
        
        42
        +	}

      
        
        43
        +

      
        
        44
        +	golden.AssertInput(t, buf.String(), "crossfile")

      
        
        45
        +}

      
        
        46
        +

      
        
        47
        +func generateTags(t *testing.T, src []byte, fname string) string {

      
        
        48
        +	t.Helper()

      
        
        49
        +

      
        
        50
        +	l := journal.NewLoader()

      
        
        51
        +	_, err := l.LoadBytes(fname, src)

      
        
        52
        +	if err != nil {

      
        
        53
        +		t.Fatalf("loading journal: %v", err)

      
        
        54
        +	}

      
        
        55
        +	absPath, _ := filepath.Abs(fname)

      
        
        56
        +	tagger := New(l, filepath.Dir(absPath))

      
        
        57
        +	var buf bytes.Buffer

      
        
        58
        +	if err := tagger.Write(&buf); err != nil {

      
        
        59
        +		t.Fatalf("writing tags: %v", err)

      
        
        60
        +	}

      
        
        61
        +	return buf.String()

      
        
        62
        +}

      
A journal/tags/testdata/basic.golden
···
        
        1
        +!_TAG_FILE_FORMAT	2	/extended format/

      
        
        2
        +!_TAG_FILE_SORTED	1	/1=sorted/

      
        
        3
        +!_TAG_PROGRAM_NAME	clerk	//

      
        
        4
        +!_TAG_PROGRAM_URL	https://olexsmir.xyz/clerk	//

      
        
        5
        +!_TAG_PROGRAM_VERSION	0.1.0	//

      
        
        6
        +!_TAG_KIND_DESCRIPTION!a	a,account	/account/

      
        
        7
        +!_TAG_KIND_DESCRIPTION!c	c,commodity	/commodity/

      
        
        8
        +!_TAG_KIND_DESCRIPTION!p	p,payee	/payee/

      
        
        9
        +!_TAG_KIND_DESCRIPTION!t	t,tag	/tag/

      
        
        10
        +$	basic.journal	/^P 2025-01-15 € $1.05$/;"	kind:c	line:16	language:hledger

      
        
        11
        +Groceries	basic.journal	/^2025-01-15 * "Groceries"$/;"	kind:p	line:8	language:hledger

      
        
        12
        +Rent	basic.journal	/^2025-02-01 "Rent"$/;"	kind:p	line:12	language:hledger

      
        
        13
        +assets:bank	basic.journal	/^account assets:bank$/;"	kind:a	line:5	language:hledger

      
        
        14
        +assets:cash	basic.journal	/^account assets:cash$/;"	kind:a	line:6	language:hledger

      
        
        15
        +expenses:food	basic.journal	/^account expenses:food$/;"	kind:a	line:3	language:hledger

      
        
        16
        +expenses:rent	basic.journal	/^account expenses:rent$/;"	kind:a	line:4	language:hledger

      
        
        17
        +€	basic.journal	/^P 2025-01-15 € $1.05$/;"	kind:c	line:16	language:hledger

      
A journal/tags/testdata/basic.input
···
        
        1
        +; basic journal — accounts + commodities from directives and postings

      
        
        2
        +

      
        
        3
        +account expenses:food

      
        
        4
        +account expenses:rent

      
        
        5
        +account assets:bank

      
        
        6
        +account assets:cash

      
        
        7
        +

      
        
        8
        +2025-01-15 * "Groceries"

      
        
        9
        +    expenses:food   $50.00

      
        
        10
        +    assets:bank

      
        
        11
        +

      
        
        12
        +2025-02-01 "Rent"

      
        
        13
        +    expenses:rent   €800.00

      
        
        14
        +    assets:bank     €-800.00

      
        
        15
        +

      
        
        16
        +P 2025-01-15 € $1.05

      
A journal/tags/testdata/crossfile.golden
···
        
        1
        +!_TAG_FILE_FORMAT	2	/extended format/

      
        
        2
        +!_TAG_FILE_SORTED	1	/1=sorted/

      
        
        3
        +!_TAG_PROGRAM_NAME	clerk	//

      
        
        4
        +!_TAG_PROGRAM_URL	https://olexsmir.xyz/clerk	//

      
        
        5
        +!_TAG_PROGRAM_VERSION	0.1.0	//

      
        
        6
        +!_TAG_KIND_DESCRIPTION!a	a,account	/account/

      
        
        7
        +!_TAG_KIND_DESCRIPTION!c	c,commodity	/commodity/

      
        
        8
        +!_TAG_KIND_DESCRIPTION!p	p,payee	/payee/

      
        
        9
        +!_TAG_KIND_DESCRIPTION!t	t,tag	/tag/

      
        
        10
        +$	decls.journal	/^commodity $$/;"	kind:c	line:1	language:hledger

      
        
        11
        +Amazon	2026.journal	/^2024-01-15 "Amazon"$/;"	kind:p	line:3	language:hledger

      
        
        12
        +assets:bank	decls.journal	/^account assets:bank$/;"	kind:a	line:2	language:hledger

      
        
        13
        +expenses:food	2026.journal	/^    expenses:food   $50.00$/;"	kind:a	line:4	language:hledger

      
A journal/tags/testdata/crossfile1.input
···
        
        1
        +commodity $

      
        
        2
        +account assets:bank

      
        
        3
        +payee Amazon

      
A journal/tags/testdata/crossfile2.input
···
        
        1
        +include decls.journal

      
        
        2
        +

      
        
        3
        +2024-01-15 "Amazon"

      
        
        4
        +    expenses:food   $50.00

      
        
        5
        +    assets:bank

      
A journal/tags/testdata/duplicates.golden
···
        
        1
        +!_TAG_FILE_FORMAT	2	/extended format/

      
        
        2
        +!_TAG_FILE_SORTED	1	/1=sorted/

      
        
        3
        +!_TAG_PROGRAM_NAME	clerk	//

      
        
        4
        +!_TAG_PROGRAM_URL	https://olexsmir.xyz/clerk	//

      
        
        5
        +!_TAG_PROGRAM_VERSION	0.1.0	//

      
        
        6
        +!_TAG_KIND_DESCRIPTION!a	a,account	/account/

      
        
        7
        +!_TAG_KIND_DESCRIPTION!c	c,commodity	/commodity/

      
        
        8
        +!_TAG_KIND_DESCRIPTION!p	p,payee	/payee/

      
        
        9
        +!_TAG_KIND_DESCRIPTION!t	t,tag	/tag/

      
        
        10
        +another	duplicates.journal	/^2025-02-01 "another"$/;"	kind:p	line:9	language:hledger

      
        
        11
        +other	duplicates.journal	/^    other$/;"	kind:a	line:7	language:hledger

      
        
        12
        +test	duplicates.journal	/^account test$/;"	kind:a	line:3	language:hledger

      
        
        13
        +test txn	duplicates.journal	/^2025-01-01 "test txn"$/;"	kind:p	line:5	language:hledger

      
A journal/tags/testdata/duplicates.input
···
        
        1
        +; same account name in directive AND postings — should use directive line

      
        
        2
        +

      
        
        3
        +account test

      
        
        4
        +

      
        
        5
        +2025-01-01 "test txn"

      
        
        6
        +    test  1

      
        
        7
        +    other

      
        
        8
        +

      
        
        9
        +2025-02-01 "another"

      
        
        10
        +    test  2

      
        
        11
        +    other

      
A journal/tags/testdata/kinds.golden
···
        
        1
        +!_TAG_FILE_FORMAT	2	/extended format/

      
        
        2
        +!_TAG_FILE_SORTED	1	/1=sorted/

      
        
        3
        +!_TAG_PROGRAM_NAME	clerk	//

      
        
        4
        +!_TAG_PROGRAM_URL	https://olexsmir.xyz/clerk	//

      
        
        5
        +!_TAG_PROGRAM_VERSION	0.1.0	//

      
        
        6
        +!_TAG_KIND_DESCRIPTION!a	a,account	/account/

      
        
        7
        +!_TAG_KIND_DESCRIPTION!c	c,commodity	/commodity/

      
        
        8
        +!_TAG_KIND_DESCRIPTION!p	p,payee	/payee/

      
        
        9
        +!_TAG_KIND_DESCRIPTION!t	t,tag	/tag/

      
        
        10
        +$	kinds.journal	/^commodity $$/;"	kind:c	line:4	language:hledger

      
        
        11
        +Amazon	kinds.journal	/^2025-03-01 * "Amazon"$/;"	kind:p	line:9	language:hledger

      
        
        12
        +EUR	kinds.journal	/^commodity EUR$/;"	kind:c	line:5	language:hledger

      
        
        13
        +assets:bank	kinds.journal	/^    assets:bank$/;"	kind:a	line:11	language:hledger

      
        
        14
        +expenses:food	kinds.journal	/^account expenses:food$/;"	kind:a	line:3	language:hledger

      
        
        15
        +receipt	kinds.journal	/^tag receipt$/;"	kind:t	line:7	language:hledger

      
A journal/tags/testdata/kinds.input
···
        
        1
        +; all four tag kinds via directives

      
        
        2
        +

      
        
        3
        +account expenses:food

      
        
        4
        +commodity $

      
        
        5
        +commodity EUR

      
        
        6
        +payee Amazon

      
        
        7
        +tag receipt

      
        
        8
        +

      
        
        9
        +2025-03-01 * "Amazon"

      
        
        10
        +    expenses:food   $45.00

      
        
        11
        +    assets:bank

      
A journal/tags/testdata/pseudo.golden
···
        
        1
        +!_TAG_FILE_FORMAT	2	/extended format/

      
        
        2
        +!_TAG_FILE_SORTED	1	/1=sorted/

      
        
        3
        +!_TAG_PROGRAM_NAME	clerk	//

      
        
        4
        +!_TAG_PROGRAM_URL	https://olexsmir.xyz/clerk	//

      
        
        5
        +!_TAG_PROGRAM_VERSION	0.1.0	//

      
        
        6
        +!_TAG_KIND_DESCRIPTION!a	a,account	/account/

      
        
        7
        +!_TAG_KIND_DESCRIPTION!c	c,commodity	/commodity/

      
        
        8
        +!_TAG_KIND_DESCRIPTION!p	p,payee	/payee/

      
        
        9
        +!_TAG_KIND_DESCRIPTION!t	t,tag	/tag/

      
        
        10
        +a	pseudo.journal	/^account a$/;"	kind:a	line:1	language:hledger

      
A main.go
···
        
        1
        +package main

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"fmt"

      
        
        5
        +	"os"

      
        
        6
        +)

      
        
        7
        +

      
        
        8
        +func main() {

      
        
        9
        +	if len(os.Args) < 2 {

      
        
        10
        +		usage()

      
        
        11
        +		os.Exit(1)

      
        
        12
        +	}

      
        
        13
        +

      
        
        14
        +	switch os.Args[1] {

      
        
        15
        +	case "tags":

      
        
        16
        +		runTags(os.Args[2:])

      
        
        17
        +	default:

      
        
        18
        +		usage()

      
        
        19
        +		os.Exit(1)

      
        
        20
        +	}

      
        
        21
        +}

      
        
        22
        +

      
        
        23
        +func usage() {

      
        
        24
        +	fmt.Fprintf(os.Stderr, `Usage: clerk <command> [options]

      
        
        25
        +

      
        
        26
        +Commands:

      
        
        27
        +  tags   Generate ctags-compatibletag file.

      
        
        28
        +`)

      
        
        29
        +}

      
A tags.go
···
        
        1
        +package main

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"flag"

      
        
        5
        +	"fmt"

      
        
        6
        +	"os"

      
        
        7
        +	"path/filepath"

      
        
        8
        +

      
        
        9
        +	"olexsmir.xyz/clerk/journal"

      
        
        10
        +	"olexsmir.xyz/clerk/journal/tags"

      
        
        11
        +)

      
        
        12
        +

      
        
        13
        +func runTags(args []string) {

      
        
        14
        +	fs := flag.NewFlagSet("tags", flag.ExitOnError)

      
        
        15
        +	output := fs.String("o", "tags", "output file, set to - for stdout")

      
        
        16
        +	fs.Usage = func() {

      
        
        17
        +		fmt.Fprintf(os.Stderr, "Usage: clerk tags [-o tags] [<journals>...]\n")

      
        
        18
        +		fs.PrintDefaults()

      
        
        19
        +	}

      
        
        20
        +	fs.Parse(args)

      
        
        21
        +

      
        
        22
        +	cwd, err := os.Getwd()

      
        
        23
        +	if err != nil {

      
        
        24
        +		fmt.Fprintf(os.Stderr, "error getting working directory: %v\n", err)

      
        
        25
        +		os.Exit(1)

      
        
        26
        +	}

      
        
        27
        +

      
        
        28
        +	rawPaths := fs.Args()

      
        
        29
        +	if len(rawPaths) == 0 {

      
        
        30
        +		rawPaths = []string{"."}

      
        
        31
        +	}

      
        
        32
        +

      
        
        33
        +	var journals []string

      
        
        34
        +	seen := make(map[string]bool)

      
        
        35
        +	for _, p := range rawPaths {

      
        
        36
        +		info, err := os.Stat(p)

      
        
        37
        +		if err != nil {

      
        
        38
        +			fmt.Fprintf(os.Stderr, "error reading %s: %v\n", p, err)

      
        
        39
        +			os.Exit(1)

      
        
        40
        +		}

      
        
        41
        +

      
        
        42
        +		if info.IsDir() {

      
        
        43
        +			entries, err := os.ReadDir(p)

      
        
        44
        +			if err != nil {

      
        
        45
        +				fmt.Fprintf(os.Stderr, "error reading directory %s: %v\n", p, err)

      
        
        46
        +				os.Exit(1)

      
        
        47
        +			}

      
        
        48
        +			for _, entry := range entries {

      
        
        49
        +				if entry.IsDir() { // TODO: keep traversing?

      
        
        50
        +					continue

      
        
        51
        +				}

      
        
        52
        +				fpath := filepath.Join(p, entry.Name())

      
        
        53
        +				if journal.IsJournalFile(fpath) && !seen[fpath] {

      
        
        54
        +					seen[fpath] = true

      
        
        55
        +					journals = append(journals, fpath)

      
        
        56
        +				}

      
        
        57
        +			}

      
        
        58
        +		} else if journal.IsJournalFile(p) && !seen[p] {

      
        
        59
        +			seen[p] = true

      
        
        60
        +			journals = append(journals, p)

      
        
        61
        +		}

      
        
        62
        +	}

      
        
        63
        +

      
        
        64
        +	if len(journals) == 0 {

      
        
        65
        +		fmt.Fprintf(os.Stderr, "no journal files found\n")

      
        
        66
        +		os.Exit(1)

      
        
        67
        +	}

      
        
        68
        +

      
        
        69
        +	loader := journal.NewLoader()

      
        
        70
        +	for _, path := range journals {

      
        
        71
        +		if _, err := loader.Load(path); err != nil {

      
        
        72
        +			fmt.Fprintf(os.Stderr, "error loading %s: %v\n", path, err)

      
        
        73
        +			os.Exit(1)

      
        
        74
        +		}

      
        
        75
        +	}

      
        
        76
        +

      
        
        77
        +	tagger := tags.New(loader, cwd)

      
        
        78
        +

      
        
        79
        +	w := os.Stdout

      
        
        80
        +	if *output != "-" {

      
        
        81
        +		f, err := os.Create(*output)

      
        
        82
        +		if err != nil {

      
        
        83
        +			fmt.Fprintf(os.Stderr, "error creating output file: %v\n", err)

      
        
        84
        +			os.Exit(1)

      
        
        85
        +		}

      
        
        86
        +		defer f.Close()

      
        
        87
        +		w = f

      
        
        88
        +	}

      
        
        89
        +

      
        
        90
        +	if err := tagger.Write(w); err != nil {

      
        
        91
        +		fmt.Fprintf(os.Stderr, "error writing tags: %v\n", err)

      
        
        92
        +		os.Exit(1)

      
        
        93
        +	}

      
        
        94
        +}