clerk/journal/lexer/lexer_test.go (view raw)
| 1 | package lexer |
| 2 | |
| 3 | import ( |
| 4 | "testing" |
| 5 | |
| 6 | "olexsmir.xyz/clerk/internal/testutil/golden" |
| 7 | "olexsmir.xyz/clerk/journal/token" |
| 8 | ) |
| 9 | |
| 10 | func TestLexer(t *testing.T) { |
| 11 | tests := []string{ |
| 12 | "account directive", |
| 13 | "automated transaction", |
| 14 | "better date", |
| 15 | "blank lines", |
| 16 | "cleared transaction", |
| 17 | "comment block directive without end", |
| 18 | "comment block directive", |
| 19 | "comment line", |
| 20 | "commodity directive", |
| 21 | "date with secondary", |
| 22 | "empty", |
| 23 | "hash comment", |
| 24 | "inline comment", |
| 25 | "market price directive with time", |
| 26 | "market price directive", |
| 27 | "simple transaction", |
| 28 | "special chars in description", |
| 29 | "star comment", |
| 30 | "transaction with code", |
| 31 | "transaction with unicode commodity symbols", |
| 32 | "transaction with virtual accounts", |
| 33 | "transaction, accounts with uppercase latters", |
| 34 | } |
| 35 | for _, tt := range tests { |
| 36 | t.Run(tt, func(t *testing.T) { |
| 37 | a := golden.Read(t, tt) |
| 38 | golden.Assert(t, a, New("j", a.Get("input")).Dump()) |
| 39 | }) |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | func FuzzLexer(f *testing.F) { |
| 44 | const maxKnownTokenType = token.C // ensures fuzzer never sees out-of-range token types |
| 45 | |
| 46 | f.Add([]byte("2024/01/01 groceries\n expenses:food $10.00\n assets:checking\n")) |
| 47 | f.Add([]byte("2024/01/01 * groceries\n expenses:food $10.00\n assets:checking\n")) |
| 48 | f.Add([]byte("2024/01/01 ! groceries\n expenses:food $10.00\n assets:checking\n")) |
| 49 | f.Add([]byte("2024/01/01 t ; inline comment\n a $10\n")) |
| 50 | f.Add([]byte("2024/01/01 t\n (a) 10 @@ $20\n [b] 30\n")) |
| 51 | f.Add([]byte("2008/06/03 * eat & shop\n expenses:food $1\n expenses:supplies $1\n assets:cash\n")) |
| 52 | f.Add([]byte("2015-01-03 * Money exchange office\n Assets:Cash -20 EUR @ 7.53 HRK\n Assets:Cash 150.60 HRK\n")) |
| 53 | f.Add([]byte("2024/01/01 ß\n (ß) 10 ß\n")) |
| 54 | f.Add([]byte("2024/01/01 t\n (! a) 10\n")) |
| 55 | f.Add([]byte("comment\nbody\nend\n")) |
| 56 | f.Add([]byte("apply tag foo\nend\n")) |
| 57 | f.Add([]byte("; a comment\n")) |
| 58 | f.Add([]byte("# a comment\n")) |
| 59 | f.Add([]byte("* a comment\n")) |
| 60 | f.Add([]byte("account expenses:food\n")) |
| 61 | f.Add([]byte("commodity 1,000.00 UAH\n")) |
| 62 | f.Add([]byte("N $\n")) |
| 63 | f.Add([]byte("P 2024-01-01 USD 41.50 UAH\n")) |
| 64 | f.Add([]byte("P 2024-01-01 12:00:00 USD 41.50 UAH\n")) |
| 65 | f.Add([]byte("P 2024-01-01 12:00 USD 41.50 UAH\n")) |
| 66 | f.Add([]byte("~ monthly\n a $10\n b\n")) |
| 67 | f.Add([]byte("= /^Income/\n expenses:food $10\n")) |
| 68 | f.Add([]byte("перевірка\n")) |
| 69 | f.Add([]byte("")) |
| 70 | f.Add([]byte("\n\n\n")) |
| 71 | f.Add([]byte("@@@\n")) |
| 72 | f.Add([]byte(" \n")) |
| 73 | f.Add([]byte("0\n")) |
| 74 | f.Add([]byte{0xff, 0xfe, 0x00}) |
| 75 | |
| 76 | f.Fuzz(func(t *testing.T, data []byte) { |
| 77 | // Pass 1: lex and validate token stream |
| 78 | l := New("j", data) |
| 79 | var tokens []token.Token |
| 80 | maxTokens := max(len(data)*2, 16) |
| 81 | prevEnd := -1 |
| 82 | for range maxTokens { |
| 83 | tok := l.Next() |
| 84 | |
| 85 | // Monotonic span |
| 86 | if tok.Span.Start.Offset < prevEnd { |
| 87 | t.Fatalf("non-monotonic span: prevEnd=%d current=%s %d", |
| 88 | prevEnd, tok.Type, tok.Span.Start.Offset) |
| 89 | } |
| 90 | |
| 91 | // Token type in range (no garbage from memory corruption) |
| 92 | if tok.Type < 0 || tok.Type > maxKnownTokenType { |
| 93 | t.Fatalf("token type out of range: %d", tok.Type) |
| 94 | } |
| 95 | |
| 96 | // Span in bounds (EOF/NEWLINE sentinels may extend one past input) |
| 97 | maxEnd := len(data) |
| 98 | if tok.Type == token.NEWLINE || tok.Type == token.EOF { |
| 99 | maxEnd = len(data) + 1 |
| 100 | } |
| 101 | if tok.Span.Start.Offset < 0 || tok.Span.End.Offset > maxEnd || |
| 102 | tok.Span.Start.Offset > tok.Span.End.Offset { |
| 103 | t.Fatalf("span out of bounds: [%d,%d] for len=%d type=%s", |
| 104 | tok.Span.Start.Offset, tok.Span.End.Offset, len(data), tok.Type) |
| 105 | } |
| 106 | |
| 107 | if tok.Type == token.EOF { |
| 108 | break |
| 109 | } |
| 110 | |
| 111 | // Non-zero-length for non-EOF tokens (NEWLINE sentinel is exempt) |
| 112 | if tok.Type != token.NEWLINE && tok.Span.End.Offset <= tok.Span.Start.Offset { |
| 113 | t.Fatalf("non-progressing token: %s %q at %d:%d-%d:%d", |
| 114 | tok.Type, tok.Literal, |
| 115 | tok.Span.Start.Line, tok.Span.Start.Col, |
| 116 | tok.Span.End.Line, tok.Span.End.Col) |
| 117 | } |
| 118 | |
| 119 | tokens = append(tokens, tok) |
| 120 | prevEnd = tok.Span.End.Offset |
| 121 | } |
| 122 | |
| 123 | if prevEnd > len(data)+1 { |
| 124 | t.Fatalf("token consumed beyond input: end=%d len=%d", prevEnd, len(data)) |
| 125 | } |
| 126 | |
| 127 | // Pass 2: re-lex the same input — token stream must be identical |
| 128 | l2 := New("j", data) |
| 129 | for _, expected := range tokens { |
| 130 | tok := l2.Next() |
| 131 | if tok.Type != expected.Type || tok.Literal != expected.Literal { |
| 132 | t.Fatalf("re-lex mismatch at offset %d: expected (%s %q), got (%s %q)", |
| 133 | expected.Span.Start.Offset, expected.Type, expected.Literal, tok.Type, tok.Literal) |
| 134 | } |
| 135 | } |
| 136 | }) |
| 137 | } |