packages/deriver/tests/Unit/Deriver/Lowerer/Declaration/Traits/CompositionTest.php

1<?php
2
3declare (strict_types=1);
4
5namespace Tests\Unit\Deriver\Lowerer\Declaration\Traits;
6
7use Deriver\Deriver\Evaluator\Evidence\Frontier;
8use Deriver\Deriver\Evaluator\TargetProfile;
9use Deriver\Deriver\Lowerer\Declaration\CallableIdentity;
10use Deriver\Deriver\Lowerer\Declaration\CallableSource;
11use Deriver\Deriver\Lowerer\Declaration\ConstantSignatures;
12use Deriver\Deriver\Lowerer\Declaration\Index;
13use Deriver\Deriver\Lowerer\Declaration\Indexer;
14use Deriver\Deriver\Lowerer\Declaration\Metadata\ClassConstant;
15use Deriver\Deriver\Lowerer\Declaration\Metadata\ClassDeclaration;
16use Deriver\Deriver\Lowerer\Declaration\Metadata\PropertyDeclaration;
17use Deriver\Deriver\Lowerer\Declaration\Metadata\Visibility;
18use Deriver\Deriver\Lowerer\Declaration\Traits\Composition;
19use Deriver\Deriver\Lowerer\Declaration\Traits\LexicalConstants;
20use Deriver\Deriver\Lowerer\Declaration\Traits\Members;
21use Deriver\Deriver\Lowerer\Declaration\Traits\PropertyScope;
22use Deriver\Deriver\Lowerer\ExpressionLowering;
23use Deriver\Deriver\Lowerer\Graph\BasicBlock;
24use Deriver\Deriver\Lowerer\Graph\CallableGraph;
25use Deriver\Deriver\Lowerer\Graph\Instruction;
26use Deriver\Deriver\Lowerer\Graph\Literal;
27use Deriver\Deriver\Lowerer\Graph\Terminator;
28use Deriver\Deriver\Lowerer\InstructionEmitter;
29use Deriver\Deriver\Lowerer\Lowerer;
30use Deriver\Deriver\Lowerer\LoweringScope;
31use Deriver\Deriver\Parser\Node\File;
32use Deriver\Deriver\Parser\Parser;
33use Deriver\Deriver\Parser\ParserConfig;
34use Deriver\Deriver\Parser\TargetSyntax;
35use Deriver\Deriver\Resolver\AssignmentPatterns;
36use Deriver\Deriver\Resolver\ClassScope;
37use Deriver\Deriver\Resolver\ExpressionValidation;
38use Deriver\Deriver\Resolver\MagicContext;
39use Deriver\Deriver\Resolver\Resolver;
40use Deriver\Deriver\Sources\LineMap;
41use Deriver\Deriver\Sources\Path;
42use Deriver\Deriver\Sources\Source;
43use Deriver\Deriver\Sources\SourceRef;
44use Deriver\Deriver\Sources\Sources;
45use Deriver\Exception\ParserException;
46use Deriver\Exception\ResolutionException;
47use PHPUnit\Framework\Attributes\CoversClass;
48use PHPUnit\Framework\Attributes\DataProvider;
49use PHPUnit\Framework\Attributes\Small;
50use PHPUnit\Framework\Attributes\UsesClass;
51use PHPUnit\Framework\TestCase;
52
53/**
54 * @covers \Deriver\Deriver\Lowerer\Declaration\Traits\Composition
55 */
56#[UsesClass(Resolver::class)]
57#[UsesClass(ExpressionValidation::class)]
58#[UsesClass(ResolutionException::class)]
59#[UsesClass(ParserConfig::class)]
60#[UsesClass(ParserException::class)]
61#[CoversClass(Composition::class)]
62#[UsesClass(BasicBlock::class)]
63#[UsesClass(CallableGraph::class)]
64#[UsesClass(CallableIdentity::class)]
65#[UsesClass(ClassConstant::class)]
66#[UsesClass(ClassDeclaration::class)]
67#[UsesClass(Instruction::class)]
68#[UsesClass(PropertyDeclaration::class)]
69#[UsesClass(Terminator::class)]
70#[UsesClass(Sources::class)]
71#[UsesClass(Path::class)]
72#[UsesClass(Source::class)]
73#[UsesClass(TargetProfile::class)]
74#[UsesClass(SourceRef::class)]
75#[UsesClass(Frontier::class)]
76#[UsesClass(Parser::class)]
77#[UsesClass(File::class)]
78#[UsesClass(Lowerer::class)]
79#[UsesClass(ExpressionLowering::class)]
80#[UsesClass(InstructionEmitter::class)]
81#[UsesClass(LoweringScope::class)]
82#[UsesClass(ConstantSignatures::class)]
83#[UsesClass(CallableSource::class)]
84#[UsesClass(Indexer::class)]
85#[UsesClass(Index::class)]
86#[UsesClass(LexicalConstants::class)]
87#[UsesClass(Members::class)]
88#[UsesClass(PropertyScope::class)]
89#[UsesClass(LineMap::class)]
90#[UsesClass(MagicContext::class)]
91#[UsesClass(AssignmentPatterns::class)]
92#[UsesClass(ClassScope::class)]
93#[UsesClass(TargetSyntax::class)]
94#[UsesClass(Literal::class)]
95#[Small]
96final class CompositionTest extends TestCase
97{
98    public function testComposeImportsNestedPrivateMembersIntoTheConsumer(): void
99    {
100        $source = '<?php trait U{private $x=4;function f(){return $this->x;}}trait T{use U;}class B{use T;}';
101        $snapshotId = 'test';
102        $input = new Sources([new Source('fixture.php', $source)]);
103        $profile = new TargetProfile();
104        $parser = new Parser(new ParserConfig($profile->version));
105        $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));
106        $index = new Index($snapshotId, $trees);
107        self::assertSame('B', $index->classes()['b']->properties['x']->className);
108        self::assertSame('B::f', $index->classes()['b']->methods['f']);
109        self::assertTrue($index->classes()['b']->composed);
110    }
111    public function testImportPreservesClassMethodPrecedence(): void
112    {
113        $source = '<?php trait T{function f(){return 1;}}class B{use T;function f(){return 2;}}';
114        $snapshotId = 'test';
115        $input = new Sources([new Source('fixture.php', $source)]);
116        $profile = new TargetProfile();
117        $parser = new Parser(new ParserConfig($profile->version));
118        $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));
119        $index = new Index($snapshotId, $trees);
120        self::assertSame([], $index->diagnostics());
121        self::assertSame('B::f', $index->classes()['b']->methods['f']);
122        self::assertSame(['T'], $index->classes()['b']->traits);
123    }
124    public function testPropertiesCreatesSeparatePrivateSlotsForEachConsumer(): void
125    {
126        $source = '<?php trait T{private $x=1;}class A{use T;}class B{use T;}';
127        $snapshotId = 'test';
128        $input = new Sources([new Source('fixture.php', $source)]);
129        $profile = new TargetProfile();
130        $parser = new Parser(new ParserConfig($profile->version));
131        $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));
132        $index = new Index($snapshotId, $trees);
133        self::assertSame('A', $index->classes()['a']->properties['x']->className);
134        self::assertSame('B', $index->classes()['b']->properties['x']->className);
135    }
136    public function testConstantsRetainsUnevaluatedInitializersInConsumerScope(): void
137    {
138        $source = '<?php trait T{const C=self::D;}class B{use T;const D=4;}';
139        $snapshotId = 'test';
140        $input = new Sources([new Source('fixture.php', $source)]);
141        $profile = new TargetProfile();
142        $parser = new Parser(new ParserConfig($profile->version));
143        $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));
144        $index = new Index($snapshotId, $trees);
145        $constant = $index->constant('B::C');
146        self::assertNotNull($constant);
147        self::assertSame('B', $constant->className);
148    }
149    public function testComposeRecordsMissingTraitsAndCyclesAtTheConsumingSource(): void
150    {
151        $source = '<?php class Consumer {use Missing;}';
152        $snapshotId = 'test';
153        $input = new Sources([new Source('fixture.php', $source)]);
154        $profile = new TargetProfile();
155        $parser = new Parser(new ParserConfig($profile->version));
156        $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));
157        $missing = new Index($snapshotId, $trees);
158        self::assertCount(1, $missing->diagnostics());
159        self::assertSame('INCOMPLETE_SOURCE', $missing->diagnostics()[0]->code);
160        self::assertSame('missing-trait:Missing', $missing->diagnostics()[0]->operation);
161        self::assertSame('fixture.php', $missing->diagnostics()[0]->at->path);
162        $source2 = '<?php trait Recursive {use Recursive;}';
163        $snapshotId1 = 'test';
164        $input1 = new Sources([new Source('fixture.php', $source2)]);
165        $profile1 = new TargetProfile();
166        $parser1 = new Parser(new ParserConfig($profile1->version));
167        $trees1 = array_map(static fn (Source $source3): File => (new Resolver())->resolve($parser1->parse(new Source(Path::normalize($source3->path), $source3->contents, $source3->declarationsOnly))), iterator_to_array($input1, false));
168        $cyclic = new Index($snapshotId1, $trees1);
169        self::assertCount(1, $cyclic->diagnostics());
170        self::assertSame('INVALID_PROGRAM', $cyclic->diagnostics()[0]->code);
171        self::assertSame('cyclic-trait-composition:Recursive', $cyclic->diagnostics()[0]->operation);
172    }
173    public function testComposeRemembersCaseInsensitiveVisitsAndLeavesUnknownTargetsUntouched(): void
174    {
175        $source = '<?php trait Shared{} class Consumer{use Shared;}';
176        $snapshotId = 'test';
177        $input = new Sources([new Source('fixture.php', $source)]);
178        $profile = new TargetProfile();
179        $parser = new Parser(new ParserConfig($profile->version));
180        $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));
181        $index = new Index($snapshotId, $trees);
182        $composition = new Composition($index);
183        $composition->compose('CONSUMER');
184        $consumer = $index->classIndex['consumer'];
185        $composition->compose('consumer');
186        $composition->compose('Missing');
187        self::assertSame(['shared' => true, 'consumer' => true], $composition->done);
188        self::assertSame($consumer, $index->classIndex['consumer']);
189        self::assertSame([], $index->diagnostics());
190    }
191    public function testComposeWaitsForBothDeclarationAndCapturedClassSyntax(): void
192    {
193        $source = '<?php class Consumer{}';
194        $snapshotId = 'test';
195        $input = new Sources([new Source('fixture.php', $source)]);
196        $profile = new TargetProfile();
197        $parser = new Parser(new ParserConfig($profile->version));
198        $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));
199        $index = new Index($snapshotId, $trees);
200        unset($index->classSources['consumer']);
201        $composition = new Composition($index);
202        $before = $index->classIndex['consumer'];
203        $composition->compose('Consumer');
204        self::assertSame([], $composition->done);
205        self::assertSame($before, $index->classIndex['consumer']);
206    }
207    public function testImportPreservesClassContractsAndRebindsCopiedMethods(): void
208    {
209        $source1 = '<?php declare(strict_types=1); interface Contract{} class Base{} trait Shared{function Run(){return __TRAIT__;}} final class Consumer extends Base implements Contract{use Shared; public const OWN=7;}';
210        $snapshotId = 'test';
211        $input = new Sources([new Source('fixture.php', $source1)]);
212        $profile = new TargetProfile();
213        $parser = new Parser(new ParserConfig($profile->version));
214        $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));
215        $index = new Index($snapshotId, $trees);
216        $before = $index->classIndex['consumer'];
217        $composition = new Composition($index);
218        $composition->import($before, $index->classSources['consumer']);
219        $after = $index->classIndex['consumer'];
220        self::assertSame('Consumer', $after->name);
221        self::assertSame('Base', $after->parent);
222        self::assertSame(['Contract'], $after->interfaces);
223        self::assertSame(['Shared'], $after->traits);
224        self::assertTrue($after->final);
225        self::assertFalse($after->abstract);
226        self::assertFalse($after->interface);
227        self::assertFalse($after->readonly);
228        self::assertFalse($after->enum);
229        self::assertTrue($after->composed);
230        self::assertSame($before->constants, $after->constants);
231        self::assertSame($before->constantDeclarations, $after->constantDeclarations);
232        $source = $index->declarations['consumer::run'];
233        self::assertSame('Consumer', $source->className);
234        self::assertSame('Consumer::run', $source->symbol);
235        self::assertSame('fixture.php', $source->path);
236        self::assertTrue($source->strict);
237        self::assertNotSame($index->declarations['shared::run']->node, $source->node);
238    }
239    #[DataProvider('providerConsumerFlags')]
240    public function testImportRetainsReadonlyAbstractAndEnumDeclarations(string $source, bool $abstract, bool $readonly, bool $enum): void
241    {
242        $snapshotId = 'test';
243        $input = new Sources([new Source('fixture.php', $source)]);
244        $profile = new TargetProfile();
245        $parser = new Parser(new ParserConfig($profile->version));
246        $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));
247        $index = new Index($snapshotId, $trees);
248        $consumer = $index->classIndex['consumer'];
249        self::assertSame($abstract, $consumer->abstract);
250        self::assertSame($readonly, $consumer->readonly);
251        self::assertSame($enum, $consumer->enum);
252        self::assertTrue($consumer->composed);
253        self::assertSame(['run' => 'Consumer::run'], $consumer->methods);
254        self::assertSame([], $index->diagnostics());
255    }
256    /**
257     * @return iterable<string,array{string,bool,bool,bool}>
258     */
259    public static function providerConsumerFlags(): iterable
260    {
261        yield 'abstract' => ['<?php trait Shared{function run(){}} abstract class Consumer{use Shared;}', true, false, false];
262        yield 'readonly' => ['<?php trait Shared{function run(){}} readonly class Consumer{use Shared;}', false, true, false];
263        yield 'enum' => ['<?php trait Shared{function run(){}} enum Consumer{use Shared;case Ready;}', false, false, true];
264    }
265    public function testPropertiesRetainsTypesVisibilityStaticReadonlyAndInitializerAbsence(): void
266    {
267        $source = '<?php trait Shared{private int $missing; protected static string $label="value"; public readonly int $id;}';
268        $snapshotId = 'test';
269        $input = new Sources([new Source('fixture.php', $source)]);
270        $profile = new TargetProfile();
271        $parser = new Parser(new ParserConfig($profile->version));
272        $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));
273        $index = new Index($snapshotId, $trees);
274        $trait = $index->classIndex['shared'];
275        $properties = (new Composition($index))->properties($trait, 'Consumer');
276        self::assertSame(['missing', 'label', 'id'], array_keys($properties));
277        self::assertSame(['Consumer', 'Consumer', 'Consumer'], array_column($properties, 'className'));
278        self::assertSame(['int', 'string', 'int'], array_column($properties, 'type'));
279        self::assertSame([Visibility::Private, Visibility::Protected, Visibility::Public], array_column($properties, 'visibility'));
280        self::assertSame([false, true, false], array_column($properties, 'static'));
281        self::assertSame([false, false, true], array_column($properties, 'readonly'));
282        self::assertNull($properties['missing']->default);
283        self::assertNull($properties['id']->default);
284        self::assertNotNull($properties['label']->default);
285        self::assertNotSame($trait->properties['label']->default, $properties['label']->default);
286        self::assertSame('Consumer', $properties['label']->default->className);
287        self::assertSame('Shared', $trait->properties['missing']->className);
288    }
289    public function testConstantsCopiesUnevaluatedSourceIdentityAndPreservesExistingConsumerDefinitions(): void
290    {
291        $source = '<?php declare(strict_types=1); trait Shared{protected const int COPIED=7; const OWN=9;} class Consumer{const OWN=11;}';
292        $snapshotId = 'test';
293        $input = new Sources([new Source('fixture.php', $source)]);
294        $profile = new TargetProfile();
295        $parser = new Parser(new ParserConfig($profile->version));
296        $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));
297        $index = new Index($snapshotId, $trees);
298        $original = $index->constantSources['consumer::OWN'];
299        $composition = new Composition($index);
300        $composition->constants($index->classIndex['shared'], 'Consumer');
301        $copied = $index->constantSources['consumer::COPIED'];
302        self::assertSame('consumer::COPIED', $copied->symbol);
303        self::assertSame($index->constantSources['shared::COPIED']->node, $copied->node);
304        self::assertSame('fixture.php', $copied->path);
305        self::assertSame('Consumer', $copied->className);
306        self::assertTrue($copied->strict);
307        self::assertSame($original, $index->constantSources['consumer::OWN']);
308    }
309    public function testImportRetainsConstantVisibilityTypesAndConsumerOwnership(): void
310    {
311        $source = '<?php trait Shared{protected const int Value=7;} class Consumer{use Shared;}';
312        $snapshotId = 'test';
313        $input = new Sources([new Source('fixture.php', $source)]);
314        $profile = new TargetProfile();
315        $parser = new Parser(new ParserConfig($profile->version));
316        $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));
317        $index = new Index($snapshotId, $trees);
318        $constant = $index->classIndex['consumer']->constantDeclarations['Value'];
319        self::assertSame('Consumer', $constant->className);
320        self::assertSame('Value', $constant->name);
321        self::assertSame(Visibility::Protected, $constant->visibility);
322        self::assertSame('int', $constant->type);
323        self::assertFalse($constant->enum);
324        self::assertSame($index->classIndex['shared']->constants['Value'], $index->classIndex['consumer']->constants['Value']);
325    }
326}
327