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