packages/deriver/tests/Unit/Deriver/Parser/ParserTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Deriver\Parser;
6
7use Deriver\Deriver\Parser\Node\File;
8use Deriver\Deriver\Parser\Parser;
9use Deriver\Deriver\Parser\ParserConfig;
10use Deriver\Deriver\Parser\TargetSyntax;
11use Deriver\Deriver\Sources\Source;
12use Deriver\Exception\ParserException;
13use PhpParser\Node\Stmt;
14use PHPUnit\Framework\Attributes\CoversClass;
15use PHPUnit\Framework\Attributes\DataProvider;
16use PHPUnit\Framework\Attributes\Small;
17use PHPUnit\Framework\Attributes\UsesClass;
18use PHPUnit\Framework\TestCase;
19
20#[CoversClass(Parser::class)]
21#[UsesClass(ParserConfig::class)]
22#[UsesClass(Source::class)]
23#[UsesClass(File::class)]
24#[UsesClass(ParserException::class)]
25#[UsesClass(TargetSyntax::class)]
26#[Small]
27final class ParserTest extends TestCase
28{
29 public function testParsePreservesTheSourceWithoutReadingOrExecutingIt(): void
30 {
31 $source = new Source('missing.php', '<?php throw new RuntimeException("must not execute");');
32 $node = (new Parser(new ParserConfig('8.3')))->parse($source);
33 self::assertSame($source, $node->source);
34 self::assertCount(1, $node->stmts);
35 }
36
37 public function testParseThrowsWithTheSourcePathAndLineOnMalformedSyntax(): void
38 {
39 $source = new Source('broken.php', "<?php\nfunction {");
40 $this->expectException(ParserException::class);
41 $this->expectExceptionMessage('broken.php:2:');
42 (new Parser(new ParserConfig('8.3')))->parse($source);
43 }
44
45 public function testParseRejectsSyntaxNewerThanTheConfiguredVersion(): void
46 {
47 $source = new Source('new.php', '<?php function target(){return new X()->f();}');
48 $this->expectException(ParserException::class);
49 $this->expectExceptionMessage('PHP 8.4');
50 (new Parser(new ParserConfig('8.3')))->parse($source);
51 }
52
53 public function testParseUsesTheSuppliedPhpVersion(): void
54 {
55 $source = new Source('new.php', '<?php function target(){return new X()->f();}');
56 $node = (new Parser(new ParserConfig('8.4')))->parse($source);
57 self::assertInstanceOf(Stmt\Function_::class, $node->stmts[0]);
58 }
59
60 public function testParseAcceptsSyntaxDeeperThanTheFormerLimit(): void
61 {
62 $source = new Source('deep.php', '<?php return ' . str_repeat('1+', 150) . '1;');
63 $node = (new Parser(new ParserConfig('8.3')))->parse($source);
64 self::assertInstanceOf(Stmt\Return_::class, $node->stmts[0]);
65 self::assertCount(150, (new \PhpParser\NodeFinder())->findInstanceOf([$node], \PhpParser\Node\Expr\BinaryOp\Plus::class));
66 }
67
68 public function testParseAcceptsSourceLargerThanTheFormerFileLimit(): void
69 {
70 $source = new Source('large.php', '<?php /*' . str_repeat('x', 4194304) . '*/ return 1;');
71 $node = (new Parser(new ParserConfig('8.3')))->parse($source);
72 self::assertInstanceOf(Stmt\Return_::class, $node->stmts[0]);
73 self::assertSame($source, $node->source);
74 }
75
76 public function testParseIsDeterministicForTheSameSourceAndConfiguration(): void
77 {
78 $parser = new Parser(new ParserConfig('8.3'));
79 $source = new Source('a.php', '<?php namespace A; function f($x) { return $x + 1; }');
80 $first = $parser->parse($source);
81 $second = $parser->parse($source);
82 self::assertSame(serialize($first), serialize($second));
83 self::assertSame($source, $first->source);
84 }
85
86 public function testParseKeepsConsecutiveSourcesIndependent(): void
87 {
88 $parser = new Parser(new ParserConfig('8.3'));
89 $first = $parser->parse(new Source('first.php', '<?php namespace First; use Vendor\\One as Alias; function f(): Alias { return new Alias(); }'));
90 $before = serialize($first);
91 $source = new Source('second.php', '<?php namespace Second; use Vendor\\Two as Alias; function f(): Alias { return new Alias(); }');
92 $second = $parser->parse($source);
93 $fresh = (new Parser(new ParserConfig('8.3')))->parse($source);
94
95 self::assertSame($before, serialize($first));
96 self::assertSame(serialize($fresh), serialize($second));
97 self::assertSame($source, $second->source);
98 }
99
100 public function testParsePreservesUnresolvedNamesAndMagicConstants(): void
101 {
102 $source = new Source('a.php', '<?php namespace App; use Vendor\Thing as Alias; function answer() { return [new Alias(), __FUNCTION__]; }');
103 $tree = (new Parser(new ParserConfig('8.3')))->parse($source);
104 $finder = new \PhpParser\NodeFinder();
105 $creation = $finder->findFirstInstanceOf([$tree], \PhpParser\Node\Expr\New_::class);
106 $magic = $finder->findFirstInstanceOf([$tree], \PhpParser\Node\Scalar\MagicConst\Function_::class);
107 self::assertInstanceOf(\PhpParser\Node\Expr\New_::class, $creation);
108 self::assertInstanceOf(\PhpParser\Node\Name::class, $creation->class);
109 self::assertFalse($creation->class->isFullyQualified());
110 self::assertSame('Alias', $creation->class->toString());
111 self::assertInstanceOf(\PhpParser\Node\Scalar\MagicConst\Function_::class, $magic);
112 self::assertFalse($magic->hasAttribute('deriver-lexical'));
113 }
114
115 #[DataProvider('providerUnresolvedSources')]
116 public function testParseLeavesSemanticValidationToResolution(string $contents): void
117 {
118 $tree = (new Parser(new ParserConfig('8.3')))->parse(new Source('a.php', $contents));
119 self::assertNotEmpty($tree->stmts);
120 }
121
122 /**
123 * @return iterable<string, array{string}>
124 */
125 public static function providerUnresolvedSources(): iterable
126 {
127 yield 'duplicate alias' => ['<?php use Vendor\One as Alias; use Vendor\Two as Alias;'];
128 yield 'class scope' => ['<?php function f() { return self::class; }'];
129 yield 'literal unpack' => ['<?php return [...7];'];
130 yield 'reference pattern' => ['<?php [&$x] = [1];'];
131 }
132
133 #[DataProvider('providerInvalidSources')]
134 public function testParseCanBeReusedAfterAnError(string $invalid): void
135 {
136 $parser = new Parser(new ParserConfig('8.3'));
137 try {
138 $parser->parse(new Source('broken.php', $invalid));
139 self::fail('Invalid source must throw.');
140 } catch (ParserException $error) {
141 self::assertSame('broken.php', $error->path);
142 }
143 $source = new Source('valid.php', '<?php namespace Valid; function f() { return 42; }');
144 $actual = $parser->parse($source);
145 $fresh = (new Parser(new ParserConfig('8.3')))->parse($source);
146 self::assertSame(serialize($fresh), serialize($actual));
147 self::assertSame($source, $actual->source);
148 }
149
150 /**
151 * @return iterable<string, array{string}>
152 */
153 public static function providerInvalidSources(): iterable
154 {
155 yield 'syntax' => ['<?php namespace Broken; function {'];
156 yield 'language version' => ['<?php namespace Broken; function f() { return new X()->f(); }'];
157 }
158}
159