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