all repos

clerk @ 0b03250d43079a3582484d8eb64805c3ba452e30

missing tooling for ledger/hledger

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

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