packages/lemon-parser/tests/Unit/Syntax/RuleReaderTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Syntax;
6
7use LemonParser\Ast\Location;
8use LemonParser\Ast\RhsItem;
9use LemonParser\Ast\Rule;
10use LemonParser\Ast\Symbol;
11use LemonParser\Scanner\CodeReader;
12use LemonParser\Scanner\Cursor;
13use LemonParser\Scanner\Scanner;
14use LemonParser\Scanner\Token;
15use LemonParser\Scanner\TokenKind;
16use LemonParser\Syntax\RuleReader;
17use LemonParser\Syntax\SymbolRegistry;
18use LemonParser\Syntax\TokenStream;
19use LemonParser\SyntaxException;
20use PHPUnit\Framework\Attributes\CoversClass;
21use PHPUnit\Framework\Attributes\Small;
22use PHPUnit\Framework\Attributes\UsesClass;
23use PHPUnit\Framework\TestCase;
24
25#[CoversClass(RuleReader::class)]
26#[UsesClass(CodeReader::class)]
27#[UsesClass(Cursor::class)]
28#[UsesClass(Location::class)]
29#[UsesClass(Scanner::class)]
30#[UsesClass(SymbolRegistry::class)]
31#[UsesClass(SyntaxException::class)]
32#[UsesClass(Token::class)]
33#[UsesClass(TokenKind::class)]
34#[UsesClass(TokenStream::class)]
35#[UsesClass(RhsItem::class)]
36#[UsesClass(Rule::class)]
37#[UsesClass(Symbol::class)]
38#[Small]
39final class RuleReaderTest extends TestCase
40{
41 public function testRead(): void
42 {
43 $stream = new TokenStream((new Scanner())->scan('expr(A) ::= expr(B) PLUS|MINUS expr(C) . next'));
44 $registry = new SymbolRegistry();
45
46 $rule = (new RuleReader())->read($stream->next(), $stream, $registry);
47
48 self::assertSame(['expr', 'A', '1:1'], [$rule->lhs->name, $rule->lhsAlias, (string) $rule->location]);
49 self::assertSame([['expr', 'B'], ['PLUS', 'MINUS'], ['expr', 'C']], array_map(static fn (RhsItem $item): array => [...array_map(static fn (Symbol $symbol): string => $symbol->name, $item->symbols), ...($item->alias === null ? [] : [$item->alias])], $rule->items));
50 self::assertSame(['1:13', '1:21', '1:32'], array_map(static fn (RhsItem $item): string => (string) $item->location(), $rule->items));
51 self::assertNull($rule->code);
52 self::assertTrue($registry->isKnown('MINUS'));
53 self::assertSame('next', $stream->peek()->text);
54 }
55
56 public function testReadAcceptsAnEmptyRightHandSide(): void
57 {
58 $stream = new TokenStream((new Scanner())->scan('empty ::= .'));
59
60 $rule = (new RuleReader())->read($stream->next(), $stream, new SymbolRegistry());
61
62 self::assertSame([], $rule->items);
63 self::assertTrue($stream->eof());
64 }
65
66 public function testReadRejectsAnIllegalCharacter(): void
67 {
68 $stream = new TokenStream((new Scanner())->scan('expr ::= expr ? expr.'));
69
70 $this->expectException(SyntaxException::class);
71 $this->expectExceptionMessage('Illegal character on RHS of rule: "?". at 1:15');
72
73 (new RuleReader())->read($stream->next(), $stream, new SymbolRegistry());
74 }
75
76 public function testReadRejectsACompoundAtTheStart(): void
77 {
78 $stream = new TokenStream((new Scanner())->scan('expr ::= |PLUS expr.'));
79
80 $this->expectException(SyntaxException::class);
81 $this->expectExceptionMessage('Illegal character on RHS of rule: "|PLUS". at 1:10');
82
83 (new RuleReader())->read($stream->next(), $stream, new SymbolRegistry());
84 }
85
86 public function testReadRejectsALowerCaseCompound(): void
87 {
88 $stream = new TokenStream((new Scanner())->scan('expr ::= PLUS|minus expr.'));
89
90 $this->expectException(SyntaxException::class);
91 $this->expectExceptionMessage('Illegal character on RHS of rule: "|minus". at 1:14');
92
93 (new RuleReader())->read($stream->next(), $stream, new SymbolRegistry());
94 }
95
96 public function testReadRejectsTheEndOfTheFile(): void
97 {
98 $stream = new TokenStream((new Scanner())->scan('expr ::= expr PLUS'));
99
100 $this->expectException(SyntaxException::class);
101 $this->expectExceptionMessage('Rule "expr" is not terminated by "." before the end of the file. at 1:1');
102
103 (new RuleReader())->read($stream->next(), $stream, new SymbolRegistry());
104 }
105
106 public function testHead(): void
107 {
108 $reader = new RuleReader();
109 $plain = new TokenStream((new Scanner())->scan('expr ::= A.'));
110 $aliased = new TokenStream((new Scanner())->scan('expr ( A ) ::= B.'));
111
112 self::assertNull($reader->head($plain->next(), $plain));
113 self::assertSame('A', $reader->head($aliased->next(), $aliased));
114 self::assertSame('B', $aliased->peek()->text);
115 }
116
117 public function testHeadRejectsAMissingArrow(): void
118 {
119 $stream = new TokenStream((new Scanner())->scan('expr : A.'));
120
121 $this->expectException(SyntaxException::class);
122 $this->expectExceptionMessage('Expected to see a ":" following the LHS symbol "expr". at 1:6');
123
124 (new RuleReader())->head($stream->next(), $stream);
125 }
126
127 public function testHeadRejectsABadAlias(): void
128 {
129 $stream = new TokenStream((new Scanner())->scan('expr(1) ::= A.'));
130
131 $this->expectException(SyntaxException::class);
132 $this->expectExceptionMessage('"1" is not a valid alias for the LHS "expr" at 1:6');
133
134 (new RuleReader())->head($stream->next(), $stream);
135 }
136
137 public function testHeadRejectsAnUnclosedAlias(): void
138 {
139 $stream = new TokenStream((new Scanner())->scan('expr(A ::= A.'));
140
141 $this->expectException(SyntaxException::class);
142 $this->expectExceptionMessage('Missing ")" following LHS alias name "A". at 1:8');
143
144 (new RuleReader())->head($stream->next(), $stream);
145 }
146
147 public function testHeadRejectsAMissingArrowAfterTheAlias(): void
148 {
149 $stream = new TokenStream((new Scanner())->scan('expr(A) A.'));
150
151 $this->expectException(SyntaxException::class);
152 $this->expectExceptionMessage('Missing "->" following: "expr(A)". at 1:9');
153
154 (new RuleReader())->head($stream->next(), $stream);
155 }
156
157 public function testCompound(): void
158 {
159 $item = new RhsItem([new Symbol('PLUS', new Location(1, 10))], 'X');
160
161 $extended = (new RuleReader())->compound($item, new Token(TokenKind::Compound, 'MINUS', new Location(1, 14), '|MINUS'));
162
163 self::assertSame(['PLUS', 'MINUS'], array_map(static fn (Symbol $symbol): string => $symbol->name, $extended->symbols));
164 self::assertSame('X', $extended->alias);
165 }
166
167 public function testCompoundRejectsANonterminal(): void
168 {
169 $item = new RhsItem([new Symbol('expr', new Location(1, 10))], null);
170
171 $this->expectException(SyntaxException::class);
172 $this->expectExceptionMessage('Cannot form a compound containing a non-terminal at 1:14');
173
174 (new RuleReader())->compound($item, new Token(TokenKind::Compound, 'MINUS', new Location(1, 14), '|MINUS'));
175 }
176
177 public function testAlias(): void
178 {
179 $stream = new TokenStream((new Scanner())->scan('X) rest'));
180 $lhs = new Token(TokenKind::Word, 'expr', new Location(1, 1), 'expr');
181
182 $named = (new RuleReader())->alias(new RhsItem([new Symbol('NUM', new Location(1, 10))], null), $stream, $lhs, null);
183
184 self::assertSame('X', $named->alias);
185 self::assertSame('rest', $stream->peek()->text);
186 }
187
188 public function testAliasRejectsABadName(): void
189 {
190 $stream = new TokenStream((new Scanner())->scan('1)'));
191 $lhs = new Token(TokenKind::Word, 'expr', new Location(1, 1), 'expr');
192
193 $this->expectException(SyntaxException::class);
194 $this->expectExceptionMessage('"1" is not a valid alias for the RHS symbol "NUM" at 1:1');
195
196 (new RuleReader())->alias(new RhsItem([new Symbol('NUM', new Location(1, 10))], null), $stream, $lhs, null);
197 }
198
199 public function testAliasRejectsAMissingCloser(): void
200 {
201 $stream = new TokenStream((new Scanner())->scan('X .'));
202 $lhs = new Token(TokenKind::Word, 'expr', new Location(1, 1), 'expr');
203
204 $this->expectException(SyntaxException::class);
205 $this->expectExceptionMessage('Missing ")" following LHS alias name "A". at 1:3');
206
207 (new RuleReader())->alias(new RhsItem([new Symbol('NUM', new Location(1, 10))], null), $stream, $lhs, 'A');
208 }
209}
210