all repos

clerk @ b08266897cc230f2a3323a2a70705116f7cfe2e1

missing tooling for ledger/hledger
20 files changed, 353 insertions(+), 116 deletions(-)
syntax: respect commodity format; =*; ===; :=
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-07-31 16:58:01 +0300
Authored at: 2026-07-31 13:22:13 +0300
Change ID: rumyuprxwtvtwtpyzxqqmpqzrltnpwqp
Parent: ea00934
M internal/decimal/decimal.go
···
        3
        3
         import (

      
        4
        4
         	"fmt"

      
        5
        5
         	"math/big"

      
        
        6
        +	"strconv"

      
        6
        7
         	"strings"

      
        7
        8
         )

      
        8
        9
         

      ···
        21
        22
         func FromString(s string) (Decimal, error) {

      
        22
        23
         	original := s

      
        23
        24
         	if s == "" {

      
        24
        
        -		return Decimal{}, fmt.Errorf("can't convert %s to decimal", original)

      
        
        25
        +		return Decimal{}, badDecimal(original)

      
        25
        26
         	}

      
        26
        27
         

      
        27
        
        -	sign := 1

      
        
        28
        +	neg := false

      
        28
        29
         	if s[0] == '+' || s[0] == '-' {

      
        29
        
        -		if s[0] == '-' {

      
        30
        
        -			sign = -1

      
        31
        
        -		}

      
        
        30
        +		neg = s[0] == '-'

      
        32
        31
         		s = s[1:]

      
        33
        32
         	}

      
        34
        33
         

      
        35
        
        -	if s == "" {

      
        36
        
        -		return Decimal{}, fmt.Errorf("can't convert %s to decimal", original)

      
        
        34
        +	exp := 0

      
        
        35
        +	if e := strings.IndexAny(s, "eE"); e >= 0 {

      
        
        36
        +		n, err := strconv.Atoi(s[e+1:])

      
        
        37
        +		if err != nil {

      
        
        38
        +			return Decimal{}, badDecimal(original)

      
        
        39
        +		}

      
        
        40
        +		// bounded so pow10 can't be asked to materialize an astronomically

      
        
        41
        +		// large number (e.g. 1E1000000000000)

      
        
        42
        +		const maxExp = 10000

      
        
        43
        +		if n > maxExp || n < -maxExp {

      
        
        44
        +			return Decimal{}, badDecimal(original)

      
        
        45
        +		}

      
        
        46
        +		exp = n

      
        
        47
        +		s = s[:e]

      
        37
        48
         	}

      
        38
        49
         

      
        39
        
        -	firstDot := strings.IndexByte(s, '.')

      
        40
        
        -	lastDot := strings.LastIndexByte(s, '.')

      
        41
        
        -	if firstDot != lastDot {

      
        42
        
        -		return Decimal{}, fmt.Errorf("can't convert %s to decimal", original)

      
        
        50
        +	intPart, fracPart := s, ""

      
        
        51
        +	if before, after, ok := strings.Cut(s, "."); ok {

      
        
        52
        +		if strings.IndexByte(after, '.') >= 0 {

      
        
        53
        +			return Decimal{}, badDecimal(original)

      
        
        54
        +		}

      
        
        55
        +		intPart, fracPart = before, after

      
        43
        56
         	}

      
        44
        57
         

      
        45
        
        -	intPart := s

      
        46
        
        -	fracPart := ""

      
        47
        
        -	if firstDot >= 0 {

      
        48
        
        -		intPart = s[:firstDot]

      
        49
        
        -		fracPart = s[firstDot+1:]

      
        
        58
        +	digits := intPart + fracPart

      
        
        59
        +	if neg {

      
        
        60
        +		digits = "-" + digits

      
        50
        61
         	}

      
        51
        
        -

      
        52
        
        -	if len(intPart)+len(fracPart) == 0 {

      
        53
        
        -		return Decimal{}, fmt.Errorf("can't convert %s to decimal", original)

      
        54
        
        -	}

      
        55
        
        -

      
        56
        
        -	for i := 0; i < len(intPart); i++ {

      
        57
        
        -		if intPart[i] < '0' || intPart[i] > '9' {

      
        58
        
        -			return Decimal{}, fmt.Errorf("can't convert %s to decimal", original)

      
        59
        
        -		}

      
        60
        
        -	}

      
        61
        
        -	for i := 0; i < len(fracPart); i++ {

      
        62
        
        -		if fracPart[i] < '0' || fracPart[i] > '9' {

      
        63
        
        -			return Decimal{}, fmt.Errorf("can't convert %s to decimal", original)

      
        64
        
        -		}

      
        
        62
        +	coeff, ok := new(big.Int).SetString(digits, 10)

      
        
        63
        +	if !ok {

      
        
        64
        +		return Decimal{}, badDecimal(original)

      
        65
        65
         	}

      
        66
        66
         

      
        67
        
        -	coeffDigits := intPart + fracPart

      
        68
        
        -	coeff := new(big.Int)

      
        69
        
        -	if _, ok := coeff.SetString(coeffDigits, 10); !ok {

      
        70
        
        -		return Decimal{}, fmt.Errorf("can't convert %s to decimal", original)

      
        
        67
        +	scale := len(fracPart) - exp

      
        
        68
        +	if scale < 0 {

      
        
        69
        +		coeff.Mul(coeff, pow10(-scale))

      
        
        70
        +		scale = 0

      
        71
        71
         	}

      
        72
        
        -	if sign < 0 && coeff.Sign() != 0 {

      
        73
        
        -		coeff.Neg(coeff)

      
        74
        
        -	}

      
        
        72
        +	return Decimal{coeff: coeff, scale: scale}.normalized(), nil

      
        
        73
        +}

      
        75
        74
         

      
        76
        
        -	out := Decimal{coeff: coeff, scale: len(fracPart)}

      
        77
        
        -	return out.normalized(), nil

      
        78
        
        -}

      
        
        75
        +func badDecimal(s string) error { return fmt.Errorf("can't convert %s to decimal", s) }

      
        79
        76
         

      
        80
        77
         func (d Decimal) String() string {

      
        81
        78
         	if d.coeff == nil || d.coeff.Sign() == 0 {

      ···
        258
        255
         	return d.coeff == nil || d.coeff.Sign() == 0

      
        259
        256
         }

      
        260
        257
         

      
        261
        
        -func align(a, b Decimal) (aCoeff *big.Int, bCoeff *big.Int, scale int) {

      
        262
        
        -	scale = max(b.scale, a.scale)

      
        263
        
        -

      
        264
        
        -	aCoeff = a.coeffOrZero()

      
        265
        
        -	bCoeff = b.coeffOrZero()

      
        266
        
        -	if delta := scale - a.scale; delta > 0 {

      
        267
        
        -		aCoeff.Mul(aCoeff, pow10(delta))

      
        268
        
        -	}

      
        269
        
        -	if delta := scale - b.scale; delta > 0 {

      
        270
        
        -		bCoeff.Mul(bCoeff, pow10(delta))

      
        271
        
        -	}

      
        272
        
        -	return aCoeff, bCoeff, scale

      
        273
        
        -}

      
        274
        
        -

      
        275
        258
         func (d Decimal) coeffOrZero() *big.Int {

      
        276
        259
         	if d.coeff == nil {

      
        277
        260
         		return new(big.Int)

      ···
        304
        287
         		abs.Neg(abs)

      
        305
        288
         	}

      
        306
        289
         	return Decimal{coeff: abs, scale: d.scale}

      
        
        290
        +}

      
        
        291
        +

      
        
        292
        +func align(a, b Decimal) (aCoeff *big.Int, bCoeff *big.Int, scale int) {

      
        
        293
        +	scale = max(b.scale, a.scale)

      
        
        294
        +

      
        
        295
        +	aCoeff = a.coeffOrZero()

      
        
        296
        +	bCoeff = b.coeffOrZero()

      
        
        297
        +	if delta := scale - a.scale; delta > 0 {

      
        
        298
        +		aCoeff.Mul(aCoeff, pow10(delta))

      
        
        299
        +	}

      
        
        300
        +	if delta := scale - b.scale; delta > 0 {

      
        
        301
        +		bCoeff.Mul(bCoeff, pow10(delta))

      
        
        302
        +	}

      
        
        303
        +	return aCoeff, bCoeff, scale

      
        307
        304
         }

      
        308
        305
         

      
        309
        306
         func pow10(n int) *big.Int {

      
M internal/decimal/decimal_test.go
···
        1
        1
         package decimal

      
        2
        2
         

      
        3
        
        -import "testing"

      
        
        3
        +import (

      
        
        4
        +	"strings"

      
        
        5
        +	"testing"

      
        
        6
        +)

      
        4
        7
         

      
        5
        8
         func TestNewFromString(t *testing.T) {

      
        6
        9
         	tests := []struct{ in, want string }{

      ···
        14
        17
         		{"-0.00", "0"},

      
        15
        18
         		{"-20", "-20"},

      
        16
        19
         		{"-.75", "-0.75"},

      
        
        20
        +		{"1E3", "1000"},

      
        
        21
        +		{"1E-3", "0.001"},

      
        
        22
        +		{"1E10000", "1" + strings.Repeat("0", 10000)},

      
        17
        23
         	}

      
        18
        24
         	for _, tt := range tests {

      
        19
        25
         		got, err := FromString(tt.in)

      ···
        32
        38
         		"1_000.00", "1,000.00",

      
        33
        39
         		"1..0",

      
        34
        40
         		"a1", "1a",

      
        
        41
        +		"1E10001", "1E-10001", "1E1000000000000",

      
        35
        42
         	}

      
        36
        43
         	for _, in := range tests {

      
        37
        44
         		if _, err := FromString(in); err == nil {

      
M journal/ast/dump.go
···
        295
        295
         	fmt.Fprintf(b, "IsStrict: %v\n", ba.IsStrict)

      
        296
        296
         	indent(b, depth+1)

      
        297
        297
         	fmt.Fprintf(b, "IsInclusive: %v\n", ba.IsInclusive)

      
        
        298
        +	indent(b, depth+1)

      
        
        299
        +	fmt.Fprintf(b, "IsAssignment: %v\n", ba.IsAssignment)

      
        298
        300
         	dumpAmount(b, &ba.Amount, depth+1)

      
        299
        301
         	if ba.Cost != nil {

      
        300
        302
         		dumpCost(b, ba.Cost, depth+1)

      
M journal/ast/entries.go
···
        109
        109
         }

      
        110
        110
         

      
        111
        111
         type BalanceAssertion struct {

      
        112
        
        -	IsStrict    bool // ==  vs =

      
        113
        
        -	IsInclusive bool // ===

      
        114
        
        -	Amount      Amount

      
        115
        
        -	Cost        *Cost // price after @/@@

      
        116
        
        -	Span        token.Span

      
        
        112
        +	IsStrict     bool // ==  vs =

      
        
        113
        +	IsInclusive  bool // =* or === (inclusive of subaccounts)

      
        
        114
        +	IsAssignment bool // := balance assignment (not a check)

      
        
        115
        +	Amount       Amount

      
        
        116
        +	Cost         *Cost // price after @/@@

      
        
        117
        +	Span         token.Span

      
        117
        118
         }

      
        118
        119
         

      
        119
        120
         type CommodityPos int

      
M journal/lexer/lexer.go
···
        54
        54
         	transactionPastStatus bool

      
        55
        55
         	postingExpectAccount  bool

      
        56
        56
         	readingNoteAfterPipe  bool

      
        
        57
        +

      
        
        58
        +	// subdirective is set when the current line is an account/commodity

      
        
        59
        +	// directive; the next indented line then lexes as directive content

      
        
        60
        +	subdirective bool

      
        57
        61
         }

      
        58
        62
         

      
        59
        63
         func New(file string, input []byte) *Lexer {

      ···
        91
        95
         }

      
        92
        96
         

      
        93
        97
         func (l *Lexer) lexDefault() token.Token {

      
        
        98
        +	if l.subdirective && l.ch != ' ' && l.ch != '\t' {

      
        
        99
        +		l.subdirective = false

      
        
        100
        +	}

      
        94
        101
         	switch {

      
        95
        102
         	case l.ch == 0:

      
        96
        103
         		return l.token(token.EOF, "")

      ···
        102
        109
         		return l.lexNewline()

      
        103
        110
         	case l.ch == ' ' || l.ch == '\t':

      
        104
        111
         		tok := l.lexIndent()

      
        105
        
        -		l.mode = ModePosting

      
        
        112
        +		if l.subdirective {

      
        
        113
        +			l.mode = ModeDirective

      
        
        114
        +			l.subdirective = false

      
        
        115
        +		} else {

      
        
        116
        +			l.mode = ModePosting

      
        
        117
        +		}

      
        106
        118
         		l.postingExpectAccount = true

      
        107
        119
         		return tok

      
        108
        120
         	case l.ch == ';' || l.ch == '#' || l.ch == '%':

      ···
        364
        376
         		return l.lexString()

      
        365
        377
         	case ':':

      
        366
        378
         		return l.lexSingle(token.COLON)

      
        
        379
        +	case ')':

      
        
        380
        +		return l.lexSingle(token.RPAREN)

      
        
        381
        +	case ']':

      
        
        382
        +		return l.lexSingle(token.RBRACKET)

      
        367
        383
         	default:

      
        368
        384
         		if l.isCommodityStart() {

      
        369
        385
         			return l.lexCommodityMark()

      ···
        432
        448
         			return token.Token{Type: token.EQEQ, Literal: "==", Span: l.span(s)}

      
        433
        449
         		}

      
        434
        450
         	}

      
        
        451
        +	if l.ch == '*' {

      
        
        452
        +		l.advance()

      
        
        453
        +		return token.Token{Type: token.EQSTAR, Literal: "=*", Span: l.span(s)}

      
        
        454
        +	}

      
        435
        455
         	return token.Token{Type: token.EQ, Literal: "=", Span: l.span(s)}

      
        436
        456
         }

      
        437
        457
         

      ···
        509
        529
         			l.advance()

      
        510
        530
         		} else if l.ch == ' ' && (l.peek() >= '0' && l.peek() <= '9') {

      
        511
        531
         			l.advance()

      
        
        532
        +		} else if l.ch == 'e' || l.ch == 'E' {

      
        
        533
        +			// exponent: consume only when E[sign]digit, so `10E ` stays an integer

      
        
        534
        +			p := l.pos + 1

      
        
        535
        +			if p < len(l.input) && (l.input[p] == '+' || l.input[p] == '-') {

      
        
        536
        +				p++

      
        
        537
        +			}

      
        
        538
        +			if p >= len(l.input) || l.input[p] < '0' || l.input[p] > '9' {

      
        
        539
        +				break

      
        
        540
        +			}

      
        
        541
        +			l.advance() // e/E

      
        
        542
        +			if l.ch == '+' || l.ch == '-' {

      
        
        543
        +				l.advance()

      
        
        544
        +			}

      
        
        545
        +			for l.isDigit() {

      
        
        546
        +				l.advance()

      
        
        547
        +			}

      
        512
        548
         		} else {

      
        513
        549
         			break

      
        514
        550
         		}

      
        515
        551
         	}

      
        516
        552
         	lit := string(l.input[s.offset:l.pos])

      
        517
        553
         	kind := token.INT

      
        518
        
        -	if strings.ContainsAny(lit, "., ") {

      
        
        554
        +	if strings.ContainsAny(lit, "., eE") {

      
        519
        555
         		kind = token.DECIMAL

      
        520
        556
         	}

      
        521
        557
         	return token.Token{Type: kind, Literal: lit, Span: l.span(s)}

      ···
        532
        568
         		kind = token.TEXT

      
        533
        569
         	} else {

      
        534
        570
         		l.mode = ModeDirective

      
        
        571
        +		l.subdirective = lit == "account" || lit == "commodity"

      
        535
        572
         	}

      
        536
        573
         	return token.Token{Type: kind, Literal: lit, Span: l.span(s)}

      
        537
        574
         }

      
A journal/lexer/testdata/fuzz/FuzzLexer/0745ffe051c1af1b
···
        
        1
        +go test fuzz v1

      
        
        2
        +[]byte("account )")

      
M journal/parser/parser.go
···
        451
        451
         	for p.got(token.INDENT) {

      
        452
        452
         		p.advance()

      
        453
        453
         		p.skipWhitespace()

      
        454
        
        -		if p.got(token.COMMODITYMARK) && p.cur.Literal == "format" {

      
        
        454
        +		if p.got(token.TEXT) && p.cur.Literal == "format" {

      
        455
        455
         			p.advance()

      
        456
        456
         			p.skipWhitespace()

      
        457
        457
         			format = p.parseAmount()

      
        458
        
        -		} else {

      
        459
        
        -			for !p.got(token.NEWLINE) && !p.got(token.EOF) {

      
        460
        
        -				p.advance()

      
        461
        
        -			}

      
        
        458
        +			p.expectNewline()

      
        
        459
        +			continue

      
        
        460
        +		}

      
        
        461
        +		for !p.got(token.NEWLINE) && !p.got(token.EOF) {

      
        
        462
        +			p.advance()

      
        462
        463
         		}

      
        463
        464
         		p.expectNewline()

      
        464
        465
         	}

      ···
        677
        678
         

      
        678
        679
         	tok, _ := p.expect(token.COMMODITYMARK)

      
        679
        680
         	mp.Commodity = tok.Literal

      
        680
        
        -	p.advance()

      
        
        681
        +	p.skipWhitespace()

      
        681
        682
         

      
        682
        683
         	mp.Amount = *p.parseAmount()

      
        683
        684
         

      ···
        861
        862
         		case token.PLUS:

      
        862
        863
         			p.advance()

      
        863
        864
         		}

      
        
        865
        +		p.skipWhitespace()

      
        864
        866
         		p.parseQuantityInto(amt)

      
        865
        867
         	} else {

      
        866
        868
         		// optional sign

      ···
        871
        873
         		case token.PLUS:

      
        872
        874
         			p.advance()

      
        873
        875
         		}

      
        
        876
        +		p.skipWhitespace()

      
        874
        877
         

      
        875
        878
         		// commodity before quantity: -$120, -eur 120:

      
        876
        879
         		if p.got(token.COMMODITYMARK) || p.got(token.TEXT) || p.got(token.STRING) {

      ···
        1010
        1013
         		posting.Cost = p.parseCost()

      
        1011
        1014
         	}

      
        1012
        1015
         

      
        1013
        
        -	// optional balance assertion

      
        
        1016
        +	// optional balance assertion or assignment

      
        1014
        1017
         	if p.got(token.WHITESPACE) {

      
        1015
        1018
         		p.skipWhitespace()

      
        1016
        1019
         	}

      
        1017
        
        -	if p.got(token.EQ) || p.got(token.EQEQ) || p.got(token.EQEQEQ) {

      
        
        1020
        +	if p.got(token.COLON) && p.willGet(token.EQ) {

      
        
        1021
        +		p.advance() // consume ':' of ':='

      
        
        1022
        +		posting.Balance = p.parseBalanceAssertion()

      
        
        1023
        +		posting.Balance.IsAssignment = true

      
        
        1024
        +	} else if p.got(token.EQ) || p.got(token.EQEQ) || p.got(token.EQEQEQ) || p.got(token.EQSTAR) {

      
        1018
        1025
         		posting.Balance = p.parseBalanceAssertion()

      
        1019
        1026
         	}

      
        1020
        1027
         

      ···
        1050
        1057
         	ba := &ast.BalanceAssertion{}

      
        1051
        1058
         	switch p.cur.Type {

      
        1052
        1059
         	case token.EQ: // basic assertion

      
        1053
        
        -	case token.EQEQ:

      
        
        1060
        +	case token.EQSTAR: // inclusive assertion

      
        
        1061
        +		ba.IsInclusive = true

      
        
        1062
        +	case token.EQEQ: // strict assertion

      
        1054
        1063
         		ba.IsStrict = true

      
        1055
        
        -	case token.EQEQEQ:

      
        
        1064
        +	case token.EQEQEQ: // strict inclusive assertion

      
        1056
        1065
         		ba.IsStrict = true

      
        1057
        1066
         		ba.IsInclusive = true

      
        1058
        1067
         	}

      
M journal/parser/parser_test.go
···
        72
        72
         		"transaction with header comment",

      
        73
        73
         		"transaction with trailing indent",

      
        74
        74
         		"transaction with balance assertion",

      
        
        75
        +		"transaction with inclusive balance assertion",

      
        
        76
        +		"transaction with balance assignment",

      
        
        77
        +		"transaction with scientific amounts",

      
        75
        78
         		"transaction with virtual accounts",

      
        76
        79
         		"virtual postings with statuses",

      
        77
        80
         		"period transaction expressions",

      
M journal/parser/testdata/commodity_with_subdirectives.txtar
···
        2
        2
         commodity UAH

      
        3
        3
           format 1 000.00 UAH

      
        4
        4
           note Божествена Гривня  ; this gets ignored

      
        
        5
        +

      
        5
        6
         -- expect --

      
        6
        7
         Journal

      
        7
        
        -  CommodityDirective j:1:1-4:1

      
        
        8
        +  CommodityDirective j:1:1-5:0

      
        8
        9
             Commodity: "UAH" j:1:11-2:0

      
        
        10
        +    Amount j:2:10-3:0

      
        
        11
        +      Quantity: 1000

      
        
        12
        +      Commodity: "UAH" j:2:19-3:0

      
        
        13
        +      CommodityPos: After

      
        
        14
        +      HasSpace: true

      
        
        15
        +      Precision: 2

      
        
        16
        +      Decimal: "."

      
        
        17
        +      Thousands: " "

      
        
        18
        +  BlankLine j:5:0-5:1

      
A journal/parser/testdata/fuzz/FuzzParser/38d8778099f755a5
···
        
        1
        +go test fuzz v1

      
        
        2
        +[]byte("D 00E000000000000000001000000000000")

      
A journal/parser/testdata/fuzz/FuzzParser/f0ddae6d71555984
···
        
        1
        +go test fuzz v1

      
        
        2
        +[]byte("P 0 10000000000E000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000")

      
M journal/parser/testdata/transaction_with_balance_assertion.txtar
···
        10
        10
         2025/04/08 groceries3

      
        11
        11
         	expenses:food  $10.00

      
        12
        12
         	assets:checking  = $0.00

      
        
        13
        +

      
        13
        14
         -- expect --

      
        14
        15
         Journal

      
        15
        16
           Transaction j:1:1-5:0

      ···
        29
        30
               BalanceAssertion j:2:24-3:0

      
        30
        31
                 IsStrict: false

      
        31
        32
                 IsInclusive: false

      
        
        33
        +        IsAssignment: false

      
        32
        34
                 Amount j:2:26-3:0

      
        33
        35
                   Quantity: 100

      
        34
        36
                   Commodity: "$" j:2:26-2:27

      ···
        59
        61
               BalanceAssertion j:6:24-7:0

      
        60
        62
                 IsStrict: true

      
        61
        63
                 IsInclusive: false

      
        
        64
        +        IsAssignment: false

      
        62
        65
                 Amount j:6:27-7:0

      
        63
        66
                   Quantity: 100

      
        64
        67
                   Commodity: "$" j:6:27-6:28

      ···
        74
        77
               BalanceAssertion j:7:19-8:0

      
        75
        78
                 IsStrict: true

      
        76
        79
                 IsInclusive: false

      
        
        80
        +        IsAssignment: false

      
        77
        81
                 Amount j:7:22-8:0

      
        78
        82
                   Quantity: 0

      
        79
        83
                   Commodity: "$" j:7:22-7:23

      ···
        82
        86
                   Precision: 2

      
        83
        87
                   Decimal: "."

      
        84
        88
           BlankLine j:9:0-9:1

      
        85
        
        -  Transaction j:9:1-12:1

      
        
        89
        +  Transaction j:9:1-13:0

      
        86
        90
             Date: 2025/04/08

      
        87
        91
             Payee: "groceries3" j:9:12-10:0

      
        88
        92
             Posting j:10:1-11:1

      ···
        96
        100
                 HasSpace: false

      
        97
        101
                 Precision: 2

      
        98
        102
                 Decimal: "."

      
        99
        
        -    Posting j:11:1-12:1

      
        
        103
        +    Posting j:11:1-13:0

      
        100
        104
               Account j:11:2-11:17

      
        101
        105
                 SubAccount: "assets" j:11:2-11:8

      
        102
        106
                 SubAccount: "checking" j:11:9-11:17

      ···
        104
        108
               BalanceAssertion j:11:19-12:0

      
        105
        109
                 IsStrict: false

      
        106
        110
                 IsInclusive: false

      
        
        111
        +        IsAssignment: false

      
        107
        112
                 Amount j:11:21-12:0

      
        108
        113
                   Quantity: 0

      
        109
        114
                   Commodity: "$" j:11:21-11:22

      ···
        111
        116
                   HasSpace: false

      
        112
        117
                   Precision: 2

      
        113
        118
                   Decimal: "."

      
        
        119
        +  BlankLine j:13:0-13:1

      
A journal/parser/testdata/transaction_with_balance_assignment.txtar
···
        
        1
        +-- input --

      
        
        2
        +2024-01-15 t

      
        
        3
        +    a  $0 := $100

      
        
        4
        +

      
        
        5
        +-- expect --

      
        
        6
        +Journal

      
        
        7
        +  Transaction j:1:1-4:0

      
        
        8
        +    Date: 2024-01-15

      
        
        9
        +    Payee: "t" j:1:12-2:0

      
        
        10
        +    Posting j:2:1-4:0

      
        
        11
        +      Account j:2:5-2:6

      
        
        12
        +        SubAccount: "a" j:2:5-2:6

      
        
        13
        +      Amount j:2:8-2:10

      
        
        14
        +        Quantity: 0

      
        
        15
        +        Commodity: "$" j:2:8-2:9

      
        
        16
        +        CommodityPos: Before

      
        
        17
        +        HasSpace: false

      
        
        18
        +        Precision: 0

      
        
        19
        +        Decimal: "."

      
        
        20
        +      BalanceAssertion j:2:12-3:0

      
        
        21
        +        IsStrict: false

      
        
        22
        +        IsInclusive: false

      
        
        23
        +        IsAssignment: true

      
        
        24
        +        Amount j:2:14-3:0

      
        
        25
        +          Quantity: 100

      
        
        26
        +          Commodity: "$" j:2:14-2:15

      
        
        27
        +          CommodityPos: Before

      
        
        28
        +          HasSpace: false

      
        
        29
        +          Precision: 0

      
        
        30
        +          Decimal: "."

      
        
        31
        +  BlankLine j:4:0-4:1

      
M journal/parser/testdata/transaction_with_cost_and_assertion.txtar
···
        2
        2
         2026-05-11 testies

      
        3
        3
         	expenses:atm  UAH 20.00 @ 1 USD = 0 UAH

      
        4
        4
         	assets:bank

      
        
        5
        +

      
        5
        6
         -- expect --

      
        6
        7
         Journal

      
        7
        
        -  Transaction j:1:1-4:1

      
        
        8
        +  Transaction j:1:1-5:0

      
        8
        9
             Date: 2026-05-11

      
        9
        10
             Payee: "testies" j:1:12-2:0

      
        10
        11
             Posting j:2:1-3:1

      ···
        29
        30
               BalanceAssertion j:2:34-3:0

      
        30
        31
                 IsStrict: false

      
        31
        32
                 IsInclusive: false

      
        
        33
        +        IsAssignment: false

      
        32
        34
                 Amount j:2:36-3:0

      
        33
        35
                   Quantity: 0

      
        34
        36
                   Commodity: "UAH" j:2:38-3:0

      ···
        36
        38
                   HasSpace: true

      
        37
        39
                   Precision: 0

      
        38
        40
                   Decimal: "."

      
        39
        
        -    Posting j:3:1-4:1

      
        
        41
        +    Posting j:3:1-5:0

      
        40
        42
               Account j:3:2-4:0

      
        41
        43
                 SubAccount: "assets" j:3:2-3:8

      
        42
        44
                 SubAccount: "bank" j:3:9-4:0

      
        43
        45
               Amount: <elided>

      
        
        46
        +  BlankLine j:5:0-5:1

      
M journal/parser/testdata/transaction_with_costs.txtar
···
        14
        14
         2015-01-03 money exchange office

      
        15
        15
             assets:cash  -20 EUR @ 7.53 HRK

      
        16
        16
             assets:cash  150.60 HRK

      
        
        17
        +

      
        17
        18
         -- expect --

      
        18
        19
         Journal

      
        19
        20
           Transaction j:1:1-5:0

      ···
        97
        98
               BalanceAssertion j:10:34-11:0

      
        98
        99
                 IsStrict: false

      
        99
        100
                 IsInclusive: false

      
        
        101
        +        IsAssignment: false

      
        100
        102
                 Amount j:10:36-10:45

      
        101
        103
                   Quantity: 20

      
        102
        104
                   Commodity: "UAH" j:10:42-10:45

      ···
        118
        120
                 SubAccount: "bank" j:11:9-12:0

      
        119
        121
               Amount: <elided>

      
        120
        122
           BlankLine j:13:0-13:1

      
        121
        
        -  Transaction j:13:1-16:1

      
        
        123
        +  Transaction j:13:1-17:0

      
        122
        124
             Date: 2015-01-03

      
        123
        125
             Payee: "money exchange office" j:13:12-14:0

      
        124
        126
             Posting j:14:1-15:1

      ···
        140
        142
                   HasSpace: true

      
        141
        143
                   Precision: 2

      
        142
        144
                   Decimal: "."

      
        143
        
        -    Posting j:15:1-16:1

      
        
        145
        +    Posting j:15:1-17:0

      
        144
        146
               Account j:15:5-15:16

      
        145
        147
                 SubAccount: "assets" j:15:5-15:11

      
        146
        148
                 SubAccount: "cash" j:15:12-15:16

      ···
        151
        153
                 HasSpace: true

      
        152
        154
                 Precision: 2

      
        153
        155
                 Decimal: "."

      
        
        156
        +  BlankLine j:17:0-17:1

      
A journal/parser/testdata/transaction_with_inclusive_balance_assertion.txtar
···
        
        1
        +-- input --

      
        
        2
        +2024-01-15 t

      
        
        3
        +    a  $1 =* $100

      
        
        4
        +    b  $2 =*$100

      
        
        5
        +    c  $3 ==* $100

      
        
        6
        +

      
        
        7
        +-- expect --

      
        
        8
        +Journal

      
        
        9
        +  Transaction j:1:1-6:0

      
        
        10
        +    Date: 2024-01-15

      
        
        11
        +    Payee: "t" j:1:12-2:0

      
        
        12
        +    Posting j:2:1-3:1

      
        
        13
        +      Account j:2:5-2:6

      
        
        14
        +        SubAccount: "a" j:2:5-2:6

      
        
        15
        +      Amount j:2:8-2:10

      
        
        16
        +        Quantity: 1

      
        
        17
        +        Commodity: "$" j:2:8-2:9

      
        
        18
        +        CommodityPos: Before

      
        
        19
        +        HasSpace: false

      
        
        20
        +        Precision: 0

      
        
        21
        +        Decimal: "."

      
        
        22
        +      BalanceAssertion j:2:11-3:0

      
        
        23
        +        IsStrict: false

      
        
        24
        +        IsInclusive: true

      
        
        25
        +        IsAssignment: false

      
        
        26
        +        Amount j:2:14-3:0

      
        
        27
        +          Quantity: 100

      
        
        28
        +          Commodity: "$" j:2:14-2:15

      
        
        29
        +          CommodityPos: Before

      
        
        30
        +          HasSpace: false

      
        
        31
        +          Precision: 0

      
        
        32
        +          Decimal: "."

      
        
        33
        +    Posting j:3:1-4:1

      
        
        34
        +      Account j:3:5-3:6

      
        
        35
        +        SubAccount: "b" j:3:5-3:6

      
        
        36
        +      Amount j:3:8-3:10

      
        
        37
        +        Quantity: 2

      
        
        38
        +        Commodity: "$" j:3:8-3:9

      
        
        39
        +        CommodityPos: Before

      
        
        40
        +        HasSpace: false

      
        
        41
        +        Precision: 0

      
        
        42
        +        Decimal: "."

      
        
        43
        +      BalanceAssertion j:3:11-4:0

      
        
        44
        +        IsStrict: false

      
        
        45
        +        IsInclusive: true

      
        
        46
        +        IsAssignment: false

      
        
        47
        +        Amount j:3:13-4:0

      
        
        48
        +          Quantity: 100

      
        
        49
        +          Commodity: "$" j:3:13-3:14

      
        
        50
        +          CommodityPos: Before

      
        
        51
        +          HasSpace: false

      
        
        52
        +          Precision: 0

      
        
        53
        +          Decimal: "."

      
        
        54
        +    Posting j:4:1-6:0

      
        
        55
        +      Account j:4:5-4:6

      
        
        56
        +        SubAccount: "c" j:4:5-4:6

      
        
        57
        +      Amount j:4:8-4:10

      
        
        58
        +        Quantity: 3

      
        
        59
        +        Commodity: "$" j:4:8-4:9

      
        
        60
        +        CommodityPos: Before

      
        
        61
        +        HasSpace: false

      
        
        62
        +        Precision: 0

      
        
        63
        +        Decimal: "."

      
        
        64
        +      BalanceAssertion j:4:11-5:0

      
        
        65
        +        IsStrict: true

      
        
        66
        +        IsInclusive: true

      
        
        67
        +        IsAssignment: false

      
        
        68
        +        Amount j:4:15-5:0

      
        
        69
        +          Quantity: 100

      
        
        70
        +          Commodity: "$" j:4:15-4:16

      
        
        71
        +          CommodityPos: Before

      
        
        72
        +          HasSpace: false

      
        
        73
        +          Precision: 0

      
        
        74
        +          Decimal: "."

      
        
        75
        +  BlankLine j:6:0-6:1

      
A journal/parser/testdata/transaction_with_scientific_amounts.txtar
···
        
        1
        +-- input --

      
        
        2
        +2024-01-15 t

      
        
        3
        +    a  1E-6 BTC

      
        
        4
        +    b  EUR 1E3

      
        
        5
        +    c  -2.5e-2 $

      
        
        6
        +

      
        
        7
        +-- expect --

      
        
        8
        +Journal

      
        
        9
        +  Transaction j:1:1-6:0

      
        
        10
        +    Date: 2024-01-15

      
        
        11
        +    Payee: "t" j:1:12-2:0

      
        
        12
        +    Posting j:2:1-3:1

      
        
        13
        +      Account j:2:5-2:6

      
        
        14
        +        SubAccount: "a" j:2:5-2:6

      
        
        15
        +      Amount j:2:8-3:0

      
        
        16
        +        Quantity: 0.000001

      
        
        17
        +        Commodity: "BTC" j:2:13-3:0

      
        
        18
        +        CommodityPos: After

      
        
        19
        +        HasSpace: true

      
        
        20
        +        Precision: 0

      
        
        21
        +        Decimal: "."

      
        
        22
        +    Posting j:3:1-4:1

      
        
        23
        +      Account j:3:5-3:6

      
        
        24
        +        SubAccount: "b" j:3:5-3:6

      
        
        25
        +      Amount j:3:8-4:0

      
        
        26
        +        Quantity: 1000

      
        
        27
        +        Commodity: "EUR" j:3:8-3:11

      
        
        28
        +        CommodityPos: Before

      
        
        29
        +        HasSpace: true

      
        
        30
        +        Precision: 0

      
        
        31
        +        Decimal: "."

      
        
        32
        +    Posting j:4:1-6:0

      
        
        33
        +      Account j:4:5-4:6

      
        
        34
        +        SubAccount: "c" j:4:5-4:6

      
        
        35
        +      Amount j:4:8-5:0

      
        
        36
        +        Quantity: -0.025

      
        
        37
        +        Commodity: "$" j:4:16-5:0

      
        
        38
        +        CommodityPos: After

      
        
        39
        +        HasSpace: true

      
        
        40
        +        Precision: 4

      
        
        41
        +        Decimal: "."

      
        
        42
        +  BlankLine j:6:0-6:1

      
M journal/parser/testdata/transaction_with_spaced_account_name.txtar
···
        2
        2
         2022-01-01 opening balances

      
        3
        3
             assets                    21 = 21

      
        4
        4
             equity:opening/closing balances

      
        
        5
        +

      
        5
        6
         -- expect --

      
        6
        7
         Journal

      
        7
        
        -  Transaction j:1:1-4:1

      
        
        8
        +  Transaction j:1:1-5:0

      
        8
        9
             Date: 2022-01-01

      
        9
        10
             Payee: "opening balances" j:1:12-2:0

      
        10
        11
             Posting j:2:1-3:1

      ···
        20
        21
               BalanceAssertion j:2:34-3:0

      
        21
        22
                 IsStrict: false

      
        22
        23
                 IsInclusive: false

      
        
        24
        +        IsAssignment: false

      
        23
        25
                 Amount j:2:36-3:0

      
        24
        26
                   Quantity: 21

      
        25
        27
                   Commodity: ""

      ···
        27
        29
                   HasSpace: false

      
        28
        30
                   Precision: 0

      
        29
        31
                   Decimal: "."

      
        30
        
        -    Posting j:3:1-4:1

      
        
        32
        +    Posting j:3:1-5:0

      
        31
        33
               Account j:3:5-4:0

      
        32
        34
                 SubAccount: "equity" j:3:5-3:11

      
        33
        35
                 SubAccount: "opening/closing balances" j:3:12-4:0

      
        34
        36
               Amount: <elided>

      
        
        37
        +  BlankLine j:5:0-5:1

      
M journal/token/token.go
···
        54
        54
         	EQ     // =

      
        55
        55
         	EQEQ   // ==

      
        56
        56
         	EQEQEQ // ===

      
        
        57
        +	EQSTAR // =* inclusive balance assertion

      
        57
        58
         	AT     // @

      
        58
        59
         	ATAT   // @@

      
        59
        60
         	PIPE   // |

      
M journal/token/type_string.go
···
        26
        26
         	_ = x[EQ-15]

      
        27
        27
         	_ = x[EQEQ-16]

      
        28
        28
         	_ = x[EQEQEQ-17]

      
        29
        
        -	_ = x[AT-18]

      
        30
        
        -	_ = x[ATAT-19]

      
        31
        
        -	_ = x[PIPE-20]

      
        32
        
        -	_ = x[PLUS-21]

      
        33
        
        -	_ = x[MINUS-22]

      
        34
        
        -	_ = x[TILDE-23]

      
        35
        
        -	_ = x[LPAREN-24]

      
        36
        
        -	_ = x[RPAREN-25]

      
        37
        
        -	_ = x[LBRACE-26]

      
        38
        
        -	_ = x[LBRACELBRACE-27]

      
        39
        
        -	_ = x[RBRACERBRACE-28]

      
        40
        
        -	_ = x[RBRACE-29]

      
        41
        
        -	_ = x[LBRACKET-30]

      
        42
        
        -	_ = x[RBRACKET-31]

      
        43
        
        -	_ = x[COMMODITYMARK-32]

      
        44
        
        -	_ = x[DATE-33]

      
        45
        
        -	_ = x[TIME-34]

      
        46
        
        -	_ = x[PARENEXPR-35]

      
        47
        
        -	_ = x[COMMENTKW-36]

      
        48
        
        -	_ = x[ACCOUNT-37]

      
        49
        
        -	_ = x[COMMODITY-38]

      
        50
        
        -	_ = x[INCLUDE-39]

      
        51
        
        -	_ = x[ALIAS-40]

      
        52
        
        -	_ = x[PAYEE-41]

      
        53
        
        -	_ = x[TAG-42]

      
        54
        
        -	_ = x[APPLY-43]

      
        55
        
        -	_ = x[END-44]

      
        56
        
        -	_ = x[YEAR-45]

      
        57
        
        -	_ = x[DECIMALMARK-46]

      
        58
        
        -	_ = x[D-47]

      
        59
        
        -	_ = x[P-48]

      
        60
        
        -	_ = x[N-49]

      
        61
        
        -	_ = x[C-50]

      
        
        29
        +	_ = x[EQSTAR-18]

      
        
        30
        +	_ = x[AT-19]

      
        
        31
        +	_ = x[ATAT-20]

      
        
        32
        +	_ = x[PIPE-21]

      
        
        33
        +	_ = x[PLUS-22]

      
        
        34
        +	_ = x[MINUS-23]

      
        
        35
        +	_ = x[TILDE-24]

      
        
        36
        +	_ = x[LPAREN-25]

      
        
        37
        +	_ = x[RPAREN-26]

      
        
        38
        +	_ = x[LBRACE-27]

      
        
        39
        +	_ = x[LBRACELBRACE-28]

      
        
        40
        +	_ = x[RBRACERBRACE-29]

      
        
        41
        +	_ = x[RBRACE-30]

      
        
        42
        +	_ = x[LBRACKET-31]

      
        
        43
        +	_ = x[RBRACKET-32]

      
        
        44
        +	_ = x[COMMODITYMARK-33]

      
        
        45
        +	_ = x[DATE-34]

      
        
        46
        +	_ = x[TIME-35]

      
        
        47
        +	_ = x[PARENEXPR-36]

      
        
        48
        +	_ = x[COMMENTKW-37]

      
        
        49
        +	_ = x[ACCOUNT-38]

      
        
        50
        +	_ = x[COMMODITY-39]

      
        
        51
        +	_ = x[INCLUDE-40]

      
        
        52
        +	_ = x[ALIAS-41]

      
        
        53
        +	_ = x[PAYEE-42]

      
        
        54
        +	_ = x[TAG-43]

      
        
        55
        +	_ = x[APPLY-44]

      
        
        56
        +	_ = x[END-45]

      
        
        57
        +	_ = x[YEAR-46]

      
        
        58
        +	_ = x[DECIMALMARK-47]

      
        
        59
        +	_ = x[D-48]

      
        
        60
        +	_ = x[P-49]

      
        
        61
        +	_ = x[N-50]

      
        
        62
        +	_ = x[C-51]

      
        62
        63
         }

      
        63
        64
         

      
        64
        
        -const _Type_name = "ILLEGALEOFWHITESPACEINDENTNEWLINEINTDECIMALSTRINGTEXTBANGSTARPERCENTHASHSEMICOLONCOLONEQEQEQEQEQEQATATATPIPEPLUSMINUSTILDELPARENRPARENLBRACELBRACELBRACERBRACERBRACERBRACELBRACKETRBRACKETCOMMODITYMARKDATETIMEPARENEXPRCOMMENTKWACCOUNTCOMMODITYINCLUDEALIASPAYEETAGAPPLYENDYEARDECIMALMARKDPNC"

      
        
        65
        +const _Type_name = "ILLEGALEOFWHITESPACEINDENTNEWLINEINTDECIMALSTRINGTEXTBANGSTARPERCENTHASHSEMICOLONCOLONEQEQEQEQEQEQEQSTARATATATPIPEPLUSMINUSTILDELPARENRPARENLBRACELBRACELBRACERBRACERBRACERBRACELBRACKETRBRACKETCOMMODITYMARKDATETIMEPARENEXPRCOMMENTKWACCOUNTCOMMODITYINCLUDEALIASPAYEETAGAPPLYENDYEARDECIMALMARKDPNC"

      
        65
        66
         

      
        66
        
        -var _Type_index = [...]uint16{0, 7, 10, 20, 26, 33, 36, 43, 49, 53, 57, 61, 68, 72, 81, 86, 88, 92, 98, 100, 104, 108, 112, 117, 122, 128, 134, 140, 152, 164, 170, 178, 186, 199, 203, 207, 216, 225, 232, 241, 248, 253, 258, 261, 266, 269, 273, 284, 285, 286, 287, 288}

      
        
        67
        +var _Type_index = [...]uint16{0, 7, 10, 20, 26, 33, 36, 43, 49, 53, 57, 61, 68, 72, 81, 86, 88, 92, 98, 104, 106, 110, 114, 118, 123, 128, 134, 140, 146, 158, 170, 176, 184, 192, 205, 209, 213, 222, 231, 238, 247, 254, 259, 264, 267, 272, 275, 279, 290, 291, 292, 293, 294}

      
        67
        68
         

      
        68
        69
         func (i Type) String() string {

      
        69
        70
         	idx := int(i) - 0