10 files changed,
337 insertions(+),
0 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-06-27 12:41:04 +0300
Authored at:
2026-06-26 16:26:44 +0300
Change ID:
wnoqouylttoptsvvvoytylmurnlkpwut
Parent:
ba9be60
jump to
A
journal/semantic/build.go
··· 1 +package semantic 2 + 3 +import ( 4 + "olexsmir.xyz/clerk/journal" 5 + "olexsmir.xyz/clerk/journal/ast" 6 +) 7 + 8 +// Build constructs [Context] from a list of parsed files. 9 +// Files should be in dependency order(includes before includers). 10 +func Build(files []*journal.ParsedFile) *Context { 11 + c := &Context{ 12 + Files: files, 13 + Accounts: make(map[string]*AccountInfo), 14 + Commodities: make(map[string]*CommodityInfo), 15 + } 16 + for i, pf := range files { 17 + for _, entry := range pf.Ast.Entries { 18 + switch e := entry.(type) { 19 + case *ast.AccountDirective: 20 + c.addAccountDirective(e) 21 + case *ast.CommodityDirective: 22 + c.addCommodityDirective(e) 23 + case *ast.Transaction: 24 + c.addPostings(i, e.Postings) 25 + case *ast.PeriodicTransaction: 26 + c.addPostings(i, e.Postings) 27 + case *ast.AutomatedTransaction: 28 + c.addPostings(i, e.Postings) 29 + } 30 + } 31 + } 32 + return c 33 +} 34 + 35 +func (c *Context) addAccountDirective(ad *ast.AccountDirective) { 36 + aname := ad.Account.String() 37 + info, ok := c.Accounts[aname] 38 + if !ok { 39 + info = &AccountInfo{} 40 + c.Accounts[aname] = info 41 + } 42 + info.Directives = append(info.Directives, ad) 43 +} 44 + 45 +func (c *Context) addCommodityDirective(cd *ast.CommodityDirective) { 46 + info, ok := c.Commodities[cd.Commodity] 47 + if !ok { 48 + info = &CommodityInfo{} 49 + c.Commodities[cd.Commodity] = info 50 + } 51 + info.Directives = append(info.Directives, cd) 52 +} 53 + 54 +func (c *Context) addPostings(fileIndex int, postings []*ast.Posting) { 55 + for _, posting := range postings { 56 + // Account tracking 57 + aname := posting.Account.String() 58 + info, ok := c.Accounts[aname] 59 + if !ok { 60 + info = &AccountInfo{} 61 + c.Accounts[aname] = info 62 + } 63 + info.Usages = append(info.Usages, AccountUsage{ 64 + FileIndex: fileIndex, 65 + Posting: posting, 66 + }) 67 + 68 + // Commodity tracking 69 + c.addCommodityUsage(fileIndex, posting.Amount) 70 + if posting.Cost != nil { 71 + c.addCommodityUsage(fileIndex, &posting.Cost.Amount) 72 + } 73 + if posting.Balance != nil { 74 + c.addCommodityUsage(fileIndex, &posting.Balance.Amount) 75 + if posting.Balance.Cost != nil { 76 + c.addCommodityUsage(fileIndex, &posting.Balance.Cost.Amount) 77 + } 78 + } 79 + } 80 +} 81 + 82 +func (c *Context) addCommodityUsage(fileIndex int, am *ast.Amount) { 83 + if am == nil || am.Commodity == "" { 84 + return 85 + } 86 + info, ok := c.Commodities[am.Commodity] 87 + if !ok { 88 + info = &CommodityInfo{} 89 + c.Commodities[am.Commodity] = info 90 + } 91 + info.Usages = append(info.Usages, CommodityUsage{ 92 + FileIndex: fileIndex, 93 + Amount: am, 94 + }) 95 +}
A
journal/semantic/context.go
··· 1 +package semantic 2 + 3 +import ( 4 + "olexsmir.xyz/clerk/journal" 5 + "olexsmir.xyz/clerk/journal/ast" 6 +) 7 + 8 +// Context holds workspace-level semantic data built from parsed journal files. 9 +type Context struct { 10 + // Files in dependency order (includes before includers). 11 + Files []*journal.ParsedFile 12 + 13 + Accounts map[string]*AccountInfo 14 + Commodities map[string]*CommodityInfo 15 +} 16 + 17 +type AccountInfo struct { 18 + // len == 0 = account is used but never declared (undeclared) 19 + // len > 1 = account is declared more than once (duplicated) 20 + // len > 0 = account appears in a posting 21 + Directives []*ast.AccountDirective 22 + Usages []AccountUsage 23 +} 24 + 25 +// AccountUsage is a single posting that references an account. 26 +type AccountUsage struct { 27 + FileIndex int 28 + Posting *ast.Posting 29 +} 30 + 31 +// CommodityInfo tracks all declarations and usages for one commodity. 32 +type CommodityInfo struct { 33 + Directives []*ast.CommodityDirective 34 + Usages []CommodityUsage 35 +} 36 + 37 +// CommodityUsage is a single amount that references a commodity. 38 +type CommodityUsage struct { 39 + FileIndex int 40 + Amount *ast.Amount 41 +}
A
journal/semantic/context_test.go
··· 1 +package semantic 2 + 3 +import ( 4 + "bytes" 5 + "testing" 6 + 7 + "olexsmir.xyz/clerk/internal/testutil/golden" 8 + "olexsmir.xyz/clerk/journal" 9 +) 10 + 11 +func TestBuild(t *testing.T) { 12 + tests := map[string][]string{ 13 + "empty": {}, 14 + "journal": {"journal"}, 15 + "include": {"include0", "include1"}, 16 + } 17 + 18 + for name, inputs := range tests { 19 + t.Run(name, func(t *testing.T) { 20 + var files []*journal.ParsedFile 21 + for _, in := range inputs { 22 + l := journal.NewLoader() 23 + pf, err := l.LoadBytes(in+".journal", golden.Load(t, in)) 24 + if err != nil { 25 + t.Fatalf("LoadBytes(%q): %v", in, err) 26 + } 27 + files = append(files, pf) 28 + } 29 + ctx := Build(files) 30 + 31 + var buf bytes.Buffer 32 + fprint(&buf, ctx) 33 + golden.Assert(t, name, buf.String()) 34 + }) 35 + } 36 +}
A
journal/semantic/dump.go
··· 1 +package semantic 2 + 3 +import ( 4 + "fmt" 5 + "io" 6 + "path/filepath" 7 + "sort" 8 +) 9 + 10 +func fprint(w io.Writer, ctx *Context) { 11 + // Files 12 + fmt.Fprintf(w, "files (%d):\n", len(ctx.Files)) 13 + for i, pf := range ctx.Files { 14 + fmt.Fprintf(w, " %d: %s\n", i, filepath.Base(pf.Path)) 15 + } 16 + 17 + // Commodities 18 + csyms := make([]string, 0, len(ctx.Commodities)) 19 + for sym := range ctx.Commodities { 20 + csyms = append(csyms, sym) 21 + } 22 + sort.Strings(csyms) 23 + 24 + fmt.Fprintf(w, "\ncommodities (%d):\n", len(ctx.Commodities)) 25 + for _, sym := range csyms { 26 + info := ctx.Commodities[sym] 27 + fmt.Fprintf(w, " %s\n", sym) 28 + fmt.Fprintf(w, " directives: %d\n", len(info.Directives)) 29 + for _, cd := range info.Directives { 30 + fmt.Fprintf(w, " commodity %s\n", cd.Commodity) 31 + } 32 + fmt.Fprintf(w, " usages: %d\n", len(info.Usages)) 33 + for _, u := range info.Usages { 34 + fmt.Fprintf(w, " file %d: %s (%q)\n", u.FileIndex, u.Amount.Commodity, u.Amount.Quantity.String()) 35 + } 36 + } 37 + 38 + // Accounts 39 + anames := make([]string, 0, len(ctx.Accounts)) 40 + for name := range ctx.Accounts { 41 + anames = append(anames, name) 42 + } 43 + sort.Strings(anames) 44 + 45 + fmt.Fprintf(w, "\naccounts (%d):\n", len(ctx.Accounts)) 46 + for _, name := range anames { 47 + info := ctx.Accounts[name] 48 + fmt.Fprintf(w, " %s\n", name) 49 + fmt.Fprintf(w, " directives: %d\n", len(info.Directives)) 50 + for _, d := range info.Directives { 51 + fmt.Fprintf(w, " account %s\n", d.Account.String()) 52 + } 53 + fmt.Fprintf(w, " usages: %d\n", len(info.Usages)) 54 + for _, u := range info.Usages { 55 + fmt.Fprintf(w, " file %d: %s\n", u.FileIndex, u.Posting.Account.String()) 56 + } 57 + } 58 +}
A
journal/semantic/testdata/include.golden
··· 1 +files (2): 2 + 0: include0.journal 3 + 1: include1.journal 4 + 5 +commodities (1): 6 + $ 7 + directives: 0 8 + usages: 1 9 + file 1: $ ("10") 10 + 11 +accounts (2): 12 + assets:checking 13 + directives: 0 14 + usages: 1 15 + file 1: assets:checking 16 + expenses:food 17 + directives: 1 18 + account expenses:food 19 + usages: 1 20 + file 1: expenses:food
A
journal/semantic/testdata/include1.input
··· 1 +2024/01/01 t 2 + expenses:food $10 3 + assets:checking
A
journal/semantic/testdata/journal.golden
··· 1 +files (1): 2 + 0: journal.journal 3 + 4 +commodities (3): 5 + $ 6 + directives: 2 7 + commodity $ 8 + commodity $ 9 + usages: 4 10 + file 0: $ ("50") 11 + file 0: $ ("100") 12 + file 0: $ ("10") 13 + file 0: $ ("5") 14 + EUR 15 + directives: 1 16 + commodity EUR 17 + usages: 0 18 + USD 19 + directives: 0 20 + usages: 1 21 + file 0: USD ("1000000") 22 + 23 +accounts (5): 24 + assets:cash 25 + directives: 0 26 + usages: 2 27 + file 0: assets:cash 28 + file 0: assets:cash 29 + assets:checking 30 + directives: 0 31 + usages: 2 32 + file 0: assets:checking 33 + file 0: assets:checking 34 + assets:credit 35 + directives: 0 36 + usages: 1 37 + file 0: assets:credit 38 + expenses:food 39 + directives: 2 40 + account expenses:food 41 + account expenses:food 42 + usages: 4 43 + file 0: expenses:food 44 + file 0: expenses:food 45 + file 0: expenses:food 46 + file 0: expenses:food 47 + expenses:transport 48 + directives: 1 49 + account expenses:transport 50 + usages: 1 51 + file 0: expenses:transport
A
journal/semantic/testdata/journal.input
··· 1 +account expenses:food 2 +account expenses:food 3 +account expenses:transport 4 + 5 +commodity $ 6 +commodity $ 7 +commodity EUR 8 + 9 +~ monthly 10 + expenses:transport $50 11 + assets:checking 12 + 13 += expr:big 14 + expenses:food $100 15 + assets:credit 16 + 17 +2024/01/01 groceries 18 + expenses:food $10 19 + assets:checking 20 + 21 +2024/01/02 lunch 22 + expenses:food $5 23 + assets:cash 24 + 25 +2024/01/04 aws 26 + expenses:food 1000000.00 USD 27 + assets:cash