packages/deriver/tests/Integration/BranchEvaluatorTest.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\Request\ReturnQuery;
14use Deriver\Deriver\Evaluator\TargetProfile;
15use Deriver\Deriver\Evaluator\Term;
16use Deriver\Deriver\Lowerer\Declaration\Index;
17use Deriver\Deriver\MicroEvaluator;
18use Deriver\Deriver\MicroEvaluatorConversion;
19use Deriver\Deriver\Parser\Parser;
20use Deriver\Deriver\Parser\ParserConfig;
21use Deriver\Deriver\Publication\CandidateQuery;
22use Deriver\Deriver\Resolver\Resolver;
23use Deriver\Deriver\Sources\Source;
24use PHPUnit\Framework\Attributes\CoversNothing;
25use PHPUnit\Framework\Attributes\DataProvider;
26use PHPUnit\Framework\TestCase;
27
28#[CoversNothing]
29final class BranchEvaluatorTest extends TestCase
30{
31 /**
32 * @return iterable<string, array{string, list<int>}>
33 */
34 public static function providerSelections(): iterable
35 {
36 yield 'standard evaluator scalar' => ['function subject(){return strlen(strtoupper("abc"));}', [3]];
37 yield 'standard evaluator filter' => ['function subject(){return count(array_filter([0,1,2]));}', [2]];
38 yield 'standard evaluator reduce' => ['function subject(){return array_reduce([1,2],fn($carry,$v)=>$carry+$v,0);}', [3]];
39 yield 'standard evaluator map' => ['function subject(){return array_map(fn($v)=>$v*2,[1,2])[1];}', [4]];
40 yield 'if false' => ['function subject(){if(false)return expensive();return 2;}', [2]];
41 yield 'if true' => ['function subject(){if(true)return 1;return expensive();}', [1]];
42 yield 'elseif' => ['function subject(){if(false)return expensive();elseif(true)return 2;return expensive();}', [2]];
43 yield 'ternary' => ['function subject(){return false?expensive():2;}', [2]];
44 yield 'nested ternary' => ['function subject(){return (true?0:expensive())?expensive():2;}', [2]];
45 yield 'while false' => ['function subject(){while(false){return expensive();}return 2;}', [2]];
46 yield 'while write' => ['function subject(){$v=2;while(false){$v=expensive();}return $v;}', [2]];
47 yield 'while break' => ['function subject(){$v=0;while(true){$v=2;break;}return $v;}', [2]];
48 yield 'changing loop input' => ['function subject(){$v=true;while($v){$v=false;}return 2;}', [2]];
49 yield 'for false' => ['function subject(){for(start();false;advance()){return expensive();}return 2;}', [2]];
50 yield 'for write' => ['function subject(){for($v=2;false;advance()){$v=expensive();}return $v;}', [2]];
51 yield 'foreach empty' => ['function subject(){foreach([] as $v){return expensive();}return 2;}', [2]];
52 yield 'foreach values' => ['function subject(){foreach([1,2] as $v){return $v;}return 3;}', [1,2,3]];
53 yield 'foreach empty write' => ['function subject(){$v=2;foreach([] as $x){$v=expensive();}return $v;}', [2]];
54 yield 'do first write' => ['function subject(){$v=0;do{$v=2;}while(false);return $v;}', [2]];
55 yield 'do first return' => ['function subject(){do{return 2;}while(false);return expensive();}', [2]];
56 yield 'switch' => ['function subject(){switch(2){case 1:return expensive();case 2:return 2;default:return expensive();}}', [2]];
57 yield 'match' => ['function subject(){return match(2){1=>expensive(),2=>2,default=>expensive()};}', [2]];
58 yield 'falsey zero' => ['function subject(){if(0)return expensive();return 2;}', [2]];
59 yield 'falsey string' => ['function subject(){if("0")return expensive();return 2;}', [2]];
60 yield 'falsey empty string' => ['function subject(){if("")return expensive();return 2;}', [2]];
61 yield 'falsey null' => ['function subject(){if(null)return expensive();return 2;}', [2]];
62 yield 'falsey float' => ['function subject(){if(-0.0)return expensive();return 2;}', [2]];
63 yield 'falsey array' => ['function subject(){if([])return expensive();return 2;}', [2]];
64 yield 'truthy string' => ['function subject(){if("false")return 2;return expensive();}', [2]];
65 yield 'truthy array' => ['function subject(){if([0])return 2;return expensive();}', [2]];
66 yield 'both boolean values' => ['function subject($x){if($x?0:4)return 1;return 2;}', [1,2]];
67 yield 'unknown keeps both' => ['function subject($x){if($x)return 1;return 2;}', [1,2]];
68 yield 'unknown plus singleton keeps both' => ['function subject($x){if($x?0:missing())return 1;return 2;}', [1,2]];
69 yield 'full values remain independent' => ['function subject($x){$v=$x?1:($x?2:0);if($v)return $v;return 3;}', [0,1,2,3]];
70 }
71
72 /**
73 * @param list<int> $expected
74 */
75 #[DataProvider('providerSelections')]
76 public function testPrunesOnlyExcludedBranches(string $source, array $expected): void
77 {
78 $evaluators = BranchEvaluators::truth();
79 $tree = (new Resolver())->resolve((new Parser(new ParserConfig('8.3')))->parse(new Source('branches.php', '<?php ' . $source)));
80 $program = new Index('branches', [$tree]);
81 $target = new TargetProfile();
82 $settings = new EvaluatorConfig($target, evaluators: array_map((new MicroEvaluatorConversion())->convert(...), $evaluators));
83 $query = new CandidateQuery(new DefinitionIndex($program), $settings, new Evaluators($target), new Snapshot('branches', [], [], $target, 'environment'));
84 $result = $query->derive(new ReturnQuery('subject'));
85 $values = array_map(static fn ($candidate) => $candidate->result, iterator_to_array($result));
86 sort($values);
87 self::assertSame($expected, $values);
88 self::assertArrayNotHasKey('expensive', $result->statistics->expandedBodies);
89 }
90
91 /**
92 * @return iterable<string, array{string}>
93 */
94 public static function providerSaturation(): iterable
95 {
96 yield 'return alternatives' => ['function values($x){if($x)return 0;if($x)return 1;return expensive();} function subject(){if(values())return 1;return 2;}'];
97 yield 'ternary alternatives' => ['function subject($x){if($x?0:($x?1:expensive()))return 1;return 2;}'];
98 yield 'local writes' => ['function subject($x){if($x){$v=0;}elseif($x){$v=1;}else{$v=expensive();}if($v)return 1;return 2;}'];
99 yield 'lazy arguments' => ['function values($x){if($x)return 0;if($x)return 1;return expensive();} function condition($x){return $x;} function subject(){if(condition(values()))return 1;return 2;}'];
100 yield 'caller origins' => ['function subject($x){if($x)return 1;return 2;} function first(){subject(0);} function second(){subject(1);} function third(){subject(expensive());}'];
101 }
102
103 #[DataProvider('providerSaturation')]
104 public function testStopsOpeningDependenciesAfterBothTruths(string $source): void
105 {
106 $opened = 0;
107 $evaluators = [...BranchEvaluators::truth(), new MicroEvaluator('expensive', '1', EvaluationOperation::Function, 'expensive', static function () use (&$opened): Term {
108 ++$opened;
109 return Term::constant(3);
110 })];
111 $tree = (new Resolver())->resolve((new Parser(new ParserConfig('8.3')))->parse(new Source('branches.php', '<?php ' . $source)));
112 $program = new Index('branches', [$tree]);
113 $target = new TargetProfile();
114 $settings = new EvaluatorConfig($target, evaluators: array_map((new MicroEvaluatorConversion())->convert(...), $evaluators));
115 $query = new CandidateQuery(new DefinitionIndex($program), $settings, new Evaluators($target), new Snapshot('branches', [], [], $target, 'environment'));
116 $result = $query->derive(new ReturnQuery('subject'));
117 $values = array_map(static fn ($candidate) => $candidate->result, iterator_to_array($result));
118 sort($values);
119 self::assertSame([1,2], $values);
120 self::assertSame(0, $opened);
121 }
122
123 public function testReplacesBranchSemanticsWithoutDemandingTheCondition(): void
124 {
125 $source = 'function subject(){if(expensive())return 1;return 2;}';
126 $evaluators = [new MicroEvaluator('always.left', '1', EvaluationOperation::Branch, '', static fn (): Term => Term::constant(true))];
127 $tree = (new Resolver())->resolve((new Parser(new ParserConfig('8.3')))->parse(new Source('branches.php', '<?php ' . $source)));
128 $program = new Index('branches', [$tree]);
129 $target = new TargetProfile();
130 $settings = new EvaluatorConfig($target, evaluators: array_map((new MicroEvaluatorConversion())->convert(...), $evaluators));
131 $query = new CandidateQuery(new DefinitionIndex($program), $settings, new Evaluators($target), new Snapshot('branches', [], [], $target, 'environment'));
132 $result = $query->derive(new ReturnQuery('subject'));
133 self::assertSame(1, $result[0]->result);
134 self::assertCount(1, $result);
135 self::assertSame(0, $result->statistics->referenceExpansions);
136 self::assertSame(1, $result->statistics->evaluatorApplications);
137 }
138
139 public function testDecliningABranchEvaluatorRetainsDefaultAlternatives(): void
140 {
141 $source = 'function subject(){return false?1:2;}';
142 $evaluators = [new MicroEvaluator('decline', '1', EvaluationOperation::Branch, '', static fn (): ?Term => null)];
143 $tree = (new Resolver())->resolve((new Parser(new ParserConfig('8.3')))->parse(new Source('branches.php', '<?php ' . $source)));
144 $program = new Index('branches', [$tree]);
145 $target = new TargetProfile();
146 $settings = new EvaluatorConfig($target, evaluators: array_map((new MicroEvaluatorConversion())->convert(...), $evaluators));
147 $query = new CandidateQuery(new DefinitionIndex($program), $settings, new Evaluators($target), new Snapshot('branches', [], [], $target, 'environment'));
148 $result = $query->derive(new ReturnQuery('subject'));
149 self::assertSame([1,2], array_map(static fn ($candidate) => $candidate->result, iterator_to_array($result)));
150 self::assertSame(0, $result->statistics->evaluatorApplications);
151 }
152 public function testEveryBranchRetainsTheAcceptedEvaluatorAndUnknownDependencyEvidence(): void
153 {
154 $source = 'function subject(){if(missing())return 1;return 2;}';
155 $evaluators = BranchEvaluators::truth();
156 $tree = (new Resolver())->resolve((new Parser(new ParserConfig('8.3')))->parse(new Source('branches.php', '<?php ' . $source)));
157 $program = new Index('branches', [$tree]);
158 $target = new TargetProfile();
159 $settings = new EvaluatorConfig($target, evaluators: array_map((new MicroEvaluatorConversion())->convert(...), $evaluators));
160 $query = new CandidateQuery(new DefinitionIndex($program), $settings, new Evaluators($target), new Snapshot('branches', [], [], $target, 'environment'));
161 $result = $query->derive(new ReturnQuery('subject'));
162 $evidence = array_map(static fn ($candidate): array => array_merge(...array_map(static fn ($alternative): array => $alternative->nodes(), $candidate->evidence)), iterator_to_array($result));
163 self::assertSame([true,true], array_map(static fn (array $nodes): bool => array_filter($nodes, static fn ($node): bool => ($node->attributes['id'] ?? '') === 'deriver.branch.truth') !== [], $evidence));
164 self::assertSame([true,true], array_map(static fn (array $nodes): bool => array_filter($nodes, static fn ($node): bool => ($node->attributes['reason'] ?? '') === 'MISSING_SOURCE') !== [], $evidence));
165 }
166
167 public function testAnInterruptedSingletonDoesNotPruneTheOtherBranch(): void
168 {
169 $source = 'function subject(){if(condition())return 1;return 2;}';
170 $evaluators = [...BranchEvaluators::truth(), new MicroEvaluator('partial', '1', EvaluationOperation::Function, 'condition', static fn (): Term => (new \Deriver\Deriver\Evaluator\Enumeration\Choices())->make([[Term::constant(false), []], [new Term(\Deriver\Deriver\Evaluator\TermKind::Deferred, 'remaining', attributes: ['reason' => 'BUDGET_EXCEEDED']), []]]))];
171 $tree = (new Resolver())->resolve((new Parser(new ParserConfig('8.3')))->parse(new Source('branches.php', '<?php ' . $source)));
172 $program = new Index('branches', [$tree]);
173 $target = new TargetProfile();
174 $settings = new EvaluatorConfig($target, evaluators: array_map((new MicroEvaluatorConversion())->convert(...), $evaluators));
175 $query = new CandidateQuery(new DefinitionIndex($program), $settings, new Evaluators($target), new Snapshot('branches', [], [], $target, 'environment'));
176 $result = $query->derive(new ReturnQuery('subject'));
177 self::assertNotEmpty(array_filter(iterator_to_array($result), static fn ($candidate): bool => $candidate->result instanceof Term && $candidate->result->kind === \Deriver\Deriver\Evaluator\TermKind::ControlledValue && $candidate->result->operands[0]->native() === 1));
178 self::assertNotEmpty(array_filter(iterator_to_array($result), static fn ($candidate): bool => $candidate->result === 2));
179 }
180
181}
182