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
jump to
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/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/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/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 +}