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; + } +}