Skip to content

Commit 5edbda6

Browse files
ondrejmirtesphpstan-bot
authored andcommitted
Implement getNamedArgumentsVariants() on UnionTypeMethodReflection and IntersectionTypeMethodReflection to combine parameters by name
- Add `combineAcceptorsByParameterName()` to `ParametersAcceptorSelector` that groups parameters by name instead of position, so named argument matching works when union/intersection members have the same parameters in different order - Implement `getNamedArgumentsVariants()` on `UnionTypeMethodReflection` and `IntersectionTypeMethodReflection` using the new combiner (previously returned null) - Skip the early return in `selectFromArgs()` when the acceptor has compound parameter names (from positional combining) and named arguments are present, allowing the named-arguments flow at line 539 to handle them correctly - Add `acceptorHasCompoundParameterNames()` helper to detect compound names created by `combineAcceptors()` (e.g. `other|target`) Closes phpstan/phpstan#14661
1 parent 2f46b52 commit 5edbda6

7 files changed

Lines changed: 305 additions & 5 deletions

File tree

src/Reflection/ParametersAcceptorSelector.php

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
use function is_int;
5656
use function is_string;
5757
use function sprintf;
58+
use function str_contains;
5859
use const ARRAY_FILTER_USE_BOTH;
5960
use const ARRAY_FILTER_USE_KEY;
6061
use const CURLOPT_SHARE;
@@ -461,7 +462,18 @@ public static function selectFromArgs(
461462
if (count($parametersAcceptors) === 1) {
462463
$acceptor = $parametersAcceptors[0];
463464
if (!self::hasAcceptorTemplateOrLateResolvableType($acceptor)) {
464-
return $acceptor;
465+
$skipEarlyReturn = false;
466+
if ($namedArgumentsVariants !== null && self::acceptorHasCompoundParameterNames($acceptor)) {
467+
foreach ($args as $arg) {
468+
if ($arg->name !== null) {
469+
$skipEarlyReturn = true;
470+
break;
471+
}
472+
}
473+
}
474+
if (!$skipEarlyReturn) {
475+
return $acceptor;
476+
}
465477
}
466478
}
467479

@@ -868,6 +880,142 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc
868880
);
869881
}
870882

883+
/**
884+
* @param ParametersAcceptor[] $acceptors
885+
*/
886+
public static function combineAcceptorsByParameterName(array $acceptors): ExtendedParametersAcceptor
887+
{
888+
if (count($acceptors) === 0) {
889+
throw new ShouldNotHappenException(
890+
'getVariants() must return at least one variant.',
891+
);
892+
}
893+
if (count($acceptors) === 1) {
894+
return self::wrapAcceptor($acceptors[0]);
895+
}
896+
897+
$parametersByName = [];
898+
$parameterNames = [];
899+
foreach ($acceptors as $acceptor) {
900+
foreach ($acceptor->getParameters() as $parameter) {
901+
$name = $parameter->getName();
902+
if (!isset($parametersByName[$name])) {
903+
$parameterNames[] = $name;
904+
$parametersByName[$name] = [];
905+
}
906+
$parametersByName[$name][] = $parameter;
907+
}
908+
}
909+
910+
$acceptorCount = count($acceptors);
911+
$parameters = [];
912+
$isVariadic = false;
913+
$returnTypes = [];
914+
$phpDocReturnTypes = [];
915+
$nativeReturnTypes = [];
916+
917+
foreach ($acceptors as $acceptor) {
918+
$returnTypes[] = $acceptor->getReturnType();
919+
if ($acceptor instanceof ExtendedParametersAcceptor) {
920+
$phpDocReturnTypes[] = $acceptor->getPhpDocReturnType();
921+
$nativeReturnTypes[] = $acceptor->getNativeReturnType();
922+
}
923+
$isVariadic = $isVariadic || $acceptor->isVariadic();
924+
}
925+
926+
foreach ($parameterNames as $name) {
927+
$params = $parametersByName[$name];
928+
$existsInAll = count($params) === $acceptorCount;
929+
930+
$types = [];
931+
$nativeTypes = [];
932+
$phpDocTypes = [];
933+
$defaultValues = [];
934+
$paramIsVariadic = false;
935+
$outType = null;
936+
$closureThisType = null;
937+
$immediatelyInvokedCallable = TrinaryLogic::createMaybe();
938+
$attributes = [];
939+
$isOptional = !$existsInAll;
940+
$passedByRef = $params[0]->passedByReference();
941+
942+
foreach ($params as $j => $param) {
943+
$types[] = $param->getType();
944+
$paramIsVariadic = $paramIsVariadic || $param->isVariadic();
945+
946+
if (!$isOptional && $param->isOptional()) {
947+
$isOptional = true;
948+
}
949+
950+
$defaultValue = $param->getDefaultValue();
951+
if ($defaultValue !== null) {
952+
$defaultValues[] = $defaultValue;
953+
}
954+
955+
if ($j > 0) {
956+
$passedByRef = $passedByRef->combine($param->passedByReference());
957+
}
958+
959+
if ($param instanceof ExtendedParameterReflection) {
960+
$nativeTypes[] = $param->getNativeType();
961+
$phpDocTypes[] = $param->getPhpDocType();
962+
$immediatelyInvokedCallable = $param->isImmediatelyInvokedCallable()->or($immediatelyInvokedCallable);
963+
$attributes = array_merge($attributes, $param->getAttributes());
964+
965+
if ($param->getOutType() !== null) {
966+
$outType = $outType === null ? $param->getOutType() : TypeCombinator::union($outType, $param->getOutType());
967+
} else {
968+
$outType = null;
969+
}
970+
971+
if ($param->getClosureThisType() !== null && $closureThisType !== null) {
972+
$closureThisType = TypeCombinator::union($closureThisType, $param->getClosureThisType());
973+
} elseif ($closureThisType === null && $param === $params[0] && $param->getClosureThisType() !== null) {
974+
$closureThisType = $param->getClosureThisType();
975+
} else {
976+
$closureThisType = null;
977+
}
978+
} else {
979+
$nativeTypes[] = new MixedType();
980+
$phpDocTypes[] = $param->getType();
981+
}
982+
}
983+
984+
$combinedDefaultValue = count($defaultValues) === count($params)
985+
? TypeCombinator::union(...$defaultValues)
986+
: null;
987+
988+
$parameters[] = new ExtendedDummyParameter(
989+
$name,
990+
TypeCombinator::union(...$types),
991+
$isOptional,
992+
$passedByRef,
993+
$paramIsVariadic,
994+
$combinedDefaultValue,
995+
TypeCombinator::union(...$nativeTypes),
996+
TypeCombinator::union(...$phpDocTypes),
997+
$outType,
998+
$immediatelyInvokedCallable,
999+
$closureThisType,
1000+
$attributes,
1001+
);
1002+
}
1003+
1004+
$returnType = TypeCombinator::union(...$returnTypes);
1005+
$phpDocReturnType = $phpDocReturnTypes === [] ? null : TypeCombinator::union(...$phpDocReturnTypes);
1006+
$nativeReturnType = $nativeReturnTypes === [] ? null : TypeCombinator::union(...$nativeReturnTypes);
1007+
1008+
return new ExtendedFunctionVariant(
1009+
TemplateTypeMap::createEmpty(),
1010+
null,
1011+
$parameters,
1012+
$isVariadic,
1013+
$returnType,
1014+
$phpDocReturnType ?? $returnType,
1015+
$nativeReturnType ?? new MixedType(),
1016+
);
1017+
}
1018+
8711019
private static function wrapAcceptor(ParametersAcceptor $acceptor): ExtendedParametersAcceptor
8721020
{
8731021
if ($acceptor instanceof ExtendedParametersAcceptor) {
@@ -907,6 +1055,16 @@ private static function wrapAcceptor(ParametersAcceptor $acceptor): ExtendedPara
9071055
);
9081056
}
9091057

1058+
private static function acceptorHasCompoundParameterNames(ParametersAcceptor $acceptor): bool
1059+
{
1060+
foreach ($acceptor->getParameters() as $parameter) {
1061+
if (str_contains($parameter->getName(), '|')) {
1062+
return true;
1063+
}
1064+
}
1065+
return false;
1066+
}
1067+
9101068
private static function wrapParameter(ParameterReflection $parameter): ExtendedParameterReflection
9111069
{
9121070
return $parameter instanceof ExtendedParameterReflection ? $parameter : new ExtendedDummyParameter(

src/Reflection/Type/IntersectionTypeMethodReflection.php

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use PHPStan\Reflection\ExtendedMethodReflection;
1111
use PHPStan\Reflection\ExtendedParametersAcceptor;
1212
use PHPStan\Reflection\MethodReflection;
13+
use PHPStan\Reflection\ParametersAcceptorSelector;
1314
use PHPStan\ShouldNotHappenException;
1415
use PHPStan\TrinaryLogic;
1516
use PHPStan\Type\Type;
@@ -128,9 +129,17 @@ public function getOnlyVariant(): ExtendedParametersAcceptor
128129
return $variants[0];
129130
}
130131

131-
public function getNamedArgumentsVariants(): ?array
132+
public function getNamedArgumentsVariants(): array
132133
{
133-
return null;
134+
$allVariants = [];
135+
foreach ($this->methods as $method) {
136+
$namedVariants = $method->getNamedArgumentsVariants();
137+
foreach ($namedVariants ?? $method->getVariants() as $variant) {
138+
$allVariants[] = $variant;
139+
}
140+
}
141+
142+
return [ParametersAcceptorSelector::combineAcceptorsByParameterName($allVariants)];
134143
}
135144

136145
public function isDeprecated(): TrinaryLogic

src/Reflection/Type/UnionTypeMethodReflection.php

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,17 @@ public function getOnlyVariant(): ExtendedParametersAcceptor
8989
return $this->getVariants()[0];
9090
}
9191

92-
public function getNamedArgumentsVariants(): ?array
92+
public function getNamedArgumentsVariants(): array
9393
{
94-
return null;
94+
$allVariants = [];
95+
foreach ($this->methods as $method) {
96+
$namedVariants = $method->getNamedArgumentsVariants();
97+
foreach ($namedVariants ?? $method->getVariants() as $variant) {
98+
$allVariants[] = $variant;
99+
}
100+
}
101+
102+
return [ParametersAcceptorSelector::combineAcceptorsByParameterName($allVariants)];
95103
}
96104

97105
public function isDeprecated(): TrinaryLogic

tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4106,4 +4106,13 @@ public function testBug14596(): void
41064106
]);
41074107
}
41084108

4109+
#[RequiresPhp('>= 8.0.0')]
4110+
public function testBug14661(): void
4111+
{
4112+
$this->checkThisOnly = false;
4113+
$this->checkNullables = true;
4114+
$this->checkUnionTypes = true;
4115+
$this->analyse([__DIR__ . '/data/bug-14661.php'], []);
4116+
}
4117+
41094118
}

tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1033,4 +1033,11 @@ public function testBug14596(): void
10331033
]);
10341034
}
10351035

1036+
#[RequiresPhp('>= 8.0.0')]
1037+
public function testBug14661(): void
1038+
{
1039+
$this->checkThisOnly = false;
1040+
$this->analyse([__DIR__ . '/data/bug-14661-static.php'], []);
1041+
}
1042+
10361043
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php // lint >= 8.0
2+
3+
declare(strict_types=1);
4+
5+
namespace Bug14661Static;
6+
7+
class A
8+
{
9+
public static function mixedOrder(
10+
?string $other = null,
11+
?string $target = null,
12+
): void {}
13+
}
14+
15+
class B
16+
{
17+
public static function mixedOrder(
18+
?string $target = null,
19+
?string $other = null,
20+
): void {}
21+
}
22+
23+
/**
24+
* @param class-string<A>|class-string<B> $class
25+
*/
26+
function staticMixedOrder(string $class): void
27+
{
28+
$class::mixedOrder(target: 'value');
29+
$class::mixedOrder(other: 'a', target: 'b');
30+
$class::mixedOrder(target: 'b', other: 'a');
31+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php // lint >= 8.0
2+
3+
declare(strict_types=1);
4+
5+
namespace Bug14661;
6+
7+
class A
8+
{
9+
public function mixedOrder(
10+
?string $other = null,
11+
?string $target = null,
12+
): void {}
13+
14+
public function sameOrder(
15+
?string $other = null,
16+
?string $target = null,
17+
): void {}
18+
19+
public function differentTypes(
20+
int $a,
21+
string $b,
22+
): void {}
23+
}
24+
25+
class B
26+
{
27+
public function mixedOrder(
28+
?string $target = null,
29+
?string $other = null,
30+
): void {}
31+
32+
public function sameOrder(
33+
?string $other = null,
34+
?string $target = null,
35+
): void {}
36+
37+
public function differentTypes(
38+
string $b,
39+
int $a,
40+
): void {}
41+
}
42+
43+
class C
44+
{
45+
public function mixedOrder(
46+
?string $target = null,
47+
?string $extra = null,
48+
?string $other = null,
49+
): void {}
50+
}
51+
52+
function mixedOrder(A|B $obj): void
53+
{
54+
$obj->mixedOrder(target: 'value');
55+
}
56+
57+
function sameOrder(A|B $obj): void
58+
{
59+
$obj->sameOrder(target: 'value');
60+
}
61+
62+
function mixedOrderBothArgs(A|B $obj): void
63+
{
64+
$obj->mixedOrder(other: 'a', target: 'b');
65+
$obj->mixedOrder(target: 'b', other: 'a');
66+
}
67+
68+
function differentTypes(A|B $obj): void
69+
{
70+
$obj->differentTypes(a: 1, b: 'hello');
71+
$obj->differentTypes(b: 'hello', a: 1);
72+
}
73+
74+
function threeWayUnion(A|B|C $obj): void
75+
{
76+
$obj->mixedOrder(target: 'value');
77+
$obj->mixedOrder(other: 'a', target: 'b');
78+
}

0 commit comments

Comments
 (0)