packages/lemon-parser/tests/Unit/Syntax/GrammarReaderTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Syntax;
6
7use LemonParser\Ast\CodeBlock;
8use LemonParser\Ast\Declaration\Associativity;
9use LemonParser\Ast\Declaration\PrecedenceDeclaration;
10use LemonParser\Ast\GrammarFile;
11use LemonParser\Ast\Location;
12use LemonParser\Ast\RhsItem;
13use LemonParser\Ast\Rule;
14use LemonParser\Ast\Symbol;
15use LemonParser\Scanner\CodeReader;
16use LemonParser\Scanner\Cursor;
17use LemonParser\Scanner\Scanner;
18use LemonParser\Scanner\Token;
19use LemonParser\Scanner\TokenKind;
20use LemonParser\Syntax\DeclarationReader;
21use LemonParser\Syntax\GrammarReader;
22use LemonParser\Syntax\RuleReader;
23use LemonParser\Syntax\SymbolListReader;
24use LemonParser\Syntax\SymbolRegistry;
25use LemonParser\Syntax\TokenStream;
26use LemonParser\SyntaxException;
27use PHPUnit\Framework\Attributes\CoversClass;
28use PHPUnit\Framework\Attributes\Small;
29use PHPUnit\Framework\Attributes\UsesClass;
30use PHPUnit\Framework\TestCase;
31
32#[CoversClass(GrammarReader::class)]
33#[UsesClass(CodeReader::class)]
34#[UsesClass(Cursor::class)]
35#[UsesClass(Location::class)]
36#[UsesClass(Scanner::class)]
37#[UsesClass(SymbolRegistry::class)]
38#[UsesClass(SyntaxException::class)]
39#[UsesClass(Token::class)]
40#[UsesClass(TokenKind::class)]
41#[UsesClass(TokenStream::class)]
42#[UsesClass(Associativity::class)]
43#[UsesClass(CodeBlock::class)]
44#[UsesClass(DeclarationReader::class)]
45#[UsesClass(GrammarFile::class)]
46#[UsesClass(PrecedenceDeclaration::class)]
47#[UsesClass(RhsItem::class)]
48#[UsesClass(Rule::class)]
49#[UsesClass(RuleReader::class)]
50#[UsesClass(SymbolListReader::class)]
51#[UsesClass(Symbol::class)]
52#[Small]
53final class GrammarReaderTest extends TestCase
54{
55    public function testRead(): void
56    {
57        $file = (new GrammarReader())->read(new TokenStream((new Scanner())->scan("%left PLUS.\nexpr ::= expr PLUS expr. [PLUS] { add(); }\nexpr ::= NUM. %right STAR. {NEVER-REDUCE} { num(); }\n")));
58
59        self::assertSame([PrecedenceDeclaration::class, Rule::class, Rule::class, PrecedenceDeclaration::class], array_map(static fn (object $item): string => $item::class, $file->items));
60        $rules = $file->rules();
61        self::assertSame(['PLUS', ' add(); ', false], [$rules[0]->precedence?->name, $rules[0]->code?->code, $rules[0]->neverReduce]);
62        self::assertSame([null, ' num(); ', true], [$rules[1]->precedence?->name, $rules[1]->code?->code, $rules[1]->neverReduce]);
63    }
64
65    public function testReadRejectsAStrayToken(): void
66    {
67        $this->expectException(SyntaxException::class);
68        $this->expectExceptionMessage('Token "PLUS" should be either "%" or a nonterminal name. at 1:1');
69
70        (new GrammarReader())->read(new TokenStream((new Scanner())->scan('PLUS ::= A.')));
71    }
72
73    public function testCode(): void
74    {
75        $reader = new GrammarReader();
76        $rule = new Rule(new Symbol('a', new Location(1, 1)), null, [], null, null, false, new Location(1, 1));
77
78        $withCode = $reader->code($rule, new Token(TokenKind::Code, ' x ', new Location(1, 10), '{ x }'));
79        $marked = $reader->code($rule, new Token(TokenKind::Code, 'NEVER-REDUCE', new Location(1, 10), '{NEVER-REDUCE}'));
80
81        self::assertSame([' x ', false], [$withCode->code?->code, $withCode->neverReduce]);
82        self::assertSame([null, true], [$marked->code, $marked->neverReduce]);
83    }
84
85    public function testCodeRejectsAMissingRule(): void
86    {
87        $this->expectException(SyntaxException::class);
88        $this->expectExceptionMessage('There is no prior rule upon which to attach the code fragment which begins on this line. at 1:10');
89
90        (new GrammarReader())->code(null, new Token(TokenKind::Code, ' x ', new Location(1, 10), '{ x }'));
91    }
92
93    public function testCodeRejectsASecondBlock(): void
94    {
95        $rule = new Rule(new Symbol('a', new Location(1, 1)), null, [], null, new CodeBlock(' x ', new Location(1, 5)), false, new Location(1, 1));
96
97        $this->expectException(SyntaxException::class);
98        $this->expectExceptionMessage('Code fragment beginning on this line is not the first to follow the previous rule. at 1:10');
99
100        (new GrammarReader())->code($rule, new Token(TokenKind::Code, ' y ', new Location(1, 10), '{ y }'));
101    }
102
103    public function testPrecedence(): void
104    {
105        $rule = new Rule(new Symbol('a', new Location(1, 1)), null, [], null, null, false, new Location(1, 1));
106        $stream = new TokenStream((new Scanner())->scan('PLUS] rest'));
107        $registry = new SymbolRegistry();
108
109        $marked = (new GrammarReader())->precedence($rule, $stream, $registry);
110
111        self::assertSame('PLUS', $marked->precedence?->name);
112        self::assertTrue($registry->isKnown('PLUS'));
113        self::assertSame('rest', $stream->peek()->text);
114    }
115
116    public function testPrecedenceRejectsANonterminal(): void
117    {
118        $this->expectException(SyntaxException::class);
119        $this->expectExceptionMessage('The precedence symbol must be a terminal. at 1:1');
120
121        (new GrammarReader())->precedence(null, new TokenStream((new Scanner())->scan('plus]')), new SymbolRegistry());
122    }
123
124    public function testPrecedenceRejectsAMissingRule(): void
125    {
126        $this->expectException(SyntaxException::class);
127        $this->expectExceptionMessage('There is no prior rule to assign precedence "[PLUS]". at 1:1');
128
129        (new GrammarReader())->precedence(null, new TokenStream((new Scanner())->scan('PLUS]')), new SymbolRegistry());
130    }
131
132    public function testPrecedenceRejectsASecondMark(): void
133    {
134        $rule = new Rule(new Symbol('a', new Location(1, 1)), null, [], new Symbol('STAR', new Location(1, 5)), null, false, new Location(1, 1));
135
136        $this->expectException(SyntaxException::class);
137        $this->expectExceptionMessage('Precedence mark on this line is not the first to follow the previous rule. at 1:1');
138
139        (new GrammarReader())->precedence($rule, new TokenStream((new Scanner())->scan('PLUS]')), new SymbolRegistry());
140    }
141
142    public function testPrecedenceRejectsAMissingCloser(): void
143    {
144        $rule = new Rule(new Symbol('a', new Location(1, 1)), null, [], null, null, false, new Location(1, 1));
145
146        $this->expectException(SyntaxException::class);
147        $this->expectExceptionMessage('Missing "]" on precedence mark. at 1:6');
148
149        (new GrammarReader())->precedence($rule, new TokenStream((new Scanner())->scan('PLUS .')), new SymbolRegistry());
150    }
151}
152