all repos

clerk @ 6fdb9097048e212574439fb0da84d0c94aa7e01b

missing tooling for ledger/hledger

clerk/journal/tags/tag.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
generate tags, 2 days ago
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
}