all repos

clerk @ f2dd916

missing tooling for ledger/hledger

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

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