7 files changed,
283 insertions(+),
12 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-05-23 17:52:23 +0300
Authored at:
2026-05-15 20:26:45 +0300
Change ID:
kxoxmsqzqutkoypnyvomnuutxlqmrlpr
Parent:
7136c82
M
go.sum
··· 1 1 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 2 2 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 3 -github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= 4 -github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= 5 3 golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= 6 4 golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= 7 5 golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
A
internal/decimal/decimal.go
··· 1 +package decimal 2 + 3 +import ( 4 + "fmt" 5 + "math/big" 6 + "strings" 7 +) 8 + 9 +type Decimal struct { 10 + scale int 11 + coeff *big.Int 12 +} 13 + 14 +func FromInt(v int64) Decimal { 15 + if v == 0 { 16 + return Decimal{} 17 + } 18 + return Decimal{coeff: big.NewInt(v)} 19 +} 20 + 21 +func FromString(s string) (Decimal, error) { 22 + original := s 23 + if s == "" { 24 + return Decimal{}, fmt.Errorf("can't convert %s to decimal", original) 25 + } 26 + 27 + sign := 1 28 + if s[0] == '+' || s[0] == '-' { 29 + if s[0] == '-' { 30 + sign = -1 31 + } 32 + s = s[1:] 33 + } 34 + 35 + if s == "" { 36 + return Decimal{}, fmt.Errorf("can't convert %s to decimal", original) 37 + } 38 + 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) 43 + } 44 + 45 + intPart := s 46 + fracPart := "" 47 + if firstDot >= 0 { 48 + intPart = s[:firstDot] 49 + fracPart = s[firstDot+1:] 50 + } 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 + } 65 + } 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) 71 + } 72 + if sign < 0 && coeff.Sign() != 0 { 73 + coeff.Neg(coeff) 74 + } 75 + 76 + out := Decimal{coeff: coeff, scale: len(fracPart)} 77 + return out.normalized(), nil 78 +} 79 + 80 +func (d Decimal) String() string { 81 + if d.coeff == nil || d.coeff.Sign() == 0 { 82 + return "0" 83 + } 84 + 85 + abs := new(big.Int).Set(d.coeff) 86 + sign := "" 87 + if abs.Sign() < 0 { 88 + sign = "-" 89 + abs.Abs(abs) 90 + } 91 + 92 + digits := abs.String() 93 + if d.scale == 0 { 94 + return sign + digits 95 + } 96 + 97 + if len(digits) <= d.scale { 98 + digits = strings.Repeat("0", d.scale-len(digits)+1) + digits 99 + } 100 + split := len(digits) - d.scale 101 + return sign + digits[:split] + "." + digits[split:] 102 +} 103 + 104 +func (d Decimal) Neg() Decimal { 105 + if d.coeff == nil || d.coeff.Sign() == 0 { 106 + return Decimal{} 107 + } 108 + return Decimal{coeff: new(big.Int).Neg(d.coeff), scale: d.scale} 109 +} 110 + 111 +func (d Decimal) Sub(other Decimal) Decimal { return d.Add(other.Neg()) } 112 +func (d Decimal) Add(other Decimal) Decimal { 113 + a, b, scale := align(d, other) 114 + sum := new(big.Int).Add(a, b) 115 + return Decimal{coeff: sum, scale: scale}.normalized() 116 +} 117 + 118 +func (d Decimal) Mul(other Decimal) Decimal { 119 + if d.IsZero() || other.IsZero() { 120 + return Decimal{} 121 + } 122 + product := new(big.Int).Mul(d.coeffOrZero(), other.coeffOrZero()) 123 + return Decimal{coeff: product, scale: d.scale + other.scale}.normalized() 124 +} 125 + 126 +func (d Decimal) Cmp(other Decimal) int { 127 + a, b, _ := align(d, other) 128 + return a.Cmp(b) 129 +} 130 + 131 +func (d Decimal) Equal(other Decimal) bool { 132 + return d.Cmp(other) == 0 133 +} 134 + 135 +func (d Decimal) IsZero() bool { 136 + return d.coeff == nil || d.coeff.Sign() == 0 137 +} 138 + 139 +func align(a, b Decimal) (aCoeff *big.Int, bCoeff *big.Int, scale int) { 140 + scale = max(b.scale, a.scale) 141 + 142 + aCoeff = a.coeffOrZero() 143 + bCoeff = b.coeffOrZero() 144 + if delta := scale - a.scale; delta > 0 { 145 + aCoeff.Mul(aCoeff, pow10(delta)) 146 + } 147 + if delta := scale - b.scale; delta > 0 { 148 + bCoeff.Mul(bCoeff, pow10(delta)) 149 + } 150 + return aCoeff, bCoeff, scale 151 +} 152 + 153 +func (d Decimal) coeffOrZero() *big.Int { 154 + if d.coeff == nil { 155 + return new(big.Int) 156 + } 157 + return new(big.Int).Set(d.coeff) 158 +} 159 + 160 +func (d Decimal) normalized() Decimal { 161 + if d.coeff == nil || d.coeff.Sign() == 0 { 162 + return Decimal{} 163 + } 164 + if d.scale == 0 { 165 + return Decimal{coeff: new(big.Int).Set(d.coeff)} 166 + } 167 + 168 + sign := d.coeff.Sign() 169 + abs := new(big.Int).Abs(d.coeff) 170 + ten := big.NewInt(10) 171 + rem := new(big.Int) 172 + for d.scale > 0 { 173 + quotient, _ := new(big.Int).QuoRem(abs, ten, rem) 174 + if rem.Sign() != 0 { 175 + break 176 + } 177 + abs = quotient 178 + d.scale-- 179 + } 180 + 181 + if sign < 0 { 182 + abs.Neg(abs) 183 + } 184 + return Decimal{coeff: abs, scale: d.scale} 185 +} 186 + 187 +func pow10(n int) *big.Int { 188 + if n <= 0 { 189 + return big.NewInt(1) 190 + } 191 + return new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(n)), nil) 192 +}
A
internal/decimal/decimal_test.go
··· 1 +package decimal 2 + 3 +import "testing" 4 + 5 +func TestNewFromString(t *testing.T) { 6 + tests := []struct{ in, want string }{ 7 + {"10.00", "10"}, 8 + {"150.60", "150.6"}, 9 + {".33", "0.33"}, 10 + {"1.", "1"}, 11 + {"0001.2300", "1.23"}, 12 + {"0", "0"}, 13 + {"0.00", "0"}, 14 + {"-0.00", "0"}, 15 + {"-20", "-20"}, 16 + {"-.75", "-0.75"}, 17 + } 18 + for _, tt := range tests { 19 + got, err := FromString(tt.in) 20 + if err != nil { 21 + t.Fatalf("NewFromString(%q) unexpected error: %v", tt.in, err) 22 + } 23 + if got.String() != tt.want { 24 + t.Fatalf("NewFromString(%q).String() = %q, want %q", tt.in, got.String(), tt.want) 25 + } 26 + } 27 +} 28 + 29 +func TestNewFromStringInvalid(t *testing.T) { 30 + tests := []string{ 31 + "", 32 + ".", 33 + "+", 34 + "-", 35 + "1_000.00", 36 + "1,000.00", 37 + "1..0", 38 + "a1", 39 + "1a", 40 + } 41 + for _, in := range tests { 42 + if _, err := FromString(in); err == nil { 43 + t.Fatalf("NewFromString(%q) expected error", in) 44 + } 45 + } 46 +} 47 + 48 +func TestArithmetic(t *testing.T) { 49 + a, _ := FromString("1.20") 50 + b, _ := FromString(".30") 51 + 52 + if got := a.Add(b).String(); got != "1.5" { 53 + t.Fatalf("a+b = %q, want %q", got, "1.5") 54 + } 55 + if got := a.Sub(b).String(); got != "0.9" { 56 + t.Fatalf("a-b = %q, want %q", got, "0.9") 57 + } 58 + if got := a.Mul(b).String(); got != "0.36" { 59 + t.Fatalf("a*b = %q, want %q", got, "0.36") 60 + } 61 +} 62 + 63 +func TestCmpAndNeg(t *testing.T) { 64 + a, _ := FromString("1.0") 65 + b, _ := FromString("1") 66 + c, _ := FromString("1.01") 67 + 68 + if a.Cmp(b) != 0 { 69 + t.Fatalf("expected %q and %q to be equal", a.String(), b.String()) 70 + } 71 + if b.Cmp(c) >= 0 { 72 + t.Fatalf("expected %q to be less than %q", b.String(), c.String()) 73 + } 74 + if got := b.Neg().String(); got != "-1" { 75 + t.Fatalf("Neg(1) = %q, want %q", got, "-1") 76 + } 77 + if got := (Decimal{}).Neg().String(); got != "0" { 78 + t.Fatalf("Neg(0) = %q, want %q", got, "0") 79 + } 80 +}
M
journal/ast/dump.go
··· 307 307 fmt.Fprintf(b, "CommodityDirective %s\n", c.Span) 308 308 indent(b, depth+1) 309 309 fmt.Fprintf(b, "Commodity: %q\n", c.Commodity) 310 - dumpAmount(b, &c.Format, depth+1) 310 + if c.Format.QuantityFmt.Decimal != 0 { 311 + dumpAmount(b, &c.Format, depth+1) 312 + } 311 313 dumpOptComment(b, c.Comment, depth+1) 312 314 } 313 315
M
journal/parser/parser.go
··· 5 5 "strconv" 6 6 "strings" 7 7 8 - "github.com/shopspring/decimal" 9 - 8 + "olexsmir.xyz/clerk/internal/decimal" 10 9 "olexsmir.xyz/clerk/journal/ast" 11 10 "olexsmir.xyz/clerk/journal/lexer" 12 11 "olexsmir.xyz/clerk/journal/token" ··· 364 363 comment := p.parseOptInlineComment() 365 364 p.expectNewline() 366 365 367 - return &ast.CommodityDirective{ 366 + cd := &ast.CommodityDirective{ 368 367 Commodity: commodity, 369 - Format: *format, 370 368 Comment: comment, 371 369 Span: p.span(s), 372 370 } 371 + if format != nil { 372 + cd.Format = *format 373 + } 374 + return cd 373 375 } 374 376 375 377 func (p *Parser) parseIncludeDirective() *ast.IncludeDirective { ··· 1083 1085 // remove thousands separators, replace decimal mark with '.' 1084 1086 normalized := normalizeLiteral(lit, amt.QuantityFmt.Thousands, amt.QuantityFmt.Decimal) 1085 1087 1086 - q, err := decimal.NewFromString(normalized) 1088 + q, err := decimal.FromString(normalized) 1087 1089 if err != nil { 1088 1090 p.errorf("invalid quantity %q: %v", lit, err) 1089 1091 return