packages/deriver/tests/Unit/Deriver/Lowerer/Declaration/Traits/MembersTest.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\ClassDeclaration;
15use Deriver\Deriver\Lowerer\Declaration\Metadata\Visibility;
16use Deriver\Deriver\Lowerer\Declaration\Traits\Composition;
17use Deriver\Deriver\Lowerer\Declaration\Traits\LexicalConstants;
18use Deriver\Deriver\Lowerer\Declaration\Traits\Members;
19use Deriver\Deriver\Lowerer\Graph\BasicBlock;
20use Deriver\Deriver\Lowerer\Graph\CallableGraph;
21use Deriver\Deriver\Lowerer\Graph\Terminator;
22use Deriver\Deriver\Lowerer\InstructionEmitter;
23use Deriver\Deriver\Lowerer\Lowerer;
24use Deriver\Deriver\Lowerer\LoweringScope;
25use Deriver\Deriver\Parser\Node\File;
26use Deriver\Deriver\Parser\Parser;
27use Deriver\Deriver\Parser\ParserConfig;
28use Deriver\Deriver\Parser\TargetSyntax;
29use Deriver\Deriver\Resolver\AssignmentPatterns;
30use Deriver\Deriver\Resolver\ClassScope;
31use Deriver\Deriver\Resolver\ExpressionValidation;
32use Deriver\Deriver\Resolver\MagicContext;
33use Deriver\Deriver\Resolver\Resolver;
34use Deriver\Deriver\Sources\LineMap;
35use Deriver\Deriver\Sources\Path;
36use Deriver\Deriver\Sources\Source;
37use Deriver\Deriver\Sources\SourceRef;
38use Deriver\Deriver\Sources\Sources;
39use Deriver\Exception\ParserException;
40use Deriver\Exception\ResolutionException;
41use PhpParser\Modifiers;
42use PhpParser\Node\Name;
43use PhpParser\Node\Stmt\ClassMethod;
44use PhpParser\Node\Stmt\Function_;
45use PhpParser\Node\Stmt\TraitUseAdaptation\Alias;
46use PhpParser\Node\Stmt\TraitUseAdaptation\Precedence;
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\Members
55 */
56#[UsesClass(Resolver::class)]
57#[UsesClass(ExpressionValidation::class)]
58#[UsesClass(ResolutionException::class)]
59#[UsesClass(ParserConfig::class)]
60#[UsesClass(ParserException::class)]
61#[CoversClass(Members::class)]
62#[UsesClass(BasicBlock::class)]
63#[UsesClass(CallableGraph::class)]
64#[UsesClass(CallableIdentity::class)]
65#[UsesClass(ClassDeclaration::class)]
66#[UsesClass(Terminator::class)]
67#[UsesClass(Sources::class)]
68#[UsesClass(Path::class)]
69#[UsesClass(Source::class)]
70#[UsesClass(TargetProfile::class)]
71#[UsesClass(SourceRef::class)]
72#[UsesClass(Frontier::class)]
73#[UsesClass(Parser::class)]
74#[UsesClass(File::class)]
75#[UsesClass(Lowerer::class)]
76#[UsesClass(InstructionEmitter::class)]
77#[UsesClass(LoweringScope::class)]
78#[UsesClass(ConstantSignatures::class)]
79#[UsesClass(CallableSource::class)]
80#[UsesClass(Indexer::class)]
81#[UsesClass(Index::class)]
82#[UsesClass(Composition::class)]
83#[UsesClass(LexicalConstants::class)]
84#[UsesClass(LineMap::class)]
85#[UsesClass(MagicContext::class)]
86#[UsesClass(AssignmentPatterns::class)]
87#[UsesClass(ClassScope::class)]
88#[UsesClass(TargetSyntax::class)]
89#[Small]
90final class MembersTest extends TestCase
91{
92    public function testCandidatesUsesImmediateTraitNamesForAdaptations(): void
93    {
94        $source = '<?php trait T{function f(){}}trait U{function f(){}}';
95        $snapshotId = 'test';
96        $input = new Sources([new Source('fixture.php', $source)]);
97        $profile = new TargetProfile();
98        $parser = new Parser(new ParserConfig($profile->version));
99        $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));
100        $index = new Index($snapshotId, $trees);
101        $class = new ClassDeclaration('B', traits: ['T', 'U']);
102        $methods = (new Members($index))->candidates($class);
103        self::assertSame(['t', 'u'], array_keys($methods['f']));
104    }
105    public function testPrecedenceRemovesOnlyExplicitlyExcludedVariants(): void
106    {
107        $source = '<?php trait T{function f(){}}trait U{function f(){}}';
108        $snapshotId = 'test';
109        $input = new Sources([new Source('fixture.php', $source)]);
110        $profile = new TargetProfile();
111        $parser = new Parser(new ParserConfig($profile->version));
112        $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));
113        $index = new Index($snapshotId, $trees);
114        $members = new Members($index);
115        $candidates = $members->candidates(new ClassDeclaration('B', traits: ['T', 'U']));
116        $adaptation = new Precedence(new Name('T'), 'f', [new Name('U')]);
117        self::assertSame(['t'], array_keys($members->precedence($candidates, [$adaptation])['f']));
118        self::assertSame(['t', 'u'], array_keys($candidates['f']));
119    }
120    public function testAliasesKeepsTheOriginalAlongsideAnExcludedTraitAlias(): void
121    {
122        $source = '<?php trait T{function f(){}}trait U{function f(){}}class B{use T,U{T::f insteadof U;U::f as protected g;}}';
123        $snapshotId = 'test';
124        $input = new Sources([new Source('fixture.php', $source)]);
125        $profile = new TargetProfile();
126        $parser = new Parser(new ParserConfig($profile->version));
127        $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));
128        $index = new Index($snapshotId, $trees);
129        $body = $index->callable('B::g');
130        self::assertNotNull($body);
131        self::assertSame(Visibility::Protected, $body->visibility);
132        self::assertSame('B', $body->className);
133        self::assertSame(['f', 'g'], array_keys($index->classes()['b']->methods));
134    }
135    public function testSelectReportsUnresolvedConcreteConflicts(): void
136    {
137        $source = '<?php trait T{function f(){}}trait U{function f(){}}class B{use T,U;}';
138        $snapshotId = 'test';
139        $input = new Sources([new Source('fixture.php', $source)]);
140        $profile = new TargetProfile();
141        $parser = new Parser(new ParserConfig($profile->version));
142        $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));
143        $index = new Index($snapshotId, $trees);
144        self::assertSame('INVALID_PROGRAM', $index->diagnostics()[0]->code);
145        self::assertSame([], $index->classes()['b']->methods);
146    }
147    public function testCandidatesRetainsSourceIdentityAndSkipsUnavailableTraitBodies(): void
148    {
149        $source = '<?php trait Alpha {function Run(){}} trait Beta {function Other(){}}';
150        $snapshotId = 'test';
151        $input = new Sources([new Source('fixture.php', $source)]);
152        $profile = new TargetProfile();
153        $parser = new Parser(new ParserConfig($profile->version));
154        $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));
155        $index = new Index($snapshotId, $trees);
156        unset($index->declarations['beta::other']);
157        $methods = (new Members($index))->candidates(new ClassDeclaration('Consumer', traits: ['ALPHA', 'Missing', 'Beta']));
158        self::assertSame(['run'], array_keys($methods));
159        self::assertSame(['alpha'], array_keys($methods['run']));
160        self::assertSame($index->declarations['alpha::run'], $methods['run']['alpha']);
161    }
162    public function testPrecedenceFiltersOnlyTheNamedMethodAndRetainsExplicitAliasSources(): void
163    {
164        $source = '<?php trait Alpha{function Run(){} function other(){}}trait Beta{function Run(){} function other(){}}trait Third{function Run(){}}';
165        $snapshotId = 'test';
166        $input = new Sources([new Source('fixture.php', $source)]);
167        $profile = new TargetProfile();
168        $parser = new Parser(new ParserConfig($profile->version));
169        $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));
170        $index = new Index($snapshotId, $trees);
171        $members = new Members($index);
172        $candidates = $members->candidates(new ClassDeclaration('Consumer', traits: ['Alpha', 'Beta', 'Third']));
173        $alias = new Alias(new Name('Beta'), 'Run', null, 'alternate');
174        $precedence = new Precedence(new Name('Alpha'), 'RUN', [new Name('BETA'), new Name('THIRD')]);
175        $selected = $members->precedence($candidates, [$alias, $precedence]);
176        self::assertSame(['alpha'], array_keys($selected['run']));
177        self::assertSame($candidates['other'], $selected['other']);
178        self::assertCount(3, $candidates['run']);
179    }
180    /**
181     * @param int|null $modifier Requested adaptation flags
182     */
183    #[DataProvider('providerAliasFlags')]
184    public function testAliasesClonesSyntaxWhileRetainingProvenanceAndUnchangedFlags(?int $modifier, int $expected): void
185    {
186        $source1 = '<?php function target(){return 1;}';
187        $snapshotId = 'test';
188        $input = new Sources([new Source('fixture.php', $source1)]);
189        $profile = new TargetProfile();
190        $parser = new Parser(new ParserConfig($profile->version));
191        $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));
192        $index = new Index($snapshotId, $trees);
193        $node = new ClassMethod('Run', ['flags' => Modifiers::PUBLIC | Modifiers::STATIC, 'stmts' => []]);
194        $source = new CallableSource('TraitName::Run', $node, 'trait.php', 'TraitName', true);
195        $adaptation = new Alias(new Name('TRAITNAME'), 'RUN', $modifier, 'Alternate');
196        $selected = (new Members($index))->aliases(['run' => ['traitname' => $source]], ['run' => $source], [$adaptation]);
197        self::assertSame(['run', 'alternate'], array_keys($selected));
198        self::assertSame($source, $selected['run']);
199        self::assertNotSame($source->node, $selected['alternate']->node);
200        self::assertInstanceOf(ClassMethod::class, $selected['alternate']->node);
201        self::assertSame($expected, $selected['alternate']->node->flags);
202        self::assertSame(Modifiers::PUBLIC | Modifiers::STATIC, $node->flags);
203        self::assertSame('TraitName::Run', $selected['alternate']->symbol);
204        self::assertSame('TraitName', $selected['alternate']->className);
205        self::assertSame('trait.php', $selected['alternate']->path);
206        self::assertTrue($selected['alternate']->strict);
207    }
208    /**
209     * @return iterable<string,array{int|null,int}>
210     */
211    public static function providerAliasFlags(): iterable
212    {
213        yield 'no modifier' => [null, Modifiers::PUBLIC | Modifiers::STATIC];
214        yield 'zero modifier' => [0, Modifiers::PUBLIC | Modifiers::STATIC];
215        yield 'public' => [Modifiers::PUBLIC, Modifiers::PUBLIC | Modifiers::STATIC];
216        yield 'protected' => [Modifiers::PROTECTED, Modifiers::PROTECTED | Modifiers::STATIC];
217        yield 'private' => [Modifiers::PRIVATE, Modifiers::PRIVATE | Modifiers::STATIC];
218        yield 'final preserving visibility' => [Modifiers::FINAL, Modifiers::PUBLIC | Modifiers::STATIC | Modifiers::FINAL];
219        yield 'final protected' => [Modifiers::FINAL | Modifiers::PROTECTED, Modifiers::PROTECTED | Modifiers::STATIC | Modifiers::FINAL];
220    }
221    public function testAliasesAppliesUnqualifiedVisibilityChangesToTheSelectedImplementation(): void
222    {
223        $source1 = '<?php trait Alpha{function Run(){}}trait Beta{function Run(){}}';
224        $snapshotId = 'test';
225        $input = new Sources([new Source('fixture.php', $source1)]);
226        $profile = new TargetProfile();
227        $parser = new Parser(new ParserConfig($profile->version));
228        $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));
229        $index = new Index($snapshotId, $trees);
230        $members = new Members($index);
231        $all = $members->candidates(new ClassDeclaration('Consumer', traits: ['Alpha', 'Beta']));
232        $source = $all['run']['beta'];
233        $selected = $members->aliases($all, ['run' => $source], [new Alias(null, 'RUN', Modifiers::PRIVATE, null)]);
234        self::assertSame(['run'], array_keys($selected));
235        self::assertSame('Beta::Run', $selected['run']->symbol);
236        self::assertInstanceOf(ClassMethod::class, $selected['run']->node);
237        self::assertTrue($selected['run']->node->isPrivate());
238    }
239    public function testAliasesLeavesUnresolvedAdaptationsAndNonMethodSourcesUnselected(): void
240    {
241        $source1 = '<?php function target(){return 1;}';
242        $snapshotId = 'test';
243        $input = new Sources([new Source('fixture.php', $source1)]);
244        $profile = new TargetProfile();
245        $parser = new Parser(new ParserConfig($profile->version));
246        $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));
247        $index = new Index($snapshotId, $trees);
248        $source = new CallableSource('function', new Function_('function'), 'fixture.php');
249        $adaptations = [new Precedence(new Name('TraitName'), 'run', [new Name('Other')]), new Alias(null, 'missing', null, 'unqualified'), new Alias(new Name('Missing'), 'run', null, 'qualified'), new Alias(new Name('TraitName'), 'run', null, 'nonmethod')];
250        self::assertSame([], (new Members($index))->aliases(['run' => ['traitname' => $source]], [], $adaptations));
251    }
252    public function testSelectPrefersAClassDeclarationAndDeduplicatesDiamondImplementations(): void
253    {
254        $source = '<?php trait Concrete{function Run(){}}trait AbstractTrait{abstract function Run();}';
255        $snapshotId = 'test';
256        $input = new Sources([new Source('fixture.php', $source)]);
257        $profile = new TargetProfile();
258        $parser = new Parser(new ParserConfig($profile->version));
259        $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));
260        $index = new Index($snapshotId, $trees);
261        $concrete = $index->declarations['concrete::run'];
262        $abstract = $index->declarations['abstracttrait::run'];
263        $class = new ClassDeclaration('Consumer', methods: ['own' => 'Consumer::own']);
264        $candidates = ['own' => ['first' => $concrete, 'second' => $abstract], 'run' => ['first' => $concrete, 'second' => $concrete, 'abstract' => $abstract], 'abstract' => ['first' => $abstract]];
265        self::assertSame(['run' => $concrete], (new Members($index))->select($class, $candidates));
266        self::assertSame([], $index->diagnostics());
267    }
268    public function testSelectReportsAConflictAtItsTraitSourceAndContinuesOtherMethods(): void
269    {
270        $source = '<?php trait Alpha{function Run(){}}trait Beta{function Run(){} function Safe(){}}';
271        $snapshotId = 'test';
272        $input = new Sources([new Source('fixture.php', $source)]);
273        $profile = new TargetProfile();
274        $parser = new Parser(new ParserConfig($profile->version));
275        $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));
276        $index = new Index($snapshotId, $trees);
277        $class = new ClassDeclaration('Consumer', traits: ['Alpha', 'Beta']);
278        $members = new Members($index);
279        $selected = $members->select($class, $members->candidates($class));
280        self::assertSame(['safe' => $index->declarations['beta::safe']], $selected);
281        self::assertCount(1, $index->diagnostics());
282        $frontier = $index->diagnostics()[0];
283        self::assertSame('INVALID_PROGRAM', $frontier->code);
284        self::assertSame('trait-method-conflict:Consumer::run', $frontier->operation);
285        self::assertSame('fixture.php', $frontier->at->path);
286        self::assertSame($index->declarations['alpha::run']->node->getStartFilePos(), $frontier->at->start);
287    }
288}
289