Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Fixture\Options;

use Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Source\Options\ConstraintWithOptions;

final class KnownNamedArgsStayNamed
{
/**
* @ConstraintWithOptions(min=1, max=400)
*/
public $description;
}

?>
-----
<?php

namespace Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Fixture\Options;

use Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Source\Options\ConstraintWithOptions;

final class KnownNamedArgsStayNamed
{
#[ConstraintWithOptions(min: 1, max: 400)]
public $description;
}

?>
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Fixture\Options;

use Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Source\Options\ConstraintWithoutOptions;

final class SkipUnknownArgWithoutOptionsParameter
{
/**
* @ConstraintWithoutOptions(min=1, other=true)
*/
public $description;
}

?>
-----
<?php

namespace Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Fixture\Options;

use Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Source\Options\ConstraintWithoutOptions;

final class SkipUnknownArgWithoutOptionsParameter
{
#[ConstraintWithoutOptions(min: 1, other: true)]
public $description;
}

?>
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Fixture\Options;

use Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Source\Options\ConstraintWithOptions;

final class UnknownNamedArgGoesToOptions
{
/**
* @ConstraintWithOptions(min=1, max=400, message="too long", allowEmptyString=true)
*/
public $description;
}

?>
-----
<?php

namespace Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Fixture\Options;

use Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Source\Options\ConstraintWithOptions;

final class UnknownNamedArgGoesToOptions
{
#[ConstraintWithOptions(min: 1, max: 400, message: 'too long', options: ['allowEmptyString' => true])]
public $description;
}

?>
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Source\Options;

use Attribute;

/**
* Mimics Symfony validator constraints: only a few named constructor parameters, everything else goes via $options.
*/
#[Attribute(Attribute::TARGET_PROPERTY)]
final class ConstraintWithOptions
{
/**
* @param array<string, mixed> $options
*/
public function __construct(
public ?int $min = null,
public ?int $max = null,
public ?string $message = null,
public array $options = [],
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Source\Options;

use Attribute;

#[Attribute(Attribute::TARGET_PROPERTY)]
final class ConstraintWithoutOptions
{
public function __construct(
public ?int $min = null,
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
use Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Source\GenericAnnotation;
use Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Source\GenericSingleImplicitAnnotation;
use Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Source\NotAnAttribute;
use Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Source\Options\ConstraintWithOptions;
use Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Source\Options\ConstraintWithoutOptions;

return static function (RectorConfig $rectorConfig): void {
$rectorConfig->ruleWithConfiguration(AnnotationToAttributeRector::class, [
Expand Down Expand Up @@ -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'),

Expand Down
4 changes: 3 additions & 1 deletion src/PhpAttribute/NodeFactory/PhpAttributeGroupFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {
}

Expand Down Expand Up @@ -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,
Expand Down
128 changes: 128 additions & 0 deletions src/PhpAttribute/NodeFactory/UnknownNamedArgsToOptionsFolder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php

declare(strict_types=1);

namespace Rector\PhpAttribute\NodeFactory;

use PhpParser\Node\Arg;
use PhpParser\Node\ArrayItem;
use PhpParser\Node\Expr\Array_;
use PhpParser\Node\Identifier;
use PhpParser\Node\Scalar\String_;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Type\ArrayType;
use PHPStan\Type\MixedType;

/**
* Annotation arguments become named attribute arguments. A named argument that the attribute constructor does not
* declare is a runtime error ("Unknown named parameter"), while the annotation form accepted it, e.g. Symfony validator
* constraints that take extra settings through an $options array.
*
* When the constructor has no parameter of that name but does have an array-accepting $options parameter, the unknown
* arguments are folded into it. In every other case the arguments are left untouched.
*/
final readonly class UnknownNamedArgsToOptionsFolder
{
private const string OPTIONS_PARAMETER = 'options';

public function __construct(
private ReflectionProvider $reflectionProvider
) {
}

/**
* @param list<Arg> $args
* @return list<Arg>
*/
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;
}
}
Loading