all repos

clerk @ 20a67e0

missing tooling for ledger/hledger

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

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