all repos

clerk @ d41beb6568dfed87d70c40a65c7b5cbd88d73eff

missing tooling for ledger/hledger

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

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