all repos

clerk @ 1f3a17349e37c8189adb11b8d956acbf3b2080d1

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
improve loader, 1 day 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
	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
}