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

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Core\Php;
6
7use PhpParser\Node\Scalar\String_;
8use PHPUnit\Framework\Attributes\CoversClass;
9use PHPUnit\Framework\Attributes\UsesClass;
10use PHPUnit\Framework\TestCase;
11use SqlCatalog\Core\Php\ClassShape;
12use SqlCatalog\Core\Php\FunctionShape;
13use SqlCatalog\Core\Php\MethodShape;
14use SqlCatalog\Core\Php\ParsedFile;
15use SqlCatalog\Core\Php\ProgramIndex;
16use SqlCatalog\Core\Php\ProgramIndexBuilder;
17use SqlCatalog\Core\Php\SourceParser;
18use SqlCatalog\Core\Php\TypeReader;
19use SqlCatalog\Core\Type\TypeShape;
20
21#[CoversClass(ProgramIndex::class)]
22#[UsesClass(ClassShape::class)]
23#[UsesClass(FunctionShape::class)]
24#[UsesClass(MethodShape::class)]
25#[UsesClass(ParsedFile::class)]
26#[UsesClass(ProgramIndexBuilder::class)]
27#[UsesClass(SourceParser::class)]
28#[UsesClass(TypeReader::class)]
29#[UsesClass(TypeShape::class)]
30#[UsesClass(\SqlCatalog\Core\Php\ParameterShape::class)]
31final class ProgramIndexTest extends TestCase
32{
33    public function testFindClassIgnoresCaseAndLeadingBackslashes(): void
34    {
35        $index = new ProgramIndex(['app\\user' => new ClassShape('App\\User', null, [], [], false, [], [], [], [])]);
36        self::assertSame('App\\User', $index->findClass('\\APP\\User')?->name);
37        self::assertNull($index->findClass(null));
38        self::assertNull($index->findClass('App\\Missing'));
39    }
40
41    public function testFindFunctionIgnoresCase(): void
42    {
43        $index = new ProgramIndex([], ['app\\find' => new FunctionShape('App\\find', [], TypeShape::unknown())]);
44        self::assertSame('App\\find', $index->findFunction('App\\Find')?->name);
45        self::assertNull($index->findFunction('App\\missing'));
46    }
47
48    public function testFindConstantReadsTheDeclaredExpression(): void
49    {
50        $value = new String_('users');
51        $index = new ProgramIndex([], [], ['App\\TABLE' => $value]);
52        self::assertSame($value, $index->findConstant('\\App\\TABLE'));
53        self::assertNull($index->findConstant('App\\MISSING'));
54    }
55
56    public function testFindMethodFollowsTheParentClass(): void
57    {
58        $index = (new ProgramIndexBuilder())->build([(new SourceParser())->parse(
59            'a.php',
60            '<?php abstract class Base { public function run(): string { return "x"; } } class Child extends Base {}',
61        )]);
62        self::assertSame('Base::run', $index->findMethod('Child', 'run')?->qualifiedName());
63        self::assertNull($index->findMethod('Child', 'missing'));
64    }
65
66    public function testFindClassConstantFollowsTheParentClass(): void
67    {
68        $index = (new ProgramIndexBuilder())->build([(new SourceParser())->parse(
69            'a.php',
70            '<?php class Base { public const TABLE = "users"; } class Child extends Base {}',
71        )]);
72        self::assertInstanceOf(String_::class, $index->findClassConstant('Child', 'TABLE'));
73        self::assertNull($index->findClassConstant('Child', 'MISSING'));
74    }
75
76    public function testFindPropertyTypeFollowsTheParentClass(): void
77    {
78        $index = (new ProgramIndexBuilder())->build([(new SourceParser())->parse(
79            'a.php',
80            '<?php class Base { protected string $order = "id"; } class Child extends Base {}',
81        )]);
82        self::assertSame('string', $index->findPropertyType('Child', 'order')?->display());
83        self::assertNull($index->findPropertyType('Child', 'missing'));
84    }
85
86    public function testIsInstanceOfFollowsParentsAndInterfaces(): void
87    {
88        $index = (new ProgramIndexBuilder())->build([(new SourceParser())->parse(
89            'a.php',
90            '<?php interface Runs {} class Base implements Runs {} class Child extends Base {}',
91        )]);
92        self::assertTrue($index->isInstanceOf('Child', 'Base'));
93        self::assertTrue($index->isInstanceOf('Child', '\\Runs'));
94        self::assertTrue($index->isInstanceOf('Child', 'Child'));
95        self::assertFalse($index->isInstanceOf('Child', 'Other'));
96        self::assertFalse($index->isInstanceOf(null, 'Base'));
97    }
98
99    public function testLineageVisitsEachDeclarationOnce(): void
100    {
101        $index = (new ProgramIndexBuilder())->build([(new SourceParser())->parse(
102            'a.php',
103            '<?php interface Runs {} class Base implements Runs {} class Child extends Base implements Runs {}',
104        )]);
105        self::assertSame(['Child', 'Base', 'Runs'], array_map(
106            static fn (ClassShape $shape): string => $shape->name,
107            $index->lineage('Child'),
108        ));
109        self::assertSame([], $index->lineage('Missing'));
110    }
111
112    public function testImplementationsOfCollectsTheConcreteBodies(): void
113    {
114        $index = (new ProgramIndexBuilder())->build([(new SourceParser())->parse(
115            'a.php',
116            '<?php abstract class Base { abstract public function table(): string; }'
117            . ' class Users extends Base { public function table(): string { return "users"; } }'
118            . ' class Orders extends Base { public function table(): string { return "orders"; } }',
119        )]);
120        self::assertCount(2, $index->implementationsOf('Base', 'table'));
121        self::assertSame([], $index->implementationsOf(null, 'table'));
122        self::assertSame([], $index->implementationsOf('Base', 'table', 1));
123    }
124
125    public function testMergePrefersTheDeclarationsOfThisIndex(): void
126    {
127        $left = new ProgramIndex(['a' => new ClassShape('A', null, [], [], false, [], [], [], [])]);
128        $right = new ProgramIndex([
129            'a' => new ClassShape('Other', null, [], [], false, [], [], [], []),
130            'b' => new ClassShape('B', null, [], [], false, [], [], [], []),
131        ]);
132        $merged = $left->merge($right);
133        self::assertSame('A', $merged->findClass('A')?->name);
134        self::assertSame('B', $merged->findClass('B')?->name);
135    }
136}
137