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