all repos

clerk @ 2ca3278

missing tooling for ledger/hledger

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

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