packages/deriver/tests/Unit/Deriver/Lowerer/Declaration/CallSiteIndexTest.php

1<?php
2
3declare (strict_types=1);
4
5namespace Tests\Unit\Deriver\Lowerer\Declaration;
6
7use Deriver\Deriver\Evaluator\TargetProfile;
8use Deriver\Deriver\Lowerer\Declaration\CallableIdentity;
9use Deriver\Deriver\Lowerer\Declaration\CallableSource;
10use Deriver\Deriver\Lowerer\Declaration\CallSiteIndex;
11use Deriver\Deriver\Lowerer\Declaration\Index;
12use Deriver\Deriver\Lowerer\Declaration\Indexer;
13use Deriver\Deriver\Lowerer\Declaration\Traits\Composition;
14use Deriver\Deriver\Lowerer\EffectInspection;
15use Deriver\Deriver\Parser\Node\File;
16use Deriver\Deriver\Parser\Parser;
17use Deriver\Deriver\Parser\ParserConfig;
18use Deriver\Deriver\Parser\TargetSyntax;
19use Deriver\Deriver\Resolver\AssignmentPatterns;
20use Deriver\Deriver\Resolver\ClassScope;
21use Deriver\Deriver\Resolver\ExpressionValidation;
22use Deriver\Deriver\Resolver\MagicContext;
23use Deriver\Deriver\Resolver\Resolver;
24use Deriver\Deriver\Sources\LineMap;
25use Deriver\Deriver\Sources\Path;
26use Deriver\Deriver\Sources\Source;
27use Deriver\Deriver\Sources\Sources;
28use Deriver\Exception\ParserException;
29use Deriver\Exception\ResolutionException;
30use PhpParser\Node\Expr\FuncCall;
31use PhpParser\Node\Name;
32use PHPUnit\Framework\Attributes\CoversClass;
33use PHPUnit\Framework\Attributes\DataProvider;
34use PHPUnit\Framework\Attributes\Small;
35use PHPUnit\Framework\Attributes\UsesClass;
36use PHPUnit\Framework\TestCase;
37
38/**
39 * @covers \Deriver\Deriver\Lowerer\Declaration\CallSiteIndex
40 */
41#[UsesClass(Resolver::class)]
42#[UsesClass(ExpressionValidation::class)]
43#[UsesClass(ResolutionException::class)]
44#[UsesClass(ParserConfig::class)]
45#[UsesClass(ParserException::class)]
46#[CoversClass(CallSiteIndex::class)]
47#[UsesClass(CallableIdentity::class)]
48#[UsesClass(Sources::class)]
49#[UsesClass(Path::class)]
50#[UsesClass(Source::class)]
51#[UsesClass(TargetProfile::class)]
52#[UsesClass(Parser::class)]
53#[UsesClass(File::class)]
54#[UsesClass(EffectInspection::class)]
55#[UsesClass(CallableSource::class)]
56#[UsesClass(Indexer::class)]
57#[UsesClass(Index::class)]
58#[UsesClass(Composition::class)]
59#[UsesClass(LineMap::class)]
60#[UsesClass(MagicContext::class)]
61#[UsesClass(AssignmentPatterns::class)]
62#[UsesClass(ClassScope::class)]
63#[UsesClass(TargetSyntax::class)]
64#[Small]
65final class CallSiteIndexTest extends TestCase
66{
67    public function testOwnersIndexesNestedClosuresAsSeparateOwners(): void
68    {
69        $source = '<?php function target(){return fn()=>sink(1);}';
70        $snapshotId = 'test';
71        $input = new Sources([new Source('fixture.php', $source)]);
72        $profile = new TargetProfile();
73        $parser = new Parser(new ParserConfig($profile->version));
74        $trees = array_map(static fn (Source $source1): File => (new Resolver())->resolve($parser->parse(new Source(Path::normalize($source1->path), $source1->contents, $source1->declarationsOnly))), iterator_to_array($input, false));
75        $index = new Index($snapshotId, $trees);
76        $owners = (new CallSiteIndex($index))->owners('sink');
77        self::assertCount(1, $owners);
78        self::assertStringStartsWith('closure:fixture.php:', $owners[0]);
79        self::assertSame(0, $index->graphCount());
80    }
81    public function testContainsDoesNotAttributeANestedFunctionCallToItsParent(): void
82    {
83        $source1 = '<?php function target(){function nested(){sink(1);}}';
84        $snapshotId = 'test';
85        $input = new Sources([new Source('fixture.php', $source1)]);
86        $profile = new TargetProfile();
87        $parser = new Parser(new ParserConfig($profile->version));
88        $trees = array_map(static fn (Source $source2): File => (new Resolver())->resolve($parser->parse(new Source(Path::normalize($source2->path), $source2->contents, $source2->declarationsOnly))), iterator_to_array($input, false));
89        $index = new Index($snapshotId, $trees);
90        $source = $index->declarations['target'];
91        $pending = [];
92        self::assertFalse((new CallSiteIndex($index))->contains($source->node, 'sink', $source, $pending, true));
93    }
94    #[DataProvider('providerNamespacedCalls')]
95    public function testOwnersFindsResolvedNamespaceAndImportedNamesWithoutCompilingBodies(string $source, string $selector): void
96    {
97        $snapshotId = 'test';
98        $input = new Sources([new Source('fixture.php', $source)]);
99        $profile = new TargetProfile();
100        $parser = new Parser(new ParserConfig($profile->version));
101        $trees = array_map(static fn (Source $source1): File => (new Resolver())->resolve($parser->parse(new Source(Path::normalize($source1->path), $source1->contents, $source1->declarationsOnly))), iterator_to_array($input, false));
102        $index = new Index($snapshotId, $trees);
103        self::assertSame(['N\target'], (new CallSiteIndex($index))->owners($selector));
104        self::assertSame(0, $index->graphCount());
105    }
106    /**
107     * @return iterable<string,array{string,string}>
108     */
109    public static function providerNamespacedCalls(): iterable
110    {
111        yield 'implicit namespace' => ['<?php namespace N;function target(){sink(1);}', 'N\sink'];
112        yield 'case-insensitive qualified selector' => ['<?php namespace N;function target(){sink(1);}', '\n\SINK'];
113        yield 'qualified syntax' => ['<?php namespace N;function target(){\N\sink(1);}', 'N\sink'];
114        yield 'function import' => ['<?php namespace N;use function M\sink as alias;function target(){alias(1);}', 'M\sink'];
115        yield 'namespace-relative syntax' => ['<?php namespace N;function target(){namespace\sink(1);}', 'N\sink'];
116        yield 'ordinary method name' => ['<?php namespace N;function target($x){$x->sink(1);}', 'SINK'];
117    }
118    public function testFallbackOnlyMatchesUnqualifiedFunctionNames(): void
119    {
120        $source = '<?php function target(){return 1;}';
121        $snapshotId = 'test';
122        $input = new Sources([new Source('fixture.php', $source)]);
123        $profile = new TargetProfile();
124        $parser = new Parser(new ParserConfig($profile->version));
125        $trees = array_map(static fn (Source $source1): File => (new Resolver())->resolve($parser->parse(new Source(Path::normalize($source1->path), $source1->contents, $source1->declarationsOnly))), iterator_to_array($input, false));
126        $index = new Index($snapshotId, $trees);
127        $calls = new CallSiteIndex($index);
128        self::assertTrue($calls->fallback(new FuncCall(new Name('sink')), 'sink'));
129        self::assertFalse($calls->fallback(new FuncCall(new Name('Local\sink')), 'sink'));
130    }
131    #[DataProvider('providerCreations')]
132    public function testCreationMatchesConstructorSelectors(string $class, string $selector, bool $expected): void
133    {
134        $source = '<?php function target(){return 1;}';
135        $snapshotId = 'test';
136        $input = new Sources([new Source('fixture.php', $source)]);
137        $profile = new TargetProfile();
138        $parser = new Parser(new ParserConfig($profile->version));
139        $trees = array_map(static fn (Source $source1): File => (new Resolver())->resolve($parser->parse(new Source(Path::normalize($source1->path), $source1->contents, $source1->declarationsOnly))), iterator_to_array($input, false));
140        self::assertSame($expected, (new CallSiteIndex(new Index($snapshotId, $trees)))->creation(new Name($class), $selector));
141    }
142    /**
143     * @return iterable<string,array{string,string,bool}>
144     */
145    public static function providerCreations(): iterable
146    {
147        yield 'wildcard' => ['N\Repo', '*', true];
148        yield 'constructor' => ['N\Repo', '\n\REPO::__Construct', true];
149        yield 'class name' => ['N\Repo', 'N\Repo', false];
150        yield 'other class' => ['N\Repo', 'N\Other::__construct', false];
151        yield 'lexical class' => ['self', 'N\Repo::__construct', true];
152        yield 'lexical class by function name' => ['self', 'sink', false];
153    }
154    public function testOwnersFindsCreationsWithoutCompilingBodies(): void
155    {
156        $source = '<?php namespace N;class Repo {} function target(){return new Repo;} function other($c){return new $c;}';
157        $snapshotId = 'test';
158        $input = new Sources([new Source('fixture.php', $source)]);
159        $profile = new TargetProfile();
160        $parser = new Parser(new ParserConfig($profile->version));
161        $trees = array_map(static fn (Source $source1): File => (new Resolver())->resolve($parser->parse(new Source(Path::normalize($source1->path), $source1->contents, $source1->declarationsOnly))), iterator_to_array($input, false));
162        $index = new Index($snapshotId, $trees);
163        self::assertSame(['N\target'], (new CallSiteIndex($index))->owners('N\Repo::__construct'));
164        self::assertSame(['N\target'], (new CallSiteIndex($index))->owners('*'));
165        self::assertSame(0, $index->graphCount());
166    }
167}
168