packages/bison-parser/tests/Unit/Syntax/RuleParserTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Syntax;
6
7use BisonParser\Ast\Line;
8use BisonParser\Ast\Location;
9use BisonParser\Ast\Rule\Action;
10use BisonParser\Ast\Rule\Alternative;
11use BisonParser\Ast\Rule\DprecItem;
12use BisonParser\Ast\Rule\EmptyItem;
13use BisonParser\Ast\Rule\ExpectItem;
14use BisonParser\Ast\Rule\MergeItem;
15use BisonParser\Ast\Rule\PrecItem;
16use BisonParser\Ast\Rule\Predicate;
17use BisonParser\Ast\Rule\Rule;
18use BisonParser\Ast\Rule\SymbolItem;
19use BisonParser\Ast\Symbol;
20use BisonParser\Ast\SymbolKind;
21use BisonParser\Scanner\CodeReader;
22use BisonParser\Scanner\Cursor;
23use BisonParser\Scanner\Directives;
24use BisonParser\Scanner\Escapes;
25use BisonParser\Scanner\Scanner;
26use BisonParser\Scanner\Token;
27use BisonParser\Scanner\TokenKind;
28use BisonParser\Syntax\DeclarationParser;
29use BisonParser\Syntax\RuleParser;
30use BisonParser\Syntax\SymbolListParser;
31use BisonParser\Syntax\TokenStream;
32use BisonParser\SyntaxException;
33use PHPUnit\Framework\Attributes\CoversClass;
34use PHPUnit\Framework\Attributes\Small;
35use PHPUnit\Framework\Attributes\UsesClass;
36use PHPUnit\Framework\TestCase;
37
38#[CoversClass(RuleParser::class)]
39#[UsesClass(Action::class)]
40#[UsesClass(Alternative::class)]
41#[UsesClass(CodeReader::class)]
42#[UsesClass(Cursor::class)]
43#[UsesClass(Directives::class)]
44#[UsesClass(DprecItem::class)]
45#[UsesClass(EmptyItem::class)]
46#[UsesClass(Escapes::class)]
47#[UsesClass(ExpectItem::class)]
48#[UsesClass(Line::class)]
49#[UsesClass(DeclarationParser::class)]
50#[UsesClass(Location::class)]
51#[UsesClass(MergeItem::class)]
52#[UsesClass(PrecItem::class)]
53#[UsesClass(Predicate::class)]
54#[UsesClass(Rule::class)]
55#[UsesClass(Scanner::class)]
56#[UsesClass(Symbol::class)]
57#[UsesClass(SymbolItem::class)]
58#[UsesClass(SymbolKind::class)]
59#[UsesClass(SymbolListParser::class)]
60#[UsesClass(SyntaxException::class)]
61#[UsesClass(Token::class)]
62#[UsesClass(TokenKind::class)]
63#[UsesClass(TokenStream::class)]
64#[Small]
65final class RuleParserTest extends TestCase
66{
67    public function testParse(): void
68    {
69        $stream = new TokenStream((new Scanner())->scan("expr[e]: expr '+' expr { \$\$ = \$1 + \$3; }\n  | NUM\n  | %empty ;; next: ;"));
70
71        $rule = (new RuleParser())->parse($stream);
72
73        self::assertSame(['expr', 'e', '1:1'], [$rule->name->value, $rule->namedReference, (string) $rule->location]);
74        self::assertSame([4, 1, 1], array_map(static fn (Alternative $alternative): int => count($alternative->items), $rule->alternatives));
75        self::assertSame(['1:10', '2:5', '3:5'], array_map(static fn (Alternative $alternative): string => (string) $alternative->location, $rule->alternatives));
76        self::assertTrue($stream->is(TokenKind::IdentifierColon));
77    }
78
79    public function testParseAcceptsSemicolonsBetweenAlternatives(): void
80    {
81        $stream = new TokenStream((new Scanner())->scan("a: %empty;|b;;|c;;;\nd: ;"));
82
83        $rule = (new RuleParser())->parse($stream);
84
85        self::assertSame([1, 1, 1], array_map(static fn (Alternative $alternative): int => count($alternative->items), $rule->alternatives));
86        self::assertSame('d', $stream->peek()->text);
87    }
88
89    public function testParseEndsAtTheNextRule(): void
90    {
91        $stream = new TokenStream((new Scanner())->scan("a: b c\nd: e"));
92
93        $rule = (new RuleParser())->parse($stream);
94
95        self::assertCount(2, $rule->alternatives[0]->items);
96        self::assertSame('d', $stream->peek()->text);
97    }
98
99    public function testParseRejectsAMissingColon(): void
100    {
101        $stream = new TokenStream([new Token(TokenKind::IdentifierColon, 'a', new Location(1, 1)), new Token(TokenKind::Pipe, '|', new Location(1, 3)), new Token(TokenKind::End, '', new Location(1, 4))]);
102
103        $this->expectException(SyntaxException::class);
104        $this->expectExceptionMessage("Expected ':' after 'a' but found '|' at 1:3");
105
106        (new RuleParser())->parse($stream);
107    }
108
109    public function testParseRejectsANonRule(): void
110    {
111        $this->expectException(SyntaxException::class);
112        $this->expectExceptionMessage("Expected a rule but found '%token' at 1:1");
113
114        (new RuleParser())->parse(new TokenStream((new Scanner())->scan('%token X')));
115    }
116
117    public function testAlternative(): void
118    {
119        $parser = new RuleParser();
120        $filled = $parser->alternative(new TokenStream((new Scanner())->scan(' a b |')), new Location(1, 1));
121        $empty = $parser->alternative(new TokenStream((new Scanner())->scan('|')), new Location(1, 1));
122
123        self::assertSame(['a', 'b'], array_map(static fn (Symbol $symbol): string => $symbol->value, $filled->symbols()));
124        self::assertSame('1:2', (string) $filled->location);
125        self::assertSame([], $empty->items);
126        self::assertSame('1:1', (string) $empty->location);
127    }
128
129    public function testItem(): void
130    {
131        $parser = new RuleParser();
132        $stream = new TokenStream((new Scanner())->scan('NUM[n] <int>{ x }[a] %?{ p } %empty ;'));
133        $symbol = $parser->item($stream);
134        $action = $parser->item($stream);
135        $predicate = $parser->item($stream);
136        $empty = $parser->item($stream);
137
138        self::assertInstanceOf(SymbolItem::class, $symbol);
139        self::assertSame(['NUM', 'n'], [$symbol->symbol->value, $symbol->namedReference]);
140        self::assertInstanceOf(Action::class, $action);
141        self::assertInstanceOf(Predicate::class, $predicate);
142        self::assertInstanceOf(EmptyItem::class, $empty);
143        self::assertNull($parser->item($stream));
144        self::assertTrue($stream->is(TokenKind::Semicolon));
145    }
146
147    public function testItemKeepsALineDirective(): void
148    {
149        $stream = new TokenStream((new Scanner())->scan("A\n#line 8 \"x.y\"\nB ;"));
150        $parser = new RuleParser();
151        $parser->item($stream);
152
153        $line = $parser->item($stream);
154
155        self::assertInstanceOf(Line::class, $line);
156        self::assertSame([8, 'x.y', '2:1'], [$line->line, $line->file, (string) $line->location()]);
157    }
158
159    public function testAction(): void
160    {
161        $parser = new RuleParser();
162        $plain = $parser->action(new TokenStream((new Scanner())->scan('{ a }')));
163        $tagged = $parser->action(new TokenStream((new Scanner())->scan('<int>{ b }[r]')));
164
165        self::assertSame([null, ' a ', null, '1:1'], [$plain->tag, $plain->code, $plain->namedReference, (string) $plain->location]);
166        self::assertSame(['int', ' b ', 'r', '1:1'], [$tagged->tag, $tagged->code, $tagged->namedReference, (string) $tagged->location]);
167    }
168
169    public function testActionRejectsATagWithoutCode(): void
170    {
171        $this->expectException(SyntaxException::class);
172        $this->expectExceptionMessage("Expected braced code after the tag but found 'x' at 1:7");
173
174        (new RuleParser())->action(new TokenStream((new Scanner())->scan('<int> x')));
175    }
176
177    public function testModifier(): void
178    {
179        $parser = new RuleParser();
180        $stream = new TokenStream((new Scanner())->scan('%empty %prec UMINUS %dprec 2 %merge <m> %expect 1 %expect-rr 3 %token'));
181        $empty = $parser->modifier($stream);
182        $prec = $parser->modifier($stream);
183        $dprec = $parser->modifier($stream);
184        $merge = $parser->modifier($stream);
185        $expect = $parser->modifier($stream);
186        $expectRr = $parser->modifier($stream);
187
188        self::assertInstanceOf(EmptyItem::class, $empty);
189        self::assertInstanceOf(PrecItem::class, $prec);
190        self::assertSame('UMINUS', $prec->symbol->value);
191        self::assertInstanceOf(DprecItem::class, $dprec);
192        self::assertSame(2, $dprec->value);
193        self::assertInstanceOf(MergeItem::class, $merge);
194        self::assertSame('m', $merge->tag);
195        self::assertInstanceOf(ExpectItem::class, $expect);
196        self::assertSame([1, false], [$expect->count, $expect->reduceReduce]);
197        self::assertInstanceOf(ExpectItem::class, $expectRr);
198        self::assertSame([3, true], [$expectRr->count, $expectRr->reduceReduce]);
199        self::assertNull($parser->modifier($stream));
200        self::assertTrue($stream->isDirective('token'));
201    }
202
203    public function testModifierRejectsADprecWithoutANumber(): void
204    {
205        $this->expectException(SyntaxException::class);
206        $this->expectExceptionMessage("Expected a number after %dprec but found ';' at 1:8");
207
208        (new RuleParser())->modifier(new TokenStream((new Scanner())->scan('%dprec ;')));
209    }
210
211    public function testModifierRejectsAMergeWithoutATag(): void
212    {
213        $this->expectException(SyntaxException::class);
214        $this->expectExceptionMessage("Expected a tag after %merge but found 'x' at 1:8");
215
216        (new RuleParser())->modifier(new TokenStream((new Scanner())->scan('%merge x')));
217    }
218
219    public function testSymbolToken(): void
220    {
221        $stream = new TokenStream((new Scanner())->scan("'+' ;"));
222
223        self::assertSame('+', (new RuleParser())->symbolToken($stream)->text);
224        self::assertTrue($stream->is(TokenKind::Semicolon));
225    }
226
227    public function testSymbolTokenRejectsANonSymbol(): void
228    {
229        $this->expectException(SyntaxException::class);
230        $this->expectExceptionMessage("Expected a symbol after %prec but found ';' at 1:1");
231
232        (new RuleParser())->symbolToken(new TokenStream((new Scanner())->scan(';')));
233    }
234
235    public function testCheckEmpty(): void
236    {
237        $parser = new RuleParser();
238        $at = new Location(1, 1);
239        $empty = new EmptyItem($at);
240        $action = new Action(null, ' x ', null, $at);
241        $predicate = new Predicate(' p ', $at);
242        $prec = new PrecItem(new Symbol(SymbolKind::Identifier, 'P', $at), $at);
243
244        $parser->checkEmpty([]);
245        $parser->checkEmpty([$empty]);
246        $parser->checkEmpty([$empty, $action]);
247        $parser->checkEmpty([$empty, $predicate]);
248        $parser->checkEmpty([$action, $empty, $prec]);
249        $parser->checkEmpty([$action, $prec, $action]);
250        $parser->checkEmpty([new SymbolItem(new Symbol(SymbolKind::Identifier, 'b', $at), null), $action]);
251
252        $this->expectException(SyntaxException::class);
253        $this->expectExceptionMessage('%empty on non-empty rule at 2:3');
254        $parser->checkEmpty([$action, new EmptyItem(new Location(2, 3)), $action]);
255    }
256
257    public function testCheckEmptyRejectsASymbol(): void
258    {
259        $this->expectException(SyntaxException::class);
260        $this->expectExceptionMessage('%empty on non-empty rule at 1:4');
261
262        (new RuleParser())->parse(new TokenStream((new Scanner())->scan("a: %empty 'b';")));
263    }
264
265    public function testCheckEmptyRejectsAMidrulePredicate(): void
266    {
267        $this->expectException(SyntaxException::class);
268        $this->expectExceptionMessage('%empty on non-empty rule at 1:4');
269
270        (new RuleParser())->parse(new TokenStream((new Scanner())->scan('a: %empty %?{ p } { x };')));
271    }
272}
273