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