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