packages/sql-catalog/tests/Unit/Core/Php/ProgramIndexBuilderTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Core\Php;
6
7use PhpParser\Node\Expr\Assign;
8use PhpParser\Node\Expr\Variable;
9use PhpParser\Node\Scalar\String_;
10use PhpParser\Node\Stmt\Class_;
11use PhpParser\Node\Stmt\ClassMethod;
12use PhpParser\Node\Stmt\Enum_;
13use PhpParser\Node\Stmt\Interface_;
14use PhpParser\NodeFinder;
15use PHPUnit\Framework\Attributes\CoversClass;
16use PHPUnit\Framework\Attributes\UsesClass;
17use PHPUnit\Framework\TestCase;
18use SqlCatalog\Core\Php\ClassShape;
19use SqlCatalog\Core\Php\FunctionShape;
20use SqlCatalog\Core\Php\MethodShape;
21use SqlCatalog\Core\Php\ParameterShape;
22use SqlCatalog\Core\Php\ParsedFile;
23use SqlCatalog\Core\Php\ProgramIndex;
24use SqlCatalog\Core\Php\ProgramIndexBuilder;
25use SqlCatalog\Core\Php\SourceParser;
26use SqlCatalog\Core\Php\TypeReader;
27use SqlCatalog\Core\Type\TypeShape;
28
29#[CoversClass(ProgramIndexBuilder::class)]
30#[UsesClass(ClassShape::class)]
31#[UsesClass(FunctionShape::class)]
32#[UsesClass(MethodShape::class)]
33#[UsesClass(ParameterShape::class)]
34#[UsesClass(ParsedFile::class)]
35#[UsesClass(ProgramIndex::class)]
36#[UsesClass(SourceParser::class)]
37#[UsesClass(TypeReader::class)]
38#[UsesClass(TypeShape::class)]
39final class ProgramIndexBuilderTest extends TestCase
40{
41    public function testBuildIndexesClassesFunctionsAndConstants(): void
42    {
43        $file = (new SourceParser())->parse(
44            'a.php',
45            '<?php namespace App; const TABLE = "users"; function find(): void {} class User {}',
46        );
47        $index = (new ProgramIndexBuilder())->build([$file]);
48        self::assertSame('App\\User', $index->findClass('App\\User')?->name);
49        self::assertSame('App\\find', $index->findFunction('App\\find')?->name);
50        self::assertInstanceOf(String_::class, $index->findConstant('App\\TABLE'));
51    }
52
53    public function testReadClassCollectsConstantsPropertiesAndMethods(): void
54    {
55        $file = (new SourceParser())->parse(
56            'a.php',
57            '<?php class User { public const TABLE = "users"; private string $order = "id";'
58            . ' public function __construct(private \\PDO $pdo) {} public function find(): void {} }',
59        );
60        $node = (new NodeFinder())->findFirstInstanceOf($file->statements, Class_::class);
61        self::assertInstanceOf(Class_::class, $node);
62        $shape = (new ProgramIndexBuilder())->readClass($node, 'a.php');
63        self::assertNotNull($shape);
64        self::assertArrayHasKey('TABLE', $shape->constants);
65        self::assertSame('string', $shape->propertyTypes['order']->display());
66        self::assertSame('PDO', $shape->propertyTypes['pdo']->display());
67        self::assertArrayHasKey('find', $shape->methods);
68        self::assertSame('a.php', $shape->methods['find']->file);
69    }
70
71    public function testReadClassRecordsEnumCases(): void
72    {
73        $file = (new SourceParser())->parse('a.php', '<?php enum Status: string { case Active = "active"; }');
74        $node = (new NodeFinder())->findFirstInstanceOf($file->statements, Enum_::class);
75        self::assertInstanceOf(Enum_::class, $node);
76        $shape = (new ProgramIndexBuilder())->readClass($node);
77        self::assertNotNull($shape);
78        self::assertTrue($shape->enum);
79        self::assertArrayHasKey('Active', $shape->enumCases);
80    }
81
82    public function testReadClassIsNullForAnAnonymousClass(): void
83    {
84        $file = (new SourceParser())->parse('a.php', '<?php $a = new class {};');
85        $node = (new NodeFinder())->findFirstInstanceOf($file->statements, Class_::class);
86        self::assertInstanceOf(Class_::class, $node);
87        self::assertNull((new ProgramIndexBuilder())->readClass($node));
88    }
89
90    public function testReadMembersReadsTheWholeClassBodyInOnePass(): void
91    {
92        $file = (new SourceParser())->parse(
93            'a.php',
94            '<?php trait T {} class B { use T; public const C = 1; private string $p = "x";'
95            . ' public function set(): void { $this->p = "y"; } }',
96        );
97        $node = (new NodeFinder())->findFirstInstanceOf($file->statements, Class_::class);
98        self::assertInstanceOf(Class_::class, $node);
99
100        $members = (new ProgramIndexBuilder())->readMembers($node);
101
102        self::assertSame(['T'], $members['traits']);
103        self::assertSame(['C'], array_keys($members['constants']));
104        self::assertSame([], $members['cases']);
105        self::assertSame(['p'], array_keys($members['properties']));
106        self::assertSame(['p'], array_keys($members['defaults']));
107        self::assertSame(['p' => true], $members['assigned']);
108    }
109
110    public function testReadParentNamesCollectsImplementedInterfaces(): void
111    {
112        $file = (new SourceParser())->parse('a.php', '<?php interface A {} class B implements A {}');
113        $node = (new NodeFinder())->findFirstInstanceOf($file->statements, Class_::class);
114        self::assertInstanceOf(Class_::class, $node);
115        self::assertSame(['A'], (new ProgramIndexBuilder())->readParentNames($node));
116    }
117
118    public function testReadParentNamesCollectsExtendedInterfaces(): void
119    {
120        $file = (new SourceParser())->parse('a.php', '<?php interface A {} interface B extends A {}');
121        $nodes = (new NodeFinder())->findInstanceOf($file->statements, Interface_::class);
122        self::assertSame(['A'], (new ProgramIndexBuilder())->readParentNames($nodes[1]));
123    }
124
125    public function testReadTraitNamesCollectsUsedTraits(): void
126    {
127        $file = (new SourceParser())->parse('a.php', '<?php trait T {} class B { use T; }');
128        $node = (new NodeFinder())->findFirstInstanceOf($file->statements, Class_::class);
129        self::assertInstanceOf(Class_::class, $node);
130        self::assertSame(['T'], (new ProgramIndexBuilder())->readTraitNames($node));
131    }
132
133    public function testReadConstantsCollectsEveryDeclaration(): void
134    {
135        $file = (new SourceParser())->parse('a.php', '<?php class B { public const A = 1, C = 2; }');
136        $node = (new NodeFinder())->findFirstInstanceOf($file->statements, Class_::class);
137        self::assertInstanceOf(Class_::class, $node);
138        self::assertSame(['A', 'C'], array_keys((new ProgramIndexBuilder())->readConstants($node)));
139    }
140
141    public function testReadEnumCasesKeepsACaseWithoutABackingValue(): void
142    {
143        $file = (new SourceParser())->parse('a.php', '<?php enum Status { case Active; }');
144        $node = (new NodeFinder())->findFirstInstanceOf($file->statements, Enum_::class);
145        self::assertInstanceOf(Enum_::class, $node);
146        self::assertSame(['Active' => null], (new ProgramIndexBuilder())->readEnumCases($node));
147    }
148
149    public function testReadPromotedPropertiesOnlyLooksAtTheConstructor(): void
150    {
151        $file = (new SourceParser())->parse(
152            'a.php',
153            '<?php class B { public function __construct(private \\PDO $pdo) {} public function other(int $x): void {} }',
154        );
155        $methods = (new NodeFinder())->findInstanceOf($file->statements, ClassMethod::class);
156        $builder = new ProgramIndexBuilder();
157        self::assertSame(['pdo'], array_keys($builder->readPromotedProperties($methods[0])));
158        self::assertSame([], $builder->readPromotedProperties($methods[1]));
159    }
160
161    public function testReadMethodRecordsTheSignature(): void
162    {
163        $file = (new SourceParser())->parse('a.php', '<?php class B { public static function find(int $id): string { return ""; } }');
164        $method = (new NodeFinder())->findFirstInstanceOf($file->statements, ClassMethod::class);
165        self::assertInstanceOf(ClassMethod::class, $method);
166        $shape = (new ProgramIndexBuilder())->readMethod('B', $method, 'a.php');
167        self::assertTrue($shape->static);
168        self::assertSame('string', $shape->returnType->display());
169        self::assertSame('id', $shape->parameters[0]->name);
170    }
171
172    public function testReadParametersSkipsParametersWithoutAPlainName(): void
173    {
174        $file = (new SourceParser())->parse('a.php', '<?php function f(int $a, string ...$rest): void {}');
175        $function = (new NodeFinder())->findFirstInstanceOf($file->statements, \PhpParser\Node\Stmt\Function_::class);
176        self::assertInstanceOf(\PhpParser\Node\Stmt\Function_::class, $function);
177        $parameters = (new ProgramIndexBuilder())->readParameters($function->params);
178        self::assertCount(2, $parameters);
179        self::assertTrue($parameters[1]->variadic);
180    }
181
182    public function testReadAssignedPropertiesFindsWritesInTheClassBody(): void
183    {
184        $file = (new SourceParser())->parse(
185            'a.php',
186            '<?php class B { private string $order = "id"; public function set(): void { $this->order = "name"; } }',
187        );
188        $node = (new NodeFinder())->findFirstInstanceOf($file->statements, Class_::class);
189        self::assertInstanceOf(Class_::class, $node);
190        self::assertSame(['order' => true], (new ProgramIndexBuilder())->readAssignedProperties($node));
191    }
192
193    public function testAssignmentTargetIsNullWhenNothingIsWrittenToAProperty(): void
194    {
195        $assign = new Assign(new Variable('a'), new String_('x'));
196        self::assertNull((new ProgramIndexBuilder())->assignmentTarget($assign));
197    }
198}
199