Skip to content

Commit 22e23ed

Browse files
committed
Support named arguments after unpacking
1 parent f6c556f commit 22e23ed

File tree

5 files changed

+80
-10
lines changed

5 files changed

+80
-10
lines changed

src/Php/PhpVersions.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,9 @@ public function supportsNamedArguments(): TrinaryLogic
3838
return IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($this->phpVersions)->result;
3939
}
4040

41+
public function supportsNamedArgumentAfterUnpackedArgument(): TrinaryLogic
42+
{
43+
return IntegerRangeType::fromInterval(80100, null)->isSuperTypeOf($this->phpVersions)->result;
44+
}
45+
4146
}

src/Rules/FunctionCallParametersCheck.php

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
use PHPStan\Type\VerbosityLevel;
2828
use function array_fill;
2929
use function array_key_exists;
30+
use function array_slice;
3031
use function count;
3132
use function implode;
3233
use function in_array;
@@ -81,12 +82,15 @@ public function check(
8182
{
8283
$functionParametersMinCount = 0;
8384
$functionParametersMaxCount = 0;
85+
$functionParameterNames = [];
8486
foreach ($parametersAcceptor->getParameters() as $parameter) {
8587
if (!$parameter->isOptional()) {
8688
$functionParametersMinCount++;
8789
}
8890

8991
$functionParametersMaxCount++;
92+
93+
$functionParameterNames[] = $parameter->getName();
9094
}
9195

9296
if ($parametersAcceptor->isVariadic()) {
@@ -101,6 +105,12 @@ public function check(
101105
$hasUnpackedArgument = false;
102106
$errors = [];
103107
foreach ($args as $arg) {
108+
$argumentName = null;
109+
if ($arg->name !== null) {
110+
$hasNamedArguments = true;
111+
$argumentName = $arg->name->toString();
112+
}
113+
104114
if ($hasNamedArguments && $arg->unpack) {
105115
$errors[] = RuleErrorBuilder::message('Named argument cannot be followed by an unpacked (...) argument.')
106116
->identifier('argument.unpackAfterNamed')
@@ -109,20 +119,25 @@ public function check(
109119
->build();
110120
}
111121
if ($hasUnpackedArgument && !$arg->unpack) {
112-
$errors[] = RuleErrorBuilder::message('Unpacked argument (...) cannot be followed by a non-unpacked argument.')
113-
->identifier('argument.nonUnpackAfterUnpacked')
114-
->line($arg->getStartLine())
115-
->nonIgnorable()
116-
->build();
122+
if ($argumentName !== null && $scope->getPhpVersion()->supportsNamedArgumentAfterUnpackedArgument()->yes()) {
123+
if (in_array($argumentName, array_slice($functionParameterNames, 0, count($arguments)), true)) {
124+
$errors[] = RuleErrorBuilder::message(sprintf('Named parameter cannot overwrite already unpacked argument $%s.', $argumentName))
125+
->identifier('argument.namedOverwriteAfterUnpacked')
126+
->line($arg->getStartLine())
127+
->nonIgnorable()
128+
->build();
129+
}
130+
} else {
131+
$errors[] = RuleErrorBuilder::message('Unpacked argument (...) cannot be followed by a non-unpacked argument.')
132+
->identifier('argument.nonUnpackAfterUnpacked')
133+
->line($arg->getStartLine())
134+
->nonIgnorable()
135+
->build();
136+
}
117137
}
118138
if ($arg->unpack) {
119139
$hasUnpackedArgument = true;
120140
}
121-
$argumentName = null;
122-
if ($arg->name !== null) {
123-
$hasNamedArguments = true;
124-
$argumentName = $arg->name->toString();
125-
}
126141
if ($arg->unpack) {
127142
$type = $scope->getType($arg->value);
128143
$arrays = $type->getConstantArrays();

tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,24 @@ public function testNamedArguments(): void
499499
$this->analyse([__DIR__ . '/data/named-arguments.php'], $errors);
500500
}
501501

502+
public function testNamedArgumentsAfterUnpacking(): void
503+
{
504+
if (PHP_VERSION_ID < 80100) {
505+
$this->markTestSkipped('Test requires PHP 8.1.');
506+
}
507+
508+
$this->analyse([__DIR__ . '/data/named-arguments-after-unpacking.php'], [
509+
[
510+
'Named parameter cannot overwrite already unpacked argument $b.',
511+
14,
512+
],
513+
[
514+
'Argument for parameter $b has already been passed.',
515+
14,
516+
],
517+
]);
518+
}
519+
502520
public function testBug4514(): void
503521
{
504522
$this->analyse([__DIR__ . '/data/bug-4514.php'], []);
@@ -1936,4 +1954,13 @@ public function testBug12051(): void
19361954
$this->analyse([__DIR__ . '/data/bug-12051.php'], []);
19371955
}
19381956

1957+
public function testBug11418(): void
1958+
{
1959+
if (PHP_VERSION_ID < 80100) {
1960+
$this->markTestSkipped('Test requires PHP 8.1.');
1961+
}
1962+
1963+
$this->analyse([__DIR__ . '/data/bug-11418.php'], []);
1964+
}
1965+
19391966
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
namespace Bug11418;
4+
5+
function foo(int $a, int $b, int $c = 3, int $d = 4): int {
6+
return $a + $b + $c + $d;
7+
}
8+
9+
var_dump(foo(...[1, 2], d: 40)); // 46
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace FunctionNamedArgumentsAfterUnpacking;
4+
5+
// https://www.php.net/manual/en/functions.arguments.php#example-180
6+
7+
function foo($a, $b, $c = 3, $d = 4) {
8+
return $a + $b + $c + $d;
9+
}
10+
11+
var_dump(foo(...[1, 2], d: 40)); // 46
12+
var_dump(foo(...['b' => 2, 'a' => 1], d: 40)); // 46
13+
14+
var_dump(foo(...[1, 2], b: 20)); // Fatal error. Named parameter $b overwrites previous argument

0 commit comments

Comments
 (0)