packages/deriver/tests/Integration/CallPathTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Integration;
6
7use Deriver\Deriver\BranchEvaluators;
8use Deriver\Deriver\Candidate\Snapshot;
9use Deriver\Deriver\EvaluationOperation;
10use Deriver\Deriver\Evaluator\Call\Evaluators;
11use Deriver\Deriver\Evaluator\EvaluatorConfig;
12use Deriver\Deriver\Evaluator\Index\DefinitionIndex;
13use Deriver\Deriver\Evaluator\TargetProfile;
14use Deriver\Deriver\Evaluator\Term;
15use Deriver\Deriver\Lowerer\Declaration\Index;
16use Deriver\Deriver\MicroEvaluator;
17use Deriver\Deriver\MicroEvaluatorConversion;
18use Deriver\Deriver\Parser\Parser;
19use Deriver\Deriver\Parser\ParserConfig;
20use Deriver\Deriver\Publication\CandidateQuery;
21use Deriver\Deriver\Query;
22use Deriver\Deriver\Resolver\Resolver;
23use Deriver\Deriver\Selection\SelectorResolution;
24use Deriver\Deriver\Sources\Source;
25use PHPUnit\Framework\Attributes\CoversNothing;
26use PHPUnit\Framework\Attributes\DataProvider;
27use PHPUnit\Framework\TestCase;
28
29#[CoversNothing]
30final class CallPathTest extends TestCase
31{
32    /**
33     * @return iterable<string, array{string, string, list<scalar|null>, bool}>
34     */
35    public static function providerPaths(): iterable
36    {
37        $source = 'function foo($a,$b){return $a.$b;}function bar(){foo("a","b");foo("c","d");}function other(){foo("x","y");}';
38        yield 'direct correlated calls' => [$source, 'bar -> foo#return', ['ab','cd'], false];
39        yield 'formal parameter' => [$source, 'bar -> foo#parameter(a)', ['a','c'], false];
40        yield 'direct through ellipsis' => [$source, 'bar -> ... -> foo#return', ['ab','cd'], false];
41        yield 'case and leading namespace' => [$source, '\\BAR -> \\FOO#return', ['ab','cd'], false];
42        $source = 'function foo($x){return helper($x);}function helper($x){return $x+1;}function bar($x){foo($x);}function entry(){bar(11);}function other(){bar(22);}';
43        yield 'ancestor inputs' => [$source, 'entry -> bar -> foo#return', [12], false];
44        yield 'ellipsis inputs' => [$source, 'entry -> ... -> foo#return', [12], false];
45        yield 'mixed edges' => [$source, 'entry -> ... -> bar -> foo#return', [12], false];
46        yield 'open outer prefix' => [$source, 'bar -> foo#return', [12,23], false];
47        yield 'not a direct parent' => [$source, 'entry -> foo#return', [], false];
48        yield 'no reverse path' => [$source, 'bar -> entry#return', [], false];
49        $source = 'function foo($x){return $x?11:27;}function bar(){foo(true);}function other(){foo(false);}';
50        yield 'default keeps conditional alternatives' => [$source, 'bar -> foo#return', [11,27], false];
51        yield 'configured pruning uses selected input' => [$source, 'bar -> foo#return', [11], true];
52        yield 'other caller has its own input' => [$source, 'other -> foo#return', [27], true];
53        $source = 'function foo(){return 7;}function bar(){if(false){foo();}}';
54        yield 'default keeps call site branch' => [$source, 'bar -> foo#return', [7], false];
55        yield 'configured pruning excludes call site' => [$source, 'bar -> foo#return', [], true];
56        yield 'constant still requires matching path' => ['function foo(){return 7;}function bar(){return 2;}', 'bar -> foo#return', [], false];
57        yield 'unused arguments and statements' => ['function foo($unused,$x){return $x;}function bar(){expensive();foo(expensive(),42);return expensive();}function other(){foo(1,expensive());}', 'bar -> foo#return', [42], false];
58        yield 'named and default arguments' => ['function foo($x=4,$y=2){return $x+$y;}function bar(){foo(y:9);}', 'bar -> foo#return', [13], false];
59        yield 'variadic arguments' => ['function foo(...$xs){return $xs[1];}function bar(){foo(1,2,3);}', 'bar -> foo#return', [2], false];
60        yield 'instance method receiver' => ['class A{public $value;function foo(){return $this->value;}function __construct($x){$this->value=$x;}}function bar(){(new A(7))->foo();}', 'bar -> A::foo#return', [7], false];
61        yield 'static methods' => ['class A{static function foo($x){return $x;}static function bar(){self::foo(5);}}', 'A::bar -> A::foo#return', [5], false];
62        yield 'dispatch excludes different class' => ['class A{function foo(){return 1;}}class B{function foo(){return 2;}}function bar(){(new B())->foo();}', 'bar -> A::foo#return', [], false];
63        yield 'dynamic function in direct caller' => ['function foo($x){return $x;}function bar(){$f="foo";$f(7);}', 'bar -> foo#return', [7], false];
64        yield 'dynamic function under ancestor' => ['function foo($x){return $x;}function bar($x){$f="foo";$f($x);}function entry(){bar(8);}', 'entry -> ... -> foo#return', [8], false];
65        yield 'unreachable recursive component' => ['function foo($x){return $x;}function recur($x){recur($x+1);foo($x);}function entry(){return 7;}', 'entry -> ... -> foo#return', [], false];
66        $prefix = 'function bar(){if(false){';
67        yield 'excluded call argument stays unopened' => [$prefix . 'unknown(expensive());}}', 'bar -> fixture.php:1:' . (strlen('<?php ' . $prefix) + 1) . '#argument(0)', [], true];
68        $prefix = 'function foo($x){return $x;}function bar($x){';
69        $source = $prefix . 'foo($x);}function entry(){bar(6);}function other(){bar(9);}';
70        $site = 'fixture.php:1:' . (strlen('<?php ' . $prefix) + 1) . '#argument(0)';
71        yield 'call site direct parent' => [$source, 'entry -> bar -> ' . $site, [6], false];
72        yield 'call site ancestor' => [$source, 'entry -> ... -> ' . $site, [6], false];
73        yield 'call site mismatch' => [$source, 'entry -> ' . $site, [], false];
74    }
75
76    /**
77     * @param list<scalar|null> $expected
78     */
79    #[DataProvider('providerPaths')]
80    public function testDeriveUsesOnlyMatchingInvocationContexts(string $source, string $query, array $expected, bool $pruning): void
81    {
82        $opened = 0;
83        $evaluators = [...($pruning ? BranchEvaluators::truth() : []), new MicroEvaluator('expensive', '1', EvaluationOperation::Function, 'expensive', static function () use (&$opened): Term {
84            ++$opened;
85            return Term::constant(99);
86        })];
87        $tree = (new Resolver())->resolve((new Parser(new ParserConfig('8.3')))->parse(new Source('fixture.php', '<?php ' . $source)));
88        $program = new Index('fixture', [$tree]);
89        $target = new TargetProfile();
90        $settings = new EvaluatorConfig($target, evaluators: array_map((new MicroEvaluatorConversion())->convert(...), $evaluators));
91        $runner = new CandidateQuery(new DefinitionIndex($program), $settings, new Evaluators($target), new Snapshot('fixture', [], [], $target, 'environment'));
92        $result = $runner->derive((new SelectorResolution($program))->resolve(Query::parse($query)));
93        self::assertEqualsCanonicalizing($expected, array_map(static fn ($candidate) => $candidate->result, iterator_to_array($result)));
94        self::assertSame(0, $opened);
95        self::assertFalse($result->interrupted);
96    }
97    public function testRepeatedQueriesKeepPathIdentityAndCallEvidenceSeparate(): void
98    {
99        $source = 'function foo($x){return $x;}function bar($x){foo($x);}function entry(){bar(7);}function other(){bar(9);}';
100        $tree = (new Resolver())->resolve((new Parser(new ParserConfig('8.3')))->parse(new Source('fixture.php', '<?php ' . $source)));
101        $program = new Index('fixture', [$tree]);
102        $target = new TargetProfile();
103        $settings = new EvaluatorConfig($target);
104        $runner = new CandidateQuery(new DefinitionIndex($program), $settings, new Evaluators($target), new Snapshot('fixture', [], [], $target, 'environment'));
105        $selection = new SelectorResolution($program);
106        $first = $runner->derive($selection->resolve(Query::parse('entry -> bar -> foo#return')));
107        $second = $runner->derive($selection->resolve(Query::parse('other -> bar -> foo#return')));
108        $repeat = $runner->derive($selection->resolve(Query::parse('entry -> bar -> foo#return')));
109        self::assertSame(7, $first[0]->result);
110        self::assertSame(9, $second[0]->result);
111        self::assertEquals($first->reference, $repeat->reference);
112        self::assertNotEquals($first->reference, $second->reference);
113        self::assertTrue($first[0]->evidence[0]->matchesCaller('entry'));
114        self::assertFalse($first[0]->evidence[0]->matchesCaller('other'));
115    }
116
117    public function testReplacesTheTargetWithItsSelectedLazyInputs(): void
118    {
119        $source = 'function foo($x,$unused){return expensive();}function bar(){foo(7,expensive());}function other(){foo(9,expensive());}';
120        $tree = (new Resolver())->resolve((new Parser(new ParserConfig('8.3')))->parse(new Source('fixture.php', '<?php ' . $source)));
121        $program = new Index('fixture', [$tree]);
122        $target = new TargetProfile();
123        $opened = 0;
124        $evaluators = [new MicroEvaluator('foo', '1', EvaluationOperation::Function, 'foo', static fn (\Deriver\Deriver\Evaluation $evaluation): Term => $evaluation->input(0)), new MicroEvaluator('expensive', '1', EvaluationOperation::Function, 'expensive', static function () use (&$opened): Term {
125            ++$opened;
126            return Term::constant(99);
127        })];
128        $settings = new EvaluatorConfig($target, evaluators: array_map((new MicroEvaluatorConversion())->convert(...), $evaluators));
129        $runner = new CandidateQuery(new DefinitionIndex($program), $settings, new Evaluators($target), new Snapshot('fixture', [], [], $target, 'environment'));
130        $result = $runner->derive((new SelectorResolution($program))->resolve(Query::parse('bar -> foo#return')));
131        self::assertSame(7, $result[0]->result);
132        self::assertSame(0, $opened);
133        self::assertSame(2, $program->graphCount());
134    }
135
136    /**
137     * @return iterable<string, array{string, string}>
138     */
139    public static function providerUnresolvedDispatch(): iterable
140    {
141        yield 'function name' => ['function foo(){return 7;}function bar($f){$f();}', 'bar -> foo#return'];
142        yield 'receiver class' => ['class A{function foo(){return 7;}}function bar($object){$object->foo();}', 'bar -> A::foo#return'];
143        yield 'static class' => ['class A{static function foo(){return 7;}}function bar($class){$class::foo();}', 'bar -> A::foo#return'];
144        yield 'method name' => ['class A{function foo(){return 7;}}function bar($name){(new A())->$name();}', 'bar -> A::foo#return'];
145        yield 'static method name' => ['class A{static function foo(){return 7;}}function bar($name){A::$name();}', 'bar -> A::foo#return'];
146    }
147
148    #[DataProvider('providerUnresolvedDispatch')]
149    public function testUnresolvedDispatchRetainsThePathBoundary(string $source, string $query): void
150    {
151        $tree = (new Resolver())->resolve((new Parser(new ParserConfig('8.3')))->parse(new Source('fixture.php', '<?php ' . $source)));
152        $program = new Index('fixture', [$tree]);
153        $target = new TargetProfile();
154        $settings = new EvaluatorConfig($target);
155        $runner = new CandidateQuery(new DefinitionIndex($program), $settings, new Evaluators($target), new Snapshot('fixture', [], [], $target, 'environment'));
156        $result = $runner->derive((new SelectorResolution($program))->resolve(Query::parse($query)));
157        self::assertCount(1, $result);
158        self::assertInstanceOf(Term::class, $result[0]->result);
159        self::assertSame('UNRESOLVED_DISPATCH', $result[0]->result->attributes['reason']);
160    }
161
162    public function testDepthBudgetRetainsAnUnfinishedPathInsteadOfUnrestrictedCandidates(): void
163    {
164        $source = 'function foo(){return 7;}function bar(){foo();}function entry(){bar();}';
165        $tree = (new Resolver())->resolve((new Parser(new ParserConfig('8.3')))->parse(new Source('fixture.php', '<?php ' . $source)));
166        $program = new Index('fixture', [$tree]);
167        $target = new TargetProfile();
168        $settings = new EvaluatorConfig($target);
169        $runner = new CandidateQuery(new DefinitionIndex($program), $settings, new Evaluators($target), new Snapshot('fixture', [], [], $target, 'environment'));
170        $query = new \Deriver\Deriver\Evaluator\Request\ReturnQuery('foo', \Deriver\Deriver\Evaluator\Request\QueryScope::through(new \Deriver\Deriver\Evaluator\Request\CallPath(['entry','bar'])), new \Deriver\Deriver\Evaluator\Request\Budget(maxDepth: 1));
171        $result = $runner->derive($query);
172        self::assertCount(1, $result);
173        self::assertInstanceOf(Term::class, $result[0]->result);
174        self::assertSame('DEPTH_LIMIT', $result[0]->result->attributes['reason']);
175    }
176
177}
178