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