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