From ae700e74996e223268bbbf1e7fded9510624485f Mon Sep 17 00:00:00 2001 From: Dave Liddament Date: Mon, 22 Jun 2026 23:30:13 +0100 Subject: [PATCH] [code-quality] Add AttributeNamedArgsRector Convert positional arguments on configured attributes into named arguments, taking the names from the attribute constructor signature. Configurable per attribute class via the AttributeNamedArgs value object, with an optional firstNamedPosition threshold to leave leading arguments positional. Skips already-named arguments and bails out when an argument maps to a variadic or missing parameter to avoid producing invalid PHP. --- ...eNamedArgsRectorFirstNamedPositionTest.php | 28 +++ .../AttributeNamedArgsRectorTest.php | 28 +++ .../Fixture/positional_except.php.inc | 27 +++ .../Fixture/positional_middleware.php.inc | 27 +++ .../Fixture/positional_only.php.inc | 27 +++ .../Fixture/skip_already_named.php.inc | 11 ++ .../Fixture/skip_other_attribute.php.inc | 10 ++ .../Fixture/skip_variadic.php.inc | 11 ++ .../Fixture/variadic_named_prefix.php.inc | 27 +++ .../positional_except.php.inc | 27 +++ .../positional_only.php.inc | 27 +++ .../skip_middleware_left_positional.php.inc | 11 ++ .../Source/Middleware.php | 11 ++ .../Source/MiddlewareWithVariadic.php | 11 ++ .../Source/OtherAttribute.php | 11 ++ .../Source/RequireToken.php | 7 + .../config/configured_rule.php | 16 ++ .../configured_rule_first_named_position.php | 14 ++ .../Attribute/AttributeNamedArgsRector.php | 169 ++++++++++++++++++ .../ValueObject/AttributeNamedArgs.php | 31 ++++ 20 files changed, 531 insertions(+) create mode 100644 rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/AttributeNamedArgsRectorFirstNamedPositionTest.php create mode 100644 rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/AttributeNamedArgsRectorTest.php create mode 100644 rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/Fixture/positional_except.php.inc create mode 100644 rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/Fixture/positional_middleware.php.inc create mode 100644 rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/Fixture/positional_only.php.inc create mode 100644 rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/Fixture/skip_already_named.php.inc create mode 100644 rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/Fixture/skip_other_attribute.php.inc create mode 100644 rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/Fixture/skip_variadic.php.inc create mode 100644 rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/Fixture/variadic_named_prefix.php.inc create mode 100644 rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/FixtureFirstNamedPosition/positional_except.php.inc create mode 100644 rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/FixtureFirstNamedPosition/positional_only.php.inc create mode 100644 rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/FixtureFirstNamedPosition/skip_middleware_left_positional.php.inc create mode 100644 rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/Source/Middleware.php create mode 100644 rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/Source/MiddlewareWithVariadic.php create mode 100644 rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/Source/OtherAttribute.php create mode 100644 rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/Source/RequireToken.php create mode 100644 rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/config/configured_rule.php create mode 100644 rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/config/configured_rule_first_named_position.php create mode 100644 rules/CodeQuality/Rector/Attribute/AttributeNamedArgsRector.php create mode 100644 rules/CodeQuality/ValueObject/AttributeNamedArgs.php diff --git a/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/AttributeNamedArgsRectorFirstNamedPositionTest.php b/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/AttributeNamedArgsRectorFirstNamedPositionTest.php new file mode 100644 index 00000000000..b6a986a395e --- /dev/null +++ b/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/AttributeNamedArgsRectorFirstNamedPositionTest.php @@ -0,0 +1,28 @@ +doTestFile($filePath); + } + + public static function provideData(): Iterator + { + return self::yieldFilesFromDirectory(__DIR__ . '/FixtureFirstNamedPosition'); + } + + public function provideConfigFilePath(): string + { + return __DIR__ . '/config/configured_rule_first_named_position.php'; + } +} diff --git a/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/AttributeNamedArgsRectorTest.php b/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/AttributeNamedArgsRectorTest.php new file mode 100644 index 00000000000..0293e82123b --- /dev/null +++ b/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/AttributeNamedArgsRectorTest.php @@ -0,0 +1,28 @@ +doTestFile($filePath); + } + + public static function provideData(): Iterator + { + return self::yieldFilesFromDirectory(__DIR__ . '/Fixture'); + } + + public function provideConfigFilePath(): string + { + return __DIR__ . '/config/configured_rule.php'; + } +} diff --git a/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/Fixture/positional_except.php.inc b/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/Fixture/positional_except.php.inc new file mode 100644 index 00000000000..a1a5519f2be --- /dev/null +++ b/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/Fixture/positional_except.php.inc @@ -0,0 +1,27 @@ + +----- + diff --git a/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/Fixture/positional_middleware.php.inc b/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/Fixture/positional_middleware.php.inc new file mode 100644 index 00000000000..d5e1188a94b --- /dev/null +++ b/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/Fixture/positional_middleware.php.inc @@ -0,0 +1,27 @@ + +----- + diff --git a/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/Fixture/positional_only.php.inc b/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/Fixture/positional_only.php.inc new file mode 100644 index 00000000000..3c4bf778795 --- /dev/null +++ b/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/Fixture/positional_only.php.inc @@ -0,0 +1,27 @@ + +----- + diff --git a/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/Fixture/skip_already_named.php.inc b/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/Fixture/skip_already_named.php.inc new file mode 100644 index 00000000000..7076df4b111 --- /dev/null +++ b/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/Fixture/skip_already_named.php.inc @@ -0,0 +1,11 @@ + +----- + diff --git a/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/FixtureFirstNamedPosition/positional_except.php.inc b/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/FixtureFirstNamedPosition/positional_except.php.inc new file mode 100644 index 00000000000..5d29d159a01 --- /dev/null +++ b/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/FixtureFirstNamedPosition/positional_except.php.inc @@ -0,0 +1,27 @@ + +----- + diff --git a/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/FixtureFirstNamedPosition/positional_only.php.inc b/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/FixtureFirstNamedPosition/positional_only.php.inc new file mode 100644 index 00000000000..1f048f0173b --- /dev/null +++ b/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/FixtureFirstNamedPosition/positional_only.php.inc @@ -0,0 +1,27 @@ + +----- + diff --git a/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/FixtureFirstNamedPosition/skip_middleware_left_positional.php.inc b/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/FixtureFirstNamedPosition/skip_middleware_left_positional.php.inc new file mode 100644 index 00000000000..5f1b08412de --- /dev/null +++ b/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/FixtureFirstNamedPosition/skip_middleware_left_positional.php.inc @@ -0,0 +1,11 @@ +ruleWithConfiguration(AttributeNamedArgsRector::class, [ + new AttributeNamedArgs(Middleware::class), + new AttributeNamedArgs(MiddlewareWithVariadic::class), + ]); +}; diff --git a/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/config/configured_rule_first_named_position.php b/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/config/configured_rule_first_named_position.php new file mode 100644 index 00000000000..f7e976081cd --- /dev/null +++ b/rules-tests/CodeQuality/Rector/Attribute/AttributeNamedArgsRector/config/configured_rule_first_named_position.php @@ -0,0 +1,14 @@ +ruleWithConfiguration(AttributeNamedArgsRector::class, [ + new AttributeNamedArgs(Middleware::class, 1), + ]); +}; diff --git a/rules/CodeQuality/Rector/Attribute/AttributeNamedArgsRector.php b/rules/CodeQuality/Rector/Attribute/AttributeNamedArgsRector.php new file mode 100644 index 00000000000..cd3541b28cf --- /dev/null +++ b/rules/CodeQuality/Rector/Attribute/AttributeNamedArgsRector.php @@ -0,0 +1,169 @@ +> + */ + public function getNodeTypes(): array + { + return [Attribute::class]; + } + + /** + * @param Attribute $node + */ + public function refactor(Node $node): ?Node + { + foreach ($this->attributeNamedArgs as $attributeNamedArg) { + if ($this->isName($node->name, $attributeNamedArg->getAttributeClass())) { + return $this->nameArguments($node, $attributeNamedArg); + } + } + + return null; + } + + /** + * @param mixed[] $configuration + */ + public function configure(array $configuration): void + { + Assert::allIsAOf($configuration, AttributeNamedArgs::class); + + $this->attributeNamedArgs = $configuration; + } + + public function provideMinPhpVersion(): int + { + return PhpVersionFeature::NAMED_ARGUMENTS; + } + + private function nameArguments(Attribute $attribute, AttributeNamedArgs $attributeNamedArgs): ?Attribute + { + $methodReflection = $this->reflectionResolver->resolveConstructorReflectionFromAttribute($attribute); + if (! $methodReflection instanceof MethodReflection) { + return null; + } + + $extendedParametersAcceptor = ParametersAcceptorSelector::combineAcceptors($methodReflection->getVariants()); + $parameters = $extendedParametersAcceptor->getParameters(); + + $namesToApply = $this->resolveArgNamesToApply( + $attribute->args, + $parameters, + $attributeNamedArgs->getFirstNamedPosition() + ); + if ($namesToApply === []) { + return null; + } + + foreach ($namesToApply as $position => $name) { + $attribute->args[$position]->name = new Identifier($name); + } + + return $attribute; + } + + /** + * Resolve the positional arguments to name, as a position => parameter-name map, or [] when + * nothing should change. Naming an argument forces every later positional argument to be named + * too (PHP forbids a positional argument after a named one). So if any argument in the named + * range maps to a variadic parameter, or to no parameter at all (overflow past a variadic), + * the whole attribute is left untouched rather than producing invalid PHP. + * + * @param Arg[] $args + * @param ParameterReflection[] $parameters + * @return array + */ + private function resolveArgNamesToApply(array $args, array $parameters, int $firstNamedPosition): array + { + $namesToApply = []; + + $count = count($args); + for ($position = $firstNamedPosition; $position < $count; ++$position) { + $arg = $args[$position]; + + // already named + if ($arg->name instanceof Identifier) { + continue; + } + + $parameter = $parameters[$position] ?? null; + + // no matching parameter, e.g. overflow past a variadic + if ($parameter === null) { + return []; + } + + // naming a variadic would rebind it or strand later positional arguments + if ($parameter->isVariadic()) { + return []; + } + + $namesToApply[$position] = $parameter->getName(); + } + + return $namesToApply; + } +} diff --git a/rules/CodeQuality/ValueObject/AttributeNamedArgs.php b/rules/CodeQuality/ValueObject/AttributeNamedArgs.php new file mode 100644 index 00000000000..226aeaaccec --- /dev/null +++ b/rules/CodeQuality/ValueObject/AttributeNamedArgs.php @@ -0,0 +1,31 @@ +attributeClass; + } + + public function getFirstNamedPosition(): int + { + return $this->firstNamedPosition; + } +}