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" style
test/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)