Skip to content

Commit c2c1497

Browse files
phpstan-botstaabmclaude
authored
Merge all callable acceptors' parameters into NativeParameterReflection when a closure is passed to a union of callables
Co-authored-by: staabm <120441+staabm@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 70c2301 commit c2c1497

5 files changed

Lines changed: 216 additions & 18 deletions

File tree

src/Analyser/NodeScopeResolver.php

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@
186186
use function is_array;
187187
use function is_int;
188188
use function is_string;
189+
use function max;
189190
use function sprintf;
190191
use function strtolower;
191192
use function trim;
@@ -3301,33 +3302,34 @@ private function doCreateCallableParameters(Scope $scope, Expr $closureExpr, ?ar
33013302

33023303
$acceptors = $passedToType->getCallableParametersAcceptors($scope);
33033304
foreach ($acceptors as $acceptor) {
3305+
$acceptorParameters = array_map(static fn (ParameterReflection $callableParameter) => new NativeParameterReflection(
3306+
$callableParameter->getName(),
3307+
$callableParameter->isOptional(),
3308+
$callableParameter->getType(),
3309+
$callableParameter->passedByReference(),
3310+
$callableParameter->isVariadic(),
3311+
$callableParameter->getDefaultValue(),
3312+
), $acceptor->getParameters());
3313+
33043314
if ($callableParameters === null) {
3305-
$callableParameters = array_map(static fn (ParameterReflection $callableParameter) => new NativeParameterReflection(
3306-
$callableParameter->getName(),
3307-
$callableParameter->isOptional(),
3308-
$callableParameter->getType(),
3309-
$callableParameter->passedByReference(),
3310-
$callableParameter->isVariadic(),
3311-
$callableParameter->getDefaultValue(),
3312-
), $acceptor->getParameters());
3315+
$callableParameters = $acceptorParameters;
33133316
continue;
33143317
}
33153318

33163319
$newParameters = [];
3317-
foreach ($acceptor->getParameters() as $i => $callableParameter) {
3320+
$parameterCount = max(count($callableParameters), count($acceptorParameters));
3321+
for ($i = 0; $i < $parameterCount; $i++) {
3322+
if (!array_key_exists($i, $acceptorParameters)) {
3323+
$newParameters[] = $callableParameters[$i]->toOptional();
3324+
continue;
3325+
}
3326+
33183327
if (!array_key_exists($i, $callableParameters)) {
3319-
$newParameters[] = $callableParameter;
3328+
$newParameters[] = $acceptorParameters[$i]->toOptional();
33203329
continue;
33213330
}
33223331

3323-
$newParameters[] = $callableParameters[$i]->union(new NativeParameterReflection(
3324-
$callableParameter->getName(),
3325-
$callableParameter->isOptional(),
3326-
$callableParameter->getType(),
3327-
$callableParameter->passedByReference(),
3328-
$callableParameter->isVariadic(),
3329-
$callableParameter->getDefaultValue(),
3330-
));
3332+
$newParameters[] = $callableParameters[$i]->union($acceptorParameters[$i]);
33313333
}
33323334

33333335
$callableParameters = $newParameters;

src/Reflection/Native/NativeParameterReflection.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,23 @@ public function getDefaultValue(): ?Type
5151
return $this->defaultValue;
5252
}
5353

54+
/** Used when merging signatures where only some of them declare this parameter. */
55+
public function toOptional(): self
56+
{
57+
if ($this->optional) {
58+
return $this;
59+
}
60+
61+
return new self(
62+
$this->name,
63+
true,
64+
$this->type,
65+
$this->passedByReference,
66+
$this->variadic,
67+
$this->defaultValue,
68+
);
69+
}
70+
5471
public function union(self $other): self
5572
{
5673
return new self(

tests/PHPStan/Analyser/AnalyserIntegrationTest.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1608,6 +1608,14 @@ public function testBug14707(): void
16081608
$this->assertNoErrors($errors);
16091609
}
16101610

1611+
#[RequiresPhp('>= 8.0.0')]
1612+
public function testBug15003(): void
1613+
{
1614+
// crash
1615+
$errors = $this->runAnalyse(__DIR__ . '/data/bug-15003.php');
1616+
$this->assertNoErrors($errors);
1617+
}
1618+
16111619
/**
16121620
* @param string[]|null $allAnalysedFiles
16131621
* @return list<Error>
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?php // lint >= 8.0
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug15003;
6+
7+
use Closure;
8+
9+
/**
10+
* @phpstan-type Foo InvokableClass|callable(string, mixed): int
11+
*/
12+
class TypeImportShortcut {}
13+
14+
interface InvokableClass
15+
{
16+
/**
17+
* @param Closure(string, ?string=): string $fail
18+
*/
19+
public function __invoke(string $foo, Closure $fail): int;
20+
}
21+
22+
/** @phpstan-import-type Foo from TypeImportShortcut */
23+
class A
24+
{
25+
26+
/** @param callable(string):Foo|Foo $param */
27+
public function foo($param): void {}
28+
29+
}
30+
31+
(new A)->foo(function(string $foo) {
32+
return 5;
33+
});
34+
35+
interface InvokableRule
36+
{
37+
38+
/**
39+
* @param Closure(string): string $fail
40+
*/
41+
public function __invoke(string $attribute, mixed $value, Closure $fail): void;
42+
43+
}
44+
45+
/**
46+
* @phpstan-type FieldValidationRule InvokableRule|(callable(string, mixed, Closure): void)
47+
* @phpstan-type ValidationRules array<int, FieldValidationRule>|FieldValidationRule
48+
*/
49+
final class Field
50+
{
51+
52+
/**
53+
* @param (callable(string): ValidationRules)|ValidationRules $rules
54+
*/
55+
public function rules($rules): self
56+
{
57+
return $this;
58+
}
59+
60+
/**
61+
* @param (callable(string): ValidationRules)|ValidationRules ...$rules
62+
*/
63+
public function creationRules($rules): self
64+
{
65+
return $this;
66+
}
67+
68+
}
69+
70+
function rules(): Field
71+
{
72+
return (new Field())->rules(function ($attribute, $value, $fail) {
73+
});
74+
}
75+
76+
function creationRules(): Field
77+
{
78+
return (new Field())->creationRules([
79+
function ($attribute, $value, $fail) {
80+
},
81+
]);
82+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ClosurePassedToUnionOfCallables;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
/** @template T */
8+
final class Invokable
9+
{
10+
11+
/** @param T $a */
12+
public function __invoke($a, string $b): void
13+
{
14+
}
15+
16+
}
17+
18+
final class Foo
19+
{
20+
21+
/**
22+
* @param callable(int, string): void|callable(int): void $cb
23+
*/
24+
public function longestFirst($cb): void
25+
{
26+
}
27+
28+
/**
29+
* @param callable(int): void|callable(int, string): void $cb
30+
*/
31+
public function shortestFirst($cb): void
32+
{
33+
}
34+
35+
/**
36+
* @param callable(int): void|Invokable<string>|callable(bool, float): void $cb
37+
*/
38+
public function withInvokable($cb): void
39+
{
40+
}
41+
42+
/**
43+
* @param callable(int...): void|callable(string, string): void $cb
44+
*/
45+
public function variadicFirst($cb): void
46+
{
47+
}
48+
49+
/**
50+
* @param callable(string, string): void|callable(int...): void $cb
51+
*/
52+
public function variadicLast($cb): void
53+
{
54+
}
55+
56+
public function run(): void
57+
{
58+
$this->longestFirst(function ($a, $b): void {
59+
assertType('int', $a);
60+
assertType('string', $b);
61+
});
62+
63+
$this->shortestFirst(function ($a, $b): void {
64+
assertType('int', $a);
65+
assertType('string', $b);
66+
});
67+
68+
$this->longestFirst(fn ($a, $b) => assertType('int', $a));
69+
$this->longestFirst(fn ($a, $b) => assertType('string', $b));
70+
$this->shortestFirst(fn ($a, $b) => assertType('int', $a));
71+
$this->shortestFirst(fn ($a, $b) => assertType('string', $b));
72+
73+
$this->withInvokable(function ($a, $b): void {
74+
assertType('bool|int|string', $a);
75+
assertType('float|string', $b);
76+
});
77+
78+
$this->variadicFirst(function ($a, $b): void {
79+
assertType('int|string', $a);
80+
assertType('string', $b);
81+
});
82+
83+
$this->variadicLast(function ($a, $b): void {
84+
assertType('int|string', $a);
85+
assertType('string', $b);
86+
});
87+
}
88+
89+
}

0 commit comments

Comments
 (0)