diff --git a/README.md b/README.md index 7ebf647..1e2606c 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,11 @@ foreach (Attributes::findTargetProperties(Column::class) as $target) { var_dump($target->attribute, $target->class, $target->name); } +// Find the target method-parameters of the UserInput attribute. +foreach (Attributes::findTargetMethodParameters(UserInput::class) as $target) { + var_dump($target->attribute, $target->class, $target->method, $target->name); +} + // Filter target methods using a predicate. // You can also filter target classes and properties. $predicate = fn($attribute) => is_a($attribute, Route::class, true); diff --git a/src/AttributeGroupsReflector.php b/src/AttributeGroupsReflector.php new file mode 100644 index 0000000..ad837ee --- /dev/null +++ b/src/AttributeGroupsReflector.php @@ -0,0 +1,51 @@ +[] + */ + public function attrGroupsToAttributes(array $attrGroups): array + { + $evaluator = new ConstExprEvaluator(function (Expr $expr) { + if ($expr instanceof Expr\ClassConstFetch && $expr->class instanceof Node\Name && $expr->name instanceof Node\Identifier) { + return constant(sprintf('%s::%s', $expr->class->toString(), $expr->name->toString())); + } + + throw new ConstExprEvaluationException("Expression of type {$expr->getType()} cannot be evaluated"); + }); + + $attributes = []; + foreach ($attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + $argValues = []; + foreach ($attr->args as $i => $arg) { + if ($arg->name === null) { + $argValues[$i] = $evaluator->evaluateDirectly($arg->value); + continue; + } + + $argValues[$arg->name->toString()] = $evaluator->evaluateDirectly($arg->value); + } + $attributes[] = new FakeAttribute( + $attr->name, + $argValues, + ); + } + } + + return $attributes; // @phpstan-ignore return.type + } +} diff --git a/src/Attributes.php b/src/Attributes.php index cf8a91a..036ce8d 100644 --- a/src/Attributes.php +++ b/src/Attributes.php @@ -61,6 +61,18 @@ public static function findTargetProperties(string $attribute): array return self::getCollection()->findTargetProperties($attribute); } + /** + * @template T of object + * + * @param class-string $attribute + * + * @return TargetMethodParameter[] + */ + public static function findTargetMethodParameters(string $attribute): array + { + return self::getCollection()->findTargetMethodParameters($attribute); + } + /** * @param callable(class-string $attribute, class-string $class):bool $predicate * @@ -91,6 +103,16 @@ public static function filterTargetProperties(callable $predicate): array return self::getCollection()->filterTargetProperties($predicate); } + /** + * @param callable(class-string $attribute, class-string $class, string $property, string $method):bool $predicate + * + * @return array> + */ + public static function filterTargetMethodParameters(callable $predicate): array + { + return self::getCollection()->filterTargetMethodParameters($predicate); + } + /** * @param class-string $class * diff --git a/src/CachedParser.php b/src/CachedParser.php new file mode 100644 index 0000000..4715bde --- /dev/null +++ b/src/CachedParser.php @@ -0,0 +1,45 @@ + */ + private array $parserCache = []; + + public function __construct(Parser $parser) + { + $this->parser = $parser; + } + + /** + * @return Node[] + */ + public function parse(string $file): array + { + if (isset($this->parserCache[$file])) { + return $this->parserCache[$file]; + } + $contents = file_get_contents($file); + if ($contents === false) { + return []; + } + + $ast = $this->parser->parse($contents); + assert($ast !== null); + $nameTraverser = new NodeTraverser(new NameResolver()); + + return $this->parserCache[$file] = $nameTraverser->traverse($ast); + } +} diff --git a/src/ClassAttributeCollector.php b/src/ClassAttributeCollector.php index 11bd4f9..aa00a35 100644 --- a/src/ClassAttributeCollector.php +++ b/src/ClassAttributeCollector.php @@ -4,10 +4,7 @@ use Attribute; use Composer\IO\IOInterface; -use PhpParser\ConstExprEvaluationException; -use PhpParser\ConstExprEvaluator; use PhpParser\Node; -use PhpParser\Node\Expr; use PhpParser\Node\Stmt\ClassLike; use PhpParser\NodeTraverser; use PhpParser\NodeVisitor\NameResolver; @@ -16,6 +13,7 @@ use ReflectionAttribute; use ReflectionClass; use ReflectionException; +use ReflectionMethod; use ReflectionProperty; use function file_get_contents; @@ -29,14 +27,12 @@ class ClassAttributeCollector { private IOInterface $io; - private Parser $parser; + private CachedParser $cachedParser; - /** @var array */ - private array $parserCache = []; public function __construct(IOInterface $io, Parser $parser) { $this->io = $io; - $this->parser = $parser; + $this->cachedParser = new CachedParser($parser); } /** * @param class-string $class @@ -45,6 +41,7 @@ public function __construct(IOInterface $io, Parser $parser) * array, * array, * array, + * array>, * } * * @throws ReflectionException @@ -54,7 +51,7 @@ public function collectAttributes(string $class): array $classReflection = new ReflectionClass($class); if ($this->isAttribute($classReflection)) { - return [ [], [], [] ]; + return [ [], [], [], [] ]; } $classAttributes = []; @@ -74,23 +71,15 @@ public function collectAttributes(string $class): array } $methodAttributes = []; + $methodParameterAttributes = []; foreach ($classReflection->getMethods() as $methodReflection) { - foreach ($this->getMethodAttributes($methodReflection) as $attribute) { - if (self::isAttributeIgnored($attribute->getName())) { - continue; - } - - $method = $methodReflection->name; - - $this->io->debug("Found attribute {$attribute->getName()} on $class::$method"); - - $methodAttributes[] = new TransientTargetMethod( - $attribute->getName(), - $attribute->getArguments(), - $method, - ); - } + $this->collectMethodAndParameterAttributes( + $class, + $methodReflection, + $methodAttributes, + $methodParameterAttributes, + ); } $propertyAttributes = []; @@ -114,7 +103,7 @@ public function collectAttributes(string $class): array } } - return [ $classAttributes, $methodAttributes, $propertyAttributes ]; + return [ $classAttributes, $methodAttributes, $propertyAttributes, $methodParameterAttributes ]; } /** @@ -189,7 +178,7 @@ public function enterNode(Node $node) return []; } - return $this->attrGroupsToAttributes($classVisitor->classNodeToReturn->attrGroups); + return (new AttributeGroupsReflector())->attrGroupsToAttributes($classVisitor->classNodeToReturn->attrGroups); } /** @@ -197,55 +186,7 @@ public function enterNode(Node $node) */ private function parse(string $file): array { - if (isset($this->parserCache[$file])) { - return $this->parserCache[$file]; - } - $contents = file_get_contents($file); - if ($contents === false) { - return []; - } - - $ast = $this->parser->parse($contents); - assert($ast !== null); - $nameTraverser = new NodeTraverser(new NameResolver()); - - return $this->parserCache[$file] = $nameTraverser->traverse($ast); - } - - /** - * @param Node\AttributeGroup[] $attrGroups - * @return ReflectionAttribute[] - */ - private function attrGroupsToAttributes(array $attrGroups): array - { - $evaluator = new ConstExprEvaluator(function (Expr $expr) { - if ($expr instanceof Expr\ClassConstFetch && $expr->class instanceof Node\Name && $expr->name instanceof Node\Identifier) { - return constant(sprintf('%s::%s', $expr->class->toString(), $expr->name->toString())); - } - - throw new ConstExprEvaluationException("Expression of type {$expr->getType()} cannot be evaluated"); - }); - - $attributes = []; - foreach ($attrGroups as $attrGroup) { - foreach ($attrGroup->attrs as $attr) { - $argValues = []; - foreach ($attr->args as $i => $arg) { - if ($arg->name === null) { - $argValues[$i] = $evaluator->evaluateDirectly($arg->value); - continue; - } - - $argValues[$arg->name->toString()] = $evaluator->evaluateDirectly($arg->value); - } - $attributes[] = new FakeAttribute( - $attr->name, - $argValues, - ); - } - } - - return $attributes; // @phpstan-ignore return.type + return $this->cachedParser->parse($file); } /** @@ -304,7 +245,7 @@ public function enterNode(Node $node): ?int return []; } - return $this->attrGroupsToAttributes($propertyVisitor->propertyNodeToReturn->attrGroups); + return (new AttributeGroupsReflector())->attrGroupsToAttributes($propertyVisitor->propertyNodeToReturn->attrGroups); } /** @@ -361,6 +302,38 @@ public function enterNode(Node $node): ?int return []; } - return $this->attrGroupsToAttributes($methodVisitor->methodNodeToReturn->attrGroups); + return (new AttributeGroupsReflector())->attrGroupsToAttributes($methodVisitor->methodNodeToReturn->attrGroups); + } + + /** + * @param string $class + * @param ReflectionMethod $methodReflection + * @param array $methodAttributes + * @param array> $methodParameterAttributes + * @return void + */ + private function collectMethodAndParameterAttributes(string $class, \ReflectionMethod $methodReflection, array &$methodAttributes, array &$methodParameterAttributes): void + { + $parameterAttributeCollector = new ParameterAttributeCollector($this->io, $this->cachedParser); + foreach ($this->getMethodAttributes($methodReflection) as $attribute) { + if (self::isAttributeIgnored($attribute->getName())) { + continue; + } + + $method = $methodReflection->name; + + $this->io->debug("Found attribute {$attribute->getName()} on $class::$method"); + + $methodAttributes[] = new TransientTargetMethod( + $attribute->getName(), + $attribute->getArguments(), + $method, + ); + } + + $parameterAttributes = $parameterAttributeCollector->collectAttributes($methodReflection); + if ($parameterAttributes !== []) { + $methodParameterAttributes[] = $parameterAttributes; + } } } diff --git a/src/Collection.php b/src/Collection.php index f2fa0a2..3cc51e9 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -24,6 +24,10 @@ final class Collection * @var array> */ private array $targetProperties; + /** + * @var array> + */ + private array $targetMethodParameters; /** * @param array> $targetClasses * Where _key_ is an attribute class and _value_ an array of arrays @@ -34,12 +38,16 @@ final class Collection * @param array> $targetProperties * Where _key_ is an attribute class and _value_ an array of arrays * where 0 are the attribute arguments, 1 is a target class, and 2 is the target property. + * @param array> $targetMethodParameters + * Where _key_ is an attribute class and _value_ an array of arrays + * where 0 are the attribute arguments, 1 is a target class, 2 is the target method, and 3 is the target parameter. */ - public function __construct(array $targetClasses, array $targetMethods, array $targetProperties) + public function __construct(array $targetClasses, array $targetMethods, array $targetProperties, array $targetMethodParameters) { $this->targetClasses = $targetClasses; $this->targetMethods = $targetMethods; $this->targetProperties = $targetProperties; + $this->targetMethodParameters = $targetMethodParameters; } /** * @template T of object @@ -118,6 +126,46 @@ private static function createMethodAttribute(string $attribute, array $argument } } + /** + * @template T of object + * + * @param class-string $attribute + * + * @return array> + */ + public function findTargetMethodParameters(string $attribute): array + { + return array_map( + fn(array $t) => self::createMethodParameterAttribute($attribute, ...$t), + $this->targetMethodParameters[$attribute] ?? [], + ); + } + + /** + * @template T of object + * + * @param class-string $attribute + * @param array $arguments + * @param class-string $class + * @param non-empty-string $method + * @param non-empty-string $parameter + * + * @return TargetMethodParameter + */ + private static function createMethodParameterAttribute(string $attribute, array $arguments, string $class, string $method, string $parameter): object + { + try { + $a = new $attribute(...$arguments); + return new TargetMethodParameter($a, $class, $method, $parameter); + } catch (Throwable $e) { + throw new RuntimeException( + "An error occurred while instantiating attribute $attribute on method $class::$method($parameter)", + 0, + $e, + ); + } + } + /** * @template T of object * @@ -202,6 +250,32 @@ public function filterTargetMethods(callable $predicate): array return $ar; } + /** + * @param callable(class-string $attribute, class-string $class, non-empty-string $method, non-empty-string $parameter):bool $predicate + * + * @return array> + */ + public function filterTargetMethodParameters(callable $predicate): array + { + $ar = []; + + foreach ($this->targetMethodParameters as $attribute => $references) { + foreach ($references as [$arguments, $class, $method, $parameter]) { + if ($predicate($attribute, $class, $method, $parameter)) { + $ar[] = self::createMethodParameterAttribute( + $attribute, + $arguments, + $class, + $method, + $parameter + ); + } + } + } + + return $ar; + } + /** * @param callable(class-string $attribute, class-string $class, non-empty-string $property):bool $predicate * diff --git a/src/MemoizeAttributeCollector.php b/src/MemoizeAttributeCollector.php index f2a80d4..5b7d292 100644 --- a/src/MemoizeAttributeCollector.php +++ b/src/MemoizeAttributeCollector.php @@ -26,12 +26,14 @@ class MemoizeAttributeCollector * array, * array, * array, + * array>, * }> * Where _key_ is a class and _value is an array where: * - `0` is a timestamp * - `1` is an array of class attributes * - `2` is an array of method attributes * - `3` is an array of property attributes + * - `4` is an array of arrays. _key_ is a method name and _value_ parameter attributes */ private array $state; @@ -62,7 +64,8 @@ public function collectAttributes(array $classMap): TransientCollection $classAttributes, $methodAttributes, $propertyAttributes, - ] = $this->state[$class] ?? [ 0, [], [], [] ]; + $methodParameterAttributes, + ] = $this->state[$class] ?? [ 0, [], [], [], [] ]; $mtime = filemtime($filepath); @@ -79,6 +82,7 @@ public function collectAttributes(array $classMap): TransientCollection $classAttributes, $methodAttributes, $propertyAttributes, + $methodParameterAttributes, ] = $classAttributeCollector->collectAttributes($class); } catch (Throwable $e) { $this->io->error( @@ -86,7 +90,7 @@ public function collectAttributes(array $classMap): TransientCollection ); } - $this->state[$class] = [ time(), $classAttributes, $methodAttributes, $propertyAttributes ]; + $this->state[$class] = [ time(), $classAttributes, $methodAttributes, $propertyAttributes, $methodParameterAttributes ]; } if (count($classAttributes)) { @@ -95,6 +99,9 @@ public function collectAttributes(array $classMap): TransientCollection if (count($methodAttributes)) { $collector->addMethodAttributes($class, $methodAttributes); } + if (count($methodParameterAttributes)) { + $collector->addMethodParameterAttributes($class, $methodParameterAttributes); + } if (count($propertyAttributes)) { $collector->addTargetProperties($class, $propertyAttributes); } diff --git a/src/ParameterAttributeCollector.php b/src/ParameterAttributeCollector.php new file mode 100644 index 0000000..230cdc6 --- /dev/null +++ b/src/ParameterAttributeCollector.php @@ -0,0 +1,138 @@ +io = $io; + $this->cachedParser = $parser; + } + + /** + * @return array + */ + public function collectAttributes(\ReflectionFunctionAbstract $reflectionFunctionAbstract): array + { + $funcParameterAttributes = []; + foreach ($reflectionFunctionAbstract->getParameters() as $parameter) { + $attributes = $this->getParameterAttributes($parameter); + $functionName = $reflectionFunctionAbstract->name; + $parameterName = $parameter->name; + assert($functionName !== ''); + assert($parameterName !== ''); + + $paramLabel = ''; + if ($reflectionFunctionAbstract instanceof \ReflectionMethod) { + $paramLabel = $reflectionFunctionAbstract->class . '::' . $functionName . '(' . $parameterName . ')'; + } elseif ($reflectionFunctionAbstract instanceof \ReflectionFunction) { + $paramLabel = $functionName . '(' . $parameterName . ')'; + } + + foreach ($attributes as $attribute) { + $this->io->debug("Found attribute {$attribute->getName()} on $paramLabel"); + + $funcParameterAttributes[] = new TransientTargetMethodParameter( + $attribute->getName(), + $attribute->getArguments(), + $functionName, + $parameterName + ); + } + } + + return $funcParameterAttributes; + } + + /** + * @return \ReflectionAttribute[] + */ + private function getParameterAttributes(\ReflectionParameter $parameterReflection): array + { + if (PHP_VERSION_ID >= 80000) { + return $parameterReflection->getAttributes(); + } + + $reflectionClass = $parameterReflection->getDeclaringClass(); + if ($reflectionClass === null || $reflectionClass->getFileName() === false) { + return []; + } + + $ast = $this->parse($reflectionClass->getFileName()); + $parameterVisitor = new class ($reflectionClass->getName(), $parameterReflection->getDeclaringFunction()->getName(), $parameterReflection->getName()) extends NodeVisitorAbstract { + private string $className; + + private string $methodName; + private string $parameterName; + + public ?Node\Param $paramToReturn = null; + + public function __construct(string $className, string $methodName, string $parameterName) + { + $this->className = $className; + $this->methodName = $methodName; + $this->parameterName = $parameterName; + } + + public function enterNode(Node $node): ?int + { + if ($node instanceof ClassLike) { + if ($node->namespacedName === null) { + return self::DONT_TRAVERSE_CHILDREN; + } + if ($node->namespacedName->toString() !== $this->className) { + return self::DONT_TRAVERSE_CHILDREN; + } + } + + if ($node instanceof Node\Stmt\ClassMethod) { + if ($node->name->toString() !== $this->methodName) { + return self::DONT_TRAVERSE_CHILDREN; + } + } + + if ($node instanceof Node\Param) { + if ( + $node->var instanceof Node\Expr\Variable + && $node->var->name === $this->parameterName + ) { + $this->paramToReturn = $node; + return self::DONT_TRAVERSE_CHILDREN; + } + } + + return null; + } + }; + $traverser = new NodeTraverser($parameterVisitor); + $traverser->traverse($ast); + + if ($parameterVisitor->paramToReturn === null) { + return []; + } + + return (new AttributeGroupsReflector())->attrGroupsToAttributes($parameterVisitor->paramToReturn->attrGroups); + } + + /** + * @return Node[] + */ + private function parse(string $file): array + { + return $this->cachedParser->parse($file); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index e92b502..f10c58f 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -27,7 +27,7 @@ final class Plugin implements PluginInterface, EventSubscriberInterface { public const CACHE_DIR = '.composer-attribute-collector'; public const VERSION_MAJOR = 2; - public const VERSION_MINOR = 0; + public const VERSION_MINOR = 1; /** * @uses onPostAutoloadDump diff --git a/src/TargetMethodParameter.php b/src/TargetMethodParameter.php new file mode 100644 index 0000000..1b025bd --- /dev/null +++ b/src/TargetMethodParameter.php @@ -0,0 +1,44 @@ +attribute = $attribute; + $this->class = $class; + $this->name = $name; + $this->method = $method; + } +} diff --git a/src/TransientCollection.php b/src/TransientCollection.php index 6f5c291..dde33e0 100644 --- a/src/TransientCollection.php +++ b/src/TransientCollection.php @@ -19,6 +19,11 @@ final class TransientCollection */ public array $methods = []; + /** + * @var array>> + */ + public array $methodParameters = []; + /** * @var array> * Where _key_ is a target class. @@ -45,6 +50,16 @@ public function addMethodAttributes(string $class, iterable $targets): void $this->methods[$class] = $targets; } + /** + * @param class-string $class + * @param iterable> $targets + * The target class. + */ + public function addMethodParameterAttributes(string $class, iterable $targets): void + { + $this->methodParameters[$class] = $targets; + } + /** * @param class-string $class * @param iterable $targets diff --git a/src/TransientCollectionRenderer.php b/src/TransientCollectionRenderer.php index 5968ed6..bb7b17a 100644 --- a/src/TransientCollectionRenderer.php +++ b/src/TransientCollectionRenderer.php @@ -2,6 +2,7 @@ namespace olvlvl\ComposerAttributeCollector; +use function is_iterable; use function var_export; /** @@ -16,6 +17,7 @@ public static function render(TransientCollection $collector): string $targetClassesCode = self::targetsToCode($collector->classes); $targetMethodsCode = self::targetsToCode($collector->methods); $targetPropertiesCode = self::targetsToCode($collector->properties); + $targetMethodParametersCode = self::targetsToCode($collector->methodParameters); return << new \olvlvl\ComposerAttributeCollector\Collection( + // classes $targetClassesCode, + // methods $targetMethodsCode, + // properties $targetPropertiesCode, + // method parameters + $targetMethodParametersCode, )); PHP; } /** * //phpcs:disable Generic.Files.LineLength.TooLong - * @param iterable> $targetByClass + * @param iterable>> $targetByClass * * @return string */ @@ -45,7 +52,7 @@ private static function targetsToCode(iterable $targetByClass): string /** * //phpcs:disable Generic.Files.LineLength.TooLong - * @param iterable> $targetByClass + * @param iterable>> $targetByClass * * @return array, class-string, 2?:non-empty-string }>> */ @@ -54,14 +61,29 @@ private static function targetsToArray(iterable $targetByClass): array $by = []; foreach ($targetByClass as $class => $targets) { - foreach ($targets as $t) { - $a = [ $t->arguments, $class ]; - - if ($t instanceof TransientTargetMethod || $t instanceof TransientTargetProperty) { - $a[] = $t->name; + foreach ($targets as $target) { + if (!is_iterable($target)) { + $target = [$target]; } - $by[$t->attribute][] = $a; + foreach ($target as $t) { + // args in order how the Target* classes expects them in __construct() + $args = [ $t->arguments, $class ]; + + if ( + $t instanceof TransientTargetMethod + || $t instanceof TransientTargetProperty + || $t instanceof TransientTargetMethodParameter + ) { + $args[] = $t->name; + } + + if ($t instanceof TransientTargetMethodParameter) { + $args[] = $t->method; + } + + $by[$t->attribute][] = $args; + } } } diff --git a/src/TransientTargetMethodParameter.php b/src/TransientTargetMethodParameter.php new file mode 100644 index 0000000..3bea4a1 --- /dev/null +++ b/src/TransientTargetMethodParameter.php @@ -0,0 +1,40 @@ + + */ + public array $arguments; + /** + * @var non-empty-string + */ + public string $name; + /** + * @var non-empty-string + */ + public string $method; + /** + * @param class-string $attribute The attribute class. + * @param array $arguments The attribute arguments. + * @param non-empty-string $method The target method. + * @param non-empty-string $name The target parameter. + */ + public function __construct(string $attribute, array $arguments, string $method, string $name) + { + $this->attribute = $attribute; + $this->arguments = $arguments; + $this->method = $method; + $this->name = $name; + } +} diff --git a/tests/Acme/PSR4/Presentation/ArticleController.php b/tests/Acme/PSR4/Presentation/ArticleController.php index 2e408c4..dbd74d5 100644 --- a/tests/Acme/PSR4/Presentation/ArticleController.php +++ b/tests/Acme/PSR4/Presentation/ArticleController.php @@ -11,6 +11,8 @@ use Acme\Attribute\Resource; use Acme\Attribute\Route; +use Acme81\Attribute\ParameterA; +use Acme81\Attribute\ParameterB; #[Resource("articles")] final class ArticleController @@ -24,4 +26,15 @@ public function list(): void public function show(int $id): void { } + + #[Route("/articles/method/", 'GET', 'articles:method')] + public function aMethod( + #[ParameterA("my parameter label")] + $myParameter, + #[ParameterB("my 2nd parameter label", "some more data")] + $anotherParameter, + #[ParameterA("my yet another parameter label")] + $yetAnotherParameter + ) { + } } diff --git a/tests/Acme81/Attribute/ParameterA.php b/tests/Acme81/Attribute/ParameterA.php new file mode 100644 index 0000000..66cc034 --- /dev/null +++ b/tests/Acme81/Attribute/ParameterA.php @@ -0,0 +1,16 @@ +label = $label; + } +} diff --git a/tests/Acme81/Attribute/ParameterB.php b/tests/Acme81/Attribute/ParameterB.php new file mode 100644 index 0000000..5ac8087 --- /dev/null +++ b/tests/Acme81/Attribute/ParameterB.php @@ -0,0 +1,19 @@ +label = $label; + $this->moreData = $moreData; + } +} diff --git a/tests/Acme81/PSR4/AFunction.php b/tests/Acme81/PSR4/AFunction.php new file mode 100644 index 0000000..c475fc1 --- /dev/null +++ b/tests/Acme81/PSR4/AFunction.php @@ -0,0 +1,11 @@ + [ + [ [ 'a' ], ArticleController::class, 'myMethod', 'myParamA', ], + [ [ 'a2' ], ArticleController::class, 'myMethod', 'myParamA2' ], + [ [ 'a3' ], ArticleController::class, 'myFoo', 'fooParam' ], + ], + ParameterB::class => [ + [ [ 'b', 'more data'], ArticleController::class, 'myMethod', 'myParamB' ], + ], + ] + ); + + $actual = $collection->filterTargetMethodParameters(fn($a) => is_a($a, ParameterA::class, true)); + + $this->assertEquals([ + new TargetMethodParameter(new ParameterA('a'), ArticleController::class, 'myMethod', 'myParamA'), + new TargetMethodParameter(new ParameterA('a2'), ArticleController::class, 'myMethod', 'myParamA2'), + new TargetMethodParameter(new ParameterA('a3'), ArticleController::class, 'myFoo', 'fooParam'), + ], $actual); + } + public function testFilterTargetProperties(): void { $collection = new Collection( @@ -110,6 +147,8 @@ public function testFilterTargetProperties(): void Text::class => [ [ [ ], Article::class, 'body' ], ] + ], + [ ] ); @@ -155,6 +194,8 @@ public function testForClass(): void Text::class => [ [ [ ], Article::class, 'body' ], ] + ], + [ ] ); diff --git a/tests/PluginTest.php b/tests/PluginTest.php index 448353e..6177868 100644 --- a/tests/PluginTest.php +++ b/tests/PluginTest.php @@ -23,13 +23,17 @@ use Acme\Attribute\Route; use Acme\Attribute\Subscribe; use Acme\PSR4\Presentation\ArticleController; +use Acme81\Attribute\ParameterA; +use Acme81\Attribute\ParameterB; use Composer\IO\NullIO; use olvlvl\ComposerAttributeCollector\Attributes; use olvlvl\ComposerAttributeCollector\Config; use olvlvl\ComposerAttributeCollector\Plugin; use olvlvl\ComposerAttributeCollector\TargetClass; use olvlvl\ComposerAttributeCollector\TargetMethod; +use olvlvl\ComposerAttributeCollector\TargetMethodParameter; use olvlvl\ComposerAttributeCollector\TargetProperty; +use PhpParser\Node\Param; use PHPUnit\Framework\TestCase; use ReflectionException; @@ -158,6 +162,10 @@ public static function provideTargetMethods(): array [ Route::class, [ + [ + new Route("/articles/method/", 'GET', 'articles:method'), + 'Acme\PSR4\Presentation\ArticleController::aMethod' + ], [ new Route("/articles", 'GET', 'articles:list'), 'Acme\PSR4\Presentation\ArticleController::list' @@ -188,6 +196,52 @@ public static function provideTargetMethods(): array ]; } + /** + * @dataProvider provideTargetMethodParameters + * + * @param class-string $attribute + * @param array $expected + */ + public function testTargetMethodParameters(string $attribute, array $expected): void + { + $actual = Attributes::findTargetMethodParameters($attribute); + + $this->assertEquals($expected, $this->collectMethodParameters($actual)); + } + + /** + * @return array }> + */ + public static function provideTargetMethodParameters(): array + { + return [ + + [ + ParameterA::class, + [ + [ + new ParameterA('my parameter label'), + 'Acme\PSR4\Presentation\ArticleController::aMethod(myParameter)' + ], + [ + new ParameterA('my yet another parameter label'), + 'Acme\PSR4\Presentation\ArticleController::aMethod(yetAnotherParameter)' + ], + ] + ], + [ + ParameterB::class, + [ + [ + new ParameterB('my 2nd parameter label', 'some more data'), + 'Acme\PSR4\Presentation\ArticleController::aMethod(anotherParameter)' + ], + ] + ], + + ]; + } + /** * @dataProvider provideTargetProperties * @@ -256,6 +310,7 @@ public function testFilterTargetMethods(): void ); $this->assertEquals([ + [ new Route("/articles/method/", 'GET', 'articles:method'), 'Acme\PSR4\Presentation\ArticleController::aMethod' ], [ new Route("/articles", 'GET', 'articles:list'), 'Acme\PSR4\Presentation\ArticleController::list' ], [ new Route("/articles/{id}", 'GET', 'articles:show'), 'Acme\PSR4\Presentation\ArticleController::show' ], [ new Get(), 'Acme\Presentation\FileController::list' ], @@ -296,6 +351,18 @@ public function testFilterTargetMethods81(): void $this->assertEquals($expected, $actual); } + public function testFilterTargetMethodParameters(): void + { + $actual = Attributes::filterTargetMethodParameters( + Attributes::predicateForAttributeInstanceOf(ParameterA::class) + ); + + $this->assertEquals([ + [ new ParameterA("my parameter label"), 'Acme\PSR4\Presentation\ArticleController::aMethod(myParameter)' ], + [ new ParameterA('my yet another parameter label'), 'Acme\PSR4\Presentation\ArticleController::aMethod(yetAnotherParameter)' ], + ], $this->collectMethodParameters($actual)); + } + public function testFilterTargetProperties(): void { $actual = Attributes::filterTargetProperties( @@ -323,6 +390,7 @@ public function testForClass(): void $this->assertEquals([ 'list' => [ new Route("/articles", 'GET', 'articles:list') ], 'show' => [ new Route("/articles/{id}", 'GET', 'articles:show') ], + 'aMethod' => [ new Route("/articles/method/", 'GET', 'articles:method') ], ], $forClass->methodsAttributes); } @@ -366,6 +434,26 @@ private function collectMethods(array $targets): array return $methods; } + /** + * @template T of object + * + * @param TargetMethodParameter[] $targets + * + * @return array + */ + private function collectMethodParameters(array $targets): array + { + $parameters = []; + + foreach ($targets as $target) { + $parameters[] = [ $target->attribute, "$target->class::$target->method($target->name)" ]; + } + + usort($parameters, fn($a, $b) => $a[1] <=> $b[1]); + + return $parameters; + } + /** * @template T of object *