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