From b7c4f24d845d9d6760c52aa0dc37ce1ed521451d Mon Sep 17 00:00:00 2001 From: Stepan Anchugov Date: Tue, 3 Nov 2026 12:12:50 +0100 Subject: [PATCH] AnnotationToAttributeRector: fold unknown named args into the attribute's $options Annotation arguments become named attribute arguments. When the attribute constructor does not declare a parameter of that name, PHP throws "Unknown named parameter" as soon as the attribute is instantiated, while the annotation form was accepted. This hits Symfony validator constraints on versions where a setting is only reachable through the $options array, e.g. on Symfony 5.4: @Assert\Length(min=1, allowEmptyString=true) -> #[Assert\Length(min: 1, allowEmptyString: true)] // Error on load Now, when the attribute class is resolvable, its constructor has no parameter of that name, and it has an array-accepting $options parameter, the unknown arguments are folded into it: #[Assert\Length(min: 1, options: ['allowEmptyString' => true])] Anything else is untouched: unresolvable classes, constructors without $options, variadic constructors, positional args, and an explicit non-array options argument. Co-Authored-By: Claude Sonnet 5.5 --- .../known_named_args_stay_named.php.inc | 29 ++++ ...nown_arg_without_options_parameter.php.inc | 29 ++++ .../unknown_named_arg_goes_to_options.php.inc | 29 ++++ .../Source/Options/ConstraintWithOptions.php | 25 ++++ .../Options/ConstraintWithoutOptions.php | 16 +++ .../config/configured_rule.php | 6 + .../NodeFactory/PhpAttributeGroupFactory.php | 4 +- .../UnknownNamedArgsToOptionsFolder.php | 128 ++++++++++++++++++ 8 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/Options/known_named_args_stay_named.php.inc create mode 100644 rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/Options/skip_unknown_arg_without_options_parameter.php.inc create mode 100644 rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/Options/unknown_named_arg_goes_to_options.php.inc create mode 100644 rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Source/Options/ConstraintWithOptions.php create mode 100644 rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Source/Options/ConstraintWithoutOptions.php create mode 100644 src/PhpAttribute/NodeFactory/UnknownNamedArgsToOptionsFolder.php diff --git a/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/Options/known_named_args_stay_named.php.inc b/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/Options/known_named_args_stay_named.php.inc new file mode 100644 index 00000000000..551bcbd022a --- /dev/null +++ b/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/Options/known_named_args_stay_named.php.inc @@ -0,0 +1,29 @@ + +----- + diff --git a/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/Options/skip_unknown_arg_without_options_parameter.php.inc b/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/Options/skip_unknown_arg_without_options_parameter.php.inc new file mode 100644 index 00000000000..393be16070f --- /dev/null +++ b/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/Options/skip_unknown_arg_without_options_parameter.php.inc @@ -0,0 +1,29 @@ + +----- + diff --git a/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/Options/unknown_named_arg_goes_to_options.php.inc b/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/Options/unknown_named_arg_goes_to_options.php.inc new file mode 100644 index 00000000000..9599eae08bd --- /dev/null +++ b/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/Options/unknown_named_arg_goes_to_options.php.inc @@ -0,0 +1,29 @@ + +----- + true])] + public $description; +} + +?> diff --git a/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Source/Options/ConstraintWithOptions.php b/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Source/Options/ConstraintWithOptions.php new file mode 100644 index 00000000000..3376cb4ee89 --- /dev/null +++ b/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Source/Options/ConstraintWithOptions.php @@ -0,0 +1,25 @@ + $options + */ + public function __construct( + public ?int $min = null, + public ?int $max = null, + public ?string $message = null, + public array $options = [], + ) { + } +} diff --git a/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Source/Options/ConstraintWithoutOptions.php b/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Source/Options/ConstraintWithoutOptions.php new file mode 100644 index 00000000000..4c281f9ad3b --- /dev/null +++ b/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Source/Options/ConstraintWithoutOptions.php @@ -0,0 +1,16 @@ +ruleWithConfiguration(AnnotationToAttributeRector::class, [ @@ -46,6 +48,10 @@ new AnnotationToAttribute('Symfony\Component\Validator\Constraints\Length'), new AnnotationToAttribute('Symfony\Component\Validator\Constraints\File'), + // unknown named args fold into the attribute's $options parameter + new AnnotationToAttribute(ConstraintWithOptions::class), + new AnnotationToAttribute(ConstraintWithoutOptions::class), + // JMS + Symfony new AnnotationToAttribute('Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter'), diff --git a/src/PhpAttribute/NodeFactory/PhpAttributeGroupFactory.php b/src/PhpAttribute/NodeFactory/PhpAttributeGroupFactory.php index 7275966c732..9cafd9db287 100644 --- a/src/PhpAttribute/NodeFactory/PhpAttributeGroupFactory.php +++ b/src/PhpAttribute/NodeFactory/PhpAttributeGroupFactory.php @@ -32,7 +32,8 @@ public function __construct( private AttributeNameFactory $attributeNameFactory, private NamedArgsFactory $namedArgsFactory, private AnnotationToAttributeIntegerValueCaster $annotationToAttributeIntegerValueCaster, - private AttributeArrayNameInliner $attributeArrayNameInliner + private AttributeArrayNameInliner $attributeArrayNameInliner, + private UnknownNamedArgsToOptionsFolder $unknownNamedArgsToOptionsFolder ) { } @@ -85,6 +86,7 @@ public function create( $this->annotationToAttributeIntegerValueCaster->castAttributeTypes($annotationToAttribute, $args); $args = $this->attributeArrayNameInliner->inlineArrayToArgs($args, $annotationToAttribute->getAttributeClass()); + $args = $this->unknownNamedArgsToOptionsFolder->fold($args, $annotationToAttribute->getAttributeClass()); $attributeName = $this->attributeNameFactory->create( $annotationToAttribute, diff --git a/src/PhpAttribute/NodeFactory/UnknownNamedArgsToOptionsFolder.php b/src/PhpAttribute/NodeFactory/UnknownNamedArgsToOptionsFolder.php new file mode 100644 index 00000000000..c9557dfe3db --- /dev/null +++ b/src/PhpAttribute/NodeFactory/UnknownNamedArgsToOptionsFolder.php @@ -0,0 +1,128 @@ + $args + * @return list + */ + public function fold(array $args, string $attributeClass): array + { + $constructorParameterNames = $this->resolveConstructorParameterNames($attributeClass); + if ($constructorParameterNames === null) { + return $args; + } + + $keptArgs = []; + $optionItems = []; + $explicitOptionsArg = null; + + foreach ($args as $arg) { + if (! $arg->name instanceof Identifier) { + $keptArgs[] = $arg; + continue; + } + + $name = $arg->name->toString(); + + if ($name === self::OPTIONS_PARAMETER) { + $explicitOptionsArg = $arg; + continue; + } + + if (in_array($name, $constructorParameterNames, true)) { + $keptArgs[] = $arg; + continue; + } + + $optionItems[] = new ArrayItem($arg->value, new String_($name)); + } + + if ($optionItems === []) { + return $args; + } + + if ($explicitOptionsArg instanceof Arg) { + // an explicit options: ... of an unexpected shape cannot be merged safely, keep the original arguments + if (! $explicitOptionsArg->value instanceof Array_) { + return $args; + } + + $optionItems = [...$explicitOptionsArg->value->items, ...$optionItems]; + } + + $keptArgs[] = new Arg(new Array_($optionItems), false, false, [], new Identifier(self::OPTIONS_PARAMETER)); + + return $keptArgs; + } + + /** + * @return string[]|null null when the attribute cannot be folded: class or constructor unknown, variadic, or no + * array-accepting $options parameter + */ + private function resolveConstructorParameterNames(string $attributeClass): ?array + { + if (! $this->reflectionProvider->hasClass($attributeClass)) { + return null; + } + + $classReflection = $this->reflectionProvider->getClass($attributeClass); + if (! $classReflection->hasConstructor()) { + return null; + } + + $variants = $classReflection->getConstructor() + ->getVariants(); + if (count($variants) !== 1) { + return null; + } + + $parameterNames = []; + $hasOptionsParameter = false; + + foreach ($variants[0]->getParameters() as $parameterReflection) { + if ($parameterReflection->isVariadic()) { + return null; + } + + $parameterNames[] = $parameterReflection->getName(); + + if ($parameterReflection->getName() === self::OPTIONS_PARAMETER) { + $arrayType = new ArrayType(new MixedType(), new MixedType()); + $hasOptionsParameter = $parameterReflection->getType() + ->accepts($arrayType, true) + ->yes(); + } + } + + return $hasOptionsParameter ? $parameterNames : null; + } +}