all repos

clerk @ cddf442e7f66ba3c73d9f4f9bcb13693d28aa6b1

missing tooling for ledger/hledger

clerk/internal/analyzer/build.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
journal: tags support, 2 months ago
1
package analyzer
2
3
import (
4
	"fmt"
5
	"sort"
6
	"strings"
7
8
	"olexsmir.xyz/clerk/journal"
9
	"olexsmir.xyz/clerk/journal/ast"
10
)
11
12
// Build constructs [Analysis] from a flat resolved journal view.
13
func Build(rj *journal.ResolvedJournal) *Analysis {
14
	fileIndex := make(map[*journal.ParsedFile]int)
15
	for i, pf := range rj.Occurrences {
16
		fileIndex[pf] = i
17
	}
18
19
	a := &Analysis{
20
		Files:                 rj.Occurrences,
21
		Accounts:              make(map[string]*AccountInfo),
22
		Commodities:           make(map[string]*CommodityInfo),
23
		Payees:                make(map[string]*PayeeInfo),
24
		Tags:                  make(map[string]*TagInfo),
25
		AccountsByPrefix:      make(map[string][]string),
26
		PayeeTemplates:        make(map[string][]PostingTemplate),
27
		CommodityDecimalMarks: make(map[string]byte),
28
		TransactionsByKey:     make(map[string][]*ast.Transaction),
29
	}
30
	for _, item := range rj.Items {
31
		if item.IsInclude {
32
			continue
33
		}
34
		idx := fileIndex[item.Occurrence]
35
		a.addEntry(idx, item.Occurrence.Ast.Entries[item.EntryIndex])
36
	}
37
	a.buildPrefixIndex()
38
	a.sortAccountNames()
39
	a.collectPayeeNames()
40
	a.collectDates()
41
	a.collectTags()
42
	return a
43
}
44
45
func TxDuplicateKey(tx *ast.Transaction) string {
46
	var b strings.Builder
47
	fmt.Fprintf(&b, "%04d-%02d-%02d|", tx.Date.Year, tx.Date.Month, tx.Date.Day)
48
	if tx.Payee != nil {
49
		b.WriteString(tx.Payee.Name)
50
	}
51
	b.WriteByte('|')
52
	for _, p := range tx.Postings {
53
		b.WriteString(p.Account.String())
54
		b.WriteByte(',')
55
	}
56
	return b.String()
57
}
58
59
func (a *Analysis) addEntry(fileIndex int, entry ast.Entry) {
60
	switch e := entry.(type) {
61
	case *ast.AccountDirective:
62
		a.addAccountDirective(e)
63
	case *ast.CommodityDirective:
64
		a.addCommodityDirective(e)
65
	case *ast.PayeeDirective:
66
		a.addPayeeDirective(e)
67
	case *ast.TagDirective:
68
		a.addTagDirective(e)
69
	case *ast.Comment:
70
		a.addCommentTags(fileIndex, nil, e)
71
	case *ast.Transaction:
72
		a.Transactions = append(a.Transactions, e)
73
		a.addPostings(fileIndex, e.Postings, &e.Date)
74
		a.addPayee(fileIndex, e.Payee)
75
		a.addPayeeTemplate(e)
76
		a.addCommentTags(fileIndex, &e.Date, e.Comment)
77
		for _, c := range e.HeaderComments {
78
			a.addCommentTags(fileIndex, &e.Date, c)
79
		}
80
		key := TxDuplicateKey(e)
81
		a.TransactionsByKey[key] = append(a.TransactionsByKey[key], e)
82
	case *ast.PeriodicTransaction:
83
		a.PeriodicTransactions = append(a.PeriodicTransactions, e)
84
		a.addPostings(fileIndex, e.Postings, nil)
85
		a.addCommentTags(fileIndex, nil, e.Comment)
86
		for _, c := range e.HeaderComments {
87
			a.addCommentTags(fileIndex, nil, c)
88
		}
89
	case *ast.AutomatedTransaction:
90
		a.AutomatedTransactions = append(a.AutomatedTransactions, e)
91
		a.addPostings(fileIndex, e.Postings, nil)
92
		a.addCommentTags(fileIndex, nil, e.Comment)
93
		for _, c := range e.HeaderComments {
94
			a.addCommentTags(fileIndex, nil, c)
95
		}
96
	case *ast.DefaultCommodityDirective:
97
		if e.Amount.Commodity != "" {
98
			mark := e.Amount.QuantityFmt.Decimal
99
			if mark != 0 {
100
				a.CommodityDecimalMarks[e.Amount.Commodity] = mark
101
			}
102
		}
103
	}
104
105
	// Every directive-like entry goes into Directives.
106
	switch entry.(type) {
107
	case *ast.AccountDirective, *ast.CommodityDirective, *ast.PayeeDirective, *ast.TagDirective, *ast.IncludeDirective,
108
		*ast.AliasDirective, *ast.YearDirective, *ast.DecimalMarkDirective, *ast.DefaultCommodityDirective, *ast.MarketPriceDirective,
109
		*ast.ConversionDirective, *ast.ApplyDirective, *ast.EndDirective, *ast.CommentBlockDirective, *ast.IgnoredDirective:
110
		a.Directives = append(a.Directives, entry)
111
	}
112
}
113
114
func (a *Analysis) addAccountDirective(ad *ast.AccountDirective) {
115
	aname := ad.Account.String()
116
	info, ok := a.Accounts[aname]
117
	if !ok {
118
		info = &AccountInfo{}
119
		a.Accounts[aname] = info
120
	}
121
	info.Directives = append(info.Directives, ad)
122
}
123
124
func (a *Analysis) addPayeeDirective(pd *ast.PayeeDirective) {
125
	info, ok := a.Payees[pd.Name]
126
	if !ok {
127
		info = &PayeeInfo{}
128
		a.Payees[pd.Name] = info
129
	}
130
	info.Directives = append(info.Directives, &ast.Payee{
131
		Name: pd.Name,
132
		Span: pd.Span,
133
	})
134
}
135
136
func (a *Analysis) addTagDirective(td *ast.TagDirective) {
137
	if td.Name == "" {
138
		return
139
	}
140
	info, ok := a.Tags[td.Name]
141
	if !ok {
142
		info = &TagInfo{}
143
		a.Tags[td.Name] = info
144
	}
145
	info.Directives = append(info.Directives, td)
146
}
147
148
func (a *Analysis) addCommentTags(fileIndex int, date *ast.Date, c *ast.Comment) {
149
	if c == nil {
150
		return
151
	}
152
	for i := range c.Tags {
153
		t := &c.Tags[i]
154
		info, ok := a.Tags[t.Key]
155
		if !ok {
156
			info = &TagInfo{}
157
			a.Tags[t.Key] = info
158
		}
159
		info.Usage = append(info.Usage, TagUsage{FileIndex: fileIndex, Tag: t})
160
		info.UsedCount++
161
		if date != nil {
162
			info.LastUsed = maxDate(info.LastUsed, *date)
163
		}
164
	}
165
}
166
167
func (a *Analysis) addCommodityDirective(cd *ast.CommodityDirective) {
168
	info, ok := a.Commodities[cd.Commodity]
169
	if !ok {
170
		info = &CommodityInfo{}
171
		a.Commodities[cd.Commodity] = info
172
	}
173
	info.Directives = append(info.Directives, cd)
174
}
175
176
func (a *Analysis) addPayee(fileIndex int, payee *ast.Payee) {
177
	if payee == nil {
178
		return
179
	}
180
	info, ok := a.Payees[payee.Name]
181
	if !ok {
182
		info = &PayeeInfo{}
183
		a.Payees[payee.Name] = info
184
	}
185
	info.Usage = append(info.Usage, PayeeUsage{
186
		FileIndex: fileIndex,
187
		Payee:     payee,
188
	})
189
	info.UsedCount++
190
}
191
192
func (a *Analysis) addPostings(fileIndex int, postings []*ast.Posting, date *ast.Date) {
193
	for _, posting := range postings {
194
		aname := posting.Account.String()
195
		info, ok := a.Accounts[aname]
196
		if !ok {
197
			info = &AccountInfo{}
198
			a.Accounts[aname] = info
199
		}
200
		info.Usages = append(info.Usages, AccountUsage{
201
			FileIndex: fileIndex,
202
			Posting:   posting,
203
		})
204
		info.UsedCount++
205
		if date != nil {
206
			info.LastUsed = maxDate(info.LastUsed, *date)
207
		}
208
209
		a.addCommodityUsage(fileIndex, posting.Amount, date)
210
		if posting.Cost != nil {
211
			a.addCommodityUsage(fileIndex, &posting.Cost.Amount, date)
212
		}
213
		if posting.Balance != nil {
214
			a.addCommodityUsage(fileIndex, &posting.Balance.Amount, date)
215
			if posting.Balance.Cost != nil {
216
				a.addCommodityUsage(fileIndex, &posting.Balance.Cost.Amount, date)
217
			}
218
		}
219
220
		a.addCommentTags(fileIndex, date, posting.Comment)
221
		for i := range posting.Comments {
222
			a.addCommentTags(fileIndex, date, &posting.Comments[i])
223
		}
224
225
		// Collect decimal mark from amount formatting.
226
		if posting.Amount != nil && posting.Amount.Commodity != "" && posting.Amount.QuantityFmt.Decimal != 0 {
227
			if _, ok := a.CommodityDecimalMarks[posting.Amount.Commodity]; !ok {
228
				a.CommodityDecimalMarks[posting.Amount.Commodity] = posting.Amount.QuantityFmt.Decimal
229
			}
230
		}
231
	}
232
}
233
234
func (a *Analysis) addPayeeTemplate(tx *ast.Transaction) {
235
	payee := ""
236
	if tx.Payee != nil {
237
		payee = tx.Payee.Name
238
	}
239
	if payee == "" {
240
		return
241
	}
242
243
	templates := make([]PostingTemplate, len(tx.Postings))
244
	for i, p := range tx.Postings {
245
		t := PostingTemplate{
246
			Account:    p.Account.String(),
247
			IsInferred: p.Amount == nil,
248
		}
249
		if p.Amount != nil {
250
			t.Amount = p.Amount.Quantity.String()
251
			t.Commodity = p.Amount.Commodity
252
		}
253
		templates[i] = t
254
	}
255
	a.PayeeTemplates[payee] = templates
256
}
257
258
func (a *Analysis) collectDates() {
259
	seen := make(map[string]bool)
260
	for _, tx := range a.Transactions {
261
		s := formatDate(tx.Date)
262
		if s != "" && !seen[s] {
263
			seen[s] = true
264
			a.Dates = append(a.Dates, tx.Date)
265
			a.DateStrings = append(a.DateStrings, s)
266
		}
267
	}
268
	sort.Slice(a.Dates, func(i, j int) bool {
269
		return a.Dates[i].Compare(a.Dates[j]) < 0
270
	})
271
	sort.Strings(a.DateStrings)
272
}
273
274
func (a *Analysis) collectTags() {
275
	names := make([]string, 0, len(a.Tags))
276
	values := make(map[string]bool)
277
	for name, info := range a.Tags {
278
		names = append(names, name)
279
		seen := make(map[string]bool)
280
		for _, u := range info.Usage {
281
			if u.Tag.Value == "" {
282
				continue
283
			}
284
			seen[u.Tag.Value] = true
285
			values[u.Tag.Value] = true
286
		}
287
		if len(seen) > 0 {
288
			info.Values = sortedKeys(seen)
289
		}
290
	}
291
	sort.Strings(names)
292
	a.TagNames = names
293
	a.TagValues = sortedKeys(values)
294
}
295
296
func sortedKeys(m map[string]bool) []string {
297
	out := make([]string, 0, len(m))
298
	for k := range m {
299
		out = append(out, k)
300
	}
301
	sort.Strings(out)
302
	return out
303
}
304
305
func formatDate(d ast.Date) string {
306
	if d.Year == 0 && d.Month == 0 && d.Day == 0 {
307
		return ""
308
	}
309
	if d.Month < 1 || d.Month > 12 || d.Day < 1 || d.Day > 31 {
310
		return ""
311
	}
312
	return fmt.Sprintf("%04d-%02d-%02d", d.Year, d.Month, d.Day)
313
}
314
315
func (a *Analysis) addCommodityUsage(fileIndex int, am *ast.Amount, date *ast.Date) {
316
	if am == nil || am.Commodity == "" {
317
		return
318
	}
319
	info, ok := a.Commodities[am.Commodity]
320
	if !ok {
321
		info = &CommodityInfo{}
322
		a.Commodities[am.Commodity] = info
323
	}
324
	info.Usages = append(info.Usages, CommodityUsage{
325
		FileIndex: fileIndex,
326
		Amount:    am,
327
	})
328
	info.UsedCount++
329
	if date != nil {
330
		info.LastUsed = maxDate(info.LastUsed, *date)
331
	}
332
}
333
334
func (a *Analysis) buildPrefixIndex() {
335
	for name := range a.Accounts {
336
		parts := strings.Split(name, ":")
337
		for i := 1; i < len(parts); i++ {
338
			prefix := strings.Join(parts[:i], ":") + ":"
339
			a.AccountsByPrefix[prefix] = append(a.AccountsByPrefix[prefix], name)
340
		}
341
	}
342
}
343
344
func (a *Analysis) collectPayeeNames() {
345
	names := make([]string, 0, len(a.Payees))
346
	for name := range a.Payees {
347
		names = append(names, name)
348
	}
349
	sort.Strings(names)
350
	a.PayeeNames = names
351
}
352
353
func (a *Analysis) sortAccountNames() {
354
	names := make([]string, 0, len(a.Accounts))
355
	for name := range a.Accounts {
356
		names = append(names, name)
357
	}
358
	sort.Strings(names)
359
	a.AccountNames = names
360
}
361
362
func maxDate(a, b ast.Date) ast.Date {
363
	if a.Year > b.Year ||
364
		(a.Year == b.Year && (a.Month > b.Month ||
365
			(a.Year == b.Year && a.Month == b.Month && a.Day > b.Day))) {
366
		return a
367
	}
368
	return b
369
}