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