all repos

clerk @ 4ad565dade8e2b0d171edeb4f6df8aeeb02b3549

missing tooling for ledger/hledger
10 files changed, 0 insertions(+), 645 deletions(-)
drop tags generator
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-07-31 17:45:31 +0300
Authored at: 2026-07-31 17:44:31 +0300
Change ID: oukpnvvylvrnmznvotkvxkwolykyyrzl
Parent: 8ff5e11
M internal/cli/cli.go
···
        63
        63
         				Action: c.lspAction,

      
        64
        64
         			},

      
        65
        65
         			{

      
        66
        
        -				Name:   "tags",

      
        67
        
        -				Usage:  "generate a tags file for journal entries",

      
        68
        
        -				Action: c.tagsAction,

      
        69
        
        -				Flags: []cli.Flag{&cli.StringFlag{

      
        70
        
        -					Name:    "out",

      
        71
        
        -					Aliases: []string{"o"},

      
        72
        
        -					Usage:   "output file, set to - for stdout",

      
        73
        
        -					Value:   "tags",

      
        74
        
        -				}},

      
        75
        
        -				Arguments: []cli.Argument{&cli.StringArgs{

      
        76
        
        -					Name:      "journals",

      
        77
        
        -					UsageText: "(path to journal files/directories)",

      
        78
        
        -					Min:       0,

      
        79
        
        -					Max:       -1,

      
        80
        
        -				}},

      
        81
        
        -			},

      
        82
        
        -			{

      
        83
        66
         				Name:   "lint",

      
        84
        67
         				Usage:  "lint journal files for common mistakes",

      
        85
        68
         				Action: c.lintAction,

      
D internal/cli/cmd_tags.go
···
        1
        
        -package cli

      
        2
        
        -

      
        3
        
        -import (

      
        4
        
        -	"context"

      
        5
        
        -	"fmt"

      
        6
        
        -	"io"

      
        7
        
        -	"os"

      
        8
        
        -

      
        9
        
        -	"github.com/urfave/cli/v3"

      
        10
        
        -

      
        11
        
        -	"olexsmir.xyz/clerk/journal"

      
        12
        
        -	"olexsmir.xyz/clerk/journal/tags"

      
        13
        
        -)

      
        14
        
        -

      
        15
        
        -func (c *Cli) tagsAction(ctx context.Context, cmd *cli.Command) error {

      
        16
        
        -	cwd, err := os.Getwd()

      
        17
        
        -	if err != nil {

      
        18
        
        -		return fmt.Errorf("getting working directory: %w", err)

      
        19
        
        -	}

      
        20
        
        -

      
        21
        
        -	journals := cmd.StringArgs("journals")

      
        22
        
        -	if len(journals) == 0 {

      
        23
        
        -		journals = []string{"."}

      
        24
        
        -	}

      
        25
        
        -

      
        26
        
        -	journalFiles, err := resolvePaths(journals)

      
        27
        
        -	if err != nil {

      
        28
        
        -		return err

      
        29
        
        -	}

      
        30
        
        -

      
        31
        
        -	if len(journalFiles) == 0 {

      
        32
        
        -		return fmt.Errorf("no journal files found")

      
        33
        
        -	}

      
        34
        
        -

      
        35
        
        -	loader := journal.NewLoader()

      
        36
        
        -

      
        37
        
        -	var rj *journal.ResolvedJournal

      
        38
        
        -	for _, file := range journalFiles {

      
        39
        
        -		resolved, err := loader.Resolve(file)

      
        40
        
        -		if err != nil {

      
        41
        
        -			fmt.Fprintf(os.Stderr, "error: %v\n", err)

      
        42
        
        -			continue

      
        43
        
        -		}

      
        44
        
        -		if rj == nil {

      
        45
        
        -			rj = resolved

      
        46
        
        -		} else {

      
        47
        
        -			// Merge: not supported for multi-root yet — use the last.

      
        48
        
        -			rj = resolved

      
        49
        
        -		}

      
        50
        
        -	}

      
        51
        
        -	if rj == nil {

      
        52
        
        -		return cli.Exit("", 1)

      
        53
        
        -	}

      
        54
        
        -

      
        55
        
        -	tagger := tags.New(rj, cwd)

      
        56
        
        -

      
        57
        
        -	out := cmd.String("out")

      
        58
        
        -	var w io.Writer = os.Stdout

      
        59
        
        -	if out != "-" {

      
        60
        
        -		f, err := os.Create(out)

      
        61
        
        -		if err != nil {

      
        62
        
        -			return fmt.Errorf("creating output file: %w", err)

      
        63
        
        -		}

      
        64
        
        -		defer f.Close()

      
        65
        
        -		w = f

      
        66
        
        -	}

      
        67
        
        -

      
        68
        
        -	if err := tagger.Write(w); err != nil {

      
        69
        
        -		return fmt.Errorf("writing tags: %w", err)

      
        70
        
        -	}

      
        71
        
        -

      
        72
        
        -	return nil

      
        73
        
        -}

      
D 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
        
        -}

      
D 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
        
        -}

      
D 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
        
        -	rj     *journal.ResolvedJournal

      
        57
        
        -	relDir string

      
        58
        
        -

      
        59
        
        -	candidates []candidate

      
        60
        
        -}

      
        61
        
        -

      
        62
        
        -// New creates a [Tagger] from a resolved journal. If relDir is not empty,

      
        63
        
        -// file paths in the tag output are made relative to relDir.

      
        64
        
        -func New(rj *journal.ResolvedJournal, relDir string) *Tagger {

      
        65
        
        -	return &Tagger{rj: rj, relDir: relDir}

      
        66
        
        -}

      
        67
        
        -

      
        68
        
        -// Write generate tags file and write it in tags format.

      
        69
        
        -func (t *Tagger) Write(w io.Writer) error {

      
        70
        
        -	tw := tags.NewWriter()

      
        71
        
        -	tw.DescribeKind(KindAccount, KindAccountDesc)

      
        72
        
        -	tw.DescribeKind(KindCommodity, KindCommodityDesc)

      
        73
        
        -	tw.DescribeKind(KindPayee, KindPayeeDesc)

      
        74
        
        -	tw.DescribeKind(KindTag, KindTagDesc)

      
        75
        
        -	for _, e := range t.collect() {

      
        76
        
        -		tw.Add(e)

      
        77
        
        -	}

      
        78
        
        -	return tw.Write(w)

      
        79
        
        -}

      
        80
        
        -

      
        81
        
        -func (t *Tagger) collect() []tags.Entry {

      
        82
        
        -	t.candidates = nil

      
        83
        
        -

      
        84
        
        -	for _, item := range t.rj.Items {

      
        85
        
        -		if item.IsInclude {

      
        86
        
        -			continue

      
        87
        
        -		}

      
        88
        
        -		pf := item.Occurrence

      
        89
        
        -		filePath := pf.Path

      
        90
        
        -		if t.relDir != "" {

      
        91
        
        -			filePath = relativePath(filePath, t.relDir)

      
        92
        
        -		}

      
        93
        
        -		t.collectFromEntry(pf, filePath, pf.Ast.Entries[item.EntryIndex])

      
        94
        
        -	}

      
        95
        
        -

      
        96
        
        -	// sort by priority

      
        97
        
        -	sort.Slice(t.candidates, func(i, j int) bool {

      
        98
        
        -		if t.candidates[i].name != t.candidates[j].name {

      
        99
        
        -			return t.candidates[i].name < t.candidates[j].name

      
        100
        
        -		}

      
        101
        
        -		if t.candidates[i].kind != t.candidates[j].kind {

      
        102
        
        -			return t.candidates[i].kind < t.candidates[j].kind

      
        103
        
        -		}

      
        104
        
        -		if t.candidates[i].priority != t.candidates[j].priority {

      
        105
        
        -			return t.candidates[i].priority < t.candidates[j].priority

      
        106
        
        -		}

      
        107
        
        -		if t.candidates[i].file != t.candidates[j].file {

      
        108
        
        -			return t.candidates[i].file < t.candidates[j].file

      
        109
        
        -		}

      
        110
        
        -		return t.candidates[i].line < t.candidates[j].line

      
        111
        
        -	})

      
        112
        
        -

      
        113
        
        -	// deduplicate

      
        114
        
        -	var entries []tags.Entry

      
        115
        
        -	for i, c := range t.candidates {

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

      
        117
        
        -			continue

      
        118
        
        -		}

      
        119
        
        -		entries = append(entries, tags.Entry{

      
        120
        
        -			Name:     c.name,

      
        121
        
        -			File:     c.file,

      
        122
        
        -			Line:     c.line,

      
        123
        
        -			Pattern:  extractLine(c.src, c.offset),

      
        124
        
        -			Kind:     c.kind,

      
        125
        
        -			Language: "hledger",

      
        126
        
        -		})

      
        127
        
        -	}

      
        128
        
        -

      
        129
        
        -	return entries

      
        130
        
        -}

      
        131
        
        -

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

      
        133
        
        -	switch e := entry.(type) {

      
        134
        
        -	case *ast.AccountDirective:

      
        135
        
        -		t.addCandidate(e.Account.String(), fpath, e.Account.Span, pf.Src, KindAccount, prioAccountDirective)

      
        136
        
        -

      
        137
        
        -	case *ast.CommodityDirective:

      
        138
        
        -		t.addCandidate(e.Commodity, fpath, e.Span, pf.Src, KindCommodity, prioCommodityDirective)

      
        139
        
        -

      
        140
        
        -	case *ast.PayeeDirective:

      
        141
        
        -		t.addCandidate(e.Name, fpath, e.Span, pf.Src, KindPayee, prioPayeeDirective)

      
        142
        
        -

      
        143
        
        -	case *ast.TagDirective:

      
        144
        
        -		t.addCandidate(e.Name, fpath, e.Span, pf.Src, KindTag, prioTagDirective)

      
        145
        
        -

      
        146
        
        -	case *ast.Transaction:

      
        147
        
        -		if e.Payee != nil {

      
        148
        
        -			t.addCandidate(e.Payee.Name, fpath, e.Payee.Span, pf.Src, KindPayee, prioPayeeTransaction)

      
        149
        
        -		}

      
        150
        
        -		for _, p := range e.Postings {

      
        151
        
        -			t.collectFromPosting(p, fpath, pf.Src)

      
        152
        
        -		}

      
        153
        
        -

      
        154
        
        -	case *ast.PeriodicTransaction:

      
        155
        
        -		for _, p := range e.Postings {

      
        156
        
        -			t.collectFromPosting(p, fpath, pf.Src)

      
        157
        
        -		}

      
        158
        
        -

      
        159
        
        -	case *ast.AutomatedTransaction:

      
        160
        
        -		for _, p := range e.Postings {

      
        161
        
        -			t.collectFromPosting(p, fpath, pf.Src)

      
        162
        
        -		}

      
        163
        
        -

      
        164
        
        -	case *ast.MarketPriceDirective:

      
        165
        
        -		t.addCandidate(e.Commodity, fpath, e.Span, pf.Src, KindCommodity, prioMarketPrice)

      
        166
        
        -		if e.Amount.Commodity != "" {

      
        167
        
        -			t.addCandidate(e.Amount.Commodity, fpath, e.Amount.Span, pf.Src, KindCommodity, prioMarketPrice)

      
        168
        
        -		}

      
        169
        
        -

      
        170
        
        -	case *ast.DefaultCommodityDirective:

      
        171
        
        -		if e.Amount.Commodity != "" {

      
        172
        
        -			t.addCandidate(e.Amount.Commodity, fpath, e.Amount.Span, pf.Src, KindCommodity, prioAmountCommodity)

      
        173
        
        -		}

      
        174
        
        -

      
        175
        
        -	case *ast.ConversionDirective:

      
        176
        
        -		if e.From.Commodity != "" {

      
        177
        
        -			t.addCandidate(e.From.Commodity, fpath, e.From.Span, pf.Src, KindCommodity, prioAmountCommodity)

      
        178
        
        -		}

      
        179
        
        -		if e.To.Commodity != "" {

      
        180
        
        -			t.addCandidate(e.To.Commodity, fpath, e.To.Span, pf.Src, KindCommodity, prioAmountCommodity)

      
        181
        
        -		}

      
        182
        
        -

      
        183
        
        -	case *ast.AliasDirective:

      
        184
        
        -		t.addCandidate(e.From.String(), fpath, e.Span, pf.Src, KindAccount, prioAliasDirective)

      
        185
        
        -		t.addCandidate(e.To.String(), fpath, e.Span, pf.Src, KindAccount, prioAliasDirective)

      
        186
        
        -	}

      
        187
        
        -}

      
        188
        
        -

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

      
        190
        
        -	t.addCandidate(p.Account.String(), filePath, p.Account.Span, src, KindAccount, prioAccountPosting)

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

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

      
        193
        
        -	}

      
        194
        
        -	if p.Cost != nil && p.Cost.Amount.Commodity != "" {

      
        195
        
        -		t.addCandidate(p.Cost.Amount.Commodity, filePath, p.Cost.Amount.Span, src, KindCommodity, prioAmountCommodity)

      
        196
        
        -	}

      
        197
        
        -	if p.Balance != nil && p.Balance.Amount.Commodity != "" {

      
        198
        
        -		t.addCandidate(p.Balance.Amount.Commodity, filePath, p.Balance.Amount.Span, src, KindCommodity, prioAmountCommodity)

      
        199
        
        -	}

      
        200
        
        -}

      
        201
        
        -

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

      
        203
        
        -	if name == "" {

      
        204
        
        -		return

      
        205
        
        -	}

      
        206
        
        -	t.candidates = append(t.candidates, candidate{

      
        207
        
        -		name:     name,

      
        208
        
        -		kind:     kind,

      
        209
        
        -		file:     fpath,

      
        210
        
        -		line:     span.Start.Line,

      
        211
        
        -		priority: priority,

      
        212
        
        -		src:      src,

      
        213
        
        -		offset:   span.Start.Offset,

      
        214
        
        -	})

      
        215
        
        -}

      
        216
        
        -

      
        217
        
        -func relativePath(target, base string) string {

      
        218
        
        -	rel, err := filepath.Rel(base, target)

      
        219
        
        -	if err != nil {

      
        220
        
        -		return target

      
        221
        
        -	}

      
        222
        
        -	return rel

      
        223
        
        -}

      
        224
        
        -

      
        225
        
        -// extractLine returns the source line containing offset.

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

      
        227
        
        -func extractLine(src []byte, offset int) string {

      
        228
        
        -	start := offset

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

      
        230
        
        -		start--

      
        231
        
        -	}

      
        232
        
        -	end := offset

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

      
        234
        
        -		end++

      
        235
        
        -	}

      
        236
        
        -	return string(src[start:end])

      
        237
        
        -}

      
D journal/tags/tag_test.go
···
        1
        
        -package tags

      
        2
        
        -

      
        3
        
        -import (

      
        4
        
        -	"bytes"

      
        5
        
        -	"testing"

      
        6
        
        -

      
        7
        
        -	"olexsmir.xyz/clerk/internal/testutil/golden"

      
        8
        
        -	"olexsmir.xyz/clerk/journal"

      
        9
        
        -)

      
        10
        
        -

      
        11
        
        -func TestTags(t *testing.T) {

      
        12
        
        -	tests := []string{"basic", "kinds", "duplicates", "crossfile"}

      
        13
        
        -	for _, tt := range tests {

      
        14
        
        -		t.Run(tt, func(t *testing.T) {

      
        15
        
        -			t.Parallel()

      
        16
        
        -			a := golden.Read(t, tt)

      
        17
        
        -			fsys, err := a.FS()

      
        18
        
        -			if err != nil {

      
        19
        
        -				t.Fatal(err)

      
        20
        
        -			}

      
        21
        
        -			l := journal.NewLoader()

      
        22
        
        -			rj, err := l.ResolveFS(fsys, "in.journal")

      
        23
        
        -			if err != nil {

      
        24
        
        -				t.Fatalf("loading journal: %v", err)

      
        25
        
        -			}

      
        26
        
        -			tagger := New(rj, "")

      
        27
        
        -			var buf bytes.Buffer

      
        28
        
        -			if err := tagger.Write(&buf); err != nil {

      
        29
        
        -				t.Fatal(err)

      
        30
        
        -			}

      
        31
        
        -			golden.Assert(t, a, buf.String())

      
        32
        
        -		})

      
        33
        
        -	}

      
        34
        
        -}

      
D journal/tags/testdata/basic.txtar
···
        1
        
        --- in.journal --

      
        2
        
        -; basic journal — accounts + commodities from directives and postings

      
        3
        
        -

      
        4
        
        -account expenses:food

      
        5
        
        -account expenses:rent

      
        6
        
        -account assets:bank

      
        7
        
        -account assets:cash

      
        8
        
        -

      
        9
        
        -2025-01-15 * "Groceries"

      
        10
        
        -    expenses:food   $50.00

      
        11
        
        -    assets:bank

      
        12
        
        -

      
        13
        
        -2025-02-01 "Rent"

      
        14
        
        -    expenses:rent   €800.00

      
        15
        
        -    assets:bank     €-800.00

      
        16
        
        -

      
        17
        
        -P 2025-01-15 € $1.05

      
        18
        
        -

      
        19
        
        --- expect --

      
        20
        
        -!_TAG_FILE_FORMAT	2	/extended format/

      
        21
        
        -!_TAG_FILE_SORTED	1	/1=sorted/

      
        22
        
        -!_TAG_PROGRAM_NAME	clerk	//

      
        23
        
        -!_TAG_PROGRAM_URL	https://olexsmir.xyz/clerk	//

      
        24
        
        -!_TAG_PROGRAM_VERSION	0.1.0	//

      
        25
        
        -!_TAG_KIND_DESCRIPTION!a	a,account	/account/

      
        26
        
        -!_TAG_KIND_DESCRIPTION!c	c,commodity	/commodity/

      
        27
        
        -!_TAG_KIND_DESCRIPTION!p	p,payee	/payee/

      
        28
        
        -!_TAG_KIND_DESCRIPTION!t	t,tag	/tag/

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

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

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

      
        32
        
        -assets:bank	in.journal	/^account assets:bank$/;"	kind:a	line:5	language:hledger

      
        33
        
        -assets:cash	in.journal	/^account assets:cash$/;"	kind:a	line:6	language:hledger

      
        34
        
        -expenses:food	in.journal	/^account expenses:food$/;"	kind:a	line:3	language:hledger

      
        35
        
        -expenses:rent	in.journal	/^account expenses:rent$/;"	kind:a	line:4	language:hledger

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

      
D journal/tags/testdata/crossfile.txtar
···
        1
        
        --- in.journal --

      
        2
        
        -include decls.journal

      
        3
        
        -

      
        4
        
        -2024-01-15 "Amazon"

      
        5
        
        -    expenses:food   $50.00

      
        6
        
        -    assets:bank

      
        7
        
        -

      
        8
        
        --- decls.journal --

      
        9
        
        -commodity $

      
        10
        
        -account assets:bank

      
        11
        
        -payee Amazon

      
        12
        
        -

      
        13
        
        --- expect --

      
        14
        
        -!_TAG_FILE_FORMAT	2	/extended format/

      
        15
        
        -!_TAG_FILE_SORTED	1	/1=sorted/

      
        16
        
        -!_TAG_PROGRAM_NAME	clerk	//

      
        17
        
        -!_TAG_PROGRAM_URL	https://olexsmir.xyz/clerk	//

      
        18
        
        -!_TAG_PROGRAM_VERSION	0.1.0	//

      
        19
        
        -!_TAG_KIND_DESCRIPTION!a	a,account	/account/

      
        20
        
        -!_TAG_KIND_DESCRIPTION!c	c,commodity	/commodity/

      
        21
        
        -!_TAG_KIND_DESCRIPTION!p	p,payee	/payee/

      
        22
        
        -!_TAG_KIND_DESCRIPTION!t	t,tag	/tag/

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

      
        24
        
        -Amazon	decls.journal	/^payee Amazon$/;"	kind:p	line:3	language:hledger

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

      
        26
        
        -expenses:food	in.journal	/^    expenses:food   $50.00$/;"	kind:a	line:4	language:hledger

      
D journal/tags/testdata/duplicates.txtar
···
        1
        
        --- in.journal --

      
        2
        
        -; same account name in directive AND postings — should use directive line

      
        3
        
        -

      
        4
        
        -account test

      
        5
        
        -

      
        6
        
        -2025-01-01 "test txn"

      
        7
        
        -    test  1

      
        8
        
        -    other

      
        9
        
        -

      
        10
        
        -2025-02-01 "another"

      
        11
        
        -    test  2

      
        12
        
        -    other

      
        13
        
        -

      
        14
        
        --- expect --

      
        15
        
        -!_TAG_FILE_FORMAT	2	/extended format/

      
        16
        
        -!_TAG_FILE_SORTED	1	/1=sorted/

      
        17
        
        -!_TAG_PROGRAM_NAME	clerk	//

      
        18
        
        -!_TAG_PROGRAM_URL	https://olexsmir.xyz/clerk	//

      
        19
        
        -!_TAG_PROGRAM_VERSION	0.1.0	//

      
        20
        
        -!_TAG_KIND_DESCRIPTION!a	a,account	/account/

      
        21
        
        -!_TAG_KIND_DESCRIPTION!c	c,commodity	/commodity/

      
        22
        
        -!_TAG_KIND_DESCRIPTION!p	p,payee	/payee/

      
        23
        
        -!_TAG_KIND_DESCRIPTION!t	t,tag	/tag/

      
        24
        
        -another	in.journal	/^2025-02-01 "another"$/;"	kind:p	line:9	language:hledger

      
        25
        
        -other	in.journal	/^    other$/;"	kind:a	line:7	language:hledger

      
        26
        
        -test	in.journal	/^account test$/;"	kind:a	line:3	language:hledger

      
        27
        
        -test txn	in.journal	/^2025-01-01 "test txn"$/;"	kind:p	line:5	language:hledger

      
D journal/tags/testdata/kinds.txtar
···
        1
        
        --- in.journal --

      
        2
        
        -; all four tag kinds via directives

      
        3
        
        -

      
        4
        
        -account expenses:food

      
        5
        
        -commodity $

      
        6
        
        -commodity EUR

      
        7
        
        -payee Amazon

      
        8
        
        -tag receipt

      
        9
        
        -

      
        10
        
        -2025-03-01 * "Amazon"

      
        11
        
        -    expenses:food   $45.00

      
        12
        
        -    assets:bank

      
        13
        
        -

      
        14
        
        --- expect --

      
        15
        
        -!_TAG_FILE_FORMAT	2	/extended format/

      
        16
        
        -!_TAG_FILE_SORTED	1	/1=sorted/

      
        17
        
        -!_TAG_PROGRAM_NAME	clerk	//

      
        18
        
        -!_TAG_PROGRAM_URL	https://olexsmir.xyz/clerk	//

      
        19
        
        -!_TAG_PROGRAM_VERSION	0.1.0	//

      
        20
        
        -!_TAG_KIND_DESCRIPTION!a	a,account	/account/

      
        21
        
        -!_TAG_KIND_DESCRIPTION!c	c,commodity	/commodity/

      
        22
        
        -!_TAG_KIND_DESCRIPTION!p	p,payee	/payee/

      
        23
        
        -!_TAG_KIND_DESCRIPTION!t	t,tag	/tag/

      
        24
        
        -$	in.journal	/^commodity $$/;"	kind:c	line:4	language:hledger

      
        25
        
        -Amazon	in.journal	/^payee Amazon$/;"	kind:p	line:6	language:hledger

      
        26
        
        -EUR	in.journal	/^commodity EUR$/;"	kind:c	line:5	language:hledger

      
        27
        
        -assets:bank	in.journal	/^    assets:bank$/;"	kind:a	line:11	language:hledger

      
        28
        
        -expenses:food	in.journal	/^account expenses:food$/;"	kind:a	line:3	language:hledger

      
        29
        
        -receipt	in.journal	/^tag receipt$/;"	kind:t	line:7	language:hledger