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