packages/sql-faker/tests/Unit/Compiler/Lemon/LemonParserTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\SqlFaker\Compiler\Lemon;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\Small;
9use PHPUnit\Framework\Attributes\UsesClass;
10use PHPUnit\Framework\TestCase;
11use RuntimeException;
12use SqlFaker\Compiler\GrammarParseException;
13use SqlFaker\Compiler\Lemon\LemonDirectives;
14use SqlFaker\Compiler\Lemon\LemonParser;
15use SqlFaker\Compiler\Lemon\LemonRules;
16use SqlFaker\Compiler\Lemon\LemonSymbols;
17use SqlFaker\Compiler\Lemon\LemonText;
18use SqlFaker\Grammar\Model\Grammar;
19use SqlFaker\Grammar\Model\NonTerminal;
20use SqlFaker\Grammar\Model\Production;
21use SqlFaker\Grammar\Model\ProductionRule;
22use SqlFaker\Grammar\Model\Symbol;
23use SqlFaker\Grammar\Model\Terminal;
24
25#[CoversClass(LemonParser::class)]
26#[CoversClass(Grammar::class)]
27#[CoversClass(NonTerminal::class)]
28#[CoversClass(Terminal::class)]
29#[CoversClass(Production::class)]
30#[CoversClass(ProductionRule::class)]
31#[UsesClass(GrammarParseException::class)]
32#[UsesClass(LemonDirectives::class)]
33#[UsesClass(LemonRules::class)]
34#[UsesClass(LemonSymbols::class)]
35#[UsesClass(LemonText::class)]
36#[UsesClass(\SqlFaker\Compiler\Lemon\LemonCondition::class)]
37#[UsesClass(\SqlFaker\Compiler\Lemon\LemonPreprocessor::class)]
38#[Small]
39final class LemonParserTest extends TestCase
40{
41 public function testParseSimpleRule(): void
42 {
43 $input = <<<'LEMON'
44 cmd ::= SELECT expr. { action }
45 LEMON;
46
47 $grammar = (new LemonParser())->parse($input);
48
49 self::assertSame('cmd', $grammar->startSymbol);
50 self::assertArrayHasKey('cmd', $grammar->ruleMap);
51 self::assertCount(1, $grammar->ruleMap['cmd']->alternatives);
52
53 $alt = $grammar->ruleMap['cmd']->alternatives[0];
54 self::assertCount(2, $alt->symbols);
55 self::assertInstanceOf(Terminal::class, $alt->symbols[0]);
56 self::assertSame('SELECT', $alt->symbols[0]->value);
57 self::assertInstanceOf(NonTerminal::class, $alt->symbols[1]);
58 self::assertSame('expr', $alt->symbols[1]->value);
59 }
60
61 public function testParseMultipleRules(): void
62 {
63 $input = <<<'LEMON'
64 cmd ::= SELECT expr.
65 cmd ::= INSERT INTO nm.
66 expr ::= INTEGER.
67 nm ::= ID.
68 LEMON;
69
70 $grammar = (new LemonParser())->parse($input);
71
72 self::assertSame('cmd', $grammar->startSymbol);
73 self::assertCount(3, $grammar->ruleMap);
74 self::assertArrayHasKey('cmd', $grammar->ruleMap);
75 self::assertArrayHasKey('expr', $grammar->ruleMap);
76 self::assertArrayHasKey('nm', $grammar->ruleMap);
77
78 self::assertCount(2, $grammar->ruleMap['cmd']->alternatives);
79 }
80
81 public function testParseEmptyProduction(): void
82 {
83 $input = <<<'LEMON'
84 opt_where ::= .
85 opt_where ::= WHERE expr.
86 expr ::= INTEGER.
87 LEMON;
88
89 $grammar = (new LemonParser())->parse($input);
90
91 self::assertCount(2, $grammar->ruleMap['opt_where']->alternatives);
92 self::assertCount(0, $grammar->ruleMap['opt_where']->alternatives[0]->symbols);
93 }
94
95 public function testParseAliasesStripped(): void
96 {
97 $input = <<<'LEMON'
98 expr(A) ::= expr(B) PLUS expr(C).
99 LEMON;
100
101 $grammar = (new LemonParser())->parse($input);
102
103 self::assertSame('expr', $grammar->startSymbol);
104 $alt = $grammar->ruleMap['expr']->alternatives[0];
105 self::assertCount(3, $alt->symbols);
106 self::assertInstanceOf(NonTerminal::class, $alt->symbols[0]);
107 self::assertSame('expr', $alt->symbols[0]->value());
108 self::assertInstanceOf(Terminal::class, $alt->symbols[1]);
109 self::assertSame('PLUS', $alt->symbols[1]->value());
110 self::assertInstanceOf(NonTerminal::class, $alt->symbols[2]);
111 self::assertSame('expr', $alt->symbols[2]->value());
112 }
113
114 public function testParseExpandsInlineTokenAlternatives(): void
115 {
116 $input = <<<'LEMON'
117 likeop(A) ::= LIKE_KW|MATCH(A).
118 expr(A) ::= expr(A) STAR|SLASH|REM(OP) expr(Y).
119 LEMON;
120
121 $grammar = (new LemonParser())->parse($input);
122
123 self::assertSame(
124 [['LIKE_KW'], ['MATCH']],
125 array_map(
126 static fn (Production $production): array => array_map(
127 static fn (Symbol $symbol): string => $symbol->value(),
128 $production->symbols,
129 ),
130 $grammar->ruleMap['likeop']->alternatives,
131 ),
132 );
133 self::assertSame(
134 [
135 ['expr', 'STAR', 'expr'],
136 ['expr', 'SLASH', 'expr'],
137 ['expr', 'REM', 'expr'],
138 ],
139 array_map(
140 static fn (Production $production): array => array_map(
141 static fn (Symbol $symbol): string => $symbol->value(),
142 $production->symbols,
143 ),
144 $grammar->ruleMap['expr']->alternatives,
145 ),
146 );
147 }
148
149 public function testParseWithDirectives(): void
150 {
151 $input = <<<'LEMON'
152 %left AND.
153 %left OR.
154 %token_type {int}
155 cmd ::= SELECT expr.
156 expr ::= INTEGER.
157 LEMON;
158
159 $grammar = (new LemonParser())->parse($input);
160
161 self::assertSame('cmd', $grammar->startSymbol);
162 self::assertCount(2, $grammar->ruleMap);
163 }
164
165 public function testParseCommentsStripped(): void
166 {
167 $input = <<<'LEMON'
168 /* This is a comment */
169 cmd ::= SELECT expr. // inline comment
170 expr ::= INTEGER.
171 LEMON;
172
173 $grammar = (new LemonParser())->parse($input);
174
175 self::assertSame('cmd', $grammar->startSymbol);
176 self::assertCount(2, $grammar->ruleMap);
177 }
178
179 public function testParseThrowsOnNoRules(): void
180 {
181 $this->expectException(GrammarParseException::class);
182 $this->expectExceptionMessage('No grammar rules parsed from the Lemon grammar.');
183
184 (new LemonParser())->parse('%left AND.');
185 }
186
187 public function testTerminalDetection(): void
188 {
189 $input = <<<'LEMON'
190 cmd ::= SELECT ALL expr FROM nm.
191 expr ::= INTEGER.
192 nm ::= ID.
193 LEMON;
194
195 $grammar = (new LemonParser())->parse($input);
196
197 $alt = $grammar->ruleMap['cmd']->alternatives[0];
198 self::assertInstanceOf(Terminal::class, $alt->symbols[0]);
199 self::assertInstanceOf(Terminal::class, $alt->symbols[1]);
200 self::assertInstanceOf(NonTerminal::class, $alt->symbols[2]);
201 self::assertInstanceOf(Terminal::class, $alt->symbols[3]);
202 self::assertInstanceOf(NonTerminal::class, $alt->symbols[4]);
203 }
204
205 public function testParseSqliteGrammarCache(): void
206 {
207 $grammar = Grammar::loadFromFile(
208 __DIR__ . '/../../../../resources/ast/sqlite-3.47.2.php'
209 );
210
211 self::assertSame('input', $grammar->startSymbol);
212 self::assertArrayHasKey('cmd', $grammar->ruleMap);
213 self::assertArrayHasKey('select', $grammar->ruleMap);
214 self::assertArrayHasKey('expr', $grammar->ruleMap);
215 self::assertGreaterThan(100, count($grammar->ruleMap));
216 }
217
218 public function testParseFileReadsAGrammarFromDisk(): void
219 {
220 $path = tempnam(sys_get_temp_dir(), 'lemon-');
221 self::assertIsString($path);
222 file_put_contents($path, "cmd ::= SELECT.\n");
223
224 self::assertSame('cmd', (new LemonParser())->parseFile($path)->startSymbol);
225
226 unlink($path);
227 }
228
229 public function testParseFileReportsAFileItCannotRead(): void
230 {
231 $this->expectException(RuntimeException::class);
232 $this->expectExceptionMessage('Failed to read: ');
233
234 (new LemonParser())->parseFile(sys_get_temp_dir() . '/no-such-lemon-grammar.y');
235 }
236}
237