Commit0da80dcbRecorded25 Mar 2026Repositorysigil-ledger
Address review findings: round-trip fidelity, =* assertions, report tests
Message
- Preserve prefix negative sign placement (-$50 round-trips correctly) by tracking sign-before-commodity flag on journal-amount - Support =* (inclusive) balance assertions in parser and formatter, alongside existing = and == support - Add 14 report module tests covering hledger-balance, hledger-register, hledger-income-statement, hledger-command, and hledger-print-json - Add 8 new ledger tests for assertion types and round-trip fidelity - Document European number format as known limitation in README
Changed
README.md | 6 +++++-
src/sigil/ledger.sgl | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
test/test-ledger.sgl | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
test/test-report.sgl | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 317 insertions(+), 35 deletions(-)Diff
README.mdmodified
@@ -56,7 +56,11 @@ Handles standard hledger journal syntax:
56
- Inline and line comments (`;`) 57
- Tags in comments (`tag:value`) 58
- Cost notation (`@ PRICE` and `@@ TOTAL`)−59
- Balance assertions (`= AMOUNT`)+59
- Balance assertions (`= AMOUNT`, `== AMOUNT`, `=* AMOUNT`)+60
+61
## Known Limitations+62
+63
- **European number format** — amounts using period as thousands separator and comma as decimal separator (e.g. `1.000,50 EUR`) are not supported. Use US-style formatting (`1,000.50 EUR`) or omit thousands separators. This matches the most common hledger usage; full `decimal-mark` directive support may be added in a future release. 64
65
## Dependencies 66
src/sigil/ledger.sglmodified
@@ -33,6 +33,7 @@
33
journal-amount? 34
journal-amount-quantity 35
journal-amount-commodity+36
journal-amount-sign-before-commodity 37
38
journal-cost 39
journal-cost?@@ -47,6 +48,7 @@
48
journal-posting-comment 49
journal-posting-tags 50
journal-posting-balance-assertion+51
journal-posting-balance-assertion-type 52
53
journal-transaction 54
journal-transaction?@@ -77,7 +79,8 @@
79
80
(define-struct journal-amount 81
(quantity default: 0)−80
(commodity default: ""))+82
(commodity default: "")+83
(sign-before-commodity default: #f)) ; #t when parsed from "-$50" form 84
85
(define-struct journal-cost 86
(type default: 'per-unit) ; 'per-unit (@) or 'total (@@)@@ -89,7 +92,8 @@
92
(cost default: #f) ; journal-cost or #f 93
(comment default: "") 94
(tags default: '()) ; alist of (name . value)−92
(balance-assertion default: #f)) ; journal-amount or #f+95
(balance-assertion default: #f) ; journal-amount or #f+96
(balance-assertion-type default: "=")) ; "=", "==", or "=*" 97
98
(define-struct journal-transaction 99
(date default: "")@@ -112,11 +116,15 @@
116
(if (not amt) 117
"" 118
(let ((qty (journal-amount-quantity amt))−115
(comm (journal-amount-commodity amt)))+119
(comm (journal-amount-commodity amt))+120
(sign-before (journal-amount-sign-before-commodity amt))) 121
(if (string-empty? comm) 122
(format-number qty) 123
(if (currency-symbol? comm)−119
(str comm (format-number qty))+124
;; Preserve original sign placement for round-trip fidelity+125
(if (and sign-before (< qty 0))+126
(str "-" comm (format-number (- qty)))+127
(str comm (format-number qty))) 128
(str (format-number qty) " " comm)))))) 129
130
;;; Check if a commodity string is a currency symbol (like $, EUR, etc.)@@ -178,7 +186,8 @@
186
(amount-str (if amt (format-amount amt) "")) 187
(cost-str (format-cost cost)) 188
(bal-str (if bal−181
(str " = " (format-amount bal))+189
(str " " (journal-posting-balance-assertion-type posting)+190
" " (format-amount bal)) 191
"")) 192
;; Combine tags and comment 193
(tag-str (format-tags tags))@@ -426,14 +435,21 @@
435
'() 436
(parse-tags-from-comment comment-text))) 437
;; Parse the main part: account amount [cost] [= assertion]−429
(parsed (parse-posting-parts main-part)))+438
;; Returns (account amount cost assertion assertion-type)+439
(parsed (parse-posting-parts main-part))+440
(p-account (car parsed))+441
(p-amount (cadr parsed))+442
(p-cost (caddr parsed))+443
(p-assertion (cadddr parsed))+444
(p-assertion-type (car (cddddr parsed)))) 445
(journal-posting−431
account: (car parsed)−432
amount: (cadr parsed)−433
cost: (caddr parsed)+446
account: p-account+447
amount: p-amount+448
cost: p-cost 449
comment: comment-text 450
tags: posting-tags−436
balance-assertion: (cadddr parsed))))+451
balance-assertion: p-assertion+452
balance-assertion-type: p-assertion-type))) 453
454
;; Split a line at the first inline comment (;), respecting the 455
;; hledger rule that ; must be preceded by 2+ spaces@@ -472,7 +488,7 @@
488
(let ((split-pos (find-double-space trimmed))) 489
(if (not split-pos) 490
;; No amount — just an account (amount inferred)−475
(list trimmed #f #f #f)+491
(list trimmed #f #f #f "=") 492
(let* ((account (string-trim (substring trimmed 0 split-pos))) 493
(amount-part (string-trim (substring trimmed split-pos (string-length trimmed))))) 494
;; Parse amount part which may include cost and balance assertion@@ -489,48 +505,63 @@
505
i 506
(loop (+ i 1))))))) 507
−492
;; Parse amount, optional cost (@/@@), and optional balance assertion (=)+508
;; Parse amount, optional cost (@/@@), and optional balance assertion (=, ==, =*)+509
;; Returns (account amount cost assertion assertion-type) 510
(define (parse-amount-cost-assertion account text) 511
(let* (;; Check for balance assertion first 512
(assertion-split (split-balance-assertion text)) 513
(amount-cost-part (car assertion-split))−497
(assertion (cdr assertion-split))+514
(assertion (cadr assertion-split))+515
(assertion-type (caddr assertion-split)) 516
;; Check for cost notation 517
(cost-split (split-cost amount-cost-part)) 518
(amount-str (string-trim (car cost-split))) 519
(cost (cdr cost-split)) 520
;; Parse the amount 521
(amount (parse-amount-string amount-str)))−504
(list account amount cost assertion)))+522
(list account amount cost assertion assertion-type))) 523
−506
;; Split off balance assertion (= or == amount) from end+524
;; Split off balance assertion (=, ==, or =* amount) from end+525
;; Returns (amount-cost-part assertion-amount assertion-type) 526
(define (split-balance-assertion text) 527
(let ((eq-pos (find-assertion-equals text))) 528
(if eq-pos−510
;; Determine if this is == (strict) or = (normal) and skip accordingly−511
(let* ((skip (if (and (< (+ eq-pos 1) (string-length text))−512
(char=? (string-ref text (+ eq-pos 1)) #\=))−513
2 1))+529
;; Determine assertion type: ==, =*, or = and skip accordingly+530
(let* ((next-char (if (< (+ eq-pos 1) (string-length text))+531
(string-ref text (+ eq-pos 1))+532
#f))+533
(assertion-type (cond+534
((and next-char (char=? next-char #\=)) "==")+535
((and next-char (char=? next-char #\*)) "=*")+536
(else "=")))+537
(skip (if (string=? assertion-type "=") 1 2)) 538
(assertion-str (string-trim (substring text (+ eq-pos skip) (string-length text)))))−515
(cons (string-trim (substring text 0 eq-pos))−516
(parse-amount-string assertion-str)))−517
(cons text #f))))+539
(list (string-trim (substring text 0 eq-pos))+540
(parse-amount-string assertion-str)+541
assertion-type))+542
(list text #f "=")))) 543
−519
;; Find the = for balance assertion, distinguishing from == and @@/@ signs−520
;; Returns position of = or #f+544
;; Find the = for balance assertion, distinguishing from ==, =* and @@/@ signs+545
;; Returns position of first = or #f 546
(define (find-assertion-equals text) 547
(let ((len (string-length text))) 548
(let loop ((i (- len 1))) 549
(if (< i 0) 550
#f−526
(if (char=? (string-ref text i) #\=)−527
;; Check it's not part of == (strict assertion)−528
;; and not preceded by @ (which would be odd but safe)−529
(if (and (> i 0) (char=? (string-ref text (- i 1)) #\=))−530
;; == strict assertion — return position of first =−531
(- i 1)−532
i)−533
(loop (- i 1)))))))+551
(let ((c (string-ref text i)))+552
(if (char=? c #\=)+553
;; Check it's not part of == (strict assertion)+554
(if (and (> i 0) (char=? (string-ref text (- i 1)) #\=))+555
;; == strict assertion — return position of first =+556
(- i 1)+557
i)+558
;; Check for =* — the * comes after =, so look for = before *+559
(if (and (char=? c #\*)+560
(> i 0)+561
(char=? (string-ref text (- i 1)) #\=))+562
;; =* inclusive assertion — return position of =+563
(- i 1)+564
(loop (- i 1))))))))) 565
566
;; Split off cost notation (@ or @@) from amount string 567
(define (split-cost text)@@ -592,7 +623,8 @@
623
(num-str (strip-digit-groups (substring text 2 (string-length text)))) 624
(qty (string->number num-str))) 625
(if qty−595
(journal-amount quantity: (- qty) commodity: commodity)+626
(journal-amount quantity: (- qty) commodity: commodity+627
sign-before-commodity: #t) 628
(journal-amount quantity: 0 commodity: text)))) 629
630
;; Parse "100.00 EUR" or just "100.00" styletest/test-ledger.sglmodified
@@ -156,6 +156,41 @@
156
"2026-03-15 * Deposit\n assets:checking $3000.00 = $5000.00\n income:salary" 157
(format-transaction txn))))) 158
+159
(test-group "format-transaction - balance assertion types"+160
(test "format inclusive balance assertion =*"+161
(let ((txn (journal-transaction+162
date: "2026-03-15"+163
status: "*"+164
description: "Verify"+165
postings: (list+166
(journal-posting+167
account: "assets:checking"+168
amount: (journal-amount quantity: 0 commodity: "$")+169
balance-assertion: (journal-amount quantity: 5000 commodity: "$")+170
balance-assertion-type: "=*")+171
(journal-posting+172
account: "income:salary")))))+173
(assert-equal+174
"2026-03-15 * Verify\n assets:checking $0.00 =* $5000.00\n income:salary"+175
(format-transaction txn))))+176
+177
(test "format strict balance assertion =="+178
(let ((txn (journal-transaction+179
date: "2026-03-15"+180
status: "*"+181
description: "Verify"+182
postings: (list+183
(journal-posting+184
account: "assets:checking"+185
amount: (journal-amount quantity: 0 commodity: "$")+186
balance-assertion: (journal-amount quantity: 5000 commodity: "$")+187
balance-assertion-type: "==")+188
(journal-posting+189
account: "income:salary")))))+190
(assert-equal+191
"2026-03-15 * Verify\n assets:checking $0.00 == $5000.00\n income:salary"+192
(format-transaction txn)))))+193
194
(test-group "format-transactions" 195
(test "format multiple transactions" 196
(let ((txns (list@@ -324,7 +359,24 @@
359
(bal (journal-posting-balance-assertion posting))) 360
(assert-true (journal-amount? bal)) 361
(assert-equal 5000.0 (journal-amount-quantity bal))−327
(assert-equal "$" (journal-amount-commodity bal))))+362
(assert-equal "$" (journal-amount-commodity bal))+363
(assert-equal "==" (journal-posting-balance-assertion-type posting))))+364
+365
(test "parse inclusive balance assertion (=*)"+366
(let* ((text "2026-03-15 * Verify\n assets:checking $0 =* $5000.00\n income:salary\n")+367
(txns (parse-journal text))+368
(posting (car (journal-transaction-postings (car txns))))+369
(bal (journal-posting-balance-assertion posting)))+370
(assert-true (journal-amount? bal))+371
(assert-equal 5000.0 (journal-amount-quantity bal))+372
(assert-equal "$" (journal-amount-commodity bal))+373
(assert-equal "=*" (journal-posting-balance-assertion-type posting))))+374
+375
(test "parse normal balance assertion type is ="+376
(let* ((text "2026-03-15 * Deposit\n assets:checking $3000.00 = $5000.00\n income:salary\n")+377
(txns (parse-journal text))+378
(posting (car (journal-transaction-postings (car txns)))))+379
(assert-equal "=" (journal-posting-balance-assertion-type posting)))) 380
381
(test "parse cost with balance assertion" 382
(let* ((text "2026-03-15 * Test\n assets:wise:eur 100.00 EUR @ 1.08 USD = 200.00 EUR\n assets:wise:usd\n")@@ -359,6 +411,38 @@
411
(formatted (format-transaction (car txns)))) 412
(assert-equal original formatted))) 413
+414
(test "parse and re-format prefix negative -$50"+415
(let* ((original (str "2026-03-15 * Refund\n"+416
" assets:checking -$50.00\n"+417
" expenses:food"))+418
(txns (parse-journal (str original "\n")))+419
(formatted (format-transaction (car txns))))+420
(assert-equal original formatted)))+421
+422
(test "parse and re-format internal negative $-50"+423
(let* ((original (str "2026-03-15 * Refund\n"+424
" assets:checking $-50.00\n"+425
" expenses:food"))+426
(txns (parse-journal (str original "\n")))+427
(formatted (format-transaction (car txns))))+428
(assert-equal original formatted)))+429
+430
(test "parse and re-format balance assertion with =="+431
(let* ((original (str "2026-03-15 * Verify\n"+432
" assets:checking $0.00 == $5000.00\n"+433
" income:salary"))+434
(txns (parse-journal (str original "\n")))+435
(formatted (format-transaction (car txns))))+436
(assert-equal original formatted)))+437
+438
(test "parse and re-format balance assertion with =*"+439
(let* ((original (str "2026-03-15 * Verify\n"+440
" assets:checking $0.00 =* $5000.00\n"+441
" income:salary"))+442
(txns (parse-journal (str original "\n")))+443
(formatted (format-transaction (car txns))))+444
(assert-equal original formatted)))+445
446
(test "parse and re-format multiple transactions" 447
(let* ((original (str "2026-03-15 * Groceries\n" 448
" expenses:food 47.23 USD\n"test/test-report.sgladded
@@ -0,0 +1,162 @@
+1
;;; Test suite for (sigil ledger report)+2
+3
(import (sigil test)+4
(sigil string)+5
(sigil ledger)+6
(sigil ledger report))+7
+8
;; Test journal path — used by all report tests+9
(define test-journal-path "/tmp/sigil-ledger-test.journal")+10
+11
;; Track whether hledger is available for conditional assertions+12
(define hledger? (hledger-available?))+13
+14
;; Write test journal file using the ledger module's own write function+15
(write-journal test-journal-path+16
(list+17
(journal-transaction+18
date: "2026-03-15"+19
status: "*"+20
description: "Grocery store"+21
postings: (list+22
(journal-posting+23
account: "expenses:food:groceries"+24
amount: (journal-amount quantity: 47.23 commodity: "$"))+25
(journal-posting+26
account: "assets:checking")))+27
(journal-transaction+28
date: "2026-03-16"+29
status: "*"+30
description: "Gas station"+31
postings: (list+32
(journal-posting+33
account: "expenses:transport:fuel"+34
amount: (journal-amount quantity: 30 commodity: "$"))+35
(journal-posting+36
account: "assets:checking")))+37
(journal-transaction+38
date: "2026-03-17"+39
status: "*"+40
description: "Paycheck"+41
postings: (list+42
(journal-posting+43
account: "assets:checking"+44
amount: (journal-amount quantity: 3000 commodity: "$"))+45
(journal-posting+46
account: "income:salary")))))+47
+48
;; ========== Availability ==========+49
+50
(test-group "hledger availability"+51
(test "hledger-available? returns boolean"+52
(assert-true (boolean? hledger?))))+53
+54
;; ========== hledger-command (first to warm up process-output->string) ==========+55
+56
(test-group "hledger-command"+57
(test "list accounts via hledger command"+58
(if (not hledger?)+59
(assert-true #t)+60
(let ((result (hledger-command "accounts" file: test-journal-path)))+61
(assert-true (string? result))+62
(assert-true (string-find result "assets:checking"))+63
(assert-true (string-find result "expenses:food:groceries")))))+64
+65
(test "hledger-command with extra args"+66
(if (not hledger?)+67
(assert-true #t)+68
(let ((result (hledger-command "bal" file: test-journal-path+69
args: (list "expenses" "--depth" "1"))))+70
(assert-true (string? result))+71
(assert-true (string-find result "expenses"))))))+72
+73
;; ========== hledger-balance ==========+74
+75
(test-group "hledger-balance"+76
(test "balance report returns non-empty string"+77
(if (not hledger?)+78
(assert-true #t)+79
(let ((result (hledger-balance test-journal-path)))+80
(assert-true (string? result))+81
(assert-true (> (string-length result) 0)))))+82
+83
(test "balance report with account query"+84
(if (not hledger?)+85
(assert-true #t)+86
(let ((result (hledger-balance test-journal-path "expenses")))+87
(assert-true (string? result))+88
(assert-true (string-find result "expenses")))))+89
+90
(test "balance report with depth option"+91
(if (not hledger?)+92
(assert-true #t)+93
(let ((result (hledger-balance test-journal-path depth: 1)))+94
(assert-true (string? result))+95
(assert-true (string-find result "assets")))))+96
+97
(test "balance report with tree option"+98
(if (not hledger?)+99
(assert-true #t)+100
(let ((result (hledger-balance test-journal-path tree: #t)))+101
(assert-true (string? result))+102
(assert-true (> (string-length result) 0)))))+103
+104
(test "balance report with date filter"+105
(if (not hledger?)+106
(assert-true #t)+107
(let ((result (hledger-balance test-journal-path+108
begin: "2026-03-16" end: "2026-03-17")))+109
(assert-true (string? result))+110
(assert-true (string-find result "fuel"))))))+111
+112
;; ========== hledger-register ==========+113
+114
(test-group "hledger-register"+115
(test "register report returns non-empty string"+116
(if (not hledger?)+117
(assert-true #t)+118
(let ((result (hledger-register test-journal-path)))+119
(assert-true (string? result))+120
(assert-true (> (string-length result) 0)))))+121
+122
(test "register report with account filter"+123
(if (not hledger?)+124
(assert-true #t)+125
(let ((result (hledger-register test-journal-path "expenses")))+126
(assert-true (string? result))+127
;; hledger may abbreviate account names in register output+128
(assert-true (string-find result "food")))))+129
+130
(test "register report with date range"+131
(if (not hledger?)+132
(assert-true #t)+133
(let ((result (hledger-register test-journal-path begin: "2026-03-17")))+134
(assert-true (string? result))+135
(assert-true (string-find result "Paycheck"))))))+136
+137
;; ========== hledger-income-statement ==========+138
+139
(test-group "hledger-income-statement"+140
(test "income statement returns non-empty string"+141
(if (not hledger?)+142
(assert-true #t)+143
(let ((result (hledger-income-statement test-journal-path)))+144
(assert-true (string? result))+145
(assert-true (> (string-length result) 0)))))+146
+147
(test "income statement shows expenses"+148
(if (not hledger?)+149
(assert-true #t)+150
(let ((result (hledger-income-statement test-journal-path)))+151
(assert-true (string-find result "expenses"))))))+152
+153
;; ========== hledger-print-json ==========+154
+155
(test-group "hledger-print-json"+156
(test "print-json returns parsed data"+157
(if (not hledger?)+158
(assert-true #t)+159
(let ((result (hledger-print-json test-journal-path)))+160
(assert-true (not (null? result)))))))+161
+162
(run-tests)