diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fc3df9..c8bb51b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ None Attributes are now collected from interfaces and traits as well as classes. +Parameter attributes are now collected. Use the method `findTargetParameters()` +to find target parameters, and the method `filterTargetParameters()` to filter +target parameters according to a predicate. + ### Deprecated Features None diff --git a/README.md b/README.md index a1c44fb..dc000d6 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,12 @@ _discover_ attribute targets in a codebase—for known targets you can use refle - Can cache discoveries to speed up consecutive runs. > [!NOTE] -> Currently, the plugin supports class, method, and property targets. +> Currently, the plugin supports class, method, property, and parameter targets. > You're welcome to [contribute](CONTRIBUTING.md) if you're interested in expending its support. +> [!WARNING] +> Attributes used on functions are ignored at this time. + #### Usage @@ -59,6 +62,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::findTargetParameters(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/phpcs.xml b/phpcs.xml index aff2a22..7f5eccb 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -18,6 +18,7 @@ tests/bootstrap.php + src/Collection.php tests/* diff --git a/src/Attributes.php b/src/Attributes.php index e4d6547..939aa98 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 TargetParameter[] + */ + public static function findTargetParameters(string $attribute): array + { + return self::getCollection()->findTargetParameters($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 filterTargetParameters(callable $predicate): array + { + return self::getCollection()->filterTargetParameters($predicate); + } + /** * @param class-string $class * diff --git a/src/ClassAttributeCollector.php b/src/ClassAttributeCollector.php index 30eab8d..290ef17 100644 --- a/src/ClassAttributeCollector.php +++ b/src/ClassAttributeCollector.php @@ -9,6 +9,7 @@ /** * @internal + * @readonly */ class ClassAttributeCollector { @@ -24,6 +25,7 @@ public function __construct( * array, * array, * array, + * array, * } * * @throws ReflectionException @@ -33,7 +35,7 @@ public function collectAttributes(string $class): array $classReflection = new ReflectionClass($class); if (self::isAttribute($classReflection)) { - return [ [], [], [] ]; + return [ [], [], [], [] ]; } $classAttributes = []; @@ -52,24 +54,18 @@ public function collectAttributes(string $class): array ); } + /** @var array $methodAttributes */ $methodAttributes = []; + /** @var array $parameterAttributes */ + $parameterAttributes = []; foreach ($classReflection->getMethods() as $methodReflection) { - foreach ($methodReflection->getAttributes() as $attribute) { - if (self::isAttributeIgnored($attribute)) { - continue; - } - - $method = $methodReflection->name; - - $this->log->debug("Found attribute {$attribute->getName()} on $class::$method"); - - $methodAttributes[] = new TransientTargetMethod( - $attribute->getName(), - $attribute->getArguments(), - $method, - ); - } + $this->collectMethodAndParameterAttributes( + $class, + $methodReflection, + $methodAttributes, + $parameterAttributes, + ); } $propertyAttributes = []; @@ -93,7 +89,7 @@ public function collectAttributes(string $class): array } } - return [ $classAttributes, $methodAttributes, $propertyAttributes ]; + return [ $classAttributes, $methodAttributes, $propertyAttributes, $parameterAttributes ]; } /** @@ -119,8 +115,75 @@ private static function isAttributeIgnored(ReflectionAttribute $attribute): bool { static $ignored = [ \ReturnTypeWillChange::class => true, + \SensitiveParameter::class => true, ]; return isset($ignored[$attribute->getName()]); // @phpstan-ignore offsetAccess.nonOffsetAccessible } + + /** + * @param array $methodAttributes + * @param array $parameterAttributes + */ + private function collectMethodAndParameterAttributes( + string $class, + \ReflectionMethod $methodReflection, + array &$methodAttributes, + array &$parameterAttributes, + ): void { + foreach ($methodReflection->getAttributes() as $attribute) { + if (self::isAttributeIgnored($attribute)) { + continue; + } + + $method = $methodReflection->name; + + $this->log->debug("Found attribute {$attribute->getName()} on $class::$method"); + + $methodAttributes[] = new TransientTargetMethod( + $attribute->getName(), + $attribute->getArguments(), + $method, + ); + } + + $parameterAttributes = array_merge( + $parameterAttributes, + $this->collectParameterAttributes($methodReflection), + ); + } + + /** + * @return array + */ + private function collectParameterAttributes(\ReflectionMethod $reflectionFunctionAbstract): array + { + $targets = []; + $class = $reflectionFunctionAbstract->class; + $method = $reflectionFunctionAbstract->name; + + foreach ($reflectionFunctionAbstract->getParameters() as $parameter) { + /** @var non-empty-string $name */ + $name = $parameter->name; + + $paramLabel = $class . '::' . $method . '(' . $name . ')'; + + foreach ($parameter->getAttributes() as $attribute) { + if (self::isAttributeIgnored($attribute)) { + continue; + } + + $this->log->debug("Found attribute {$attribute->getName()} on $paramLabel"); + + $targets[] = new TransientTargetParameter( + $attribute->getName(), + $attribute->getArguments(), + $method, + $name + ); + } + } + + return $targets; + } } diff --git a/src/Collection.php b/src/Collection.php index d95240e..90e38d0 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -22,11 +22,15 @@ 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> $targetParameters + * 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( private array $targetClasses, private array $targetMethods, private array $targetProperties, + private array $targetParameters, ) { } @@ -109,6 +113,50 @@ private static function createMethodAttribute( } } + /** + * @template T of object + * + * @param class-string $attribute + * + * @return array> + */ + public function findTargetParameters(string $attribute): array + { + return array_map( + fn(array $t) => self::createParameterAttribute($attribute, ...$t), + $this->targetParameters[$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 TargetParameter + */ + private static function createParameterAttribute( + string $attribute, + array $arguments, + string $class, + string $method, + string $parameter, + ): object { + try { + $a = new $attribute(...$arguments); + return new TargetParameter($a, $class, $method, $parameter); + } catch (Throwable $e) { + throw new RuntimeException( + "An error occurred while instantiating attribute $attribute on parameter $class::$method($parameter)", + previous: $e, + ); + } + } + /** * @template T of object * @@ -196,6 +244,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 filterTargetParameters(callable $predicate): array + { + $ar = []; + + foreach ($this->targetParameters as $attribute => $references) { + foreach ($references as [$arguments, $class, $method, $parameter]) { + if ($predicate($attribute, $class, $method, $parameter)) { + $ar[] = self::createParameterAttribute( + $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 63c9835..565c6be 100644 --- a/src/MemoizeAttributeCollector.php +++ b/src/MemoizeAttributeCollector.php @@ -22,12 +22,10 @@ class MemoizeAttributeCollector * array, * array, * array, + * array, * }> - * Where _key_ is a class and _value is an array where: + * 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 */ private array $state; @@ -58,7 +56,8 @@ public function collectAttributes(array $classMap): TransientCollection $classAttributes, $methodAttributes, $propertyAttributes, - ] = $this->state[$class] ?? [ 0, [], [], [] ]; + $parameterAttributes, + ] = $this->state[$class] ?? [ 0, [], [], [], [] ]; $mtime = filemtime($filepath); @@ -75,6 +74,7 @@ public function collectAttributes(array $classMap): TransientCollection $classAttributes, $methodAttributes, $propertyAttributes, + $parameterAttributes, ] = $classAttributeCollector->collectAttributes($class); } catch (Throwable $e) { $this->log->error( @@ -82,7 +82,13 @@ public function collectAttributes(array $classMap): TransientCollection ); } - $this->state[$class] = [ time(), $classAttributes, $methodAttributes, $propertyAttributes ]; + $this->state[$class] = [ + time(), + $classAttributes, + $methodAttributes, + $propertyAttributes, + $parameterAttributes, + ]; } if (count($classAttributes)) { @@ -91,6 +97,9 @@ public function collectAttributes(array $classMap): TransientCollection if (count($methodAttributes)) { $collector->addMethodAttributes($class, $methodAttributes); } + if (count($parameterAttributes)) { + $collector->addParameterAttributes($class, $parameterAttributes); + } if (count($propertyAttributes)) { $collector->addTargetProperties($class, $propertyAttributes); } diff --git a/src/Plugin.php b/src/Plugin.php index 4606261..a2ac034 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -28,7 +28,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/TargetParameter.php b/src/TargetParameter.php new file mode 100644 index 0000000..334ded6 --- /dev/null +++ b/src/TargetParameter.php @@ -0,0 +1,28 @@ +> + */ + public array $parameters = []; + /** * @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 addParameterAttributes(string $class, iterable $targets): void + { + $this->parameters[$class] = $targets; + } + /** * @param class-string $class * @param iterable $targets diff --git a/src/TransientCollectionRenderer.php b/src/TransientCollectionRenderer.php index 60f8018..dbb8fe3 100644 --- a/src/TransientCollectionRenderer.php +++ b/src/TransientCollectionRenderer.php @@ -16,6 +16,7 @@ public static function render(TransientCollection $collector): string $targetClassesCode = self::targetsToCode($collector->classes); $targetMethodsCode = self::targetsToCode($collector->methods); $targetPropertiesCode = self::targetsToCode($collector->properties); + $targetParametersCode = self::targetsToCode($collector->parameters); return <<> $targetByClass + * + * @param iterable> $targetByClass * * @return string */ @@ -45,9 +48,15 @@ private static function targetsToCode(iterable $targetByClass): string /** * //phpcs:disable Generic.Files.LineLength.TooLong - * @param iterable> $targetByClass * - * @return array, class-string, 2?:non-empty-string }>> + * @param iterable> $targetByClass + * + * @return array, + * class-string, + * 2?:non-empty-string, + * 3?:non-empty-string + * }>> */ private static function targetsToArray(iterable $targetByClass): array { @@ -57,7 +66,15 @@ private static function targetsToArray(iterable $targetByClass): array foreach ($targets as $t) { $a = [ $t->arguments, $class ]; - if ($t instanceof TransientTargetMethod || $t instanceof TransientTargetProperty) { + if ($t instanceof TransientTargetParameter) { + $a[] = $t->method; + } + + if ( + $t instanceof TransientTargetMethod + || $t instanceof TransientTargetProperty + || $t instanceof TransientTargetParameter + ) { $a[] = $t->name; } diff --git a/src/TransientTargetParameter.php b/src/TransientTargetParameter.php new file mode 100644 index 0000000..1418493 --- /dev/null +++ b/src/TransientTargetParameter.php @@ -0,0 +1,24 @@ + $arguments The attribute arguments. + * @param non-empty-string $method The target method. + * @param non-empty-string $name The target parameter. + */ + public function __construct( + public string $attribute, + public array $arguments, + public string $method, + public string $name, + ) { + } +} diff --git a/tests/Acme/PSR4/Presentation/ArticleController.php b/tests/Acme/PSR4/Presentation/ArticleController.php index 07ccb3a..537bb1f 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..f1a4ff7 --- /dev/null +++ b/tests/Acme81/Attribute/ParameterA.php @@ -0,0 +1,14 @@ + 'articles:show', 'pattern' => "/articles/{id}", 'method' => 'GET' ], 'show', ), + new TransientTargetMethod( + 'Acme\Attribute\Route', + [ "/articles/method/", 'GET', 'articles:method' ], + 'aMethod', + ), ], [], + [ + new TransientTargetParameter( + 'Acme81\Attribute\ParameterA', + ["my parameter label"], + 'aMethod', + 'myParameter' + ), + new TransientTargetParameter( + 'Acme81\Attribute\ParameterB', + ["my 2nd parameter label", "some more data"], + 'aMethod', + 'anotherParameter' + ), + new TransientTargetParameter( + 'Acme81\Attribute\ParameterA', + ["my yet another parameter label"], + 'aMethod', + 'yetAnotherParameter' + ), + ], ] ], @@ -108,6 +137,7 @@ public static function provideCollectAttributes(): array new TransientTargetMethod('Acme\Attribute\Subscribe', [], 'onEventA'), ], [], + [], ] ], @@ -127,6 +157,7 @@ public static function provideCollectAttributes(): array new TransientTargetProperty('Acme\Attribute\ActiveRecord\Text', [], 'body'), new TransientTargetProperty('Acme\Attribute\ActiveRecord\Boolean', [], 'active'), ], + [], ] ], diff --git a/tests/CollectionTest.php b/tests/CollectionTest.php index 4dfabbb..68f1b55 100644 --- a/tests/CollectionTest.php +++ b/tests/CollectionTest.php @@ -18,10 +18,13 @@ use Acme\PSR4\DeleteMenu; use Acme\PSR4\Presentation\ArticleController; use Closure; +use Acme81\Attribute\ParameterA; +use Acme81\Attribute\ParameterB; use olvlvl\ComposerAttributeCollector\Attributes; use olvlvl\ComposerAttributeCollector\Collection; use olvlvl\ComposerAttributeCollector\TargetClass; use olvlvl\ComposerAttributeCollector\TargetMethod; +use olvlvl\ComposerAttributeCollector\TargetParameter; use olvlvl\ComposerAttributeCollector\TargetProperty; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -52,6 +55,8 @@ public function testInstantiationErrorIsDecorated(string $expectedMessage, Closu Serial::class => [ [ [ 'Primary' => true ], Article::class, 'id' ], ] + ], + targetParameters: [ ] ); @@ -104,6 +109,8 @@ public function testFilterTargetClasses(): void targetMethods: [ ], targetProperties: [ + ], + targetParameters: [ ] ); @@ -134,6 +141,8 @@ public function testFilterTargetMethods(): void ], ], targetProperties: [ + ], + targetParameters: [ ] ); @@ -146,6 +155,36 @@ public function testFilterTargetMethods(): void ], $actual); } + public function testFilterTargetParameters(): void + { + $collection = new Collection( + targetClasses: [ + ], + targetMethods: [ + ], + targetProperties: [ + ], + targetParameters: [ + ParameterA::class => [ + [ [ '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->filterTargetParameters(fn($a) => is_a($a, ParameterA::class, true)); + + $this->assertEquals([ + new TargetParameter(new ParameterA('a'), ArticleController::class, 'myMethod', 'myParamA'), + new TargetParameter(new ParameterA('a2'), ArticleController::class, 'myMethod', 'myParamA2'), + new TargetParameter(new ParameterA('a3'), ArticleController::class, 'myFoo', 'fooParam'), + ], $actual); + } + public function testFilterTargetProperties(): void { $collection = new Collection( @@ -175,6 +214,8 @@ public function testFilterTargetProperties(): void Text::class => [ [ [ ], Article::class, 'body' ], ] + ], + targetParameters: [ ] ); @@ -220,6 +261,8 @@ public function testForClass(): void Text::class => [ [ [ ], Article::class, 'body' ], ] + ], + targetParameters: [ ] ); diff --git a/tests/PluginTest.php b/tests/PluginTest.php index 954202d..25eaaeb 100644 --- a/tests/PluginTest.php +++ b/tests/PluginTest.php @@ -24,12 +24,16 @@ use Acme\Attribute\Route; use Acme\Attribute\Subscribe; use Acme\PSR4\Presentation\ArticleController; +use Acme81\Attribute\ParameterA; +use Acme81\Attribute\ParameterB; use olvlvl\ComposerAttributeCollector\Attributes; use olvlvl\ComposerAttributeCollector\Config; use olvlvl\ComposerAttributeCollector\Plugin; use olvlvl\ComposerAttributeCollector\TargetClass; use olvlvl\ComposerAttributeCollector\TargetMethod; +use olvlvl\ComposerAttributeCollector\TargetParameter; use olvlvl\ComposerAttributeCollector\TargetProperty; +use PhpParser\Node\Param; use PHPUnit\Framework\TestCase; use ReflectionException; @@ -164,6 +168,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' @@ -194,6 +202,52 @@ public static function provideTargetMethods(): array ]; } + /** + * @dataProvider provideTargetParameters + * + * @param class-string $attribute + * @param array $expected + */ + public function testTargetParameters(string $attribute, array $expected): void + { + $actual = Attributes::findTargetParameters($attribute); + + $this->assertEquals($expected, $this->collectParameters($actual)); + } + + /** + * @return array }> + */ + public static function provideTargetParameters(): 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 * @@ -262,6 +316,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' ], @@ -301,6 +356,18 @@ public function testFilterTargetMethods81(): void $this->assertEquals($expected, $actual); } + public function testFilterTargetParameters(): void + { + $actual = Attributes::filterTargetParameters( + 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->collectParameters($actual)); + } + public function testFilterTargetProperties(): void { $actual = Attributes::filterTargetProperties( @@ -326,8 +393,9 @@ public function testForClass(): void ], $forClass->classAttributes); $this->assertEquals([ - 'list' => [ new Route("/articles", id: 'articles:list') ], - 'show' => [ new Route("/articles/{id}", id: 'articles:show') ], + '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); } @@ -371,6 +439,26 @@ private function collectMethods(array $targets): array return $methods; } + /** + * @template T of object + * + * @param TargetParameter[] $targets + * + * @return array + */ + private function collectParameters(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 *