all repos

clerk @ 1f3a17349e37c8189adb11b8d956acbf3b2080d1

missing tooling for ledger/hledger

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

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