all repos

clerk @ 2e3f494aa9727445f15867f393b81acbc4881dc3

missing tooling for ledger/hledger
12 files changed, 57 insertions(+), 63 deletions(-)
ast: store postings as values instead of pointers
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-09-02 19:12:11 +0300
Authored at: 2026-09-02 18:39:10 +0300
Change ID: rkulqspkupnrqoouxzqrorqkuookmmtx
Parent: 747f2da
M internal/analyzer/build.go
···
        230
        230
         	info.UsedCount++

      
        231
        231
         }

      
        232
        232
         

      
        233
        
        -func (a *Analysis) addPostings(fileIndex int, postings []*ast.Posting, names []string, date *ast.Date) {

      
        
        233
        +func (a *Analysis) addPostings(fileIndex int, postings []ast.Posting, names []string, date *ast.Date) {

      
        234
        234
         	if names == nil {

      
        235
        235
         		names = make([]string, len(postings))

      
        236
        236
         		for i, p := range postings {

      ···
        246
        246
         		}

      
        247
        247
         		info.Usages = append(info.Usages, AccountUsage{

      
        248
        248
         			FileIndex: fileIndex,

      
        249
        
        -			Posting:   posting,

      
        
        249
        +			Posting:   &postings[i],

      
        250
        250
         		})

      
        251
        251
         		info.UsedCount++

      
        252
        252
         		if date != nil {

      
M internal/linter/rule_missing_commodity.go
···
        43
        43
         	return finds

      
        44
        44
         }

      
        45
        45
         

      
        46
        
        -func (m *MissingCommodity) checkPostings(finds *[]Find, postings []*ast.Posting) {

      
        
        46
        +func (m *MissingCommodity) checkPostings(finds *[]Find, postings []ast.Posting) {

      
        47
        47
         	for _, posting := range postings {

      
        48
        48
         		if posting.Amount == nil {

      
        49
        49
         			continue

      
M internal/linter/rule_multiple_omitted_amounts.go
···
        25
        25
         	return finds

      
        26
        26
         }

      
        27
        27
         

      
        28
        
        -func (m *MultipleOmittedAmounts) check(postings []*ast.Posting) []Find {

      
        
        28
        +func (m *MultipleOmittedAmounts) check(postings []ast.Posting) []Find {

      
        29
        29
         	var finds []Find

      
        30
        30
         	for _, p := range postings {

      
        31
        31
         		if p.Amount == nil && p.Balance == nil {

      
M internal/linter/rule_unbalanced_transaction.go
···
        29
        29
         	return finds

      
        30
        30
         }

      
        31
        31
         

      
        32
        
        -func (u *UnbalancedTransaction) check(postings []*ast.Posting, span token.Span) []Find {

      
        
        32
        +func (u *UnbalancedTransaction) check(postings []ast.Posting, span token.Span) []Find {

      
        33
        33
         	var hasExpr, hasCost bool

      
        34
        34
         	var realPostings []*ast.Posting

      
        35
        35
         	autoBalancingPostings := 0

      
        36
        36
         

      
        37
        
        -	for _, posting := range postings {

      
        
        37
        +	for i := range postings {

      
        
        38
        +		posting := &postings[i]

      
        38
        39
         		if posting.Type != ast.PostingReal {

      
        39
        40
         			continue

      
        40
        41
         		}

      
M internal/lsp/textdocument_folding_range.go
···
        84
        84
         }

      
        85
        85
         

      
        86
        86
         // postingsFold folds the posting block of a transaction, leaving the header visible.

      
        87
        
        -func postingsFold(postings []*ast.Posting) *protocol.FoldingRange {

      
        
        87
        +func postingsFold(postings []ast.Posting) *protocol.FoldingRange {

      
        88
        88
         	if len(postings) < 2 {

      
        89
        89
         		return nil

      
        90
        90
         	}

      
M internal/lsp/textdocument_hover.go
···
        147
        147
         	return nil

      
        148
        148
         }

      
        149
        149
         

      
        150
        
        -func hoverInPostings(content string, postings []*ast.Posting, cursor int) *hoverElement {

      
        
        150
        +func hoverInPostings(content string, postings []ast.Posting, cursor int) *hoverElement {

      
        151
        151
         	for _, p := range postings {

      
        152
        152
         		if spanContains(content, p.Account.Span, cursor) {

      
        153
        153
         			return &hoverElement{kind: hoverAccount, span: p.Account.Span, name: p.Account.String()}

      
M internal/lsp/textdocument_rename.go
···
        211
        211
         	return &symbolRef{symbolCommodity, am.Commodity, am.CommoditySpan}

      
        212
        212
         }

      
        213
        213
         

      
        214
        
        -func symbolInPostings(content string, postings []*ast.Posting, cursor int) *symbolRef {

      
        
        214
        +func symbolInPostings(content string, postings []ast.Posting, cursor int) *symbolRef {

      
        215
        215
         	for _, p := range postings {

      
        216
        216
         		if spanContains(content, p.Account.Span, cursor) {

      
        217
        217
         			return &symbolRef{symbolAccount, p.Account.String(), p.Account.Span}

      
M internal/lsp/textdocument_semantic_tokens.go
···
        384
        384
         		emitComment(t.HeaderComments[i], emit)

      
        385
        385
         	}

      
        386
        386
         	for _, p := range t.Postings {

      
        387
        
        -		visitPosting(content, p, emit)

      
        
        387
        +		visitPosting(content, &p, emit)

      
        388
        388
         	}

      
        389
        389
         }

      
        390
        390
         

      ···
        421
        421
         		emitComment(pt.HeaderComments[i], emit)

      
        422
        422
         	}

      
        423
        423
         	for _, p := range pt.Postings {

      
        424
        
        -		visitPosting(content, p, emit)

      
        
        424
        +		visitPosting(content, &p, emit)

      
        425
        425
         	}

      
        426
        426
         }

      
        427
        427
         

      ···
        437
        437
         		emitComment(at.HeaderComments[i], emit)

      
        438
        438
         	}

      
        439
        439
         	for _, p := range at.Postings {

      
        440
        
        -		visitPosting(content, p, emit)

      
        
        440
        +		visitPosting(content, &p, emit)

      
        441
        441
         	}

      
        442
        442
         }

      
        443
        443
         

      
M journal/ast/dump.go
···
        154
        154
         			dumpComment(b, c, depth+2)

      
        155
        155
         		}

      
        156
        156
         	}

      
        157
        
        -	for _, p := range t.Postings {

      
        158
        
        -		dumpPosting(b, p, depth+1)

      
        
        157
        +	for i := range t.Postings {

      
        
        158
        +		dumpPosting(b, &t.Postings[i], depth+1)

      
        159
        159
         	}

      
        160
        160
         }

      
        161
        161
         

      ···
        172
        172
         			dumpComment(b, c, depth+2)

      
        173
        173
         		}

      
        174
        174
         	}

      
        175
        
        -	for _, p := range t.Postings {

      
        176
        
        -		dumpPosting(b, p, depth+1)

      
        
        175
        +	for i := range t.Postings {

      
        
        176
        +		dumpPosting(b, &t.Postings[i], depth+1)

      
        177
        177
         	}

      
        178
        178
         }

      
        179
        179
         

      ···
        210
        210
         			dumpComment(b, c, depth+2)

      
        211
        211
         		}

      
        212
        212
         	}

      
        213
        
        -	for _, p := range t.Postings {

      
        214
        
        -		dumpPosting(b, p, depth+1)

      
        
        213
        +	for i := range t.Postings {

      
        
        214
        +		dumpPosting(b, &t.Postings[i], depth+1)

      
        215
        215
         	}

      
        216
        216
         }

      
        217
        217
         

      
M journal/ast/entries.go
···
        18
        18
         	Note           *Note      // part after |

      
        19
        19
         	Comment        *Comment   // inline ; on header line

      
        20
        20
         	HeaderComments []*Comment // indented ; lines before first posting

      
        21
        
        -	Postings       []*Posting

      
        
        21
        +	Postings       []Posting

      
        22
        22
         	Span           token.Span

      
        23
        23
         }

      
        24
        24
         

      ···
        40
        40
         	Description    *Description // optional description

      
        41
        41
         	Comment        *Comment     // optional inline comment

      
        42
        42
         	HeaderComments []*Comment

      
        43
        
        -	Postings       []*Posting

      
        
        43
        +	Postings       []Posting

      
        44
        44
         	Span           token.Span

      
        45
        45
         }

      
        46
        46
         

      ···
        48
        48
         

      
        49
        49
         type AutomatedTransaction struct {

      
        50
        50
         	Expr           Expr

      
        51
        
        -	Postings       []*Posting

      
        
        51
        +	Postings       []Posting

      
        52
        52
         	Comment        *Comment   // inline ; on header line

      
        53
        53
         	HeaderComments []*Comment // indented ; lines before first posting

      
        54
        54
         	Span           token.Span

      
M journal/parser/parser.go
···
        243
        243
         	return at

      
        244
        244
         }

      
        245
        245
         

      
        246
        
        -func (p *Parser) parseHeaderCommentsAndPostings() (comments []*ast.Comment, postings []*ast.Posting) {

      
        
        246
        +func (p *Parser) parseHeaderCommentsAndPostings() (comments []*ast.Comment, postings []ast.Posting) {

      
        247
        247
         	for p.got(token.INDENT) && p.willGet(token.SEMICOLON) {

      
        248
        248
         		p.advance() // consume indent

      
        249
        249
         		comments = append(comments, p.parseComment())

      
        250
        250
         	}

      
        251
        251
         

      
        
        252
        +	postings = make([]ast.Posting, 0, 2) // most transactions have 2 postings, small optimization

      
        252
        253
         	for p.got(token.INDENT) {

      
        253
        
        -		if posting := p.parsePosting(); posting != nil {

      
        
        254
        +		if posting, ok := p.parsePosting(); ok {

      
        254
        255
         			postings = append(postings, posting)

      
        255
        256
         		}

      
        256
        257
         	}

      ···
        423
        424
         			amt.CommoditySpan = commoditySpan

      
        424
        425
         			amt.CommodityPos = ast.CommodityBefore

      
        425
        426
         			amt.HasSpace = hadSpace

      
        426
        
        -			format = &ast.FormatSubDirective{Amount: *amt}

      
        
        427
        +			format = &ast.FormatSubDirective{Amount: amt}

      
        427
        428
         		}

      
        428
        429
         	case token.INT, token.DECIMAL:

      
        429
        430
         		amt := p.parseAmount()

      
        430
        431
         		commodity = amt.Commodity

      
        431
        432
         		commoditySpan = amt.CommoditySpan

      
        432
        
        -		format = &ast.FormatSubDirective{Amount: *amt}

      
        
        433
        +		format = &ast.FormatSubDirective{Amount: amt}

      
        433
        434
         	default:

      
        434
        435
         		p.errorf("expected commodity name or amount, got %s", p.cur.Type)

      
        435
        436
         	}

      ···
        471
        472
         			}

      
        472
        473
         			c := p.parseOptInlineComment()

      
        473
        474
         			p.expectNewline()

      
        474
        
        -			format = &ast.FormatSubDirective{KeywordSpan: kw, Amount: *amt, Comment: c}

      
        
        475
        +			format = &ast.FormatSubDirective{KeywordSpan: kw, Amount: amt, Comment: c}

      
        475
        476
         		case p.got(token.SEMICOLON): // comment line

      
        476
        477
         			c := p.parseCommentRest(p.cur.Span)

      
        477
        478
         			p.expectNewline()

      ···
        622
        623
         	com := &ast.DefaultCommodityDirective{}

      
        623
        624
         	p.expect(token.D)

      
        624
        625
         	p.skipWhitespace()

      
        625
        
        -	com.Amount = *p.parseAmount()

      
        
        626
        +	com.Amount = p.parseAmount()

      
        626
        627
         	com.Comment = p.parseOptInlineComment()

      
        627
        628
         	p.expectNewline()

      
        628
        629
         	com.Span = p.span(s)

      ···
        636
        637
         	p.skipWhitespace()

      
        637
        638
         

      
        638
        639
         	if p.isAmountStart() {

      
        639
        
        -		cd.From = *p.parseAmount()

      
        
        640
        +		cd.From = p.parseAmount()

      
        640
        641
         	} else {

      
        641
        642
         		p.errorf("expected amount, got %s", p.cur.Type)

      
        642
        643
         	}

      ···
        646
        647
         		p.advance()

      
        647
        648
         		p.skipWhitespace()

      
        648
        649
         		if p.isAmountStart() {

      
        649
        
        -			cd.To = *p.parseAmount()

      
        
        650
        +			cd.To = p.parseAmount()

      
        650
        651
         		} else {

      
        651
        652
         			p.errorf("expected amount, got %s", p.cur.Type)

      
        652
        653
         		}

      ···
        697
        698
         	}

      
        698
        699
         	p.skipWhitespace()

      
        699
        700
         

      
        700
        
        -	mp.Amount = *p.parseAmount()

      
        
        701
        +	mp.Amount = p.parseAmount()

      
        701
        702
         

      
        702
        703
         	mp.Comment = p.parseOptInlineComment()

      
        703
        704
         

      ···
        846
        847
         	}

      
        847
        848
         }

      
        848
        849
         

      
        849
        
        -func (p *Parser) parseAmount() *ast.Amount {

      
        
        850
        +func (p *Parser) parseAmount() ast.Amount {

      
        850
        851
         	s := p.cur.Span

      
        851
        
        -	amt := &ast.Amount{

      
        852
        
        -		QuantityFmt: ast.QuantityFormat{},

      
        853
        
        -	}

      
        854
        
        -	defer func() {

      
        855
        
        -		// The span covers from the first token to the start of the next unconsumed token.

      
        856
        
        -		// Since parseQuantityInto (and possible commodity consumption) advanced past the last

      
        857
        
        -		// amount token, p.cur points to the next token after the amount — which is the correct end.

      
        858
        
        -		amt.Span = p.span(s)

      
        859
        
        -	}()

      
        
        852
        +	amt := ast.Amount{QuantityFmt: ast.QuantityFormat{}}

      
        860
        853
         

      
        861
        
        -	p.parseAmountSign(amt)

      
        
        854
        +	p.parseAmountSign(&amt)

      
        862
        855
         	p.skipWhitespace()

      
        863
        856
         

      
        864
        857
         	// commodity before quantity: $10.00, eur 10.00

      ···
        875
        868
         	}

      
        876
        869
         

      
        877
        870
         	// optional sign after commodity: $ -10

      
        878
        
        -	p.parseAmountSign(amt)

      
        
        871
        +	p.parseAmountSign(&amt)

      
        879
        872
         	p.skipWhitespace()

      
        880
        873
         

      
        881
        
        -	p.parseQuantityInto(amt)

      
        
        874
        +	p.parseQuantityInto(&amt)

      
        882
        875
         

      
        883
        876
         	// commodity after quantity: 10.00 UAH, 10.00 "EUR" (only if not set)

      
        884
        877
         	if amt.Commodity == "" {

      ···
        902
        895
         		}

      
        903
        896
         	}

      
        904
        897
         

      
        
        898
        +	amt.Span = p.span(s)

      
        905
        899
         	return amt

      
        906
        900
         }

      
        907
        901
         

      ···
        916
        910
         	}

      
        917
        911
         }

      
        918
        912
         

      
        919
        
        -func (p *Parser) parseAmountWithOptExpr() *ast.Amount {

      
        
        913
        +func (p *Parser) parseAmountWithOptExpr() ast.Amount {

      
        920
        914
         	if p.got(token.STAR) {

      
        921
        915
         		p.advance()

      
        922
        916
         		p.skipWhitespace()

      
        923
        917
         		amt := p.parseAmount()

      
        924
        
        -		if amt != nil {

      
        925
        
        -			amt.IsExpr = true

      
        926
        
        -		}

      
        
        918
        +		amt.IsExpr = true

      
        927
        919
         		return amt

      
        928
        920
         	}

      
        929
        921
         	if p.got(token.PARENEXPR) {

      
        930
        922
         		lit := p.cur.Literal

      
        931
        
        -		amt := &ast.Amount{

      
        
        923
        +		amt := ast.Amount{

      
        932
        924
         			IsExpr:      true,

      
        933
        925
         			QuantityFmt: ast.QuantityFormat{},

      
        934
        926
         		}

      ···
        942
        934
         	return p.parseAmount()

      
        943
        935
         }

      
        944
        936
         

      
        945
        
        -func (p *Parser) parsePosting() *ast.Posting {

      
        
        937
        +func (p *Parser) parsePosting() (ast.Posting, bool) {

      
        946
        938
         	s := p.cur.Span

      
        947
        
        -	posting := &ast.Posting{}

      
        
        939
        +	posting := ast.Posting{}

      
        948
        940
         	p.expect(token.INDENT)

      
        949
        941
         

      
        950
        942
         	// exit if it's empty line

      
        951
        943
         	if p.got(token.NEWLINE) || p.got(token.EOF) {

      
        952
        944
         		p.syncToNextline()

      
        953
        
        -		return nil

      
        
        945
        +		return ast.Posting{}, false

      
        954
        946
         	}

      
        955
        947
         

      
        956
        948
         	// optional status, outside of brackets, '! (account)'

      ···
        975
        967
         	if p.cur.Type != token.TEXT {

      
        976
        968
         		p.errorf("expected account name, got %s", p.cur.Type)

      
        977
        969
         		p.syncToNextline()

      
        978
        
        -		return nil

      
        
        970
        +		return ast.Posting{}, false

      
        979
        971
         	}

      
        980
        972
         

      
        981
        973
         	posting.Account = p.parseAccount()

      ···
        992
        984
         	if p.got(token.WHITESPACE) {

      
        993
        985
         		p.skipWhitespace()

      
        994
        986
         		if p.isAmountStart() {

      
        995
        
        -			posting.Amount = p.parseAmountWithOptExpr()

      
        
        987
        +			amt := p.parseAmountWithOptExpr()

      
        
        988
        +			posting.Amount = &amt

      
        996
        989
         		}

      
        997
        990
         	}

      
        998
        991
         

      ···
        1023
        1016
         	}

      
        1024
        1017
         

      
        1025
        1018
         	posting.Span = p.span(s)

      
        1026
        
        -	return posting

      
        
        1019
        +	return posting, true

      
        1027
        1020
         }

      
        1028
        1021
         

      
        1029
        1022
         func (p *Parser) parseCost() *ast.Cost {

      ···
        1033
        1026
         	p.skipWhitespace()

      
        1034
        1027
         	return &ast.Cost{

      
        1035
        1028
         		IsTotal: isTotal,

      
        1036
        
        -		Amount:  *p.parseAmount(),

      
        
        1029
        +		Amount:  p.parseAmount(),

      
        1037
        1030
         		Span:    p.span(s),

      
        1038
        1031
         	}

      
        1039
        1032
         }

      ···
        1055
        1048
         	p.advance()

      
        1056
        1049
         	p.skipWhitespace()

      
        1057
        1050
         

      
        1058
        
        -	ba.Amount = *p.parseAmount()

      
        
        1051
        +	ba.Amount = p.parseAmount()

      
        1059
        1052
         	p.skipWhitespace()

      
        1060
        1053
         	if p.got(token.AT) || p.got(token.ATAT) {

      
        1061
        1054
         		c := p.parseCost()

      ···
        1097
        1090
         

      
        1098
        1091
         func (p *Parser) parseAccount() ast.Account {

      
        1099
        1092
         	s := p.cur.Span

      
        1100
        
        -	acc := ast.Account{}

      
        
        1093
        +	acc := ast.Account{Name: make([]ast.SubAccount, 0, 2)}

      
        1101
        1094
         

      
        1102
        1095
         	sub, ok := p.readAccountSegment()

      
        1103
        1096
         	if !ok {

      
M journal/printer/transaction_postings.go
···
        8
        8
         	"olexsmir.xyz/clerk/journal/ast"

      
        9
        9
         )

      
        10
        10
         

      
        11
        
        -func (p *printer) writePostings(postings []*ast.Posting) {

      
        
        11
        +func (p *printer) writePostings(postings []ast.Posting) {

      
        12
        12
         	if len(postings) == 0 {

      
        13
        13
         		return

      
        14
        14
         	}

      ···
        21
        21
         	}

      
        22
        22
         }

      
        23
        23
         

      
        24
        
        -func (p *printer) writePostingsTwoSpaces(postings []*ast.Posting, maxAcct int) {

      
        
        24
        +func (p *printer) writePostingsTwoSpaces(postings []ast.Posting, maxAcct int) {

      
        25
        25
         	for _, pt := range postings {

      
        26
        
        -		p.writePostingLine(pt, maxAcct)

      
        
        26
        +		p.writePostingLine(&pt, maxAcct)

      
        27
        27
         		p.buf.WriteByte('\n')

      
        28
        28
         		for _, c := range pt.Comments {

      
        29
        29
         			p.buf.WriteString(p.indent)

      ···
        33
        33
         	}

      
        34
        34
         }

      
        35
        35
         

      
        36
        
        -func (p *printer) writePostingsTabbed(postings []*ast.Posting, maxAcct int) {

      
        
        36
        +func (p *printer) writePostingsTabbed(postings []ast.Posting, maxAcct int) {

      
        37
        37
         	var tmp strings.Builder

      
        38
        38
         	tw := tabwriter.NewWriter(&tmp, 0, 0, 2, ' ', tabwriter.StripEscape)

      
        39
        39
         

      
        40
        40
         	lp := &printer{cfg: p.cfg, indent: p.indent}

      
        41
        41
         	for _, pt := range postings {

      
        42
        42
         		lp.buf.Reset()

      
        43
        
        -		lp.writePostingLine(pt, maxAcct)

      
        
        43
        +		lp.writePostingLine(&pt, maxAcct)

      
        44
        44
         		_, _ = fmt.Fprintln(tw, lp.buf.String())

      
        45
        45
         		for _, c := range pt.Comments {

      
        46
        46
         			lp.buf.Reset()

      ···
        128
        128
         	}

      
        129
        129
         }

      
        130
        130
         

      
        131
        
        -func measureTxAccts(postings []*ast.Posting) int {

      
        
        131
        +func measureTxAccts(postings []ast.Posting) int {

      
        132
        132
         	maxAcct := 0

      
        133
        133
         	for _, p := range postings {

      
        134
        134
         		n := len(p.Account.Name)