all repos

clerk @ e2e9e2b27783c07fe5724268bf7ae828b3aacac7

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
improve analyzer, 1 day 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 list of parsed files.
13
// Files should be in dependency order (includes before includers).
14
func Build(files []*journal.ParsedFile) *Analysis {
15
	a := &Analysis{
16
		Files:                 files,
17
		Accounts:              make(map[string]*AccountInfo),
18
		Commodities:           make(map[string]*CommodityInfo),
19
		Payees:                make(map[string]*PayeeInfo),
20
		AccountsByPrefix:      make(map[string][]string),
21
		PayeeTemplates:        make(map[string][]PostingTemplate),
22
		CommodityDecimalMarks: make(map[string]byte),
23
		TransactionsByKey:     make(map[string][]*ast.Transaction),
24
	}
25
	for i, pf := range a.Files {
26
		for _, entry := range pf.Ast.Entries {
27
			a.addEntry(i, entry)
28
		}
29
	}
30
	a.buildPrefixIndex()
31
	a.sortAccountNames()
32
	a.collectPayeeNames()
33
	a.collectDates()
34
	a.collectTagNames()
35
	return a
36
}
37
38
func TxDuplicateKey(tx *ast.Transaction) string {
39
	var b strings.Builder
40
	fmt.Fprintf(&b, "%04d-%02d-%02d|", tx.Date.Year, tx.Date.Month, tx.Date.Day)
41
	if tx.Payee != nil {
42
		b.WriteString(tx.Payee.Name)
43
	}
44
	b.WriteByte('|')
45
	for _, p := range tx.Postings {
46
		b.WriteString(p.Account.String())
47
		b.WriteByte(',')
48
	}
49
	return b.String()
50
}
51
52
func (a *Analysis) addEntry(fileIndex int, entry ast.Entry) {
53
	switch e := entry.(type) {
54
	case *ast.AccountDirective:
55
		a.addAccountDirective(e)
56
	case *ast.CommodityDirective:
57
		a.addCommodityDirective(e)
58
	case *ast.PayeeDirective:
59
		a.addPayeeDirective(e)
60
	case *ast.Transaction:
61
		a.Transactions = append(a.Transactions, e)
62
		a.addPostings(fileIndex, e.Postings, &e.Date)
63
		a.addPayee(fileIndex, e.Payee)
64
		a.addPayeeTemplate(e)
65
		key := TxDuplicateKey(e)
66
		a.TransactionsByKey[key] = append(a.TransactionsByKey[key], e)
67
	case *ast.PeriodicTransaction:
68
		a.PeriodicTransactions = append(a.PeriodicTransactions, e)
69
		a.addPostings(fileIndex, e.Postings, nil)
70
	case *ast.AutomatedTransaction:
71
		a.AutomatedTransactions = append(a.AutomatedTransactions, e)
72
		a.addPostings(fileIndex, e.Postings, nil)
73
	case *ast.DecimalMarkDirective:
74
		// Collect commodity decimal marks from directives with explicit format,
75
		// but DecimalMarkDirective sets the default for all commodities.
76
		// We don't track a "default" — individual commodities get theirs from amount formatting.
77
	case *ast.DefaultCommodityDirective:
78
		if e.Amount.Commodity != "" {
79
			mark := e.Amount.QuantityFmt.Decimal
80
			if mark != 0 {
81
				a.CommodityDecimalMarks[e.Amount.Commodity] = mark
82
			}
83
		}
84
	}
85
86
	// Every directive-like entry goes into Directives.
87
	switch entry.(type) {
88
	case *ast.AccountDirective, *ast.CommodityDirective, *ast.PayeeDirective, *ast.TagDirective, *ast.IncludeDirective,
89
		*ast.AliasDirective, *ast.YearDirective, *ast.DecimalMarkDirective, *ast.DefaultCommodityDirective, *ast.MarketPriceDirective,
90
		*ast.ConversionDirective, *ast.ApplyDirective, *ast.EndDirective, *ast.CommentBlockDirective, *ast.IgnoredDirective:
91
		a.Directives = append(a.Directives, entry)
92
	}
93
}
94
95
func (a *Analysis) addAccountDirective(ad *ast.AccountDirective) {
96
	aname := ad.Account.String()
97
	info, ok := a.Accounts[aname]
98
	if !ok {
99
		info = &AccountInfo{}
100
		a.Accounts[aname] = info
101
	}
102
	info.Directives = append(info.Directives, ad)
103
}
104
105
func (a *Analysis) addPayeeDirective(pd *ast.PayeeDirective) {
106
	info, ok := a.Payees[pd.Name]
107
	if !ok {
108
		info = &PayeeInfo{}
109
		a.Payees[pd.Name] = info
110
	}
111
	info.Directives = append(info.Directives, &ast.Payee{
112
		Name: pd.Name,
113
		Span: pd.Span,
114
	})
115
}
116
117
func (a *Analysis) addCommodityDirective(cd *ast.CommodityDirective) {
118
	info, ok := a.Commodities[cd.Commodity]
119
	if !ok {
120
		info = &CommodityInfo{}
121
		a.Commodities[cd.Commodity] = info
122
	}
123
	info.Directives = append(info.Directives, cd)
124
}
125
126
func (a *Analysis) addPayee(fileIndex int, payee *ast.Payee) {
127
	if payee == nil {
128
		return
129
	}
130
	info, ok := a.Payees[payee.Name]
131
	if !ok {
132
		info = &PayeeInfo{}
133
		a.Payees[payee.Name] = info
134
	}
135
	info.Usage = append(info.Usage, PayeeUsage{
136
		FileIndex: fileIndex,
137
		Payee:     payee,
138
	})
139
	info.UsedCount++
140
}
141
142
func (a *Analysis) addPostings(fileIndex int, postings []*ast.Posting, date *ast.Date) {
143
	for _, posting := range postings {
144
		aname := posting.Account.String()
145
		info, ok := a.Accounts[aname]
146
		if !ok {
147
			info = &AccountInfo{}
148
			a.Accounts[aname] = info
149
		}
150
		info.Usages = append(info.Usages, AccountUsage{
151
			FileIndex: fileIndex,
152
			Posting:   posting,
153
		})
154
		info.UsedCount++
155
		if date != nil {
156
			info.LastUsed = maxDate(info.LastUsed, *date)
157
		}
158
159
		a.addCommodityUsage(fileIndex, posting.Amount, date)
160
		if posting.Cost != nil {
161
			a.addCommodityUsage(fileIndex, &posting.Cost.Amount, date)
162
		}
163
		if posting.Balance != nil {
164
			a.addCommodityUsage(fileIndex, &posting.Balance.Amount, date)
165
			if posting.Balance.Cost != nil {
166
				a.addCommodityUsage(fileIndex, &posting.Balance.Cost.Amount, date)
167
			}
168
		}
169
170
		// Collect decimal mark from amount formatting.
171
		if posting.Amount != nil && posting.Amount.Commodity != "" && posting.Amount.QuantityFmt.Decimal != 0 {
172
			if _, ok := a.CommodityDecimalMarks[posting.Amount.Commodity]; !ok {
173
				a.CommodityDecimalMarks[posting.Amount.Commodity] = posting.Amount.QuantityFmt.Decimal
174
			}
175
		}
176
	}
177
}
178
179
func (a *Analysis) addPayeeTemplate(tx *ast.Transaction) {
180
	payee := ""
181
	if tx.Payee != nil {
182
		payee = tx.Payee.Name
183
	}
184
	if payee == "" {
185
		return
186
	}
187
188
	templates := make([]PostingTemplate, len(tx.Postings))
189
	for i, p := range tx.Postings {
190
		t := PostingTemplate{
191
			Account:    p.Account.String(),
192
			IsInferred: p.Amount == nil,
193
		}
194
		if p.Amount != nil {
195
			t.Amount = p.Amount.Quantity.String()
196
			t.Commodity = p.Amount.Commodity
197
		}
198
		templates[i] = t
199
	}
200
	a.PayeeTemplates[payee] = templates
201
}
202
203
func (a *Analysis) collectDates() {
204
	seen := make(map[string]bool)
205
	for _, tx := range a.Transactions {
206
		s := formatDate(tx.Date)
207
		if s != "" && !seen[s] {
208
			seen[s] = true
209
			a.Dates = append(a.Dates, tx.Date)
210
			a.DateStrings = append(a.DateStrings, s)
211
		}
212
	}
213
	sort.Slice(a.Dates, func(i, j int) bool {
214
		return dateLess(a.Dates[i], a.Dates[j])
215
	})
216
	sort.Strings(a.DateStrings)
217
}
218
219
func (a *Analysis) collectTagNames() {
220
	seen := make(map[string]bool)
221
	for _, d := range a.Directives {
222
		if td, ok := d.(*ast.TagDirective); ok && td.Name != "" && !seen[td.Name] {
223
			seen[td.Name] = true
224
			a.TagNames = append(a.TagNames, td.Name)
225
		}
226
	}
227
	sort.Strings(a.TagNames)
228
}
229
230
func formatDate(d ast.Date) string {
231
	if d.Year == 0 && d.Month == 0 && d.Day == 0 {
232
		return ""
233
	}
234
	if d.Month < 1 || d.Month > 12 || d.Day < 1 || d.Day > 31 {
235
		return ""
236
	}
237
	return fmt.Sprintf("%04d-%02d-%02d", d.Year, d.Month, d.Day)
238
}
239
240
func dateLess(a, b ast.Date) bool {
241
	if a.Year != b.Year {
242
		return a.Year < b.Year
243
	}
244
	if a.Month != b.Month {
245
		return a.Month < b.Month
246
	}
247
	return a.Day < b.Day
248
}
249
250
func (a *Analysis) addCommodityUsage(fileIndex int, am *ast.Amount, date *ast.Date) {
251
	if am == nil || am.Commodity == "" {
252
		return
253
	}
254
	info, ok := a.Commodities[am.Commodity]
255
	if !ok {
256
		info = &CommodityInfo{}
257
		a.Commodities[am.Commodity] = info
258
	}
259
	info.Usages = append(info.Usages, CommodityUsage{
260
		FileIndex: fileIndex,
261
		Amount:    am,
262
	})
263
	info.UsedCount++
264
	if date != nil {
265
		info.LastUsed = maxDate(info.LastUsed, *date)
266
	}
267
}
268
269
func (a *Analysis) buildPrefixIndex() {
270
	for name := range a.Accounts {
271
		parts := strings.Split(name, ":")
272
		for i := 1; i < len(parts); i++ {
273
			prefix := strings.Join(parts[:i], ":") + ":"
274
			a.AccountsByPrefix[prefix] = append(a.AccountsByPrefix[prefix], name)
275
		}
276
	}
277
}
278
279
func (a *Analysis) collectPayeeNames() {
280
	names := make([]string, 0, len(a.Payees))
281
	for name := range a.Payees {
282
		names = append(names, name)
283
	}
284
	sort.Strings(names)
285
	a.PayeeNames = names
286
}
287
288
func (a *Analysis) sortAccountNames() {
289
	names := make([]string, 0, len(a.Accounts))
290
	for name := range a.Accounts {
291
		names = append(names, name)
292
	}
293
	sort.Strings(names)
294
	a.AccountNames = names
295
}
296
297
func maxDate(a, b ast.Date) ast.Date {
298
	if a.Year > b.Year ||
299
		(a.Year == b.Year && (a.Month > b.Month ||
300
			(a.Year == b.Year && a.Month == b.Month && a.Day > b.Day))) {
301
		return a
302
	}
303
	return b
304
}