all repos

clerk @ 2e3f494aa9727445f15867f393b81acbc4881dc3

missing tooling for ledger/hledger

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

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