all repos

clerk @ fd46af1560de1b82a363b5dd083110d0deb5525a

missing tooling for ledger/hledger

clerk/journal/parser/parser_test.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
journal: split "EUR123" into commodity+quantity, fix quoted commodities in postings, 1 month ago
1
package parser
2
3
import (
4
	"testing"
5
6
	"olexsmir.xyz/clerk/internal/testutil/golden"
7
	"olexsmir.xyz/clerk/journal/ast"
8
	"olexsmir.xyz/clerk/journal/lexer"
9
)
10
11
func TestParser_ParseFile(t *testing.T) {
12
	tests := []struct{ name, inp string }{
13
		{"blank line", "\n"},
14
		{"comment semicolon", "; a comment\n"},
15
		{"comment hash", "# a comment\n"},
16
		{"comment percent", "% a comment\n"},
17
		{"comment star", "* a comment\n"},
18
		{"alias directive", "alias checking = assets:bank:checking\n"},
19
		{"tag directive", "tag project-xyz\n"},
20
		{"year directive", "year 1488\n"},
21
		{"decimal-mark directive", "decimal-mark ,\n"},
22
		{"C directive", "C 1.00s = 100c\n"},
23
		{"D directive", `D $1.00
24
D 10 UAH
25
`},
26
		{"P directive", "P 2024/01/01 USD 40.50 UAH\n"},
27
		{"P directive with time", "P 2024-01-01 12:00:00 USD 40.50 UAH\n"},
28
		{"N directive", "N $\n"},
29
		{"inclue directive", "include ./path-to.journal\n"},
30
		{"inclue directive with comment", "include ./path.journal ; something importnat\n"},
31
		{"apply tag directive", "apply tag hashtag\n"},
32
		{"apply fixed directive", "apply fixed CAD $0.90\n"},
33
		{"end apply directive", "end apply tag\n"},
34
		{"comment directive", "comment\nsome text\nend comment\n"},
35
		{"comment directive end alone", "comment\nsome text\nend\n"},
36
		{"comment directive with header", "comment tag:hidden\nstuff\nend\n"},
37
		{"empty comment block", "comment\nend\n"},
38
		{"never ending comment directive", "comment\nsome text\n"},
39
		{"nested apply tag directives", `apply tag hashtag
40
apply tag nestedtag: true
41
2011/01/27 Book Store
42
  expenses:books  $20.00
43
  liabilities:mastercard
44
end apply tag
45
end apply tag
46
`},
47
		{"account directive", "account expenses:food\n"},
48
		{"account directive with comment", "account expenses:food ; my account\n"},
49
		{"account with subdirectives", `account expenses:food
50
  note some note
51
  alias food  ; this gets ignored
52
`},
53
		{"comodity directive", "commodity $\n"},
54
		{"comodity directive word", "commodity UAH\n"},
55
		{"comodity directive no space", "commodity $1000.00\n"},
56
		{"commodity quantity first", "commodity 1,000.00 UAH\n"},
57
		{"commodity quantity after", "commodity UAH 1,000.00\n"},
58
		{"commodity with subdirectives", `commodity UAH
59
  format 1 000.00 UAH
60
  note Божествена Гривня  ; this gets ignored
61
`},
62
		{"payee directive", `payee grocery store
63
payee 'grocery store 3'
64
payee "grocery store 2"
65
payee grocery store 1
66
`},
67
		{"transaction", "2024/01/01\n"},
68
		{"automated transaction", `= ^income
69
    (liabilities:tax)  *.33
70
71
= expenses:gifts
72
    budget:gifts  *-1
73
    assets:budget  *1
74
75
= income:salary
76
    budget:savings  (amount * 0.5)
77
`},
78
		{"transaction with payee", "2024-01-01 groceries\n"},
79
		{"transaction with digit payees", `2002/01/01 * 1a1a6305d06ce4b284dba0d267c23f69d70c20be
80
    af0628973ff35bd62ddb048fa41dd8d83c1c46fe       $474.31
81
    fc6f6f10f627ad1a5af9d488c98405a1498d019d
82
83
2002/03/01 * 9861ce541c17b11f627e71c26bf350b33141f62b
84
    0ecbb1b15e2cf3e515cc0f8533e5bb0fb2326728        $14.91
85
    fc6f6f10f627ad1a5af9d488c98405a1498d019d
86
`},
87
		{"account with spaces", `2026-05-11 testies
88
	expenses:account name  20.00
89
	assets:bank
90
`},
91
		{"transaction with multiword payee", "2024-01-01 opening balances\n"},
92
		{"transaction pending", "2024-01-01 ! groceries\n"},
93
		{"transaction clearerd", "2024-01-01 * groceries\n"},
94
		{"transaction with note", "2024-01-01 groceries | eating out\n"},
95
		{"transaction with comment", "2024-01-01 groceries ; note\n"},
96
		{"transaction with secondary date", "2024/01/01=2024/01/02 groceries\n"},
97
		{"transaction with costs", `2026-05-11 testies
98
	expenses:atm  20.00 UAH @ 1 USD
99
	assets:bank
100
101
2026-05-11 testies2
102
	expenses:atm  20.00 UAH @@ 1 USD
103
	assets:bank
104
105
2026-05-12 testies3
106
	expenses:atm  20.00 UAH @ 1 USD = 20.00 UAH @ 1 USD
107
	assets:bank
108
109
2015-01-03 money exchange office
110
    assets:cash  -20 EUR @ 7.53 HRK
111
    assets:cash  150.60 HRK
112
`},
113
		{"transaction with cost and assertion", `2026-05-11 testies
114
	expenses:atm  UAH 20.00 @ 1 USD = 0 UAH
115
	assets:bank
116
`},
117
		{"transaction with posting", `2024-01-01 groceries
118
    expenses:food  $10.00
119
    assets:checking
120
`},
121
		{"transaction with unicode commodity symbols", `2024-01-01 groceries
122
    expenses:food  €10.00
123
    expenses:food  £5.00
124
    expenses:food  ₹700.00
125
    assets:checking
126
`},
127
		{"transaction with quoted commodity", `2025-06-16 groceries
128
    expenses:food  10.00 "EUR"
129
    assets:cash
130
`},
131
		{"transaction with strange commodity symbols", `2024-01-01 groceries
132
2026-05-20
133
  asdf  123 $€£
134
  asdf2
135
136
2026-05-20
137
  asdf  123 bytes
138
  asdf2
139
`},
140
		{"special chars in description", `
141
2024/01/01 groceries | 1 + 2
142
2024/01/01 groceries | 1 * 2
143
2024/01/01 groceries | 1 ! 2
144
2024/01/01 groceries | 1 # 2
145
2024/01/01 groceries | 1 % 2
146
2024/01/01 groceries | 1 ^ 2
147
2024/01/01 groceries | 1 & 2
148
2024/01/01 groceries | 1 ( 2
149
2024/01/01 groceries | 1 ) 2
150
2024/01/01 groceries | 1 [ 2
151
2024/01/01 groceries | 1 ] 2
152
2024/01/01 groceries | 1 { 2
153
2024/01/01 groceries | 1 } 2
154
2026-06-07 * payee !one | something *important*
155
    expenses:food  40.00
156
    assets:cash
157
`},
158
		{"transaction with tabs", `2024-01-01 groceries
159
	expenses:food  $10.00
160
	assets:checking
161
`},
162
		{"transaction in ukrainian", `2024/03/02 Обід
163
  витрати:їжа  350 UAH
164
  активи:готівка
165
`},
166
		{"transaction with code", `2024-01-01 (123) groceries
167
    expenses:food  $10.00
168
    assets:checking
169
`},
170
		{"transaction with posting amounts", `2024.01.01 groceries
171
    expenses:food  10.00 UAH
172
    assets:checking  -10.00 UAH
173
`},
174
		{"transaction with spaced account name", `2022-01-01 opening balances
175
    assets                    21 = 21
176
    equity:opening/closing balances
177
`},
178
		{"transaction with inline comment", `2024/01/01 groceries
179
	Expenses:Good  $10.00 ; food
180
	Assets:Checking
181
`},
182
		{"transaction with header comment", `2024/01/01 groceries
183
	; header comment
184
	expenses:food  $10.00
185
	assets:checking
186
`},
187
		{"transaction with trailing indent", `
188
2013/1/1 * pay taxes
189
    expenses:personal:tax              $1250
190
    assets:bank:checking               $-1250
191
192
`},
193
		{"transaction with balance assertion", `2024/01/01 groceries
194
	expenses:food  $10.00 = $100.00
195
	assets:checking
196
197
2025/03/07 groceries2
198
	expenses:food  $10.00 == $100.00
199
	assets:checking  == $0.00
200
201
2025/04/08 groceries3
202
	expenses:food  $10.00
203
	assets:checking  = $0.00
204
`},
205
		{"transaction with virtual accounts", `2024/01/01 groceries
206
	(virtual:account)  1 PESO
207
	[something:else]   5 PESO
208
	something:else
209
`},
210
		{"virtual postings with statuses", `2024/01/01 test
211
  ! (assets:cash)  $10
212
  (income:gift)  $-10
213
214
2024/01/01 test
215
    (! assets:cash)  $10
216
    (income:gift)  $-10
217
218
2024/01/01 test
219
    ! (! assets:cash)  $10
220
    (* income:gift)  $-10
221
`},
222
		{"period transaction expressions", `
223
~ monthly
224
    expenses:rent          $2000
225
    assets:bank:checking
226
227
~ monthly from 2023-04-15 to 2023-06-16
228
    expenses:utilities          $400
229
    assets:bank:checking
230
231
~ every 2 months  in 2023, we will review
232
    expenses:utilities          $400
233
    assets:bank:checking
234
235
~ monthly  Next year blah blah
236
    expenses:food  $100
237
    assets:checking
238
239
~ monthly from 2018/6 ;In 2019 we will change this
240
    expenses:food  $100
241
    assets:checking
242
`},
243
		{"unexpected token", `@@@ garbage\n`},
244
		{"recovery after bad posting", `2024-01-01 groceries
245
    @@@invalid
246
    assets:checking
247
248
2024/01/02 salary
249
    income:salary  $1000
250
    assets:checking
251
`},
252
		{"illegal only", "@@@\n"},
253
		{"illegal at start", "@@@ garbage\n"},
254
		{"illegal in posting", `2024/01/01 groceries
255
    @@@invalid
256
    assets:checking
257
`},
258
		{"illegal between transactions", `2024/01/01 groceries
259
    expenses:food  $10
260
    assets:checking
261
@@@
262
2024/01/02 salary
263
    income:salary  $1000
264
    assets:checking
265
`},
266
		{"multiple bad postings", `2024/01/01 groceries
267
    expenses:food  $10
268
    @@@bad1
269
    @bad2
270
    assets:checking
271
`},
272
		{"three bad postings", `2024/01/01 groceries
273
    expenses:food  $10
274
    @@@bad1
275
    @bad2
276
    @bad3
277
    assets:checking
278
`},
279
		{"quoted payee names", `2024-01-01 "groceries store"
280
  expenses:food  $10
281
  assets:checking
282
`},
283
		{"digit group marks", `2024-01-01 groceries
284
  expenses:food  1 000.00 UAH
285
  expenses:supplies  1'000.00 USD
286
  assets:checking  -2_000.00 USD
287
`},
288
		{"directive prefixes", `!account expenses:food
289
@commodity USD
290
`},
291
		{"bad between good", `2024/01/01 groceries
292
    expenses:food  $10
293
    @@@bad
294
    assets:cash  $5
295
`},
296
		{"all postings bad", `2024/01/01 groceries
297
    @@@bad1
298
    @bad2
299
    @bad3
300
`},
301
		{"bad then next transaction", `2024/01/01 groceries
302
    expenses:food  $10
303
    @@@bad
304
305
2024/01/02 salary
306
    income:salary  $1000
307
    assets:checking
308
`},
309
		{"comment between bad postings", `2024/01/01 groceries
310
    expenses:food  $10
311
    @@@bad1
312
    ; a comment
313
    @bad2
314
    assets:checking
315
`},
316
		{"bad posting at end", `2024/01/01 groceries
317
    expenses:food  $10
318
    @@@bad
319
320
2024/01/02 salary
321
    income:salary  $1000
322
    assets:checking
323
`},
324
	}
325
	for _, tt := range tests {
326
		t.Run(tt.name, func(t *testing.T) {
327
			l := lexer.New("j", []byte(tt.inp))
328
			f := New(l).ParseJournal()
329
			golden.Assert(t, tt.name, ast.Dump(f))
330
		})
331
	}
332
}
333
334
func FuzzParser(f *testing.F) {
335
	f.Add([]byte(""))
336
	f.Add([]byte("account expenses:food\n"))
337
	f.Add([]byte("account a\n  ; subdirective\n"))
338
	f.Add([]byte("commodity 1,000.00 UAH\n"))
339
	f.Add([]byte("include other.journal\n"))
340
	f.Add([]byte("alias checking = assets:bank:checking\n"))
341
	f.Add([]byte("2024/01/01 * groceries\n    expenses:food  $10.00\n    assets:checking\n"))
342
	f.Add([]byte("2024/01/01=2024/01/02 groceries\n"))
343
	f.Add([]byte("2024/01/01 groceries\n  expenses:food  $10.00\n  assets:checking\n"))
344
	f.Add([]byte("2008/06/03 * eat & shop\n    expenses:food      $1\n    expenses:supplies  $1\n    assets:cash\n"))
345
	f.Add([]byte("2015-01-03 * Money exchange office\n    Assets:Cash  -20 EUR @ 7.53 HRK\n    Assets:Cash  150.60 HRK\n"))
346
	f.Add([]byte("2024/01/01 t ; inline comment\n  a  $10\n"))
347
	f.Add([]byte("2024/01/01 t\n  (a)  10 @@ $20\n  [b]  30\n"))
348
	f.Add([]byte("2024/01/01 ß\n  (ß)  10 ß\n"))
349
	f.Add([]byte("2024/01/01 t\n  (! a)  10\n"))
350
	f.Add([]byte("2024/01/01 t\n  a  $10 == $10\n"))
351
	f.Add([]byte("  2024/01/01 t\n  a  $10\n"))
352
	f.Add([]byte("P 2024/01/01 USD 41.50 UAH\n"))
353
	f.Add([]byte("P 2024-01-01 12:00:00 USD 41.50 UAH\n"))
354
	f.Add([]byte("P 2024-01-01 12:00 USD 41.50 UAH\n"))
355
	f.Add([]byte("~ monthly\n    expenses:food  $100\n    assets:checking\n"))
356
	f.Add([]byte("= /^Income/\n  expenses:food  $10\n"))
357
	f.Add([]byte("; a comment\n"))
358
	f.Add([]byte("comment\nbody\nend\n"))
359
	f.Add([]byte("\n\n\n"))
360
	f.Add([]byte("перевірка\n"))
361
	f.Add([]byte("N $\n"))
362
	f.Add([]byte("apply tag foo\nend\n"))
363
	f.Add([]byte("@@@\n"))
364
	f.Add([]byte("   \n"))
365
	f.Add([]byte("0\n"))
366
	f.Add([]byte{0xff, 0xfe, 0x00})
367
368
	f.Fuzz(func(t *testing.T, data []byte) {
369
		l := lexer.New("j", data)
370
		j := New(l).ParseJournal()
371
		if j == nil {
372
			t.Fatalf("nil journal for input %q", string(data))
373
		}
374
375
		// error spans must be in bounds (allow sentinel extension past input)
376
		dataLen := len(data)
377
		for _, e := range j.Errors {
378
			if e.Span.Start.Offset < 0 {
379
				t.Fatal("error span start is negative")
380
			}
381
			if e.Span.End.Offset > dataLen+1 {
382
				t.Fatalf("error span end out of bounds: [%d,%d] len=%d", e.Span.Start.Offset, e.Span.End.Offset, dataLen)
383
			}
384
			if len(e.Message) == 0 {
385
				t.Fatal("empty error message")
386
			}
387
		}
388
389
		// dump must not panic and must be deterministic
390
		dump1, dump2 := ast.Dump(j), ast.Dump(j)
391
		if dump1 != dump2 {
392
			t.Fatal("non-deterministic dump")
393
		}
394
	})
395
}