packages/bison-parser/tests/Unit/Syntax/SymbolListParserTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Syntax;
6
7use BisonParser\Ast\Declaration\Symbols\Alias;
8use BisonParser\Ast\Declaration\Symbols\SymbolEntry;
9use BisonParser\Ast\Location;
10use BisonParser\Ast\Symbol;
11use BisonParser\Ast\SymbolKind;
12use BisonParser\Ast\Tag;
13use BisonParser\Scanner\CodeReader;
14use BisonParser\Scanner\Cursor;
15use BisonParser\Scanner\Directives;
16use BisonParser\Scanner\Escapes;
17use BisonParser\Scanner\Scanner;
18use BisonParser\Scanner\Token;
19use BisonParser\Scanner\TokenKind;
20use BisonParser\Syntax\SymbolListParser;
21use BisonParser\Syntax\TokenStream;
22use BisonParser\SyntaxException;
23use PHPUnit\Framework\Attributes\CoversClass;
24use PHPUnit\Framework\Attributes\Small;
25use PHPUnit\Framework\Attributes\UsesClass;
26use PHPUnit\Framework\TestCase;
27
28#[CoversClass(SymbolListParser::class)]
29#[UsesClass(Alias::class)]
30#[UsesClass(CodeReader::class)]
31#[UsesClass(Cursor::class)]
32#[UsesClass(Directives::class)]
33#[UsesClass(Escapes::class)]
34#[UsesClass(Location::class)]
35#[UsesClass(Scanner::class)]
36#[UsesClass(Symbol::class)]
37#[UsesClass(SymbolEntry::class)]
38#[UsesClass(SymbolKind::class)]
39#[UsesClass(SyntaxException::class)]
40#[UsesClass(Tag::class)]
41#[UsesClass(Token::class)]
42#[UsesClass(TokenKind::class)]
43#[UsesClass(TokenStream::class)]
44#[Small]
45final class SymbolListParserTest extends TestCase
46{
47 public function testTokenDeclarations(): void
48 {
49 $entries = (new SymbolListParser())->tokenDeclarations(new TokenStream((new Scanner())->scan('<int> NUM 258 "number" STR _("text") <str> ID \'+\' ;')));
50
51 self::assertSame(
52 [['NUM', 'int', 258, 'number', false], ['STR', 'int', null, 'text', true], ['ID', 'str', null, null, null], ['+', 'str', null, null, null]],
53 array_map(static fn (SymbolEntry $entry): array => [$entry->symbol->value, $entry->tag, $entry->number, $entry->alias?->text, $entry->alias?->translatable], $entries),
54 );
55 }
56
57 public function testPrecedenceDeclarations(): void
58 {
59 $entries = (new SymbolListParser())->precedenceDeclarations(new TokenStream((new Scanner())->scan('<int> PLUS 258 "+" \'-\' %%')));
60
61 self::assertSame(
62 [['PLUS', 'int', 258, SymbolKind::Identifier], ['+', 'int', null, SymbolKind::String], ['-', 'int', null, SymbolKind::CharLiteral]],
63 array_map(static fn (SymbolEntry $entry): array => [$entry->symbol->value, $entry->tag, $entry->number, $entry->symbol->kind], $entries),
64 );
65 }
66
67 public function testTypeDeclarations(): void
68 {
69 $stream = new TokenStream((new Scanner())->scan('<int> expr term 258'));
70
71 $entries = (new SymbolListParser())->typeDeclarations($stream);
72
73 self::assertSame([['expr', 'int', null], ['term', 'int', null]], array_map(static fn (SymbolEntry $entry): array => [$entry->symbol->value, $entry->tag, $entry->number], $entries));
74 self::assertTrue($stream->is(TokenKind::Integer));
75 }
76
77 public function testEntries(): void
78 {
79 $entries = (new SymbolListParser())->entries(new TokenStream((new Scanner())->scan('A B <t> C %%')), false, false, [SymbolKind::Identifier]);
80
81 self::assertSame([['A', null], ['B', null], ['C', 't']], array_map(static fn (SymbolEntry $entry): array => [$entry->symbol->value, $entry->tag], $entries));
82 }
83
84 public function testEntriesRejectsAnEmptyList(): void
85 {
86 $this->expectException(SyntaxException::class);
87 $this->expectExceptionMessage("Expected a symbol but found '%%' at 1:1");
88
89 (new SymbolListParser())->entries(new TokenStream((new Scanner())->scan('%%')), true, true, [SymbolKind::Identifier]);
90 }
91
92 public function testEntriesRejectsATagWithoutASymbol(): void
93 {
94 $this->expectException(SyntaxException::class);
95 $this->expectExceptionMessage("Expected a symbol after the tag but found '%%' at 1:7");
96
97 (new SymbolListParser())->entries(new TokenStream((new Scanner())->scan('<int> %%')), true, true, [SymbolKind::Identifier]);
98 }
99
100 public function testEntry(): void
101 {
102 $parser = new SymbolListParser();
103 $numbered = $parser->entry(new TokenStream((new Scanner())->scan('NUM 258 "n"')), 'int', true, false);
104 $string = $parser->entry(new TokenStream((new Scanner())->scan('"n" 258')), null, true, true);
105
106 self::assertSame(['NUM', 'int', 258, null], [$numbered->symbol->value, $numbered->tag, $numbered->number, $numbered->alias]);
107 self::assertSame('_("text")', $parser->entry(new TokenStream((new Scanner())->scan('STR _("text")')), null, true, true)->alias?->spelling);
108 self::assertSame(['n', null, 258, null], [$string->symbol->value, $string->tag, $string->number, $string->alias]);
109 }
110
111 public function testSymbols(): void
112 {
113 $symbols = (new SymbolListParser())->symbols(new TokenStream((new Scanner())->scan('program \'x\' "y" %%')));
114
115 self::assertSame(['program', 'x', 'y'], array_map(static fn (Symbol $symbol): string => $symbol->value, $symbols));
116 }
117
118 public function testSymbolsRejectsAnEmptyList(): void
119 {
120 $this->expectException(SyntaxException::class);
121 $this->expectExceptionMessage('Expected a symbol but found end of file at 1:1');
122
123 (new SymbolListParser())->symbols(new TokenStream((new Scanner())->scan('')));
124 }
125
126 public function testTargets(): void
127 {
128 $targets = (new SymbolListParser())->targets(new TokenStream((new Scanner())->scan('NUM <int> <*> <> \'+\' %%')));
129
130 self::assertSame(
131 [[Symbol::class, 'NUM'], [Tag::class, 'int'], [Tag::class, '*'], [Tag::class, ''], [Symbol::class, '+']],
132 array_map(static fn (Symbol|Tag $target): array => [$target::class, $target instanceof Tag ? $target->name : $target->value], $targets),
133 );
134 }
135
136 public function testTargetsRejectsAnEmptyList(): void
137 {
138 $this->expectException(SyntaxException::class);
139 $this->expectExceptionMessage("Expected a symbol or tag but found ';' at 1:1");
140
141 (new SymbolListParser())->targets(new TokenStream((new Scanner())->scan(';')));
142 }
143
144 public function testPassesLine(): void
145 {
146 $parser = new SymbolListParser();
147 $between = new TokenStream((new Scanner())->scan("A\n#line 2\nB"));
148 $before = new TokenStream((new Scanner())->scan("#line 2\n%%"));
149 $between->next();
150
151 self::assertTrue($parser->passesLine($between));
152 self::assertSame('B', $between->peek()->text);
153 self::assertFalse($parser->passesLine($before));
154 self::assertTrue($before->is(TokenKind::Line));
155 }
156
157 public function testEntriesPassOverALineBetweenEntries(): void
158 {
159 $stream = new TokenStream((new Scanner())->scan("A\n#line 2\n<t> B\n#line 3\n%%"));
160
161 $entries = (new SymbolListParser())->entries($stream, false, false, [SymbolKind::Identifier]);
162
163 self::assertSame([['A', null], ['B', 't']], array_map(static fn (SymbolEntry $entry): array => [$entry->symbol->value, $entry->tag], $entries));
164 self::assertTrue($stream->is(TokenKind::Line));
165 }
166
167 public function testStartsSymbol(): void
168 {
169 $parser = new SymbolListParser();
170
171 self::assertTrue($parser->startsSymbol(new Token(TokenKind::Identifier, 'x', new Location(1, 1))));
172 self::assertTrue($parser->startsSymbol(new Token(TokenKind::CharLiteral, 'x', new Location(1, 1))));
173 self::assertTrue($parser->startsSymbol(new Token(TokenKind::String, 'x', new Location(1, 1))));
174 self::assertFalse($parser->startsSymbol(new Token(TokenKind::IdentifierColon, 'x', new Location(1, 1))));
175 self::assertFalse($parser->startsSymbol(new Token(TokenKind::Tag, 'x', new Location(1, 1))));
176 }
177
178 public function testSymbol(): void
179 {
180 $parser = new SymbolListParser();
181 $lhs = $parser->symbol(new Token(TokenKind::IdentifierColon, 'expr', new Location(3, 1)));
182
183 self::assertSame([SymbolKind::Identifier, 'expr', '3:1'], [$lhs->kind, $lhs->value, (string) $lhs->location]);
184 $literal = $parser->symbol(new Token(TokenKind::CharLiteral, '+', new Location(1, 1), "'+'"));
185 $string = $parser->symbol(new Token(TokenKind::String, 'x', new Location(1, 1), '"\\x78"'));
186
187 self::assertSame([SymbolKind::CharLiteral, "'+'"], [$literal->kind, $literal->spelling]);
188 self::assertSame([SymbolKind::String, '"\\x78"'], [$string->kind, $string->spelling]);
189 self::assertNull($lhs->spelling);
190 }
191
192 public function testSymbolRejectsAnotherToken(): void
193 {
194 $this->expectException(SyntaxException::class);
195 $this->expectExceptionMessage('Expected a symbol but found braced code at 1:1');
196
197 (new SymbolListParser())->symbol(new Token(TokenKind::Code, 'x', new Location(1, 1)));
198 }
199
200 public function testNtermDeclarations(): void
201 {
202 $stream = new TokenStream((new Scanner())->scan('<int> expr <str> name term %%'));
203
204 $entries = (new SymbolListParser())->ntermDeclarations($stream);
205
206 self::assertSame([['expr', 'int'], ['name', 'str'], ['term', 'str']], array_map(static fn (SymbolEntry $entry): array => [$entry->symbol->value, $entry->tag], $entries));
207 self::assertTrue($stream->is(TokenKind::Section));
208 }
209
210 public function testNtermDeclarationsLeaveANumberUnread(): void
211 {
212 $stream = new TokenStream((new Scanner())->scan('expr 258'));
213
214 self::assertCount(1, (new SymbolListParser())->ntermDeclarations($stream));
215 self::assertTrue($stream->is(TokenKind::Integer));
216 }
217
218 public function testNtermDeclarationsRejectAStringAlias(): void
219 {
220 $this->expectException(SyntaxException::class);
221 $this->expectExceptionMessage('Expected an identifier but found string at 1:6');
222
223 (new SymbolListParser())->ntermDeclarations(new TokenStream((new Scanner())->scan('expr "e"')));
224 }
225
226 public function testNtermDeclarationsRejectACharacterLiteral(): void
227 {
228 $this->expectException(SyntaxException::class);
229 $this->expectExceptionMessage('Expected an identifier but found character literal at 1:1');
230
231 (new SymbolListParser())->ntermDeclarations(new TokenStream((new Scanner())->scan("'c'")));
232 }
233
234 public function testTokenDeclarationsRejectAStringStartingAnEntry(): void
235 {
236 $this->expectException(SyntaxException::class);
237 $this->expectExceptionMessage('Expected an identifier or a character literal but found string at 1:7');
238
239 (new SymbolListParser())->tokenDeclarations(new TokenStream((new Scanner())->scan('X "a" "b"')));
240 }
241
242 public function testEntriesRejectASymbolOfAnotherKind(): void
243 {
244 $this->expectException(SyntaxException::class);
245 $this->expectExceptionMessage('Expected an identifier or a character literal but found string at 1:3');
246
247 (new SymbolListParser())->entries(new TokenStream((new Scanner())->scan('a "b"')), false, false, [SymbolKind::Identifier, SymbolKind::CharLiteral]);
248 }
249
250 public function testKind(): void
251 {
252 $parser = new SymbolListParser();
253
254 self::assertSame(SymbolKind::Identifier, $parser->kind(new Token(TokenKind::Identifier, 'x', new Location(1, 1))));
255 self::assertSame(SymbolKind::Identifier, $parser->kind(new Token(TokenKind::IdentifierColon, 'x', new Location(1, 1))));
256 self::assertSame(SymbolKind::CharLiteral, $parser->kind(new Token(TokenKind::CharLiteral, 'x', new Location(1, 1))));
257 self::assertSame(SymbolKind::String, $parser->kind(new Token(TokenKind::String, 'x', new Location(1, 1))));
258 self::assertNull($parser->kind(new Token(TokenKind::Tag, 'x', new Location(1, 1))));
259 }
260
261 public function testDescribe(): void
262 {
263 $parser = new SymbolListParser();
264
265 self::assertSame('an identifier', $parser->describe([SymbolKind::Identifier]));
266 self::assertSame('an identifier or a character literal', $parser->describe([SymbolKind::Identifier, SymbolKind::CharLiteral]));
267 self::assertSame('an identifier, a character literal or a string', $parser->describe([SymbolKind::Identifier, SymbolKind::CharLiteral, SymbolKind::String]));
268 }
269}
270