all repos

clerk @ a69e534d08842f6663eecddd5380d88cf40d2096

missing tooling for ledger/hledger
10 files changed, 337 insertions(+), 0 deletions(-)
journal: add semantic model
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
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/empty.golden
···
        
        1
        +files (0):

      
        
        2
        +

      
        
        3
        +commodities (0):

      
        
        4
        +

      
        
        5
        +accounts (0):

      
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/include0.input
···
        
        1
        +account 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