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