├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── composer.json ├── docker-compose.yaml ├── phpcs.xml └── src ├── Attributes.php ├── ClassAttributeCollector.php ├── Collection.php ├── Config.php ├── Datastore.php ├── Datastore ├── FileDatastore.php └── RuntimeDatastore.php ├── Filter.php ├── Filter ├── Chain.php ├── ContentFilter.php └── InterfaceFilter.php ├── ForClass.php ├── MemoizeAttributeCollector.php ├── MemoizeClassMapFilter.php ├── MemoizeClassMapGenerator.php ├── Plugin.php ├── TargetClass.php ├── TargetMethod.php ├── TargetProperty.php ├── TransientCollection.php ├── TransientCollectionRenderer.php ├── TransientTargetClass.php ├── TransientTargetMethod.php └── TransientTargetProperty.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## v2.0.2 4 | 5 | ### New Requirements 6 | 7 | None 8 | 9 | ### New features 10 | 11 | None 12 | 13 | ### Deprecated Features 14 | 15 | None 16 | 17 | ### Backward Incompatible Changes 18 | 19 | None 20 | 21 | ### Other Changes 22 | 23 | - Fix PHP 8.4 deprecation notice "Implicitly marking parameter * as nullable is deprecated." 24 | - Simplify attribute creation functions. 25 | 26 | 27 | 28 | ## v2.0.1 29 | 30 | ### New Requirements 31 | 32 | None 33 | 34 | ### New features 35 | 36 | None 37 | 38 | ### Backward Incompatible Changes 39 | 40 | None 41 | 42 | ### Deprecated Features 43 | 44 | None 45 | 46 | ### Other Changes 47 | 48 | - #26 Fix enum support on PHP < 8.2.0 – @mnavarrocarter 49 | 50 | 51 | ## v1.2 to v2.0 52 | 53 | ### New Requirements 54 | 55 | None 56 | 57 | ### New features 58 | 59 | - The plugin now collects attributes on properties. `Attributes::findTargetProperties()` returns target properties, and `filterTargetProperties()` filters properties with a predicate. 60 | 61 | ### Deprecated Features 62 | 63 | - The `ignore-paths` directive has been replaced by `exclude`. 64 | 65 | ### Backward Incompatible Changes 66 | 67 | - The paths defined by the `include` and `exclude` directives are relative to the `composer.json` file. The `{vendor}` placeholder is replaced by the absolute path to the vendor directory. 68 | 69 | ### Other Changes 70 | 71 | - The plugin no longer uses a file cache by default. To persist a cache between runs, set the environment variable `COMPOSER_ATTRIBUTE_COLLECTOR_USE_CACHE` to `1`, `yes`, or `true`. 72 | 73 | 74 | 75 | ## v1.1 to v1.2 76 | 77 | ### New Requirements 78 | 79 | None 80 | 81 | ### New features 82 | 83 | - [#11](https://github.com/olvlvl/composer-attribute-collector/pull/11) Attribute instantiation errors are decorated to help find origin (@withinboredom @olvlvl) 84 | - [#12](https://github.com/olvlvl/composer-attribute-collector/pull/12) `Attributes::filterTargetClasses()` can filter target classes using a predicate (@olvlvl) 85 | - [#12](https://github.com/olvlvl/composer-attribute-collector/pull/12) `Attributes::filterTargetMethods()` can filter target methods using a predicate. `Attributes::predicateForAttributeInstanceOf()` can be used to create a predicate to filter classes or methods targeted by an attribute class or subclass (@olvlvl) 86 | - [#10](https://github.com/olvlvl/composer-attribute-collector/pull/10) 3 types of cache speed up generation by limiting updates to changed files (@xepozz @olvlvl) 87 | 88 | ### Deprecated Features 89 | 90 | None 91 | 92 | ### Backward Incompatible Changes 93 | 94 | None 95 | 96 | ### Other Changes 97 | 98 | None 99 | 100 | 101 | 102 | ## v1.0 to v1.1 103 | 104 | ### New Requirements 105 | 106 | None 107 | 108 | ### New features 109 | 110 | - File paths matching `symfony/cache/Traits` are ignored. 111 | - The option `extra.composer-attribute-collection.ignore-paths` can be used to ignore paths. 112 | 113 | ### Deprecated Features 114 | 115 | None 116 | 117 | ### Backward Incompatible Changes 118 | 119 | None 120 | 121 | ### Other Changes 122 | 123 | None 124 | 125 | 150 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available 126 | at [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via Pull Requests. 6 | 7 | ## Pull Requests 8 | 9 | - **Code style** — We're following a [Coding Standard][]. Check the code style with `make lint`. 10 | - **Code health** — We're using [PHPStan][] to analyse the code, with maximum scrutiny. Check the code with `make lint`. 11 | - **Add tests!** — Your contribution won't be accepted if it does not have tests. 12 | - **Document any change in behaviour** — Make sure the `README.md` and any other relevant documentation are kept 13 | up-to-date. 14 | - **Consider our release cycle** — We follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not 15 | an option. 16 | - **Create feature branches** — We won't pull from your main branch. 17 | - **One pull request per feature** — If you want to do more than one thing, send multiple pull requests. 18 | - **Send coherent history** — Make sure each individual commit in your pull request is meaningful. If you had to make 19 | multiple intermediate commits while developing, please [squash them][git-squash] before submitting. 20 | 21 | ## Running Tests 22 | 23 | We provide a Docker container for local development. Run `make test-container` to create a new session. Inside the 24 | container run `make test` to run the test suite. Alternatively, run `make test-coverage` for a breakdown of the code 25 | coverage. The coverage report is available in `build/coverage/index.html`. 26 | 27 | **Thanks for your contribution**! 28 | 29 | 30 | [Coding Standard]: phpcs.xml 31 | [git-squash]: http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages 32 | [PHPStan]: https://phpstan.org/user-guide/getting-started 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The olvlvl/composer-attribute-collector package is free software. 2 | It is released under the terms of the following BSD License. 3 | 4 | Copyright (c) 2022 by Olivier Laviale 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without modification, 8 | are permitted provided that the following conditions are met: 9 | 10 | * Redistributions of source code must retain the above copyright notice, 11 | this list of conditions and the following disclaimer. 12 | 13 | * Redistributions in binary form must reproduce the above copyright notice, 14 | this list of conditions and the following disclaimer in the documentation 15 | and/or other materials provided with the distribution. 16 | 17 | * Neither the name of Olivier Laviale nor the names of its 18 | contributors may be used to endorse or promote products derived from this 19 | software without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 22 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 23 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 25 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 26 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 27 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 28 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # composer-attribute-collector 2 | 3 | [![Release](https://img.shields.io/packagist/v/olvlvl/composer-attribute-collector.svg)](https://packagist.org/packages/olvlvl/composer-attribute-collector) 4 | [![Code Coverage](https://coveralls.io/repos/github/olvlvl/composer-attribute-collector/badge.svg?branch=main)](https://coveralls.io/r/olvlvl/composer-attribute-collector?branch=main) 5 | [![Downloads](https://img.shields.io/packagist/dt/olvlvl/composer-attribute-collector.svg)](https://packagist.org/packages/olvlvl/composer-attribute-collector) 6 | 7 | **composer-attribute-collector** is a plugin for [Composer][]. Its ambition is to provide a 8 | convenient way—and near zero cost—to retrieve targets of PHP 8 attributes. After the autoloader has 9 | been dumped, the plugin collects attribute targets and generates a static file. These targets can be 10 | retrieved through a convenient interface, without reflection. The plugin is useful when you need to 11 | _discover_ attribute targets in a codebase—for known targets you can use reflection. 12 | 13 | 14 | 15 | #### Features 16 | 17 | - Little configuration 18 | - No reflection in the generated file 19 | - No impact on performance 20 | - No dependency (except Composer of course) 21 | - A single interface to get attribute targets: classes, methods, and properties 22 | - Can cache discoveries to speed up consecutive runs. 23 | 24 | > [!NOTE] 25 | > Currently, the plugin supports class, method, and property targets. 26 | > You're welcome to [contribute](CONTRIBUTING.md) if you're interested in expending its support. 27 | 28 | 29 | 30 | #### Usage 31 | 32 | The following example demonstrates how targets and their attributes can be retrieved: 33 | 34 | ```php 35 | attribute is an instance of the specified attribute 48 | // with the actual data. 49 | var_dump($target->attribute, $target->name); 50 | } 51 | 52 | // Find the target methods of the Route attribute. 53 | foreach (Attributes::findTargetMethods(Route::class) as $target) { 54 | var_dump($target->attribute, $target->class, $target->name); 55 | } 56 | 57 | // Find the target properties of the Column attribute. 58 | foreach (Attributes::findTargetProperties(Column::class) as $target) { 59 | var_dump($target->attribute, $target->class, $target->name); 60 | } 61 | 62 | // Filter target methods using a predicate. 63 | // You can also filter target classes and properties. 64 | $predicate = fn($attribute) => is_a($attribute, Route::class, true); 65 | # or 66 | $predicate = Attributes::predicateForAttributeInstanceOf(Route::class); 67 | 68 | foreach (Attributes::filterTargetMethods($predicate) as $target) { 69 | var_dump($target->attribute, $target->class, $target->name); 70 | } 71 | 72 | // Find class, method, and property attributes for the ArticleController class. 73 | $attributes = Attributes::forClass(ArticleController::class); 74 | 75 | var_dump($attributes->classAttributes); 76 | var_dump($attributes->methodsAttributes); 77 | var_dump($attributes->propertyAttributes); 78 | ``` 79 | 80 | 81 | 82 | ## Getting started 83 | 84 | Here are a few steps to get you started. 85 | 86 | ### 1\. Configure the plugin 87 | 88 | The plugin only inspects paths and files specified in the configuration with the `include` property. 89 | That is usually your "src" directory. Add this section to your `composer.json` file to enable the 90 | generation of the "attributes" file when the autoloader is dumped. 91 | 92 | ```json 93 | { 94 | "extra": { 95 | "composer-attribute-collector": { 96 | "include": [ 97 | "src" 98 | ] 99 | } 100 | } 101 | } 102 | ``` 103 | 104 | Check the [Configuration options](#configuration) for more details. 105 | 106 | 107 | 108 | ### 2\. Install the plugin 109 | 110 | Use [Composer][] to install the plugin. 111 | You will be asked if you trust the plugin and wish to activate it, select `y` to proceed. 112 | 113 | ```shell 114 | composer require olvlvl/composer-attribute-collector 115 | ``` 116 | 117 | You should see log messages similar to this: 118 | 119 | ``` 120 | Generating autoload files 121 | Generating attributes file 122 | Generated attributes file in 9.137 ms 123 | Generated autoload files 124 | ``` 125 | 126 | > [!TIP] 127 | > See the [Frequently Asked Questions](#frequently-asked-questions) section 128 | > to automatically refresh the "attributes" file during development. 129 | 130 | 131 | 132 | ### 3\. Autoload the "attributes" file 133 | 134 | You can require the "attributes" file using `require_once 'vendor/attributes.php';` but you might 135 | prefer to use Composer's autoloading feature: 136 | 137 | ```json 138 | { 139 | "autoloading": { 140 | "files": [ 141 | "vendor/attributes.php" 142 | ] 143 | } 144 | } 145 | ``` 146 | 147 | 148 | 149 | ## Configuration 150 | 151 | Here are a few ways you can configure the plugin. 152 | 153 | 154 | 155 | ### Including paths or files ([root-only][]) 156 | 157 | Use the `include` property to define the paths or files to inspect for attributes. Without this 158 | property, the "attributes" file will be empty. 159 | 160 | The specified paths are relative to the `composer.json` file, and the `{vendor}` placeholder is 161 | replaced with the path to the vendor folder. 162 | 163 | ```json 164 | { 165 | "extra": { 166 | "composer-attribute-collector": { 167 | "include": [ 168 | "path-or-file/to/include" 169 | ] 170 | } 171 | } 172 | } 173 | ``` 174 | 175 | ### Excluding paths or files ([root-only][]) 176 | 177 | Use the `exclude` property to exclude paths or files from inspection. This is handy when files 178 | cause issues or have side effects. 179 | 180 | The specified paths are relative to the `composer.json` file, and the `{vendor}` placeholder is 181 | replaced with the path to the vendor folder. 182 | 183 | ```json 184 | { 185 | "extra": { 186 | "composer-attribute-collector": { 187 | "exclude": [ 188 | "path-or-file/to/exclude" 189 | ] 190 | } 191 | } 192 | } 193 | ``` 194 | 195 | ### Cache discoveries between runs 196 | 197 | The plugin is able to maintain a cache to reuse discoveries between runs. To enable the cache, 198 | set the environment variable `COMPOSER_ATTRIBUTE_COLLECTOR_USE_CACHE` to `1`, `yes`, or `true`. 199 | Cache items are persisted in the `.composer-attribute-collector` directory, you might want to add 200 | it to your `.gitignore` file. 201 | 202 | ```shell 203 | COMPOSER_ATTRIBUTE_COLLECTOR_USE_CACHE=1 composer dump-autoload 204 | ``` 205 | 206 | 207 | 208 | ## Test drive with the Symfony Demo 209 | 210 | You can try the plugin with a fresh installation of the [Symfony Demo Application](https://github.com/symfony/demo). 211 | 212 | > [!TIP] 213 | > The demo application configured with the plugin is [available on GitHub](https://github.com/olvlvl/composer-attribute-collector-usecase-symfony). 214 | 215 | See the [Getting started](#getting-started) section to set up the plugin. If all went well, the file 216 | `vendor/attributes.php` should be available. 217 | 218 | Now, you can try to get the controller methods tagged as routes. Create a PHP file with the 219 | following content and run it: 220 | 221 | ```php 222 | class#$target->name, path: {$target->attribute->getPath()}\n"; 234 | } 235 | ``` 236 | 237 | You should see an output similar to the following excerpt: 238 | 239 | ``` 240 | action: App\Controller\BlogController#index, path: / 241 | action: App\Controller\BlogController#index, path: /rss.xml 242 | action: App\Controller\BlogController#index, path: /page/{page<[1-9]\d{0,8}>} 243 | action: App\Controller\BlogController#postShow, path: /posts/{slug} 244 | action: App\Controller\BlogController#commentNew, path: /comment/{postSlug}/new 245 | action: App\Controller\BlogController#search, path: /search 246 | ``` 247 | 248 | 249 | 250 | ## Frequently Asked Questions 251 | 252 | **Do I need to generate an optimized autoloader?** 253 | 254 | You don't need to generate an optimized autoloader for this to work. The plugin uses code similar 255 | to Composer to find classes. Anything that works with Composer should work with the plugin. 256 | 257 | **Can I use the plugin during development?** 258 | 259 | Yes, you can use the plugin during development, but keep in mind the "attributes" file is only 260 | generated after the autoloader is dumped. If you modify attributes you will have to run 261 | `composer dump-autoload` to refresh the "attributes" file. 262 | 263 | As a workaround you could have watchers on the directories that contain classes with attributes to 264 | run `XDEBUG_MODE=off composer dump-autoload` when you make changes. [PhpStorm offers file 265 | watchers][phpstorm-watchers]. You could also use [spatie/file-system-watcher][], it only requires 266 | PHP. If the plugin is too slow for your liking, try running the command with 267 | `COMPOSER_ATTRIBUTE_COLLECTOR_USE_CACHE=yes`, it will enable caching and speed up consecutive runs. 268 | 269 | 270 | 271 | ---------- 272 | 273 | 274 | 275 | ## Continuous Integration 276 | 277 | The project is continuously tested by [GitHub actions](https://github.com/olvlvl/composer-attribute-collector/actions). 278 | 279 | [![Tests](https://github.com/olvlvl/composer-attribute-collector/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/olvlvl/composer-attribute-collector/actions/workflows/test.yml) 280 | [![Static Analysis](https://github.com/olvlvl/composer-attribute-collector/actions/workflows/static-analysis.yml/badge.svg?branch=main)](https://github.com/olvlvl/composer-attribute-collector/actions/workflows/static-analysis.yml) 281 | [![Code Style](https://github.com/olvlvl/composer-attribute-collector/actions/workflows/code-style.yml/badge.svg?branch=main)](https://github.com/olvlvl/composer-attribute-collector/actions/workflows/code-style.yml) 282 | 283 | 284 | 285 | ## Code of Conduct 286 | 287 | This project adheres to a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in 288 | this project and its community, you're expected to uphold this code. 289 | 290 | 291 | 292 | ## Contributing 293 | 294 | See [CONTRIBUTING](CONTRIBUTING.md) for details. 295 | 296 | 297 | 298 | [Composer]: https://getcomposer.org/ 299 | [root-only]: https://getcomposer.org/doc/04-schema.md#root-package 300 | [spatie/file-system-watcher]: https://github.com/spatie/file-system-watcher 301 | [phpstorm-watchers]: https://www.jetbrains.com/help/phpstorm/using-file-watchers.html 302 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "olvlvl/composer-attribute-collector", 3 | "type": "composer-plugin", 4 | "description": "A convenient and near zero-cost way to retrieve targets of PHP 8 attributes", 5 | "license": "BSD-3-Clause", 6 | "authors": [ 7 | { 8 | "name": "Olivier Laviale", 9 | "email": "olivier.laviale@gmail.com", 10 | "homepage": "https://olvlvl.com/", 11 | "role": "Developer" 12 | } 13 | ], 14 | "autoload": { 15 | "psr-4": { 16 | "olvlvl\\ComposerAttributeCollector\\": "src" 17 | } 18 | }, 19 | "autoload-dev": { 20 | "psr-4": { 21 | "tests\\olvlvl\\ComposerAttributeCollector\\": "tests", 22 | "Acme\\": "tests/Acme", 23 | "Acme81\\": "tests/Acme81" 24 | }, 25 | "classmap": [ 26 | "tests/Acme/ClassMap" 27 | ] 28 | }, 29 | "config": { 30 | "sort-packages": true 31 | }, 32 | "require": { 33 | "php": ">=8.0", 34 | "composer-plugin-api": "^2.0" 35 | }, 36 | "require-dev": { 37 | "composer/composer": ">=2.4", 38 | "phpstan/phpstan": "^2.0", 39 | "phpunit/phpunit": "^9.5" 40 | }, 41 | "extra": { 42 | "class": "olvlvl\\ComposerAttributeCollector\\Plugin", 43 | "composer-attribute-collector": { 44 | "include": [ 45 | "tests" 46 | ], 47 | "exclude": [ 48 | "tests/Acme/PSR4/IncompatibleSignature.php" 49 | ] 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | app80: 4 | build: 5 | context: . 6 | args: 7 | PHP_TAG: '8.0-cli-buster' 8 | environment: 9 | PHP_IDE_CONFIG: 'serverName=olvlvl/attribute-collector' 10 | volumes: 11 | - .:/app:delegated 12 | - ~/.composer:/root/.composer:delegated 13 | working_dir: /app 14 | app81: 15 | build: 16 | context: . 17 | args: 18 | PHP_TAG: '8.1-cli-buster' 19 | environment: 20 | PHP_IDE_CONFIG: 'serverName=olvlvl/attribute-collector' 21 | volumes: 22 | - .:/app:delegated 23 | - ~/.composer:/root/.composer:delegated 24 | working_dir: /app 25 | app82: 26 | build: 27 | context: . 28 | args: 29 | PHP_TAG: '8.2-cli-bookworm' 30 | environment: 31 | PHP_IDE_CONFIG: 'serverName=olvlvl/attribute-collector' 32 | volumes: 33 | - .:/app:delegated 34 | - ~/.composer:/root/.composer:delegated 35 | working_dir: /app 36 | app84: 37 | build: 38 | context: . 39 | args: 40 | PHP_TAG: '8.4-cli-bookworm' 41 | environment: 42 | PHP_IDE_CONFIG: 'serverName=olvlvl/attribute-collector' 43 | volumes: 44 | - .:/app:delegated 45 | - ~/.composer:/root/.composer:delegated 46 | working_dir: /app 47 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | src 5 | tests 6 | tests/sandbox/* 7 | tests/sandbox-memoize-classmap/* 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | tests/bootstrap.php 19 | 20 | 21 | tests/* 22 | 23 | 24 | tests/* 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/Attributes.php: -------------------------------------------------------------------------------- 1 | $attribute 32 | * 33 | * @return TargetClass[] 34 | */ 35 | public static function findTargetClasses(string $attribute): array 36 | { 37 | return self::getCollection()->findTargetClasses($attribute); 38 | } 39 | 40 | /** 41 | * @template T of object 42 | * 43 | * @param class-string $attribute 44 | * 45 | * @return TargetMethod[] 46 | */ 47 | public static function findTargetMethods(string $attribute): array 48 | { 49 | return self::getCollection()->findTargetMethods($attribute); 50 | } 51 | 52 | /** 53 | * @template T of object 54 | * 55 | * @param class-string $attribute 56 | * 57 | * @return TargetProperty[] 58 | */ 59 | public static function findTargetProperties(string $attribute): array 60 | { 61 | return self::getCollection()->findTargetProperties($attribute); 62 | } 63 | 64 | /** 65 | * @param callable(class-string $attribute, class-string $class):bool $predicate 66 | * 67 | * @return array> 68 | */ 69 | public static function filterTargetClasses(callable $predicate): array 70 | { 71 | return self::getCollection()->filterTargetClasses($predicate); 72 | } 73 | 74 | /** 75 | * @param callable(class-string $attribute, class-string $class, string $method):bool $predicate 76 | * 77 | * @return array> 78 | */ 79 | public static function filterTargetMethods(callable $predicate): array 80 | { 81 | return self::getCollection()->filterTargetMethods($predicate); 82 | } 83 | 84 | /** 85 | * @param callable(class-string $attribute, class-string $class, string $property):bool $predicate 86 | * 87 | * @return array> 88 | */ 89 | public static function filterTargetProperties(callable $predicate): array 90 | { 91 | return self::getCollection()->filterTargetProperties($predicate); 92 | } 93 | 94 | /** 95 | * @param class-string $class 96 | * 97 | * @return Closure(class-string $attribute):bool 98 | */ 99 | public static function predicateForAttributeInstanceOf(string $class): Closure 100 | { 101 | return fn(string $attribute): bool => is_a($attribute, $class, true); 102 | } 103 | 104 | /** 105 | * @var array 106 | */ 107 | private static array $forClassCache = []; 108 | 109 | /** 110 | * @param class-string $class 111 | * 112 | * @return ForClass 113 | */ 114 | public static function forClass(string $class): ForClass 115 | { 116 | return self::$forClassCache[$class] ??= self::getCollection()->forClass($class); 117 | } 118 | 119 | private static function getCollection(): Collection 120 | { 121 | return self::$collection ??= ( 122 | self::$provider ?? throw new LogicException("provider not set yet") 123 | )(); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/ClassAttributeCollector.php: -------------------------------------------------------------------------------- 1 | , 26 | * array, 27 | * array, 28 | * } 29 | * 30 | * @throws ReflectionException 31 | */ 32 | public function collectAttributes(string $class): array 33 | { 34 | $classReflection = new ReflectionClass($class); 35 | 36 | if (self::isAttribute($classReflection)) { 37 | return [ [], [], [] ]; 38 | } 39 | 40 | $classAttributes = []; 41 | $attributes = $classReflection->getAttributes(); 42 | 43 | foreach ($attributes as $attribute) { 44 | if (self::isAttributeIgnored($attribute)) { 45 | continue; 46 | } 47 | 48 | $this->io->debug("Found attribute {$attribute->getName()} on $class"); 49 | 50 | $classAttributes[] = new TransientTargetClass( 51 | $attribute->getName(), 52 | $attribute->getArguments(), 53 | ); 54 | } 55 | 56 | $methodAttributes = []; 57 | 58 | foreach ($classReflection->getMethods() as $methodReflection) { 59 | foreach ($methodReflection->getAttributes() as $attribute) { 60 | if (self::isAttributeIgnored($attribute)) { 61 | continue; 62 | } 63 | 64 | $method = $methodReflection->name; 65 | 66 | $this->io->debug("Found attribute {$attribute->getName()} on $class::$method"); 67 | 68 | $methodAttributes[] = new TransientTargetMethod( 69 | $attribute->getName(), 70 | $attribute->getArguments(), 71 | $method, 72 | ); 73 | } 74 | } 75 | 76 | $propertyAttributes = []; 77 | 78 | foreach ($classReflection->getProperties() as $propertyReflection) { 79 | foreach ($propertyReflection->getAttributes() as $attribute) { 80 | if (self::isAttributeIgnored($attribute)) { 81 | continue; 82 | } 83 | 84 | $property = $propertyReflection->name; 85 | assert($property !== ''); 86 | 87 | $this->io->debug("Found attribute {$attribute->getName()} on $class::$property"); 88 | 89 | $propertyAttributes[] = new TransientTargetProperty( 90 | $attribute->getName(), 91 | $attribute->getArguments(), 92 | $property, 93 | ); 94 | } 95 | } 96 | 97 | return [ $classAttributes, $methodAttributes, $propertyAttributes ]; 98 | } 99 | 100 | /** 101 | * Determines if a class is an attribute. 102 | * 103 | * @param ReflectionClass $classReflection 104 | */ 105 | private static function isAttribute(ReflectionClass $classReflection): bool 106 | { 107 | foreach ($classReflection->getAttributes() as $attribute) { 108 | if ($attribute->getName() === Attribute::class) { 109 | return true; 110 | } 111 | } 112 | 113 | return false; 114 | } 115 | 116 | /** 117 | * @param ReflectionAttribute $attribute 118 | */ 119 | private static function isAttributeIgnored(ReflectionAttribute $attribute): bool 120 | { 121 | static $ignored = [ 122 | \ReturnTypeWillChange::class => true, 123 | ]; 124 | 125 | return isset($ignored[$attribute->getName()]); // @phpstan-ignore offsetAccess.nonOffsetAccessible 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Collection.php: -------------------------------------------------------------------------------- 1 | > $targetClasses 17 | * Where _key_ is an attribute class and _value_ an array of arrays 18 | * where 0 are the attribute arguments and 1 is a target class. 19 | * @param array> $targetMethods 20 | * Where _key_ is an attribute class and _value_ an array of arrays 21 | * where 0 are the attribute arguments, 1 is a target class, and 2 is the target method. 22 | * @param array> $targetProperties 23 | * Where _key_ is an attribute class and _value_ an array of arrays 24 | * where 0 are the attribute arguments, 1 is a target class, and 2 is the target property. 25 | */ 26 | public function __construct( 27 | private array $targetClasses, 28 | private array $targetMethods, 29 | private array $targetProperties, 30 | ) { 31 | } 32 | 33 | /** 34 | * @template T of object 35 | * 36 | * @param class-string $attribute 37 | * 38 | * @return array> 39 | */ 40 | public function findTargetClasses(string $attribute): array 41 | { 42 | return array_map( 43 | fn(array $t) => self::createClassAttribute($attribute, ...$t), 44 | $this->targetClasses[$attribute] ?? [], 45 | ); 46 | } 47 | 48 | /** 49 | * @template T of object 50 | * 51 | * @param class-string $attribute 52 | * @param array $arguments 53 | * @param class-string $class 54 | * 55 | * @return TargetClass 56 | */ 57 | private static function createClassAttribute(string $attribute, array $arguments, string $class): object 58 | { 59 | try { 60 | $a = new $attribute(...$arguments); 61 | return new TargetClass($a, $class); 62 | } catch (Throwable $e) { 63 | throw new RuntimeException( 64 | "An error occurred while instantiating attribute $attribute on class $class", 65 | previous: $e, 66 | ); 67 | } 68 | } 69 | 70 | /** 71 | * @template T of object 72 | * 73 | * @param class-string $attribute 74 | * 75 | * @return array> 76 | */ 77 | public function findTargetMethods(string $attribute): array 78 | { 79 | return array_map( 80 | fn(array $t) => self::createMethodAttribute($attribute, ...$t), 81 | $this->targetMethods[$attribute] ?? [], 82 | ); 83 | } 84 | 85 | /** 86 | * @template T of object 87 | * 88 | * @param class-string $attribute 89 | * @param array $arguments 90 | * @param class-string $class 91 | * @param non-empty-string $method 92 | * 93 | * @return TargetMethod 94 | */ 95 | private static function createMethodAttribute( 96 | string $attribute, 97 | array $arguments, 98 | string $class, 99 | string $method, 100 | ): object { 101 | try { 102 | $a = new $attribute(...$arguments); 103 | return new TargetMethod($a, $class, $method); 104 | } catch (Throwable $e) { 105 | throw new RuntimeException( 106 | "An error occurred while instantiating attribute $attribute on method $class::$method", 107 | previous: $e, 108 | ); 109 | } 110 | } 111 | 112 | /** 113 | * @template T of object 114 | * 115 | * @param class-string $attribute 116 | * 117 | * @return array> 118 | */ 119 | public function findTargetProperties(string $attribute): array 120 | { 121 | return array_map( 122 | fn(array $t) => self::createPropertyAttribute($attribute, ...$t), 123 | $this->targetProperties[$attribute] ?? [], 124 | ); 125 | } 126 | 127 | /** 128 | * @template T of object 129 | * 130 | * @param class-string $attribute 131 | * @param array $arguments 132 | * @param class-string $class 133 | * @param non-empty-string $property 134 | * 135 | * @return TargetProperty 136 | */ 137 | private static function createPropertyAttribute( 138 | string $attribute, 139 | array $arguments, 140 | string $class, 141 | string $property, 142 | ): object { 143 | try { 144 | $a = new $attribute(...$arguments); 145 | return new TargetProperty($a, $class, $property); 146 | } catch (Throwable $e) { 147 | throw new RuntimeException( 148 | "An error occurred while instantiating attribute $attribute on property $class::$property", 149 | previous: $e, 150 | ); 151 | } 152 | } 153 | 154 | /** 155 | * @param callable(class-string $attribute, class-string $class):bool $predicate 156 | * 157 | * @return array> 158 | */ 159 | public function filterTargetClasses(callable $predicate): array 160 | { 161 | $ar = []; 162 | 163 | foreach ($this->targetClasses as $attribute => $references) { 164 | foreach ($references as [$arguments, $class]) { 165 | if ($predicate($attribute, $class)) { 166 | $ar[] = self::createClassAttribute($attribute, $arguments, $class); 167 | } 168 | } 169 | } 170 | 171 | return $ar; 172 | } 173 | 174 | /** 175 | * @param callable(class-string $attribute, class-string $class, non-empty-string $method):bool $predicate 176 | * 177 | * @return array> 178 | */ 179 | public function filterTargetMethods(callable $predicate): array 180 | { 181 | $ar = []; 182 | 183 | foreach ($this->targetMethods as $attribute => $references) { 184 | foreach ($references as [$arguments, $class, $method]) { 185 | if ($predicate($attribute, $class, $method)) { 186 | $ar[] = self::createMethodAttribute( 187 | $attribute, 188 | $arguments, 189 | $class, 190 | $method, 191 | ); 192 | } 193 | } 194 | } 195 | 196 | return $ar; 197 | } 198 | 199 | /** 200 | * @param callable(class-string $attribute, class-string $class, non-empty-string $property):bool $predicate 201 | * 202 | * @return array> 203 | */ 204 | public function filterTargetProperties(callable $predicate): array 205 | { 206 | $ar = []; 207 | 208 | foreach ($this->targetProperties as $attribute => $references) { 209 | foreach ($references as [$arguments, $class, $property]) { 210 | if ($predicate($attribute, $class, $property)) { 211 | $ar[] = self::createPropertyAttribute( 212 | $attribute, 213 | $arguments, 214 | $class, 215 | $property, 216 | ); 217 | } 218 | } 219 | } 220 | 221 | return $ar; 222 | } 223 | 224 | /** 225 | * @param class-string $class 226 | */ 227 | public function forClass(string $class): ForClass 228 | { 229 | $classAttributes = []; 230 | 231 | foreach ($this->filterTargetClasses(fn($a, $c): bool => $c === $class) as $targetClass) { 232 | $classAttributes[] = $targetClass->attribute; 233 | } 234 | 235 | $methodAttributes = []; 236 | 237 | foreach ($this->filterTargetMethods(fn($a, $c): bool => $c === $class) as $targetMethod) { 238 | $methodAttributes[$targetMethod->name][] = $targetMethod->attribute; 239 | } 240 | 241 | $propertyAttributes = []; 242 | 243 | foreach ($this->filterTargetProperties(fn($a, $c): bool => $c === $class) as $targetProperty) { 244 | $propertyAttributes[$targetProperty->name][] = $targetProperty->attribute; 245 | } 246 | 247 | return new ForClass( 248 | $classAttributes, 249 | $methodAttributes, 250 | $propertyAttributes, 251 | ); 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /src/Config.php: -------------------------------------------------------------------------------- 1 | getPackage()->getExtra()[self::EXTRA] ?? []; 54 | 55 | $include = self::expandPaths($extra[self::EXTRA_INCLUDE] ?? [], $vendorDir, $rootDir); 56 | $exclude = self::expandPaths($extra[self::EXTRA_EXCLUDE] ?? [], $vendorDir, $rootDir); 57 | 58 | $useCache = filter_var(Platform::getEnv(self::ENV_USE_CACHE), FILTER_VALIDATE_BOOL); 59 | 60 | return new self( 61 | $vendorDir, 62 | attributesFile: "$vendorDir/attributes.php", 63 | include: $include, 64 | exclude: $exclude, 65 | useCache: $useCache, 66 | ); 67 | } 68 | 69 | /** 70 | * @return non-empty-string 71 | */ 72 | public static function resolveVendorDir(PartialComposer $composer): string 73 | { 74 | $vendorDir = $composer->getConfig()->get('vendor-dir'); 75 | 76 | if (!is_string($vendorDir) || !$vendorDir) { 77 | throw new RuntimeException("Unable to determine vendor directory"); 78 | } 79 | 80 | return $vendorDir; 81 | } 82 | 83 | /** 84 | * @readonly 85 | * @var non-empty-string|null 86 | */ 87 | public ?string $excludeRegExp; 88 | 89 | /** 90 | * @param non-empty-string $attributesFile 91 | * Absolute path to the `attributes.php` file. 92 | * @param non-empty-string[] $include 93 | * Paths that should be included in the attribute collection. 94 | * @param non-empty-string[] $exclude 95 | * Paths that should be excluded from the attribute collection. 96 | * @param bool $useCache 97 | * Whether a cache should be used during the process. 98 | */ 99 | public function __construct( 100 | public string $vendorDir, 101 | public string $attributesFile, 102 | public array $include, 103 | public array $exclude, 104 | public bool $useCache, 105 | ) { 106 | $this->excludeRegExp = count($exclude) ? self::compileExclude($this->exclude) : null; 107 | } 108 | 109 | /** 110 | * @param non-empty-string[] $exclude 111 | * 112 | * @return non-empty-string 113 | */ 114 | private static function compileExclude(array $exclude): string 115 | { 116 | $regexp = implode('|', array_map(fn (string $path) => preg_quote($path), $exclude)); 117 | 118 | return "($regexp)"; 119 | } 120 | 121 | /** 122 | * @param non-empty-string[] $paths 123 | * @param non-empty-string $vendorDir 124 | * @param non-empty-string $rootDir 125 | * 126 | * @return non-empty-string[] 127 | */ 128 | private static function expandPaths(array $paths, string $vendorDir, string $rootDir): array 129 | { 130 | if (str_ends_with($vendorDir, DIRECTORY_SEPARATOR)) { 131 | throw new InvalidArgumentException("vendorDir must not end with a directory separator, given: $vendorDir"); 132 | } 133 | 134 | if (!str_ends_with($rootDir, DIRECTORY_SEPARATOR)) { 135 | throw new InvalidArgumentException("rootDir must end with a directory separator, given: $rootDir"); 136 | } 137 | 138 | $expanded = []; 139 | 140 | foreach ($paths as $path) { 141 | if (str_starts_with($path, self::VENDOR_PLACEHOLDER)) { 142 | $path = $vendorDir . substr($path, strlen(self::VENDOR_PLACEHOLDER)); 143 | } else { 144 | $path = $rootDir . $path; 145 | } 146 | 147 | $expanded[] = $path; 148 | } 149 | 150 | return $expanded; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/Datastore.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | public function get(string $key): array; 14 | 15 | /** 16 | * @param array $data 17 | */ 18 | public function set(string $key, array $data): void; 19 | } 20 | -------------------------------------------------------------------------------- /src/Datastore/FileDatastore.php: -------------------------------------------------------------------------------- 1 | formatFilename($key); 42 | 43 | if (!file_exists($filename)) { 44 | return []; 45 | } 46 | 47 | return self::safeGet($filename); 48 | } 49 | 50 | public function set(string $key, array $data): void 51 | { 52 | $filename = $this->formatFilename($key); 53 | 54 | file_put_contents($filename, serialize($data)); 55 | } 56 | 57 | /** 58 | * @return mixed[] 59 | */ 60 | private function safeGet(string $filename): array 61 | { 62 | $str = file_get_contents($filename); 63 | 64 | if ($str === false) { 65 | return []; 66 | } 67 | 68 | $errored = false; 69 | 70 | set_error_handler(function (int $errno, string $errstr) use (&$errored, $filename): bool { 71 | $errored = true; 72 | 73 | $this->io->warning("Unable to unserialize cache item $filename: $errstr"); 74 | 75 | return true; 76 | }); 77 | 78 | $ar = unserialize($str); 79 | 80 | restore_error_handler(); 81 | 82 | if ($errored || !is_array($ar)) { 83 | return []; 84 | } 85 | 86 | return $ar; 87 | } 88 | 89 | private function formatFilename(string $key): string 90 | { 91 | $major = Plugin::VERSION_MAJOR; 92 | $minor = Plugin::VERSION_MINOR; 93 | 94 | return $this->dir . DIRECTORY_SEPARATOR . "v$major-$minor-$key"; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Datastore/RuntimeDatastore.php: -------------------------------------------------------------------------------- 1 | > 11 | */ 12 | private array $datastore = []; 13 | 14 | public function get(string $key): array 15 | { 16 | return $this->datastore[$key] ?? []; 17 | } 18 | 19 | public function set(string $key, array $data): void 20 | { 21 | $this->datastore[$key] = $data; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Filter.php: -------------------------------------------------------------------------------- 1 | $filters 12 | */ 13 | public function __construct( 14 | private iterable $filters 15 | ) { 16 | } 17 | 18 | public function filter(string $filepath, string $class, IOInterface $io): bool 19 | { 20 | foreach ($this->filters as $filter) { 21 | if ($filter->filter($filepath, $class, $io) === false) { 22 | return false; 23 | } 24 | } 25 | 26 | return true; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Filter/ContentFilter.php: -------------------------------------------------------------------------------- 1 | debug("Discarding '$class' because it looks like an attribute"); 30 | return false; 31 | } 32 | 33 | return true; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Filter/InterfaceFilter.php: -------------------------------------------------------------------------------- 1 | warning("Discarding '$class' because an error occurred during loading: {$e->getMessage()}"); 21 | 22 | return false; 23 | } 24 | 25 | return true; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/ForClass.php: -------------------------------------------------------------------------------- 1 | $classAttributes 12 | * Where _value_ is an attribute. 13 | * @param array> $methodsAttributes 14 | * Where _key_ is a method and _value_ and iterable where _value_ is an attribute. 15 | * @param array> $propertyAttributes 16 | * Where _key_ is a property and _value_ and iterable where _value_ is an attribute. 17 | */ 18 | public function __construct( 19 | public iterable $classAttributes, 20 | public array $methodsAttributes, 21 | public array $propertyAttributes, 22 | ) { 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/MemoizeAttributeCollector.php: -------------------------------------------------------------------------------- 1 | , 24 | * array, 25 | * array, 26 | * }> 27 | * Where _key_ is a class and _value is an array where: 28 | * - `0` is a timestamp 29 | * - `1` is an array of class attributes 30 | * - `2` is an array of method attributes 31 | * - `3` is an array of property attributes 32 | */ 33 | private array $state; 34 | 35 | public function __construct( 36 | private ClassAttributeCollector $classAttributeCollector, 37 | private Datastore $datastore, 38 | private IOInterface $io, 39 | ) { 40 | /** @phpstan-ignore-next-line */ 41 | $this->state = $this->datastore->get(self::KEY); 42 | } 43 | 44 | /** 45 | * @param array $classMap 46 | * Where _key_ is a class and _value_ its pathname. 47 | */ 48 | public function collectAttributes(array $classMap): TransientCollection 49 | { 50 | $filterClasses = []; 51 | $classAttributeCollector = $this->classAttributeCollector; 52 | $collector = new TransientCollection(); 53 | 54 | foreach ($classMap as $class => $filepath) { 55 | $filterClasses[$class] = true; 56 | 57 | [ 58 | $timestamp, 59 | $classAttributes, 60 | $methodAttributes, 61 | $propertyAttributes, 62 | ] = $this->state[$class] ?? [ 0, [], [], [] ]; 63 | 64 | $mtime = filemtime($filepath); 65 | 66 | if ($timestamp < $mtime) { 67 | if ($timestamp) { 68 | $diff = $mtime - $timestamp; 69 | $this->io->debug("Refresh attributes of class '$class' in '$filepath' ($diff sec ago)"); 70 | } else { 71 | $this->io->debug("Collect attributes of class '$class' in '$filepath'"); 72 | } 73 | 74 | try { 75 | [ 76 | $classAttributes, 77 | $methodAttributes, 78 | $propertyAttributes, 79 | ] = $classAttributeCollector->collectAttributes($class); 80 | } catch (Throwable $e) { 81 | $this->io->error( 82 | "Attribute collection failed for $class: {$e->getMessage()}", 83 | ); 84 | } 85 | 86 | $this->state[$class] = [ time(), $classAttributes, $methodAttributes, $propertyAttributes ]; 87 | } 88 | 89 | if (count($classAttributes)) { 90 | $collector->addClassAttributes($class, $classAttributes); 91 | } 92 | if (count($methodAttributes)) { 93 | $collector->addMethodAttributes($class, $methodAttributes); 94 | } 95 | if (count($propertyAttributes)) { 96 | $collector->addTargetProperties($class, $propertyAttributes); 97 | } 98 | } 99 | 100 | /** 101 | * Classes might have been removed, we need to filter entries according to the classes found. 102 | */ 103 | $this->state = array_filter( 104 | $this->state, 105 | static fn(string $k): bool => $filterClasses[$k] ?? false, 106 | ARRAY_FILTER_USE_KEY, 107 | ); 108 | 109 | $this->datastore->set(self::KEY, $this->state); 110 | 111 | return $collector; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/MemoizeClassMapFilter.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | private array $state; 26 | 27 | public function __construct( 28 | private Datastore $datastore, 29 | private IOInterface $io, 30 | ) { 31 | /** @phpstan-ignore-next-line */ 32 | $this->state = $this->datastore->get(self::KEY); 33 | } 34 | 35 | /** 36 | * @param array $classMap 37 | * Where _key_ is a class and _value_ its pathname. 38 | * @param Closure(class-string, non-empty-string): bool $filter 39 | * 40 | * @return array 41 | */ 42 | public function filter(array $classMap, Closure $filter): array 43 | { 44 | $filtered = []; 45 | $paths = []; 46 | 47 | foreach ($classMap as $class => $pathname) { 48 | $paths[$pathname] = true; 49 | [ $timestamp, $keep ] = $this->state[$pathname] ?? [ 0, false ]; 50 | 51 | $mtime = filemtime($pathname); 52 | 53 | assert(is_int($mtime)); 54 | 55 | if ($timestamp < $mtime) { 56 | if ($timestamp) { 57 | $diff = $mtime - $timestamp; 58 | $this->io->debug("Refresh filtered file '$pathname' ($diff sec ago)"); 59 | } else { 60 | $this->io->debug("Filter '$pathname'"); 61 | } 62 | 63 | $keep = $filter($class, $pathname); 64 | $this->state[$pathname] = [ time(), $keep ]; 65 | } 66 | 67 | if ($keep) { 68 | $filtered[$class] = $pathname; 69 | } 70 | } 71 | 72 | /** 73 | * Paths might have been removed, we need to filter entries according to the paths found. 74 | */ 75 | $this->state = array_filter( 76 | $this->state, 77 | static fn(string $k): bool => $paths[$k] ?? false, 78 | ARRAY_FILTER_USE_KEY 79 | ); 80 | 81 | $this->datastore->set(self::KEY, $this->state); 82 | 83 | return $filtered; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/MemoizeClassMapGenerator.php: -------------------------------------------------------------------------------- 1 | }> 28 | * Where _key_ is a directory path and _value_ an array 29 | * where `0` is a timestamp, and `1` is an array 30 | * where _key_ is a class and _value_ its pathname. 31 | */ 32 | private array $state; 33 | 34 | /** 35 | * @var array 36 | */ 37 | private array $paths; 38 | 39 | public function __construct( 40 | private Datastore $datastore, 41 | private IOInterface $io, 42 | ) { 43 | /** @phpstan-ignore-next-line */ 44 | $this->state = $this->datastore->get(self::KEY); 45 | } 46 | 47 | /** 48 | * @return array 49 | * Where _key_ is a class and _value_ its path. 50 | */ 51 | public function getMap(): array 52 | { 53 | /** 54 | * Paths might have been removed, we need to filter according to the paths provided during {@link scanPaths()} 55 | */ 56 | $this->state = array_filter( 57 | $this->state, 58 | fn(string $k): bool => $this->paths[$k] ?? false, 59 | ARRAY_FILTER_USE_KEY 60 | ); 61 | 62 | $this->datastore->set(self::KEY, $this->state); 63 | 64 | $maps = []; 65 | 66 | foreach ($this->state as [, $map]) { 67 | $maps[] = $map; 68 | } 69 | 70 | return array_merge(...$maps); 71 | } 72 | 73 | /** 74 | * Iterate over all files in the given directory searching for classes 75 | * 76 | * @param non-empty-string $path 77 | * The path to search in. 78 | * @param non-empty-string|null $excluded 79 | * Regex that matches file paths to be excluded from the classmap 80 | * 81 | * @throws RuntimeException When the path is neither an existing file nor directory 82 | */ 83 | public function scanPaths(string $path, ?string $excluded = null): void 84 | { 85 | $this->paths[$path] = true; 86 | [ $timestamp ] = $this->state[$path] ?? [ 0 ]; 87 | 88 | if ($this->shouldUpdate($timestamp, $path)) { 89 | $inner = new ClassMapGenerator(); 90 | $inner->avoidDuplicateScans(); 91 | $inner->scanPaths($path, $excluded); 92 | $map = $inner->getClassMap()->getMap(); 93 | 94 | $this->state[$path] = [ time(), $map ]; 95 | } 96 | } 97 | 98 | private function shouldUpdate(int $timestamp, string $path): bool 99 | { 100 | if (!$timestamp) { 101 | return true; 102 | } 103 | 104 | $mtime = filemtime($path); 105 | 106 | assert(is_int($mtime)); 107 | 108 | if ($timestamp < $mtime) { 109 | $diff = $mtime - $timestamp; 110 | $this->io->debug("Refresh class map for path '$path' ($diff sec ago)"); 111 | 112 | return true; 113 | } 114 | 115 | // Could be a file referenced as a class map, we don't want to iterate over that. 116 | if (!is_dir($path)) { 117 | return false; 118 | } 119 | 120 | foreach (new DirectoryIterator($path) as $di) { 121 | if ($di->isDir() && !$di->isDot()) { 122 | if ($this->shouldUpdate($timestamp, $di->getPathname())) { 123 | return true; 124 | } 125 | } 126 | } 127 | 128 | return false; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Plugin.php: -------------------------------------------------------------------------------- 1 | 'onPostAutoloadDump', 41 | ]; 42 | } 43 | 44 | /** 45 | * @codeCoverageIgnore 46 | */ 47 | public function activate(Composer $composer, IOInterface $io): void 48 | { 49 | $vendorDir = Config::resolveVendorDir($composer); 50 | $filename = $vendorDir . DIRECTORY_SEPARATOR . "attributes.php"; 51 | 52 | if (file_exists($filename)) { 53 | return; 54 | } 55 | 56 | $stub = <<getComposer(); 84 | $config = Config::from($composer); 85 | $io = $event->getIO(); 86 | 87 | require_once $config->vendorDir . "/autoload.php"; 88 | 89 | $start = microtime(true); 90 | $io->write('Generating attributes file'); 91 | self::dump($config, $io); 92 | $elapsed = self::renderElapsedTime($start); 93 | $io->write("Generated attributes file in $elapsed"); 94 | } 95 | 96 | public static function dump(Config $config, IOInterface $io): void 97 | { 98 | // 99 | // Scan the included paths 100 | // 101 | $start = microtime(true); 102 | $datastore = self::buildDefaultDatastore($config, $io); 103 | $classMapGenerator = new MemoizeClassMapGenerator($datastore, $io); 104 | foreach ($config->include as $include) { 105 | $classMapGenerator->scanPaths($include, $config->excludeRegExp); 106 | } 107 | $classMap = $classMapGenerator->getMap(); 108 | $elapsed = self::renderElapsedTime($start); 109 | $io->debug("Generating attributes file: scanned paths in $elapsed"); 110 | 111 | // 112 | // Filter the class map 113 | // 114 | $start = microtime(true); 115 | $classMapFilter = new MemoizeClassMapFilter($datastore, $io); 116 | $filter = self::buildFileFilter(); 117 | $classMap = $classMapFilter->filter( 118 | $classMap, 119 | fn (string $class, string $filepath): bool => $filter->filter($filepath, $class, $io) 120 | ); 121 | $elapsed = self::renderElapsedTime($start); 122 | $io->debug("Generating attributes file: filtered class map in $elapsed"); 123 | 124 | // 125 | // Collect attributes 126 | // 127 | $start = microtime(true); 128 | $attributeCollector = new MemoizeAttributeCollector(new ClassAttributeCollector($io), $datastore, $io); 129 | $collection = $attributeCollector->collectAttributes($classMap); 130 | $elapsed = self::renderElapsedTime($start); 131 | $io->debug("Generating attributes file: collected attributes in $elapsed"); 132 | 133 | // 134 | // Render attributes 135 | // 136 | $start = microtime(true); 137 | $code = self::render($collection); 138 | file_put_contents($config->attributesFile, $code); 139 | $elapsed = self::renderElapsedTime($start); 140 | $io->debug("Generating attributes file: rendered code in $elapsed"); 141 | } 142 | 143 | private static function buildDefaultDatastore(Config $config, IOInterface $io): Datastore 144 | { 145 | if (!$config->useCache) { 146 | return new RuntimeDatastore(); 147 | } 148 | 149 | $basePath = Platform::getCwd(); 150 | 151 | assert($basePath !== ''); 152 | 153 | return new FileDatastore($basePath . DIRECTORY_SEPARATOR . self::CACHE_DIR, $io); 154 | } 155 | 156 | private static function renderElapsedTime(float $start): string 157 | { 158 | return sprintf("%.03f ms", (microtime(true) - $start) * 1000); 159 | } 160 | 161 | private static function buildFileFilter(): Filter 162 | { 163 | return new Filter\Chain([ 164 | new ContentFilter(), 165 | new InterfaceFilter() 166 | ]); 167 | } 168 | 169 | private static function render(TransientCollection $collector): string 170 | { 171 | return TransientCollectionRenderer::render($collector); 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/TargetClass.php: -------------------------------------------------------------------------------- 1 | > 14 | */ 15 | public array $classes = []; 16 | 17 | /** 18 | * @var array> 19 | */ 20 | public array $methods = []; 21 | 22 | /** 23 | * @var array> 24 | * Where _key_ is a target class. 25 | */ 26 | public array $properties = []; 27 | 28 | /** 29 | * @param class-string $class 30 | * @param iterable $targets 31 | * The target class. 32 | */ 33 | public function addClassAttributes(string $class, iterable $targets): void 34 | { 35 | $this->classes[$class] = $targets; 36 | } 37 | 38 | /** 39 | * @param class-string $class 40 | * @param iterable $targets 41 | * The target class. 42 | */ 43 | public function addMethodAttributes(string $class, iterable $targets): void 44 | { 45 | $this->methods[$class] = $targets; 46 | } 47 | 48 | /** 49 | * @param class-string $class 50 | * @param iterable $targets 51 | * The target class. 52 | */ 53 | public function addTargetProperties(string $class, iterable $targets): void 54 | { 55 | $this->properties[$class] = $targets; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/TransientCollectionRenderer.php: -------------------------------------------------------------------------------- 1 | classes); 17 | $targetMethodsCode = self::targetsToCode($collector->methods); 18 | $targetPropertiesCode = self::targetsToCode($collector->properties); 19 | 20 | return << new \olvlvl\ComposerAttributeCollector\Collection( 26 | targetClasses: $targetClassesCode, 27 | targetMethods: $targetMethodsCode, 28 | targetProperties: $targetPropertiesCode, 29 | )); 30 | PHP; 31 | } 32 | 33 | /** 34 | * //phpcs:disable Generic.Files.LineLength.TooLong 35 | * @param iterable> $targetByClass 36 | * 37 | * @return string 38 | */ 39 | private static function targetsToCode(iterable $targetByClass): string 40 | { 41 | $array = self::targetsToArray($targetByClass); 42 | 43 | return var_export($array, true); 44 | } 45 | 46 | /** 47 | * //phpcs:disable Generic.Files.LineLength.TooLong 48 | * @param iterable> $targetByClass 49 | * 50 | * @return array, class-string, 2?:non-empty-string }>> 51 | */ 52 | private static function targetsToArray(iterable $targetByClass): array 53 | { 54 | $by = []; 55 | 56 | foreach ($targetByClass as $class => $targets) { 57 | foreach ($targets as $t) { 58 | $a = [ $t->arguments, $class ]; 59 | 60 | if ($t instanceof TransientTargetMethod || $t instanceof TransientTargetProperty) { 61 | $a[] = $t->name; 62 | } 63 | 64 | $by[$t->attribute][] = $a; 65 | } 66 | } 67 | 68 | return $by; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/TransientTargetClass.php: -------------------------------------------------------------------------------- 1 | $arguments The attribute arguments. 14 | */ 15 | public function __construct( 16 | public string $attribute, 17 | public array $arguments, 18 | ) { 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/TransientTargetMethod.php: -------------------------------------------------------------------------------- 1 | $arguments The attribute arguments. 14 | * @param non-empty-string $name The target method. 15 | */ 16 | public function __construct( 17 | public string $attribute, 18 | public array $arguments, 19 | public string $name, 20 | ) { 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/TransientTargetProperty.php: -------------------------------------------------------------------------------- 1 | $arguments The attribute arguments. 14 | * @param non-empty-string $name The target property. 15 | */ 16 | public function __construct( 17 | public string $attribute, 18 | public array $arguments, 19 | public string $name, 20 | ) { 21 | } 22 | } 23 | --------------------------------------------------------------------------------