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