├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── quality-assurance.yaml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── Makefile ├── README.md ├── SECURITY.md ├── composer.json ├── default-.env ├── phpstan.neon.dist └── src ├── Analyzer.php ├── AttributeParser.php ├── Attributes ├── GenericClass.php ├── GenericClassConstant.php ├── GenericParameter.php ├── GenericProperty.php └── Reflect │ ├── CollectClassConstants.php │ ├── CollectEnumCases.php │ ├── CollectMethods.php │ ├── CollectParameters.php │ ├── CollectProperties.php │ ├── CollectStaticMethods.php │ ├── CollectStaticProperties.php │ ├── HasVisibility.php │ ├── MethodType.php │ ├── ReflectClass.php │ ├── ReflectClassConstant.php │ ├── ReflectEnum.php │ ├── ReflectEnumCase.php │ ├── ReflectMethod.php │ ├── ReflectParameter.php │ └── ReflectProperty.php ├── ClassAnalyzer.php ├── ClassType.php ├── CustomAnalysis.php ├── Excludable.php ├── Finalizable.php ├── FromReflectionClass.php ├── FromReflectionClassConstant.php ├── FromReflectionEnum.php ├── FromReflectionEnumCase.php ├── FromReflectionFunction.php ├── FromReflectionMethod.php ├── FromReflectionParameter.php ├── FromReflectionProperty.php ├── FuncAnalyzer.php ├── FunctionAnalyzer.php ├── HasSubAttributes.php ├── Inheritable.php ├── MemoryCacheAnalyzer.php ├── MemoryCacheFunctionAnalyzer.php ├── Multivalue.php ├── ParseClassConstants.php ├── ParseEnumCases.php ├── ParseMethods.php ├── ParseParameters.php ├── ParseProperties.php ├── ParseStaticMethods.php ├── ParseStaticProperties.php ├── Psr6CacheAnalyzer.php ├── Psr6FunctionCacheAnalyzer.php ├── ReadsClass.php ├── ReflectionDefinitionBuilder.php ├── RequiredAttributeArgumentsMissing.php ├── SupportsScopes.php ├── TransitiveProperty.php ├── TypeComplexity.php ├── TypeDef.php └── Visibility.php /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at http://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_size = 4 9 | indent_style = space 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [Crell] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Detailed description 4 | 5 | Provide a detailed description of the change or addition you are proposing. 6 | 7 | Make it clear if the issue is a bug, an enhancement or just a question. 8 | 9 | ## Context 10 | 11 | Why is this change important to you? How would you use it? 12 | 13 | How can it benefit other users? 14 | 15 | ## Possible implementation 16 | 17 | Not obligatory, but suggest an idea for implementing addition or change. 18 | 19 | ## Your environment 20 | 21 | Include as many relevant details about the environment you experienced the bug in and how to reproduce it. 22 | 23 | * Version used (e.g. PHP 5.6, HHVM 3): 24 | * Operating system and version (e.g. Ubuntu 16.04, Windows 7): 25 | * Link to your project: 26 | * ... 27 | * ... 28 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | Describe your changes in detail. 6 | 7 | ## Motivation and context 8 | 9 | Why is this change required? What problem does it solve? 10 | 11 | If it fixes an open issue, please link to the issue here (if you write `fixes #num` 12 | or `closes #num`, the issue will be automatically closed when the pull is accepted.) 13 | 14 | ## How has this been tested? 15 | 16 | Please describe in detail how you tested your changes. 17 | 18 | Include details of your testing environment, and the tests you ran to 19 | see how your change affects other areas of the code, etc. 20 | 21 | ## Screenshots (if appropriate) 22 | 23 | ## Types of changes 24 | 25 | What types of changes does your code introduce? Put an `x` in all the boxes that apply: 26 | - [ ] Bug fix (non-breaking change which fixes an issue) 27 | - [ ] New feature (non-breaking change which adds functionality) 28 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 29 | 30 | ## Checklist: 31 | 32 | Go over all the following points, and put an `x` in all the boxes that apply. 33 | 34 | Please, please, please, don't send your pull request until all of the boxes are ticked. Once your pull request is created, it will trigger a build on our [continuous integration](http://www.phptherightway.com/#continuous-integration) server to make sure your [tests and code style pass](https://help.github.com/articles/about-required-status-checks/). 35 | 36 | - [ ] I have read the **[CONTRIBUTING](CONTRIBUTING.md)** document. 37 | - [ ] My pull request addresses exactly one patch/feature. 38 | - [ ] I have created a branch for this patch/feature. 39 | - [ ] Each individual commit in the pull request is meaningful. 40 | - [ ] I have added tests to cover my changes. 41 | - [ ] If my change requires a change to the documentation, I have updated it accordingly. 42 | 43 | If you're unsure about any of these, don't hesitate to ask. We're here to help! 44 | -------------------------------------------------------------------------------- /.github/workflows/quality-assurance.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Quality assurance 3 | on: 4 | push: 5 | branches: ['master'] 6 | pull_request: ~ 7 | 8 | jobs: 9 | phpunit: 10 | name: PHPUnit tests on ${{ matrix.php }} ${{ matrix.composer-flags }} 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | php: [ '8.1', '8.2', '8.3', '8.4' ] 15 | composer-flags: [ '' ] 16 | phpunit-flags: [ '--coverage-text' ] 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: shivammathur/setup-php@v2 20 | with: 21 | php-version: ${{ matrix.php }} 22 | coverage: xdebug 23 | tools: composer:v2 24 | - run: composer install --no-progress ${{ matrix.composer-flags }} 25 | - run: vendor/bin/phpunit ${{ matrix.phpunit-flags }} 26 | phpstan: 27 | name: PHPStan checks on ${{ matrix.php }} 28 | runs-on: ubuntu-latest 29 | strategy: 30 | matrix: 31 | php: [ '8.1', '8.2', '8.3', '8.4' ] 32 | composer-flags: [ '' ] 33 | steps: 34 | - uses: actions/checkout@v2 35 | - uses: shivammathur/setup-php@v2 36 | with: 37 | php-version: ${{ matrix.php }} 38 | coverage: xdebug 39 | tools: composer:v2 40 | - run: composer install --no-progress ${{ matrix.composer-flags }} 41 | - run: vendor/bin/phpstan 42 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `AttributeUtils` will be documented in this file. 4 | 5 | Updates should follow the [Keep a CHANGELOG](http://keepachangelog.com/) principles. 6 | 7 | ## 1.2.0 - 2024-07-11 8 | 9 | ### Added 10 | - Sub-attribute callbacks may now be closures. No need to reference methods by string anymore. 11 | 12 | ## 1.1.0 - 2024-02-24 13 | 14 | ### Added 15 | - Added a separate analyzer for functions and closures. 16 | 17 | ## 1.0.0 - 2023-10-30 18 | 19 | ### Added 20 | - Stable release. 21 | - Switch to PHPUnit 10. 22 | - Improve PHPStan conformance. 23 | 24 | ## 0.8.2 - 2023-03-13 25 | 26 | ### Added 27 | - `ReadsClass` interface for attributes to be passed the class attribute they're associated with. 28 | - `Finalizable` interface for attributes to do final cleanup. 29 | 30 | ### Deprecated 31 | - Nothing 32 | 33 | ### Fixed 34 | - Improved code documentation to PHPStan level 7. 35 | 36 | ## 0.8.1 - 2022-04-21 37 | 38 | Initial public release, Release Candidate. 39 | 40 | ## NEXT - YYYY-MM-DD 41 | 42 | ### Added 43 | - Nothing 44 | 45 | ### Deprecated 46 | - Nothing 47 | 48 | ### Fixed 49 | - Nothing 50 | 51 | ### Removed 52 | - Nothing 53 | 54 | ### Security 55 | - Nothing 56 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | The Code Manifesto 2 | ================== 3 | 4 | We want to work in an ecosystem that empowers developers to reach their potential--one that encourages growth and effective collaboration. A space that is safe for all. 5 | 6 | A space such as this benefits everyone that participates in it. It encourages new developers to enter our field. It is through discussion and collaboration that we grow, and through growth that we improve. 7 | 8 | In the effort to create such a place, we hold to these values: 9 | 10 | 1. **Discrimination limits us.** This includes discrimination on the basis of race, gender, sexual orientation, gender identity, age, nationality, technology and any other arbitrary exclusion of a group of people. 11 | 2. **Boundaries honor us.** Your comfort levels are not everyone’s comfort levels. Remember that, and if brought to your attention, heed it. 12 | 3. **We are our biggest assets.** None of us were born masters of our trade. Each of us has been helped along the way. Return that favor, when and where you can. 13 | 4. **We are resources for the future.** As an extension of #3, share what you know. Make yourself a resource to help those that come after you. 14 | 5. **Respect defines us.** Treat others as you wish to be treated. Make your discussions, criticisms and debates from a position of respectfulness. Ask yourself, is it true? Is it necessary? Is it constructive? Anything less is unacceptable. 15 | 6. **Reactions require grace.** Angry responses are valid, but abusive language and vindictive actions are toxic. When something happens that offends you, handle it assertively, but be respectful. Escalate reasonably, and try to allow the offender an opportunity to explain themselves, and possibly correct the issue. 16 | 7. **Opinions are just that: opinions.** Each and every one of us, due to our background and upbringing, have varying opinions. That is perfectly acceptable. Remember this: if you respect your own opinions, you should respect the opinions of others. 17 | 8. **To err is human.** You might not intend it, but mistakes do happen and contribute to build experience. Tolerate honest mistakes, and don't hesitate to apologize if you make one yourself. 18 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via Pull Requests on [Github](https://github.com/Crell/AttributeUtils). 6 | 7 | 8 | ## Pull Requests 9 | 10 | - **Talk first** - Before filing a Pull Request with a new feature, open an issue to discuss it first. Not all feature requests are appropriate, and we really hate rejecting a PR after someone has done spec work on it. Make sure the idea fits with the intent of the library first before trying to file a PR. (We may be able to suggest a better way of doing it.) 11 | 12 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - Check the code style with ``$ composer check-style`` and fix it with ``$ composer fix-style``. 13 | 14 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 15 | 16 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 17 | 18 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 19 | 20 | - **Create feature branches** - Don't ask us to pull from your master branch. 21 | 22 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 23 | 24 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 25 | 26 | - **Be functional** - This library leverages [`Crell/fp`](https://www.github.com/Crell/fp) for easier functional-programming-style code. Please be consistent and do the same whenever possible. For instance, don't use a `foreach()` loop when a map or filter would be clearer at communicating intent. 27 | 28 | ## Running Tests 29 | 30 | ``` bash 31 | $ composer test 32 | ``` 33 | 34 | 35 | **Happy coding**! 36 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ### GNU LESSER GENERAL PUBLIC LICENSE 2 | 3 | Version 3, 29 June 2007 4 | 5 | Copyright (C) 2007 Free Software Foundation, Inc. 6 | 7 | 8 | Everyone is permitted to copy and distribute verbatim copies of this 9 | license document, but changing it is not allowed. 10 | 11 | This version of the GNU Lesser General Public License incorporates the 12 | terms and conditions of version 3 of the GNU General Public License, 13 | supplemented by the additional permissions listed below. 14 | 15 | #### 0. Additional Definitions. 16 | 17 | As used herein, "this License" refers to version 3 of the GNU Lesser 18 | General Public License, and the "GNU GPL" refers to version 3 of the 19 | GNU General Public License. 20 | 21 | "The Library" refers to a covered work governed by this License, other 22 | than an Application or a Combined Work as defined below. 23 | 24 | An "Application" is any work that makes use of an interface provided 25 | by the Library, but which is not otherwise based on the Library. 26 | Defining a subclass of a class defined by the Library is deemed a mode 27 | of using an interface provided by the Library. 28 | 29 | A "Combined Work" is a work produced by combining or linking an 30 | Application with the Library. The particular version of the Library 31 | with which the Combined Work was made is also called the "Linked 32 | Version". 33 | 34 | The "Minimal Corresponding Source" for a Combined Work means the 35 | Corresponding Source for the Combined Work, excluding any source code 36 | for portions of the Combined Work that, considered in isolation, are 37 | based on the Application, and not on the Linked Version. 38 | 39 | The "Corresponding Application Code" for a Combined Work means the 40 | object code and/or source code for the Application, including any data 41 | and utility programs needed for reproducing the Combined Work from the 42 | Application, but excluding the System Libraries of the Combined Work. 43 | 44 | #### 1. Exception to Section 3 of the GNU GPL. 45 | 46 | You may convey a covered work under sections 3 and 4 of this License 47 | without being bound by section 3 of the GNU GPL. 48 | 49 | #### 2. Conveying Modified Versions. 50 | 51 | If you modify a copy of the Library, and, in your modifications, a 52 | facility refers to a function or data to be supplied by an Application 53 | that uses the facility (other than as an argument passed when the 54 | facility is invoked), then you may convey a copy of the modified 55 | version: 56 | 57 | - a) under this License, provided that you make a good faith effort 58 | to ensure that, in the event an Application does not supply the 59 | function or data, the facility still operates, and performs 60 | whatever part of its purpose remains meaningful, or 61 | - b) under the GNU GPL, with none of the additional permissions of 62 | this License applicable to that copy. 63 | 64 | #### 3. Object Code Incorporating Material from Library Header Files. 65 | 66 | The object code form of an Application may incorporate material from a 67 | header file that is part of the Library. You may convey such object 68 | code under terms of your choice, provided that, if the incorporated 69 | material is not limited to numerical parameters, data structure 70 | layouts and accessors, or small macros, inline functions and templates 71 | (ten or fewer lines in length), you do both of the following: 72 | 73 | - a) Give prominent notice with each copy of the object code that 74 | the Library is used in it and that the Library and its use are 75 | covered by this License. 76 | - b) Accompany the object code with a copy of the GNU GPL and this 77 | license document. 78 | 79 | #### 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, taken 82 | together, effectively do not restrict modification of the portions of 83 | the Library contained in the Combined Work and reverse engineering for 84 | debugging such modifications, if you also do each of the following: 85 | 86 | - a) Give prominent notice with each copy of the Combined Work that 87 | the Library is used in it and that the Library and its use are 88 | covered by this License. 89 | - b) Accompany the Combined Work with a copy of the GNU GPL and this 90 | license document. 91 | - c) For a Combined Work that displays copyright notices during 92 | execution, include the copyright notice for the Library among 93 | these notices, as well as a reference directing the user to the 94 | copies of the GNU GPL and this license document. 95 | - d) Do one of the following: 96 | - 0) Convey the Minimal Corresponding Source under the terms of 97 | this License, and the Corresponding Application Code in a form 98 | suitable for, and under terms that permit, the user to 99 | recombine or relink the Application with a modified version of 100 | the Linked Version to produce a modified Combined Work, in the 101 | manner specified by section 6 of the GNU GPL for conveying 102 | Corresponding Source. 103 | - 1) Use a suitable shared library mechanism for linking with 104 | the Library. A suitable mechanism is one that (a) uses at run 105 | time a copy of the Library already present on the user's 106 | computer system, and (b) will operate properly with a modified 107 | version of the Library that is interface-compatible with the 108 | Linked Version. 109 | - e) Provide Installation Information, but only if you would 110 | otherwise be required to provide such information under section 6 111 | of the GNU GPL, and only to the extent that such information is 112 | necessary to install and execute a modified version of the 113 | Combined Work produced by recombining or relinking the Application 114 | with a modified version of the Linked Version. (If you use option 115 | 4d0, the Installation Information must accompany the Minimal 116 | Corresponding Source and Corresponding Application Code. If you 117 | use option 4d1, you must provide the Installation Information in 118 | the manner specified by section 6 of the GNU GPL for conveying 119 | Corresponding Source.) 120 | 121 | #### 5. Combined Libraries. 122 | 123 | You may place library facilities that are a work based on the Library 124 | side by side in a single library together with other library 125 | facilities that are not Applications and are not covered by this 126 | License, and convey such a combined library under terms of your 127 | choice, if you do both of the following: 128 | 129 | - a) Accompany the combined library with a copy of the same work 130 | based on the Library, uncombined with any other library 131 | facilities, conveyed under the terms of this License. 132 | - b) Give prominent notice with the combined library that part of it 133 | is a work based on the Library, and explaining where to find the 134 | accompanying uncombined form of the same work. 135 | 136 | #### 6. Revised Versions of the GNU Lesser General Public License. 137 | 138 | The Free Software Foundation may publish revised and/or new versions 139 | of the GNU Lesser General Public License from time to time. Such new 140 | versions will be similar in spirit to the present version, but may 141 | differ in detail to address new problems or concerns. 142 | 143 | Each version is given a distinguishing version number. If the Library 144 | as you received it specifies that a certain numbered version of the 145 | GNU Lesser General Public License "or any later version" applies to 146 | it, you have the option of following the terms and conditions either 147 | of that published version or of any later version published by the 148 | Free Software Foundation. If the Library as you received it does not 149 | specify a version number of the GNU Lesser General Public License, you 150 | may choose any version of the GNU Lesser General Public License ever 151 | published by the Free Software Foundation. 152 | 153 | If the Library as you received it specifies that a proxy can decide 154 | whether future versions of the GNU Lesser General Public License shall 155 | apply, that proxy's public statement of acceptance of any version is 156 | permanent authorization for you to choose that version for the 157 | Library. 158 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | compose_command = docker-compose run -u $(id -u ${USER}):$(id -g ${USER}) --rm php81 2 | 3 | build: docker-compose.yml 4 | docker-compose build 5 | 6 | shell: build 7 | $(compose_command) bash 8 | 9 | destroy: 10 | docker-compose down -v 11 | 12 | composer: build 13 | $(compose_command) composer install 14 | 15 | test: build 16 | $(compose_command) vendor/bin/phpunit 17 | 18 | phpstan: build 19 | $(compose_command) vendor/bin/phpstan 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Attribute Utilities 2 | 3 | [![Latest Version on Packagist][ico-version]][link-packagist] 4 | [![Software License][ico-license]](LICENSE.md) 5 | [![Total Downloads][ico-downloads]][link-downloads] 6 | 7 | AttributeUtils provides utilities to simplify working with and reading Attributes in PHP 8.1 and later. 8 | 9 | Its primary tool is the Class Analyzer, which allows you to analyze a given class or enum with respect to some attribute class. Attribute classes may implement various interfaces in order to opt-in to additional behavior, as described below. The overall intent is to provide a simple but powerful framework for reading metadata off of a class, including with reflection data. 10 | 11 | ## Install 12 | 13 | Via Composer 14 | 15 | ``` bash 16 | $ composer require crell/attributeutils 17 | ``` 18 | 19 | ## Usage 20 | 21 | ### Basic usage 22 | 23 | The most important class in the system is `Analyzer`, which implements the `ClassAnalyzer` interface. 24 | 25 | ```php 26 | 27 | #[MyAttribute(a: 1, b: 2)] 28 | class Point 29 | { 30 | public int $x; 31 | public int $y; 32 | public int $z; 33 | } 34 | 35 | $analyzer = new Crell\AttributeUtils\Analyzer(); 36 | 37 | $attrib = $analyzer->analyze(Point::class, MyAttribute::class); 38 | 39 | // $attrib is now an instance of MyAttribute. 40 | print $attrib->a . PHP_EOL; // Prints 1 41 | print $attrib->b . PHP_EOL; // Prints 2 42 | ``` 43 | 44 | All interaction with the reflection system is abstracted away by the `Analyzer`. 45 | 46 | You may analyze any class with respect to any attribute. If the attribute is not found, a new instance of the attribute class will be created with no arguments, that is, using whatever it's default argument values are. If any arguments are required, a `RequiredAttributeArgumentsMissing` exception will be thrown. 47 | 48 | The net result is that you can analyze a class with respect to any attribute class you like, as long as it has no required arguments. 49 | 50 | The most important part of `Analyzer`, though, is that it lets attributes opt-in to additional behavior to become a complete class analysis and reflection framework. 51 | 52 | ### Reflection 53 | 54 | If a class attribute implements [`Crell\AttributeUtils\FromReflectionClass`](src/FromReflectionClass.php), then once the attribute has been instantiated the `ReflectionClass` representation of the class being analyzed will be passed to the `fromReflection()` method. The attribute may then save whatever reflection information it needs, however it needs. For example, if you want the attribute object to know the name of the class it came from, you can save `$reflection->getName()` and/or `$reflection->getShortName()` to non-constructor properties on the object. Or, you can save them if and only if certain constructor arguments were not provided. 55 | 56 | If you are saving a reflection value literally, it is *strongly recommended* that you use a property name consistent with those in the [`ReflectClass`](src/Attributes/Reflect/ReflectClass.php) attribute. That way, the names are consistent across all attributes, even different libraries, and the resulting code is easier for other developers to read and understand. (We'll cover `ReflectClass` more later.) 57 | 58 | In the following example, an attribute accepts a `$name` argument. If one is not provided, the class's short-name will be used instead. 59 | 60 | ```php 61 | #[\Attribute] 62 | class AttribWithName implements FromReflectionClass 63 | { 64 | public readonly string $name; 65 | 66 | public function __construct(?string $name = null) 67 | { 68 | if ($name) { 69 | $this->name = $name; 70 | } 71 | } 72 | 73 | public function fromReflection(\ReflectionClass $subject): void 74 | { 75 | $this->name ??= $subject->getShortName(); 76 | } 77 | } 78 | ``` 79 | 80 | The reflection object itself should *never ever* be saved to the attribute object. Reflection objects cannot be cached, so saving it would render the attribute object uncacheable. It's also wasteful, as any data you need can be retrieved from the reflection object and saved individually. 81 | 82 | There are similarly [`FromReflectionProperty`](src/FromReflectionProperty.php), [`FromReflectionMethod`](src/FromReflectionMethod.php), [`FromReflectionClassConstant`](src/FromReflectionClassConstant.php), and [`FromReflectionParameter`](src/FromReflectionParameter.php) interfaces that do the same for their respective bits of a class. 83 | 84 | ### Additional class components 85 | 86 | The class attribute may also opt-in to analyzing various portions of the class, such as its properties, methods, and constants. It does so by implementing the [`ParseProperties`](src/ParseProperties.php), [`ParseStaticProperties`](src/ParseStaticProperties.php), [`ParseMethods`](src/ParseMethods.php), [`ParseStaticMethods`](src/ParseStaticMethods.php), or [`ParseClassConstants`](src/ParseClassConstants.php) interfaces, respectively. They all work the same way, so we'll look at properties in particular. 87 | 88 | An example is the easiest way to explain it: 89 | 90 | ```php 91 | #[\Attribute(\Attribute::TARGET_CLASS)] 92 | class MyClass implements ParseProperties 93 | { 94 | public readonly array $properties; 95 | 96 | public function propertyAttribute(): string 97 | { 98 | return MyProperty::class; 99 | } 100 | 101 | public function setProperties(array $properties): void 102 | { 103 | $this->properties = $properties; 104 | } 105 | 106 | public function includePropertiesByDefault(): bool 107 | { 108 | return true; 109 | } 110 | } 111 | 112 | #[\Attribute(\Attribute::TARGET_PROPERTY)] 113 | class MyProperty 114 | { 115 | public function __construct( 116 | public readonly string $column = '', 117 | ) {} 118 | } 119 | 120 | #[MyClass] 121 | class Something 122 | { 123 | #[MyProperty(column: 'beep')] 124 | protected property $foo; 125 | 126 | private property $bar; 127 | } 128 | 129 | $attrib = $analyzer->analyze(Something::class, MyClass::class); 130 | ``` 131 | 132 | In this example, the `MyClass` attribute will first be instantiated. It has no arguments, which is fine. However, the interface methods specify that the Analyzer should then parse `Something`'s properties with respect to `MyProperty`. If a property has no such attribute, it should be included anyway and instantiated with no arguments. 133 | 134 | The Analyzer will dutifully create an array of two `MyProperty` instances, one for `$foo` and one for `$bar`; the former having the `column` value `beep`, and the latter having the default empty string value. That array will then be passed to `MyClass::setProperties()` for `MyClass` to save, or parse, or filter, or do whatever it wants. 135 | 136 | If `includePropertiesByDefault()` returned `false`, then the array would have only one value, from `$foo`. `$bar` would be ignored. 137 | 138 | Note: The array that is passed to `setProperties` is indexed by the name of the property already, so you do not need to do so yourself. 139 | 140 | The property-targeting attribute (`MyProperty`) may also implement `FromReflectionProperty` to get the corresponding `ReflectionProperty` passed to it, just as the class attribute can. 141 | 142 | The Analyzer includes only object level properties in `ParseProperties`. If you want static properties, use the `ParseStaticProperties` interface, which works the exact same way. Both interfaces may be implemented at the same time. 143 | 144 | The `ParseClassConstant` interface works the same way as `ParseProperties`. 145 | 146 | ### Methods 147 | 148 | `ParseMethods` works the same way as `ParseProperties` (and also has a corresponding `ParseStaticMethods` interface for static methods). However, a method-targeting attribute may also itself implement [`ParseParameters`](src/ParseParameters.php) in order to examine parameters on that method. `ParseParameters` repeats the same pattern as `ParseProperties` above, with the methods suitably renamed. 149 | 150 | ### Class-referring components 151 | 152 | A component-targeting attribute may also implement [`ReadsClass`](src/ReadsClass.php). If so, then the class's attribute will be passed to the `fromClassAttribute()` method after all other setup has been done. That allows the attribute to inherit default values from the class, or otherwise vary its behavior based on properties set on the class attribute. 153 | 154 | ### Excluding values 155 | 156 | When parsing components of a class, whether they are included depends on a number of factors. The `includePropertiesByDefault()`, `includeMethodsByDefault()`, etc. methods on the various `Parse*` interfaces determine whether components that lack an attribute should be included with a default value, or excluded entirely. 157 | 158 | If the `include*()` method returns true, it is still possible to exclude a specific component if desired. The attribute for that component may implement the [`Excludable`](src/Excludable.php) interface, with has a single method, `exclude()`. 159 | 160 | What then happens is the Analyzer will load all attributes of that type, then filter out the ones that return `true` from that method. That allows individual properties, methods, etc. to opt-out of being parsed. You may use whatever logic you wish for `exclude()`, although the most common approach will be something like this: 161 | 162 | ```php 163 | #[\Attribute(\Attribute::TARGET_PROPERTY)] 164 | class MyProperty implements Excludable 165 | { 166 | public function __construct( 167 | public readonly bool $exclude = false, 168 | ) {} 169 | 170 | public function exclude(): bool 171 | { 172 | return $this->exclude; 173 | } 174 | } 175 | 176 | class Something 177 | { 178 | #[MyProperty(exclude: true)] 179 | private int $val; 180 | } 181 | ``` 182 | 183 | If you are taking this manual approach, it is strongly recommended that you use the naming convention here for consistency. 184 | 185 | ### Attribute inheritance 186 | 187 | By default, attributes in PHP are not inheritable. That is, if class `A` has an attribute on it, and `B` extends `A`, then asking reflection what attributes `B` has will find none. Sometimes that's OK, but other times it is highly annoying to have to repeat values. 188 | 189 | `Analyzer` addresses that limitation by letting attributes opt-in to being inherited. Any attribute — for a class, property, method, constant, or parameter — may also implement the [`Inheritable`](src/Inheritable.php) marker interface. This interface has no methods, but signals to the system that it should itself check parent classes and interfaces for an attribute if it is not found. 190 | 191 | For example: 192 | 193 | ```php 194 | #[\Attribute(\Attribute::TARGET_CLASS)] 195 | class MyClass implements Inheritable 196 | { 197 | public function __construct(public string $name = '') {} 198 | } 199 | 200 | #[MyClass(name: 'Jorge')] 201 | class A {} 202 | 203 | class B extends A {} 204 | 205 | $attrib = $analyzer->analyze(B::class, MyClass::class); 206 | 207 | print $attrib->name . PHP_EOL; // prints Jorge 208 | ``` 209 | 210 | Because `MyClass` is inheritable, the Analyzer notes that it is absent on `B` so checks class `A` instead. All attribute components may be inheritable if desired just by implementing the interface. 211 | 212 | When checking for inherited attributes, ancestor classes are all checked first, then implemented interfaces, in the order returned by `class_implements()`. Properties will not check for interfaces, of course, as interfaces cannot have properties. 213 | 214 | ### Attribute child classes 215 | 216 | When checking for an attribute, the Analyzer uses an `instanceof` check in Reflection. That means a child class, or even a class implementing an interface, of what you specify will still be found and included. That is true for all attribute types. 217 | 218 | ### Sub-attributes 219 | 220 | `Analyzer` can only handle a single attribute on each target. However, it also supports the concept of "sub-attributes." Sub-attributes work similarly to the way a class can opt-in to parsing properties or methods, but for sibling attributes instead of child components. That way, any number of attributes on the same component can be folded together into a single attribute object. Any attribute for any component may opt-in to sub-attributes by implementing [`HasSubAttributes`](src/HasSubAttributes.php). 221 | 222 | The following example should make it clearer: 223 | 224 | ```php 225 | #[\Attribute(\Attribute::TARGET_CLASS)] 226 | class MainAttrib implements HasSubAttributes 227 | { 228 | public readonly int $age; 229 | 230 | public function __construct( 231 | public readonly string name = 'none', 232 | ) {} 233 | 234 | public function subAttributes(): array 235 | { 236 | return [Age::class => 'fromAge']; 237 | } 238 | 239 | public function fromAge(?Age $sub): void 240 | { 241 | $this->age = $sub?->age ?? 0; 242 | } 243 | } 244 | 245 | #[\Attribute(\Attribute::TARGET_CLASS)] 246 | class Age 247 | { 248 | public function __construct(public readonly int $age = 0) {} 249 | } 250 | 251 | #[MainAttrib(name: 'Larry')] 252 | #[Age(21)] 253 | class A {} 254 | 255 | class B {} 256 | 257 | $attribA = $analyzer->analyze(A::class, MainAttrib::class); 258 | 259 | print "$attribA->name, $attribA->age\n"; // prints "Larry, 21" 260 | 261 | $attribB = $analyzer->analyze(B::class, MainAttrib::class); 262 | 263 | print "$attribB->name, $attribB->age\n"; // prints "none, 0" 264 | ``` 265 | 266 | The `subAttributes()` method returns an associative array of attribute class names mapped to methods to call. They may be strings, or an inline closure, or a closed reference to a method, which may be private if desired. For example: 267 | 268 | ```php 269 | #[\Attribute(\Attribute::TARGET_CLASS)] 270 | class MainAttrib implements HasSubAttributes 271 | { 272 | public readonly int $age; 273 | public readonly string $name; 274 | 275 | public function __construct( 276 | public readonly string name = 'none', 277 | ) {} 278 | 279 | public function subAttributes(): array 280 | { 281 | return [ 282 | Age::class => $this->fromAge(...), 283 | Name::class => function (?Name $sub) { 284 | $this->name = $sub?->name ?? 'Anonymous'; 285 | } 286 | ]; 287 | } 288 | 289 | private function fromAge(?Age $sub): void 290 | { 291 | $this->age = $sub?->age ?? 0; 292 | } 293 | } 294 | ``` 295 | 296 | After the `MainAttrib` is loaded, the Analyzer will look for any of the listed sub-attributes, and then pass their result to the corresponding method. The main attribute can then save the whole sub-attribute, or pull pieces out of it to save, or whatever else it wants to do. 297 | 298 | An attribute may have any number of sub-attributes it wishes. 299 | 300 | Note that if the sub-attribute is missing, `null` will be passed to the method. That is to allow a sub-attribute to have required parameters if and only if it is specified, while keeping the sub-attribute itself optional. You therefore *must* make the callback method's argument nullable. 301 | 302 | Sub-attributes may also be `Inheritable`. 303 | 304 | ### Multi-value sub-attributes 305 | 306 | By default, PHP attributes can only be placed on a given target once. However, they may be marked as "repeatable," in which case multiple of the same attribute may be placed on the same target. (Class, property, method, etc.) 307 | 308 | The Analyzer does not support multi-value attributes, but it does support multi-value sub-attributes. If the sub-attribute implements the [`Multivalue`](src/Multivalue.php) marker interface, then an array of sub-attributes will be passed to the callback instead. 309 | 310 | For example: 311 | 312 | ```php 313 | #[\Attribute(\Attribute::TARGET_CLASS)] 314 | class MainAttrib implements HasSubAttributes 315 | { 316 | public readonly array $knows; 317 | 318 | public function __construct( 319 | public readonly string name = 'none', 320 | ) {} 321 | 322 | public function subAttributes(): array 323 | { 324 | return [Knows::class => 'fromKnows']; 325 | } 326 | 327 | public function fromKnows(array $knows): void 328 | { 329 | $this->knows = $knows; 330 | } 331 | } 332 | 333 | #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] 334 | class Knows implements Multivalue 335 | { 336 | public function __construct(public readonly string $name) {} 337 | } 338 | 339 | #[MainAttrib(name: 'Larry')] 340 | #[Knows('Kai')] 341 | #[Knows('Molly')] 342 | class A {} 343 | 344 | class B {} 345 | ``` 346 | 347 | In this case, any number of `Knows` attributes may be included, including zero, but if included the `$name` argument is required. The `fromKnows()` method will be called with a (possibly empty, in the case of `B`) array of `Knows` objects, and can do what it likes with it. In this example the objects are saved in their entirety, but they could also be mushed into a single array or used to set some other value if desired. 348 | 349 | Note that if a multi-value sub-attribute is `Inheritable`, ancestor classes will only be checked if there are no local sub-attributes. If there is at least one, it will take precedence and the ancestors will be ignored. 350 | 351 | Note: In order to make use of multi-value sub-attributes, the attribute class itself must be marked as "repeatable" as in the example above or PHP will generate an error. However, that is not sufficient for the Analyzer to parse it as multi-value. That's because attributes may also be multi-value when implementing scopes, but still only single-value from the Analzyer's point of view. See the section on Scopes below. 352 | 353 | ### Finalizing an attribute 354 | 355 | Attributes that opt-in to several functional interfaces may not always have an easy time of knowing when to do default handling. It may not be obvious when the attribute setup is "done." Attribute classes may therefore opt-in to the [`Finalizable`](src/Finalizable.php) interface. If specified, it is guaranteed to be the last method called on the attribute. The attribute may then do whatever final preparation is appropriate to consider the object "ready." 356 | 357 | ### Caching 358 | 359 | The main `Analyzer` class does no caching whatsoever. However, it implements a `ClassAnalyzer` interface which allows it to be easily wrapped in other implementations that provide a caching layer. 360 | 361 | For example, the [`MemoryCacheAnalyzer`](src/MemoryCacheAnalyzer.php) class provides a simple wrapper that caches results in a static variable in memory. You should almost always use this wrapper for performance. 362 | 363 | ```php 364 | $analyzer = new MemoryCacheAnalyzer(new Analyzer()); 365 | ``` 366 | 367 | A PSR-6 cache bridge is also included, allowing the Analyzer to be used with any PSR-6 compatible cache pool. 368 | 369 | ```php 370 | $anaylzer = new Psr6CacheAnalyzer(new Analyzer(), $somePsr6CachePoolObject); 371 | ``` 372 | 373 | Wrappers may also compose each other, so the following would be an entirely valid and probably good approach: 374 | 375 | ```php 376 | $analyzer = new MemoryCacheAnalyzer(new Psr6CacheAnalyzer(new Analyzer(), $psr6CachePool)); 377 | ``` 378 | 379 | ## Advanced features 380 | 381 | There are a couple of other advanced features also available. These are less frequently used, but in the right circumstances they can be very helpful. 382 | 383 | ### Scopes 384 | 385 | Attributes may opt-in to supporting "scopes". "Scopes" allow you to specify alternate versions of the same attribute to use in different contexts. Examples include different serialization groups or different languages. Often, scopes will be hidden behind some other name in another library (like language), which is fine. 386 | 387 | If an attribute implements [`SupportsScopes`](src/SupportsScopes.php), then when looking for attributes additional filtering will be performed. The exact logic also interacts with exclusion and whether a class attribute specifies a component should be loaded by default if missing, leading to a highly robust set of potential rules for what attribute to use when. 388 | 389 | As an example, let's consider providing alternate language versions of a property attribute. The logic is identical for any component, as well as for sub-attributes. 390 | 391 | ```php 392 | #[\Attribute(\Attribute::TARGET_CLASS)] 393 | class Labeled implements ParseProperties 394 | { 395 | public readonly array $properties; 396 | 397 | public function setProperties(array $properties): void 398 | { 399 | $this->properties ??= $properties; 400 | } 401 | 402 | public function includePropertiesByDefault(): bool 403 | { 404 | return true; 405 | } 406 | 407 | public function propertyAttribute(): string 408 | { 409 | return Label::class; 410 | } 411 | } 412 | 413 | #[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)] 414 | class Label implements SupportsScopes, Excludable 415 | { 416 | public function __construct( 417 | public readonly string $name = 'Untitled', 418 | public readonly ?string $language = null, 419 | public readonly bool $exclude = false, 420 | ) {} 421 | 422 | public function scopes(): array 423 | { 424 | return [$this->language]; 425 | } 426 | 427 | public function exclude(): bool 428 | { 429 | return $this->exclude; 430 | } 431 | } 432 | 433 | #[Labeled] 434 | class App 435 | { 436 | #[Label(name: 'Installation')] 437 | #[Label(name: 'Instalación', language: 'es')] 438 | public string $install; 439 | 440 | #[Label(name: 'Setup')] 441 | #[Label(name: 'Configurar', language: 'es')] 442 | #[Label(name: 'Einrichten', language: 'de')] 443 | public string $setup; 444 | 445 | #[Label(name: 'Einloggen', language: 'de')] 446 | #[Label(language: 'fr', exclude: true)] 447 | public string $login; 448 | 449 | public string $customization; 450 | } 451 | ``` 452 | 453 | The `Labeled` attribute on the class is nothing we haven't seen before. The `Label` attribute for properties is both excludable and supports scopes, although it exposes it with the name `language`. 454 | 455 | Calling the Analyzer as we've seen before will ignore the scoped versions, and result in an array of `Label`s with names "Installation", "Setup", "Untitled", and "Untitled". However, it may also be invoked with a specific scope: 456 | 457 | ```php 458 | $labels = $analyzer->analyze(App::class, Labeled::class, scopes: ['es']); 459 | ``` 460 | 461 | Now, `$labels` will contain an array of `Label`s with names "Instalación", "Configurar", "Untitled", and "Untitled". On `$stepThree`, there is no `es` scoped version so it falls back to the default. Similarly, a scope of `de` will result in "Installation", "Einrichten", "Einloggen", and "Untitled" (as "Installation" is spelled the same in both English and German). 462 | 463 | A scope of `fr` will result in the default (English) for each case, except for `$stepThree` which will be omitted entirely. The `exclude` directive is applicable only in that scope. The result will therefore be "Installation", "Setup", "Untitled". 464 | 465 | (If you were doing this for real, it would make sense to derive a default `name` off of the property name itself via `FromReflectionProperty` rather than a hard-coded "Untitled.") 466 | 467 | By contrast, if `Labeled::includePropertiesByDefault()` returns false, then `$customization` will not be included in any scope. `$login` will be included in `de` only, and in no other scope at all. That's because there is no default-scope option specified, and so in any scope other than `de` no default will be created. A lookup for scope `fr` will be empty. 468 | 469 | A useful way to control what properties are included is to make the class-level attribute scope-aware as well, and control `includePropertiesByDefault()` via an argument. That way, for example, `includePropertiesByDefault()` can return true in the unscoped case, but false when a scope is explicitly specified; that way, properties will only be included in a scope if they explicitly opt-in to being in that scope, while in the unscoped case all properties are included. 470 | 471 | Note that the `scopes()` method returns an array. That means an attribute being part of multiple scopes is fully supported. How you populate the return of that method (whether an array argument or something else) is up to you. 472 | 473 | Additionally, scopes are looked up as an ORed array. That is, the following command: 474 | 475 | ```php 476 | $labels = $analyzer->analyze(SomeClass::class, AnAttribute::class, scopes: ['One', 'Two']); 477 | ``` 478 | 479 | will retrieve any attributes that return *either* `One` or `Two` from their `scopes()` method. If multiple attributes on the same component match that rule (say, one returns `['One']` and another returns `['Two']`), the lexically first will be used. 480 | 481 | ### Transitivity 482 | 483 | Transitivity applies only to attributes on properties, and only if the attribute in question can target both properties and classes. It is an alternate form of inheritance. Specifically, if a property is typed to a class or interface, and the attribute in question implements `TransitiveProperty`, and the property does not have that attribute on it, then instead of looking up the inheritance tree the analyzer will first look at the class the property is typed for. 484 | 485 | That's a lot of conditionals, so here's an example to make it clearer: 486 | 487 | ```php 488 | 489 | #[\Attribute(\Attribute::TARGET_PROPERTY)] 490 | class MyClass implements ParseProperties 491 | { 492 | public readonly array $properties; 493 | 494 | public function setProperties(array $properties): void 495 | { 496 | $this->properties = $properties; 497 | } 498 | 499 | public function includePropertiesByDefault(): bool { return true; } 500 | 501 | public function propertyAttribute(): string { return FancyName::class; } 502 | } 503 | 504 | 505 | #[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_CLASS)] 506 | class FancyName implements TransitiveProperty 507 | { 508 | public function __construct(public readonly string $name = '') {} 509 | } 510 | 511 | class Stuff 512 | { 513 | #[FancyName('A happy little integer')] 514 | protected int $foo; 515 | 516 | protected string $bar; 517 | 518 | protected Person $personOne; 519 | 520 | #[FancyName('Her Majesty Queen Elizabeth II')] 521 | protected Person $personTwo; 522 | } 523 | 524 | #[FancyName('I am not an object, I am a free man!')] 525 | class Person 526 | { 527 | } 528 | 529 | $attrib = $analyzer->analyze(Stuff::class, MyClass::class); 530 | 531 | print $attrib->properties['foo']->name . PHP_EOL; // prints "A happy little integer" 532 | print $attrib->properties['bar']->name . PHP_EOL; // prints "" 533 | print $attrib->properties['personOne']->name . PHP_EOL; // prints "I am not an object, I am a free man!" 534 | print $attrib->properties['personTwo']->name . PHP_EOL; // prints "Her Majesty Queen Elizabeth II" 535 | ``` 536 | 537 | Because `$personTwo` has a `FancyName` attribute, it behaves as normal. However, `$personOne` does not, so it jumps over to the `Person` class to look for the attribute and finds it there. 538 | 539 | If an attribute implements both `Inheritable` and `Transitive`, then first the class being analyzed will be checked, then its ancestor classes, then its implemented interfaces, then the transitive class for which it is typed, and then that class's ancestors until it finds an appropriate attribute. 540 | 541 | Both main attributes and sub-attributes may be declared `Transitive`. 542 | 543 | ### Custom analysis 544 | 545 | As a last resort, an attribute may also implement the [`CustomAnalysis`](src/CustomAnalysis.php) interface. If it does so, the analyzer itself will be passed to the `customAnalysis()` method of the attribute, which may then take whatever actions it wishes. This feature is intended as a last resort only, and it's possible to create unpleasant infinite loops if you are not careful. 99% of the time you should use some other, any other mechanism. But it's there if you need it. 546 | 547 | ### Dependency Injection 548 | 549 | The Analyzer is designed to be usable on its own without any setup. Making it available via a Dependency Injection Container is recommended. An appropriate cache wrapper should also be included in the DI configuration. 550 | 551 | ## Function analysis 552 | 553 | There is also support for retrieving attributes on functions, via a separate analyzer (that works essentially the same way). The `FuncAnalyzer` class implements the `FunctionAnalyzer` interface. 554 | 555 | ```php 556 | use Crell\AttributeUtils\FuncAnalyzer; 557 | 558 | #[MyFunc] 559 | function beep(int $a) {} 560 | 561 | $closure = #[MyClosure] fn(int $a) => $a + 1; 562 | 563 | // For functions... 564 | $analyzer = new FuncAnalyzer(); 565 | $funcDef = $analyzer->analyze('beep', MyFunc::class); 566 | 567 | // For closures 568 | $analyzer = new FuncAnalyzer(); 569 | $funcDef = $analyzer->analyze($closure, MyFunc::class); 570 | ``` 571 | 572 | Sub-attributes, `ParseParameters`, and `Finalizable` all work on functions exactly as they do on classes and methods, as do scopes. There is also a corresponding `FromReflectionFunction` interface for receiving the `ReflectionFunction` object. 573 | 574 | There are also cache wrappers available for the FuncAnalyzer as well. They work the same way as on the class analyzer. 575 | 576 | ```php 577 | # In-memory cache. 578 | $analyzer = new MemoryCacheFunctionAnalyzer(new FuncAnalyzer()); 579 | 580 | # PSR-6 cache. 581 | $anaylzer = new Psr6CacheFunctionAnalyzer(new FuncAnalyzer(), $somePsr6CachePoolObject); 582 | 583 | # Both caches. 584 | $analyzer = new MemoryCacheFunctionAnalyzer( 585 | new Psr6CacheFunctionAnalyzer(new FuncAnalyzer(), $psr6CachePool) 586 | ); 587 | ``` 588 | 589 | As with the class analyzer, it's best to wire these up in your DI container. 590 | 591 | ## The Reflect library 592 | 593 | One of the many uses for `Analyzer` is to extract reflection information from a class. Sometimes you only need some of it, but there's no reason you can't grab all of it. The result is an attribute that can carry all the same information as reflection, but can be cached if desired while reflection objects cannot be. 594 | 595 | A complete set of such attributes is provided in the [`Attributes/Reflect`](src/Attributes/Reflect) directory. They cover all components of a class. As none of them have any arguments, there is no need to put them on any class. The default "empty" version of each will get used, which will then self-populate using the `FromReflection*` interfaces. 596 | 597 | The net result is that a full reflection summary of any arbitrary class may be obtained by calling: 598 | 599 | ```php 600 | use Crell\AttributeUtls\Attributes\Reflect\ReflectClass; 601 | 602 | $reflect = $analyzer->analyze($someClass, ReflectClass::class); 603 | ``` 604 | 605 | `$reflect` now contains a complete copy of the class, properties, constants, methods, and parameters reflection information, in well-defined, easily cacheable objects. See each class's docblocks for a complete list of all available information. 606 | 607 | To analyze an Enum, use `ReflectEnum::class` instead. 608 | 609 | Even if you do not need to use the entire Reflect tree, it's worth studying as an example of how to really leverage the Analyzer. Additionally, if you are saving any reflection values as-is onto your attribute you are encouraged to use the same naming conventions as those classes, for consistency. 610 | 611 | A number of traits are included as well that handle the common case of collecting all of a given class component. Feel free to use them in your own classes if you wish. 612 | 613 | ## Advanced tricks 614 | 615 | The following are a collection of advanced and fancy uses of the Analyzer, mostly to help demonstrate just how powerful it can be when used appropriately. 616 | 617 | ### Multi-value attributes 618 | 619 | As noted, the Analyzer supports only a single main attribute on each component. However, sub-attributes may be multi-value, and an omitted attribute can be filled in with a default "empty" attribute. That leads to the following way to simulate multi-value attributes. It works on any component, although for simplicity we'll show it on classes. 620 | 621 | ```php 622 | #[\Attribute(Attribute::TARGET_CLASS)] 623 | class Names implements HasSubAttributes, IteratorAggregate, ArrayAccess 624 | { 625 | protected readonly array $names; 626 | 627 | public function subAttributes(): array 628 | { 629 | return [Alias::class => 'fromAliases']; 630 | } 631 | 632 | public function fromAliases(array $aliases): void 633 | { 634 | $this->names = $aliases; 635 | } 636 | 637 | public function getIterator(): \ArrayIterator 638 | { 639 | return new ArrayIterator($this->names); 640 | } 641 | 642 | public function offsetExists(mixed $offset): bool 643 | { 644 | return array_key_exists($offset, $this->names); 645 | } 646 | 647 | public function offsetGet(mixed $offset): Alias 648 | { 649 | return $this->names[$offset]; 650 | } 651 | 652 | public function offsetSet(mixed $offset, mixed $value): void 653 | { 654 | throw new InvalidArgumentException(); 655 | } 656 | 657 | public function offsetUnset(mixed $offset): void 658 | { 659 | throw new InvalidArgumentException(); 660 | } 661 | } 662 | 663 | #[\Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] 664 | class Alias implements Multivalue 665 | { 666 | public function __construct( 667 | public readonly string $first, 668 | public readonly string $last, 669 | ) {} 670 | 671 | public function fullName(): string 672 | { 673 | return "$this->first $this->last"; 674 | } 675 | } 676 | 677 | #[Alias(first: 'Bruce', last: 'Wayne')] 678 | #[Alias(first: 'Bat', last: 'Man')] 679 | class Hero 680 | { 681 | // ... 682 | } 683 | 684 | $names = $analyzer->analyze(Hero::class, Names::class); 685 | 686 | foreach ($names as $name) { 687 | print $name->fullName() . PHP_EOL; 688 | } 689 | 690 | // Output: 691 | Bruce Wayne 692 | Bat Man 693 | ``` 694 | 695 | The `IteratorAggregate` and `ArrayAccess` interfaces are optional; I include them here just to show that you can do it if you want. Here, the `Names` attribute is never put on a class directly. However, by analyzing a class "with respect to" `Names`, you can collect all the multi-value sub-attributes that it has, giving the impression of a multi-value attribute. 696 | 697 | Note that `Alias` needs to implement `Multivalue` so the analyzer knows to expect more than one of them. 698 | 699 | ## Interface attributes 700 | 701 | Normally, attributes do not inherit. That means an attribute on an interface has no bearing on classes that implement that interface. However, attributes may opt-in to inheriting via the Analzyer. 702 | 703 | A good use for that is sub-attributes, which may also be specified as an interface. For example, consider this modified version of the example above: 704 | 705 | ```php 706 | 707 | #[\Attribute(\Attribute::TARGET_CLASS)] 708 | class Names implements HasSubAttributes, IteratorAggregate, ArrayAccess 709 | { 710 | protected readonly array $names; 711 | 712 | public function subAttributes(): array 713 | { 714 | return [Name::class => 'fromNames']; 715 | } 716 | 717 | public function fromNames(array $names): void 718 | { 719 | $this->names = $names; 720 | } 721 | 722 | // The same ArrayAccess and IteratorAggregate code as above. 723 | } 724 | 725 | interface Name extends Multivalue 726 | { 727 | public function fullName(): string; 728 | } 729 | 730 | #[\Attribute(\Attribute::TARGET_CLASS)] 731 | class RealName implements Name 732 | { 733 | public function __construct( 734 | public readonly string $first, 735 | public readonly string $last, 736 | ) {} 737 | 738 | public function fullName(): string 739 | { 740 | return "$this->first $this->last"; 741 | } 742 | } 743 | 744 | #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] 745 | class Alias implements Name 746 | { 747 | public function __construct(public readonly string $name) {} 748 | 749 | public function fullName(): string 750 | { 751 | return $this->name; 752 | } 753 | } 754 | 755 | #[RealName(first: 'Bruce', last: 'Wayne')] 756 | #[Alias('Batman')] 757 | #[Alias('The Dark Knight')] 758 | #[Alias('The Caped Crusader')] 759 | class Hero 760 | { 761 | // ... 762 | } 763 | ``` 764 | 765 | You can now mix and match `RealName` and `Alias` on the same class. Only one `RealName` is allowed, but any number of `Alias` attributes are allowed. All are `Name` according to the `Names` main attribute, and so all will get picked up and made available. 766 | 767 | Note that the interface must be marked `Multivalue` so that `Analyzer` will allow more than one attribute of that type. However, the `RealName` attribute is not marked as repeatable, so PHP will prevent more than one `RealName` being used at once while `Alias` may be used any number of times. 768 | 769 | ### One of many options 770 | 771 | In a similar vein, it's possible to use sub-attributes to declare that a component may be marked with one of a few attributes, but only one of them. 772 | 773 | ```php 774 | interface DisplayType {} 775 | 776 | #[\Attribute(\Attribute::TARGET_CLASS)] 777 | class Screen implements DisplayType 778 | { 779 | public function __construct(public readonly string $color) {} 780 | } 781 | 782 | #[\Attribute(\Attribute::TARGET_CLASS)] 783 | class Audio implements DisplayType 784 | { 785 | public function __construct(public readonly int $volume) {} 786 | } 787 | 788 | #[\Attribute(Attribute::TARGET_CLASS)] 789 | class DisplayInfo implements HasSubAttributes 790 | { 791 | public readonly ?DisplayType $type; 792 | 793 | public function subAttributes(): array 794 | { 795 | return [DisplayType::class => $this->fromDisplayType(...)]; 796 | } 797 | 798 | public function fromDisplayType(?DisplayType $type): void 799 | { 800 | $this->type = $type; 801 | } 802 | } 803 | 804 | #[Screen('#00AA00')] 805 | class A {} 806 | 807 | #[Audio(10)] 808 | class B {} 809 | 810 | class C {} 811 | 812 | $displayInfoA = $analyzer->analzyer(A::class, DisplayInfo::class); 813 | $displayInfoB = $analyzer->analzyer(B::class, DisplayInfo::class); 814 | $displayInfoC = $analyzer->analzyer(C::class, DisplayInfo::class); 815 | ``` 816 | 817 | In this case, a class may be marked with either `Screen` or `Audio`, but not both. If both are specified, only the first one listed will be used; the others will be ignored. 818 | 819 | In this example, `$displayInfoA->type` will be an instance of `Screen`, `$displayInfoB->type` will be an instance of `Audio`, and `$displayInfoC->type` will be `null`. 820 | 821 | ## Change log 822 | 823 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 824 | 825 | ## Testing 826 | 827 | ``` bash 828 | $ composer test 829 | ``` 830 | 831 | ## Contributing 832 | 833 | Please see [CONTRIBUTING](CONTRIBUTING.md) and [CODE_OF_CONDUCT](CODE_OF_CONDUCT.md) for details. 834 | 835 | ## Security 836 | 837 | If you discover any security related issues, please use the [GitHub security reporting form](https://github.com/Crell/AttributeUtils/security) rather than the issue queue. 838 | 839 | ## Credits 840 | 841 | - [Larry Garfield][link-author] 842 | - [All Contributors][link-contributors] 843 | 844 | Initial development of this library was sponsored by [TYPO3 GmbH](https://typo3.com/). 845 | 846 | ## License 847 | 848 | The Lesser GPL version 3 or later. Please see [License File](LICENSE.md) for more information. 849 | 850 | [ico-version]: https://img.shields.io/packagist/v/Crell/AttributeUtils.svg?style=flat-square 851 | [ico-license]: https://img.shields.io/badge/License-LGPLv3-green.svg?style=flat-square 852 | [ico-downloads]: https://img.shields.io/packagist/dt/Crell/AttributeUtils.svg?style=flat-square 853 | 854 | [link-packagist]: https://packagist.org/packages/Crell/AttributeUtils 855 | [link-downloads]: https://packagist.org/packages/Crell/AttributeUtils 856 | [link-author]: https://github.com/Crell 857 | [link-contributors]: ../../contributors 858 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Brand Promise 2 | 3 | Perfect security is not an achievable goal, but it is a goal to strive for nonetheless. To that end, we welcome responsible security reports from both users and external security researchers. 4 | 5 | # Scope 6 | 7 | If you believe you've found a security issue in software that is maintained in this repository, we encourage you to notify us. 8 | 9 | | Version | In scope | Source code | 10 | | ------- | -------- |-----------------------------------------| 11 | | latest | ✅ | https://github.com/Crell/AttributeUtils | 12 | 13 | Only the latest stable release of this library is supported. In general, bug and security fixes will not be backported unless there is a substantial imminent threat to users in not doing so. 14 | 15 | # How to Submit a Report 16 | 17 | To submit a vulnerability report, please contact us through [GitHub](https://github.com/Crell/AttributeUtils/security). Your submission will be reviewed as soon as feasible, but as this is a volunteer project we cannot guarantee a response time. 18 | 19 | # Safe Harbor 20 | 21 | We support safe harbor for security researchers who: 22 | 23 | * Make a good faith effort to avoid privacy violations, destruction of data, and interruption or degradation of our services. 24 | * Only interact with accounts you own or with explicit permission of the account holder. If you do encounter Personally Identifiable Information (PII) contact us immediately, do not proceed with access, and immediately purge any local information. 25 | * Provide us with a reasonable amount of time to resolve vulnerabilities prior to any disclosure to the public or a third-party. 26 | 27 | We will consider activities conducted consistent with this policy to constitute "authorized" conduct and will not pursue civil action or initiate a complaint to law enforcement. We will help to the extent we can if legal action is initiated by a third party against you. 28 | 29 | Please submit a report to us before engaging in conduct that may be inconsistent with or unaddressed by this policy. 30 | 31 | # Preferences 32 | 33 | * Please provide detailed reports with reproducible steps and a clearly defined impact. 34 | * Include the version number of the vulnerable package in your report. 35 | * Providing a suggested fix is welcome, but not required, and we may choose to implement our own, based on your submitted fix or not. 36 | * This is a volunteer project. We will make every effort to respond to security reports in a timely manner, but that may be a week or two on the first contact. 37 | 38 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crell/attributeutils", 3 | "type": "library", 4 | "description": "A robust, flexible attribute handling framework", 5 | "keywords": [ 6 | "Attributes", 7 | "Reflection" 8 | ], 9 | "homepage": "https://github.com/Crell/AttributeUtils", 10 | "license": "LGPL-3.0-or-later", 11 | "authors": [ 12 | { 13 | "name": "Larry Garfield", 14 | "email": "larry@garfieldtech.com", 15 | "homepage": "http://www.garfieldtech.com/", 16 | "role": "Developer" 17 | } 18 | ], 19 | "require": { 20 | "php": "~8.1", 21 | "crell/fp": "~1.0.0" 22 | }, 23 | "require-dev": { 24 | "phpbench/phpbench": "^1.2", 25 | "phpstan/phpstan": "^1.10", 26 | "phpunit/phpunit": "~10.3", 27 | "psr/cache": "^3.0", 28 | "psr/cache-util": "^2.0" 29 | }, 30 | "suggest": { 31 | "psr/cache": "Caching analyzer rests is recommended, and a bridge for psr/cache is included." 32 | }, 33 | "autoload": { 34 | "psr-4": { 35 | "Crell\\AttributeUtils\\": "src" 36 | } 37 | }, 38 | "autoload-dev": { 39 | "psr-4": { 40 | "Crell\\AttributeUtils\\": "tests" 41 | } 42 | }, 43 | "scripts": { 44 | "phpstan": "vendor/bin/phpstan", 45 | "test": "vendor/bin/phpunit", 46 | "all-checks": [ 47 | "@test", 48 | "@phpstan" 49 | ], 50 | "benchmarks": "vendor/bin/phpbench run benchmarks --report=aggregate" 51 | }, 52 | "extra": { 53 | "branch-alias": { 54 | "dev-master": "1.0-dev" 55 | } 56 | }, 57 | "config": { 58 | "sort-packages": true 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /default-.env: -------------------------------------------------------------------------------- 1 | # See https://docs.docker.com/compose/env-file/ 2 | 3 | # global configuration 4 | # For production 5 | #COMPOSE_FILE=docker-compose.yml 6 | # For local dev 7 | #COMPOSE_FILE=docker-compose.yml:docker-compose.override.yml 8 | # For local dev with tunnel 9 | 10 | COMPOSE_FILE=docker-compose.yml 11 | # Ip of the host that docker can reach 12 | HOST_IP=172.17.0.1 13 | # Xdebug IDE key 14 | IDE_KEY=docker-xdebug 15 | # Port your IDE is listening on 16 | XDEBUG_PORT=9003 17 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 8 3 | paths: 4 | - src 5 | checkGenericClassInNonGenericObjectType: false 6 | ignoreErrors: 7 | - '#Match expression does not handle remaining value#' 8 | - '#has an uninitialized readonly property (.*). Assign it in the constructor.#' 9 | - '#Readonly property (.*) is assigned outside of the constructor.#' 10 | # I don't know what's up with this one. Need to come back to it and fix the docblock. 11 | - '#Method (.*)::analyze\(\) should return T of object but returns object.#' 12 | 13 | # Tests include an absurd number of classes that are by design not tricked-out, so don't 14 | # bother checking those. 15 | -------------------------------------------------------------------------------- /src/Analyzer.php: -------------------------------------------------------------------------------- 1 | getInheritedAttribute($subject, $attribute) ?? new $attribute; 29 | 30 | if ($classDef instanceof FromReflectionClass) { 31 | $classDef->fromReflection($subject); 32 | } 33 | 34 | if ($subject instanceof \ReflectionEnum && $classDef instanceof FromReflectionEnum) { 35 | $classDef->fromReflection($subject); 36 | } 37 | 38 | $defBuilder->loadSubAttributes($classDef, $subject); 39 | 40 | if ($classDef instanceof ParseProperties) { 41 | $properties = $defBuilder->getDefinitions( 42 | // Reflection can get only static, but not only non-static. Because of course. 43 | array_filter($subject->getProperties(), static fn (\ReflectionProperty $r) => !$r->isStatic()), 44 | fn (\ReflectionProperty $r) 45 | => $defBuilder->getComponentDefinition($r, $classDef->propertyAttribute(), $classDef->includePropertiesByDefault(), FromReflectionProperty::class, $classDef) 46 | ); 47 | $classDef->setProperties($properties); 48 | } 49 | 50 | if ($classDef instanceof ParseStaticProperties) { 51 | $properties = $defBuilder->getDefinitions( 52 | $subject->getProperties(\ReflectionProperty::IS_STATIC), 53 | fn (\ReflectionProperty $r) 54 | => $defBuilder->getComponentDefinition($r, $classDef->staticPropertyAttribute(), $classDef->includeStaticPropertiesByDefault(), FromReflectionProperty::class, $classDef) 55 | ); 56 | $classDef->setStaticProperties($properties); 57 | } 58 | 59 | if ($classDef instanceof ParseMethods) { 60 | $methods = $defBuilder->getDefinitions( 61 | // Reflection can get only static, but not only non-static. Because of course. 62 | array_filter($subject->getMethods(), static fn (\ReflectionMethod $r) => !$r->isStatic()), 63 | fn (\ReflectionMethod $r) 64 | => $defBuilder->getMethodDefinition($r, $classDef->methodAttribute(), $classDef->includeMethodsByDefault(), $classDef), 65 | ); 66 | $classDef->setMethods($methods); 67 | } 68 | 69 | if ($classDef instanceof ParseStaticMethods) { 70 | $methods = $defBuilder->getDefinitions( 71 | $subject->getMethods(\ReflectionMethod::IS_STATIC), 72 | fn (\ReflectionMethod $r) 73 | => $defBuilder->getMethodDefinition($r, $classDef->staticMethodAttribute(), $classDef->includeStaticMethodsByDefault(), $classDef), 74 | ); 75 | $classDef->setStaticMethods($methods); 76 | } 77 | 78 | // Enum cases have to come before constants, because 79 | // constants will include enums cases. It's up to the 80 | // implementing attribute class to filter out the enums 81 | // from the constants. Sadly, there is no better API for it. 82 | if ($subject instanceof \ReflectionEnum && $classDef instanceof ParseEnumCases) { 83 | $cases = $defBuilder->getDefinitions( 84 | $subject->getCases(), 85 | fn (\ReflectionEnumUnitCase $r) 86 | => $defBuilder->getComponentDefinition($r, $classDef->caseAttribute(), $classDef->includeCasesByDefault(), FromReflectionEnumCase::class, $classDef), 87 | ); 88 | $classDef->setCases($cases); 89 | } 90 | 91 | if ($classDef instanceof ParseClassConstants) { 92 | $constants = $defBuilder->getDefinitions( 93 | $subject->getReflectionConstants(), 94 | fn (\ReflectionClassConstant $r) 95 | => $defBuilder->getComponentDefinition($r, $classDef->constantAttribute(), $classDef->includeConstantsByDefault(), FromReflectionClassConstant::class, $classDef), 96 | ); 97 | $classDef->setConstants($constants); 98 | } 99 | 100 | if ($classDef instanceof CustomAnalysis) { 101 | $classDef->customAnalysis($this); 102 | } 103 | 104 | if ($classDef instanceof Finalizable) { 105 | $classDef->finalize(); 106 | } 107 | 108 | return $classDef; 109 | } catch (\ArgumentCountError $e) { 110 | $this->translateArgumentCountError($e); 111 | } 112 | } 113 | 114 | /** 115 | * Throws a domain-specific exception based on an ArgumentCountError. 116 | * 117 | * This is absolutely hideous, but this is what happens when your throwable 118 | * puts all the useful information in the message text rather than as useful 119 | * properties or methods or something. 120 | * 121 | * Conclusion: Write better, more debuggable exceptions than PHP does. 122 | */ 123 | protected function translateArgumentCountError(\ArgumentCountError $error): never 124 | { 125 | $message = $error->getMessage(); 126 | // PHPStan doesn't understand this syntax style of sscanf(), so skip it. 127 | // @phpstan-ignore-next-line 128 | [$classAndMethod, $passedCount, $file, $line, $expectedCount] = sscanf( 129 | string: $message, 130 | format: "Too few arguments to function %s::%s, %d passed in %s on line %d and exactly %d expected" 131 | ); 132 | [$className, $methodName] = \explode('::', $classAndMethod ?? ''); 133 | 134 | throw RequiredAttributeArgumentsMissing::create($className, $error); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/AttributeParser.php: -------------------------------------------------------------------------------- 1 | $scopes 17 | * The scopes for which this analysis should run. 18 | */ 19 | public function __construct(private readonly array $scopes = []) {} 20 | 21 | /** 22 | * Returns a single attribute of a given type from a target, or null if not found. 23 | */ 24 | public function getAttribute(\Reflector $target, string $name): ?object 25 | { 26 | return $this->getAttributes($target, $name)[0] ?? null; 27 | } 28 | 29 | /** 30 | * Get all attributes of a given type from a target. 31 | * 32 | * Unfortunately PHP has no common interface for "reflection objects that support attributes", 33 | * and enumerating them manually is stupidly verbose and clunky. Instead just refer 34 | * to any reflectable thing and hope for the best. 35 | * 36 | * @return array 37 | */ 38 | public function getAttributes(\Reflector $target, string $name): array 39 | { 40 | // @phpstan-ignore-next-line. 41 | $attribs = pipe($target->getAttributes($name, \ReflectionAttribute::IS_INSTANCEOF), 42 | amap(method('newInstance')), 43 | ); 44 | 45 | if (is_a($name, SupportsScopes::class, true)) { 46 | $attribs = $this->filterForScopes($attribs, $this->scopes); 47 | } 48 | 49 | return array_values($attribs); 50 | } 51 | 52 | /** 53 | * Filters a list of attributes based on their declared scope. 54 | * 55 | * If the current scope list is empty, meaning no scope, then only 56 | * attributes that also have no scope at all (their scope list is `[null]`) 57 | * will be retained. 58 | * 59 | * If the current scope is non-empty, and there is at least one in-scope 60 | * attribute, then only attributes that match the current scope are returned. 61 | * 62 | * If there are only unscoped attributes, then those will be returned. That 63 | * allows a scoped lookup to still include all attributes that are not scope-specific. 64 | * To explicitly exclude an attribute in unscoped or scoped requests, implement 65 | * `Excludable` and mark it excluded in the appropriate scope. 66 | * 67 | * @param SupportsScopes[] $attribs 68 | * An array of loaded attribute objects. 69 | * @param array $currentScopes 70 | * The scopes being requested. 71 | * @return SupportsScopes[] 72 | */ 73 | protected function filterForScopes(array $attribs, array $currentScopes): array 74 | { 75 | // If the request is for "unscoped" attributes, filter out 76 | // any that specify a scope other than unscoped. 77 | if (empty($currentScopes)) { 78 | return pipe($attribs, 79 | afilter($this->hasNoScope(...)), 80 | ); 81 | } 82 | 83 | // If both a scoped and unscoped version of an attribute is present, 84 | // and the request is for that scope, we want the scoped version, NOT 85 | // the unscoped version, regardless of lexical order. That requires 86 | // this extra song-and-dance to see if we should fall back to the unscoped 87 | // version or not. 88 | 89 | // Attributes that are in the current scope OR unscoped. 90 | $attribs = \array_filter($attribs, $this->inScopeOrUnscoped($currentScopes)); 91 | 92 | // If there are any attributes with a scope, filter out the 93 | // ones that have no scope. 94 | $attribsWithNonDefaultScope = \array_filter($attribs, $this->hasAnyScope(...)); 95 | if (count($attribsWithNonDefaultScope)) { 96 | $attribs = \array_filter($attribs, $this->matchesScopes($currentScopes)); 97 | } 98 | 99 | return $attribs; 100 | } 101 | 102 | /** 103 | * Determines if an attribute has no scope specified at all. 104 | * 105 | * @param SupportsScopes $attr 106 | * @return bool 107 | */ 108 | protected function hasNoScope(SupportsScopes $attr): bool 109 | { 110 | return $attr->scopes() === [null]; 111 | } 112 | 113 | /** 114 | * Determines if an attribute has any scope specified. 115 | * 116 | * @param SupportsScopes $attr 117 | * @return bool 118 | */ 119 | protected function hasAnyScope(SupportsScopes $attr): bool 120 | { 121 | return !$this->hasNoScope($attr); 122 | } 123 | 124 | /** 125 | * Builds a pipe-friendly closure that determines if an attribute is in a scope. 126 | * 127 | * @param array $scopes 128 | * The name of the scope, or null if checking for the unscoped case. 129 | * @return \Closure 130 | */ 131 | protected function matchesScopes(array $scopes): \Closure 132 | { 133 | return static fn (SupportsScopes $attr): bool 134 | => (bool)array_intersect($scopes, $attr->scopes()); 135 | } 136 | 137 | /** 138 | * Builds a pipe-friendly closure that determines if an attribute is in a scope, or supports unscoped cases. 139 | * 140 | * This is a performance optimization of matchesScopes($scopes) || hasNoScope($attr). 141 | * 142 | * @param array $scopes 143 | */ 144 | protected function inScopeOrUnscoped(array $scopes): \Closure 145 | { 146 | return static function (SupportsScopes $attr) use ($scopes): bool { 147 | $attrScopes = $attr->scopes(); 148 | return $attrScopes === [null] 149 | || array_intersect($attrScopes, $scopes); 150 | }; 151 | } 152 | 153 | /** 154 | * Retrieves a single attribute from a class element, including opt-in inheritance and transitiveness. 155 | * 156 | * @see getInheritedAttributes() 157 | */ 158 | public function getInheritedAttribute(\Reflector $target, string $name): ?object 159 | { 160 | return $this->getInheritedAttributes($target, $name)[0] ?? null; 161 | } 162 | 163 | /** 164 | * Retrieves multiple attributes from a class element, including opt-in inheritance and transitiveness. 165 | * 166 | * If the attribute in question implements Inheritable, then parent classes 167 | * will also be checked for the attribute. 168 | * 169 | * If the element is a property that is typed for a class and implements 170 | * TransitiveProperty, then the class pointed at by the property will also be 171 | * checked. If it implements both interfaces, then parents of the class 172 | * pointed to by the property will be checked as well. 173 | * 174 | * @param \Reflector $target 175 | * The property from which to get an attribute. 176 | * @param string $name 177 | * @return array 178 | */ 179 | public function getInheritedAttributes(\Reflector $target, string $name): array 180 | { 181 | $attributes = pipe($this->attributeInheritanceTree($target, $name), 182 | firstValue(fn ($r): array => $this->getAttributes($r, $name)) 183 | ); 184 | 185 | if ($attributes) { 186 | return $attributes; 187 | } 188 | 189 | // Transitivity is only supported on properties at this time. 190 | // It's not clear that it makes any sense on methods or constants. 191 | if ($target instanceof \ReflectionProperty 192 | && is_a($name, TransitiveProperty::class, true) 193 | && $class = $this->getPropertyClass($target)) 194 | { 195 | return pipe($this->classAncestors($class), 196 | // PHPStan gets confused by firstValue() and thinks $c is a string, not class-string. 197 | // @phpstan-ignore-next-line 198 | firstValue(fn (string $c): array => $this->getAttributes(new \ReflectionClass($c), $name)), 199 | ) ?? []; 200 | } 201 | 202 | return []; 203 | } 204 | 205 | /** 206 | * A generator to produce reflections of all the ancestors of a reflectable. 207 | * 208 | * The property itself will be included first, and parents will only be 209 | * scanned if the attribute implements the Inheritable interface. 210 | * 211 | * @return iterable<\Reflector> 212 | * @see Inheritable 213 | */ 214 | protected function attributeInheritanceTree(\Reflector $subject, string $attributeType): iterable 215 | { 216 | // Check the subject itself, first. 217 | yield $subject; 218 | 219 | if (is_a($attributeType, Inheritable::class, true)) { 220 | yield from match(get_class($subject)) { 221 | \ReflectionClass::class => $this->classInheritanceTree($subject), 222 | \ReflectionObject::class => $this->classInheritanceTree($subject), 223 | \ReflectionProperty::class => $this->classElementInheritanceTree($subject), 224 | \ReflectionMethod::class => $this->classElementInheritanceTree($subject), 225 | \ReflectionClassConstant::class => $this->classElementInheritanceTree($subject), 226 | \ReflectionParameter::class => $this->parameterInheritanceTree($subject), 227 | // If it's an enum, there's nothing to inherit so just stub that out. 228 | \ReflectionEnum::class => [], 229 | }; 230 | } 231 | } 232 | 233 | /** 234 | * Returns all the ReflectionClasses in a subject's inheritance tree. 235 | * 236 | * This includes both classes and interfaces. 237 | * 238 | * @param \ReflectionClass $subject 239 | * The reflection of the class for which we want the ancestors. 240 | * @return iterable<\ReflectionClass> 241 | * @throws \ReflectionException 242 | */ 243 | protected function classInheritanceTree(\ReflectionClass $subject): iterable 244 | { 245 | $ancestors = $this->classAncestors($subject->getName(), false); 246 | foreach ($ancestors as $ancestor) { 247 | yield new \ReflectionClass($ancestor); 248 | } 249 | } 250 | 251 | /** 252 | * Returns all of the ReflectionParameters in a subject's inheritance tree. 253 | * 254 | * That is, it returns the reflection of the parent class's copy of a 255 | * parameter on the same method, if defined. 256 | * 257 | * @param \ReflectionParameter $subject 258 | * The reflection of the Parameter for which we want the ancestors. 259 | * @return \ReflectionParameter[] 260 | * @throws \ReflectionException 261 | */ 262 | protected function parameterInheritanceTree(\ReflectionParameter $subject): iterable 263 | { 264 | $parameterName = $subject->getName(); 265 | $methodName = $subject->getDeclaringFunction()->name; 266 | 267 | $declaringClass = $subject->getDeclaringClass()?->name; 268 | 269 | if (!$declaringClass) { 270 | return; 271 | } 272 | 273 | foreach ($this->classAncestors($declaringClass) as $class) { 274 | $rClass = new \ReflectionClass($class); 275 | if ($rClass->hasMethod($methodName)) { 276 | $rMethod = $rClass->getMethod($methodName); 277 | foreach ($rMethod->getParameters() as $rParam) { 278 | if ($rParam->name === $parameterName) { 279 | yield $rParam; 280 | break; 281 | } 282 | } 283 | } 284 | } 285 | } 286 | 287 | /** 288 | * Returns all of the reflections in a subject's inheritance tree. 289 | * 290 | * This method works for the "basic" class elements: Properties, methods, and constants. 291 | * 292 | * For other types, see their respective methods. 293 | * 294 | * @param \ReflectionProperty|\ReflectionMethod|\ReflectionClassConstant $subject 295 | * The reflection of the component for which we want the ancestors. 296 | * @return iterable<\Reflector> 297 | * @throws \ReflectionException 298 | */ 299 | protected function classElementInheritanceTree(\ReflectionProperty|\ReflectionMethod|\ReflectionClassConstant $subject): iterable 300 | { 301 | $subjectName = $subject->getName(); 302 | 303 | [$hasMethod, $getMethod] = match(get_class($subject)) { 304 | \ReflectionProperty::class => ['hasProperty', 'getProperty'], 305 | \ReflectionMethod::class => ['hasMethod', 'getMethod'], 306 | \ReflectionClassConstant::class => ['hasConstant', 'getReflectionConstant'], 307 | }; 308 | 309 | foreach ($this->classAncestors($subject->getDeclaringClass()->name) as $class) { 310 | $rClass = new \ReflectionClass($class); 311 | if ($rClass->$hasMethod($subjectName)) { 312 | yield $rClass->$getMethod($subjectName); 313 | } 314 | } 315 | } 316 | 317 | /** 318 | * Returns a list of all class and interface parents of a class. 319 | * 320 | * @param class-string $class 321 | * @return array 322 | */ 323 | public function classAncestors(string $class, bool $includeClass = true): array 324 | { 325 | // These methods both return associative arrays, making + safe. 326 | /** @var array $ancestors */ 327 | $ancestors = class_parents($class) + class_implements($class); 328 | return $includeClass 329 | ? [$class => $class] + $ancestors 330 | : $ancestors 331 | ; 332 | } 333 | 334 | /** 335 | * Returns the class or interface a given property is typed for, or null if it's not so typed. 336 | * 337 | * @param \ReflectionProperty $rProperty 338 | * The property to check 339 | * @return class-string|null 340 | * The class/interface name, or null. 341 | */ 342 | protected function getPropertyClass(\ReflectionProperty $rProperty): ?string 343 | { 344 | $rType = $rProperty->getType(); 345 | if ($rType instanceof \ReflectionNamedType && (class_exists($rType->getName()) || interface_exists($rType->getName()))) { 346 | return $rType->getName(); 347 | } 348 | return null; 349 | } 350 | } 351 | -------------------------------------------------------------------------------- /src/Attributes/GenericClass.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | public array $properties; 25 | 26 | public function __construct( 27 | protected string $propertyAttribute, 28 | protected bool $includeByDefault = true, 29 | ) {} 30 | 31 | public function setProperties(array $properties): void 32 | { 33 | $this->properties = $properties; 34 | } 35 | 36 | public function includePropertiesByDefault(): bool 37 | { 38 | return $this->includeByDefault; 39 | } 40 | 41 | public function propertyAttribute(): string 42 | { 43 | return $this->propertyAttribute; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Attributes/GenericClassConstant.php: -------------------------------------------------------------------------------- 1 | constants = $constants; 20 | } 21 | 22 | public function includeConstantsByDefault(): bool 23 | { 24 | return true; 25 | } 26 | 27 | abstract public function constantAttribute(): string; 28 | } 29 | -------------------------------------------------------------------------------- /src/Attributes/Reflect/CollectEnumCases.php: -------------------------------------------------------------------------------- 1 | cases = $cases; 18 | } 19 | 20 | public function includeCasesByDefault(): bool 21 | { 22 | return true; 23 | } 24 | 25 | abstract public function caseAttribute(): string; 26 | } 27 | -------------------------------------------------------------------------------- /src/Attributes/Reflect/CollectMethods.php: -------------------------------------------------------------------------------- 1 | methods = $methods; 18 | } 19 | 20 | public function includeMethodsByDefault(): bool 21 | { 22 | return true; 23 | } 24 | 25 | abstract public function methodAttribute(): string; 26 | } 27 | -------------------------------------------------------------------------------- /src/Attributes/Reflect/CollectParameters.php: -------------------------------------------------------------------------------- 1 | parameters = $parameters; 18 | } 19 | 20 | public function includeParametersByDefault(): bool 21 | { 22 | return true; 23 | } 24 | 25 | abstract public function parameterAttribute(): string; 26 | } 27 | -------------------------------------------------------------------------------- /src/Attributes/Reflect/CollectProperties.php: -------------------------------------------------------------------------------- 1 | properties = $properties; 18 | } 19 | 20 | public function includePropertiesByDefault(): bool 21 | { 22 | return true; 23 | } 24 | 25 | abstract public function propertyAttribute(): string; 26 | } 27 | -------------------------------------------------------------------------------- /src/Attributes/Reflect/CollectStaticMethods.php: -------------------------------------------------------------------------------- 1 | staticMethods = $methods; 18 | } 19 | 20 | public function includeStaticMethodsByDefault(): bool 21 | { 22 | return true; 23 | } 24 | 25 | abstract public function staticMethodAttribute(): string; 26 | } 27 | -------------------------------------------------------------------------------- /src/Attributes/Reflect/CollectStaticProperties.php: -------------------------------------------------------------------------------- 1 | staticProperties = $properties; 18 | } 19 | 20 | public function includeStaticPropertiesByDefault(): bool 21 | { 22 | return true; 23 | } 24 | 25 | abstract public function staticPropertyAttribute(): string; 26 | } 27 | -------------------------------------------------------------------------------- /src/Attributes/Reflect/HasVisibility.php: -------------------------------------------------------------------------------- 1 | visibility = match (true) { 21 | // @phpstan-ignore-next-line 22 | $subject->isPrivate() => Visibility::Private, 23 | // @phpstan-ignore-next-line 24 | $subject->isProtected() => Visibility::Protected, 25 | // @phpstan-ignore-next-line 26 | $subject->isPublic() => Visibility::Public, 27 | }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Attributes/Reflect/MethodType.php: -------------------------------------------------------------------------------- 1 | phpName = $subject->getName(); 74 | $this->shortName = $subject->getShortName(); 75 | $this->namespace = $subject->getNamespaceName(); 76 | $this->isInternal = $subject->isInternal(); 77 | // isUserDefined() is the inverse of isInternal, so no need to cache that. 78 | $this->isInstantiable = $subject->isInstantiable(); 79 | $this->isCloneable = $subject->isCloneable(); 80 | 81 | $this->structType = match (true) { 82 | $subject->isInterface() => ClassType::Interface, 83 | $subject->isTrait() => ClassType::Trait, 84 | $subject->isAnonymous() => ClassType::AnonymousClass, 85 | default => ClassType::NormalClass, 86 | }; 87 | 88 | // @todo getFileName, getStartLine, getEndLine - Needed or no? Should they go in a separate struct? 89 | // @todo do we want getDocComment, or is that too much data to cache? 90 | 91 | // @todo getTraits(), getTraitNames(), Do we include traits or not? 92 | 93 | $this->isFinal = $subject->isFinal(); 94 | 95 | // @todo getParentClass() returns a ReflectionClass. What do we do with that? 96 | 97 | $this->isIterable = $subject->isIterable(); 98 | 99 | // @todo We're ignoring extension information for now. 100 | } 101 | 102 | public function constantAttribute(): string 103 | { 104 | return ReflectClassConstant::class; 105 | } 106 | 107 | public function methodAttribute(): string 108 | { 109 | return ReflectMethod::class; 110 | } 111 | 112 | public function staticMethodAttribute(): string 113 | { 114 | return ReflectMethod::class; 115 | } 116 | 117 | public function propertyAttribute(): string 118 | { 119 | return ReflectProperty::class; 120 | } 121 | 122 | public function staticPropertyAttribute(): string 123 | { 124 | return ReflectProperty::class; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Attributes/Reflect/ReflectClassConstant.php: -------------------------------------------------------------------------------- 1 | |object 23 | */ 24 | public readonly int|string|array|object $value; 25 | 26 | /** 27 | * True if this is a final constant, false otherwise. 28 | */ 29 | public readonly bool $isFinal; 30 | 31 | public function fromReflection(\ReflectionClassConstant $subject): void 32 | { 33 | $this->phpName = $subject->getName(); 34 | $this->value = $subject->getValue(); 35 | 36 | $this->parseVisibility($subject); 37 | 38 | $this->isFinal = $subject->isFinal(); 39 | 40 | // @todo Do we include doc comment, or the declaring class? 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Attributes/Reflect/ReflectEnum.php: -------------------------------------------------------------------------------- 1 | phpName = $subject->getName(); 59 | $this->shortName = $subject->getShortName(); 60 | $this->namespace = $subject->getNamespaceName(); 61 | $this->isInternal = $subject->isInternal(); 62 | // isUserDefined() is the inverse of isInternal, so no need to cache that. 63 | 64 | $this->isBacked = $subject->isBacked(); 65 | 66 | // getName() is a valid method on ReflectionType, even if the 67 | // stubs in PHP are outdated. 68 | // @phpstan-ignore-next-line 69 | $this->backingType = $this->isBacked ? $subject->getBackingType()?->getName() : null; 70 | 71 | // @todo getFileName, getStartLine, getEndLine - Needed or no? Should they go in a separate struct? 72 | // @todo do we want getDocComment, or is that too much data to cache? 73 | 74 | // @todo getTraits(), getTraitNames(), Do we include traits or not? 75 | 76 | $this->isIterable = $subject->isIterable(); 77 | 78 | // @todo We're ignoring extension information for now. 79 | } 80 | 81 | /** 82 | * @param ReflectClassConstant[] $constants 83 | */ 84 | public function setConstants(array $constants): void 85 | { 86 | // There's no way to tell a constant apart from a case in advance, 87 | // so we have to accept all of them and then filter. Yes, this is gross. 88 | $this->constants = array_diff_key($constants, $this->cases); 89 | } 90 | 91 | public function constantAttribute(): string 92 | { 93 | return ReflectClassConstant::class; 94 | } 95 | 96 | public function methodAttribute(): string 97 | { 98 | return ReflectMethod::class; 99 | } 100 | 101 | public function staticMethodAttribute(): string 102 | { 103 | return ReflectMethod::class; 104 | } 105 | 106 | public function caseAttribute(): string 107 | { 108 | return ReflectEnumCase::class; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Attributes/Reflect/ReflectEnumCase.php: -------------------------------------------------------------------------------- 1 | phpName = $subject->getName(); 30 | 31 | if ($subject instanceof \ReflectionEnumBackedCase) { 32 | $this->isBacked = true; 33 | $this->value = $subject->getBackingValue(); 34 | } else { 35 | $this->isBacked = false; 36 | } 37 | 38 | // @todo Do we include doc comment, or the declaring class? 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Attributes/Reflect/ReflectMethod.php: -------------------------------------------------------------------------------- 1 | phpName = $subject->getName(); 79 | 80 | // @todo I'm not convinced isDeprecated() is useful, so skipping that. 81 | 82 | $this->isInternal = $subject->isInternal(); 83 | // isUserDefined() is the inverse of isInternal, so no need to cache that. 84 | 85 | $this->isGenerator = $subject->isGenerator(); 86 | $this->isVariadic = $subject->isVariadic(); 87 | $this->returnsReference = $subject->returnsReference(); 88 | $this->isAbstract = $subject->isAbstract(); 89 | $this->isFinal = $subject->isFinal(); 90 | $this->isStatic = $subject->isStatic(); 91 | 92 | $this->parseVisibility($subject); 93 | 94 | $this->methodType = match (true) { 95 | $subject->isConstructor() => MethodType::Constructor, 96 | $subject->isDestructor() => MethodType::Destructor, 97 | default => MethodType::Normal, 98 | }; 99 | 100 | // @todo Skipping extension info, file lines, doc comment, etc. 101 | 102 | // @todo getNumberOfParameters and getNumberOfRequiredParameters seem redundant with having the parameters available. 103 | 104 | $this->returnType = new TypeDef($subject->getReturnType()); 105 | } 106 | 107 | /** 108 | * @param ReflectParameter[] $parameters 109 | */ 110 | public function setParameters(array $parameters): void 111 | { 112 | $this->parameters = $parameters; 113 | } 114 | 115 | public function includeParametersByDefault(): bool 116 | { 117 | return true; 118 | } 119 | 120 | public function parameterAttribute(): string 121 | { 122 | return ReflectParameter::class; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Attributes/Reflect/ReflectParameter.php: -------------------------------------------------------------------------------- 1 | phpName = $subject->getName(); 48 | $this->isPassByReference = $subject->isPassedByReference(); 49 | $this->position = $subject->getPosition(); 50 | $this->isOptional = $subject->isOptional(); 51 | $this->isVariadic = $subject->isVariadic(); 52 | 53 | $this->type = new TypeDef($subject->getType()); 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/Attributes/Reflect/ReflectProperty.php: -------------------------------------------------------------------------------- 1 | phpName = $subject->getName(); 47 | 48 | // @todo Do we want to capture getValue() or no? 49 | 50 | $this->parseVisibility($subject); 51 | 52 | $this->isStatic = $subject->isStatic(); 53 | 54 | // This naming makes more sense than "default". 55 | $this->isDynamic = !$subject->isDefault(); 56 | 57 | // @todo Declaring class? DocComment? 58 | 59 | $this->isPromoted = $subject->isPromoted(); 60 | 61 | // @todo Do we want the default value and such? 62 | 63 | $this->type = new TypeDef($subject->getType()); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/ClassAnalyzer.php: -------------------------------------------------------------------------------- 1 | $attribute 22 | * The fully qualified class name of the class attribute to analyze. 23 | * @param array $scopes 24 | * The scopes for which this analysis should run. 25 | * @return T 26 | * The class attribute requested, including dependent data as appropriate. 27 | */ 28 | public function analyze(string|object $class, string $attribute, array $scopes = []): object; 29 | } 30 | -------------------------------------------------------------------------------- /src/ClassType.php: -------------------------------------------------------------------------------- 1 | getAttribute($subject, $attribute) ?? new $attribute; 16 | 17 | if ($funcDef instanceof FromReflectionFunction) { 18 | $funcDef->fromReflection($subject); 19 | } 20 | 21 | $defBuilder->loadSubAttributes($funcDef, $subject); 22 | 23 | if ($funcDef instanceof ParseParameters) { 24 | $parameters = $defBuilder->getDefinitions( 25 | $subject->getParameters(), 26 | fn (\ReflectionParameter $p) 27 | => $defBuilder->getComponentDefinition($p, $funcDef->parameterAttribute(), $funcDef->includeParametersByDefault(), FromReflectionParameter::class, $funcDef) 28 | ); 29 | $funcDef->setParameters($parameters); 30 | } 31 | 32 | if ($funcDef instanceof Finalizable) { 33 | $funcDef->finalize(); 34 | } 35 | 36 | return $funcDef; 37 | } catch (\ArgumentCountError $e) { 38 | $this->translateArgumentCountError($e); 39 | } 40 | } 41 | 42 | /** 43 | * Throws a domain-specific exception based on an ArgumentCountError. 44 | * 45 | * This is absolutely hideous, but this is what happens when your throwable 46 | * puts all the useful information in the message text rather than as useful 47 | * properties or methods or something. 48 | * 49 | * Conclusion: Write better, more debuggable exceptions than PHP does. 50 | */ 51 | protected function translateArgumentCountError(\ArgumentCountError $error): never 52 | { 53 | $message = $error->getMessage(); 54 | // PHPStan doesn't understand this syntax style of sscanf(), so skip it. 55 | // @phpstan-ignore-next-line 56 | [$classAndMethod, $passedCount, $file, $line, $expectedCount] = sscanf( 57 | string: $message, 58 | format: "Too few arguments to function %s::%s, %d passed in %s on line %d and exactly %d expected" 59 | ); 60 | [$className, $methodName] = \explode('::', $classAndMethod ?? ''); 61 | 62 | throw RequiredAttributeArgumentsMissing::create($className, $error); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/FunctionAnalyzer.php: -------------------------------------------------------------------------------- 1 | $attribute 14 | * The fully qualified class name of the class attribute to analyze. 15 | * @param array $scopes 16 | * The scopes for which this analysis should run. 17 | * @return T 18 | * The function attribute requested, including dependent data as appropriate. 19 | */ 20 | public function analyze(string|\Closure $function, string $attribute, array $scopes = []): object; 21 | } 22 | -------------------------------------------------------------------------------- /src/HasSubAttributes.php: -------------------------------------------------------------------------------- 1 | 22 | * A mapping of attribute class name to the callback method that should be called with it. 23 | */ 24 | public function subAttributes(): array; 25 | } 26 | -------------------------------------------------------------------------------- /src/Inheritable.php: -------------------------------------------------------------------------------- 1 | >> 14 | */ 15 | private array $cache = []; 16 | 17 | public function __construct(private readonly ClassAnalyzer $analyzer) 18 | {} 19 | 20 | public function analyze(object|string $class, string $attribute, array $scopes = []): object 21 | { 22 | $key = is_object($class) ? $class::class : $class; 23 | 24 | $scopekey = ''; 25 | if ($scopes) { 26 | sort($scopes); 27 | $scopekey = implode(',', $scopes); 28 | } 29 | 30 | return $this->cache[$key][$attribute][$scopekey] ??= $this->analyzer->analyze($class, $attribute, $scopes); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/MemoryCacheFunctionAnalyzer.php: -------------------------------------------------------------------------------- 1 | >> 14 | */ 15 | private array $cache = []; 16 | 17 | public function __construct( 18 | private readonly FunctionAnalyzer $analyzer, 19 | ) {} 20 | 21 | public function analyze(string|\Closure $function, string $attribute, array $scopes = []): object 22 | { 23 | // We cannot cache a closure, as we have no reliable identifier for it. 24 | if ($function instanceof \Closure) { 25 | return $this->analyzer->analyze($function, $attribute, $scopes); 26 | } 27 | 28 | $scopekey = ''; 29 | if ($scopes) { 30 | sort($scopes); 31 | $scopekey = implode(',', $scopes); 32 | } 33 | 34 | return $this->cache[$function][$attribute][$scopekey] ??= $this->analyzer->analyze($function, $attribute, $scopes); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Multivalue.php: -------------------------------------------------------------------------------- 1 | $constants 27 | * The attribute objects on the constants. 28 | */ 29 | public function setConstants(array $constants): void; 30 | 31 | public function includeConstantsByDefault(): bool; 32 | 33 | public function constantAttribute(): string; 34 | } 35 | -------------------------------------------------------------------------------- /src/ParseEnumCases.php: -------------------------------------------------------------------------------- 1 | $cases 27 | * The attribute objects on the enum cases. 28 | */ 29 | public function setCases(array $cases): void; 30 | 31 | public function includeCasesByDefault(): bool; 32 | 33 | public function caseAttribute(): string; 34 | } 35 | -------------------------------------------------------------------------------- /src/ParseMethods.php: -------------------------------------------------------------------------------- 1 | $methods 27 | * The attribute objects on the methods. 28 | */ 29 | public function setMethods(array $methods): void; 30 | 31 | public function includeMethodsByDefault(): bool; 32 | 33 | public function methodAttribute(): string; 34 | } 35 | -------------------------------------------------------------------------------- /src/ParseParameters.php: -------------------------------------------------------------------------------- 1 | $parameters 27 | * The attribute objects on the parameters. 28 | */ 29 | public function setParameters(array $parameters): void; 30 | 31 | public function includeParametersByDefault(): bool; 32 | 33 | public function parameterAttribute(): string; 34 | } 35 | -------------------------------------------------------------------------------- /src/ParseProperties.php: -------------------------------------------------------------------------------- 1 | $properties 27 | * The attribute objects on the properties. 28 | */ 29 | public function setProperties(array $properties): void; 30 | 31 | public function includePropertiesByDefault(): bool; 32 | 33 | public function propertyAttribute(): string; 34 | } 35 | -------------------------------------------------------------------------------- /src/ParseStaticMethods.php: -------------------------------------------------------------------------------- 1 | $methods 27 | * The attribute objects on the methods. 28 | */ 29 | public function setStaticMethods(array $methods): void; 30 | 31 | public function includeStaticMethodsByDefault(): bool; 32 | 33 | public function staticMethodAttribute(): string; 34 | } 35 | -------------------------------------------------------------------------------- /src/ParseStaticProperties.php: -------------------------------------------------------------------------------- 1 | $properties 27 | * The attribute objects on the properties. 28 | */ 29 | public function setStaticProperties(array $properties): void; 30 | 31 | public function includeStaticPropertiesByDefault(): bool; 32 | 33 | public function staticPropertyAttribute(): string; 34 | } 35 | -------------------------------------------------------------------------------- /src/Psr6CacheAnalyzer.php: -------------------------------------------------------------------------------- 1 | buildKey($class, $attribute, $scopes); 22 | 23 | $item = $this->pool->getItem($key); 24 | if ($item->isHit()) { 25 | return $item->get(); 26 | } 27 | 28 | // No expiration; the cached data would only need to change 29 | // if the source code changes. 30 | $value = $this->analyzer->analyze($class, $attribute, $scopes); 31 | $item->set($value); 32 | $this->pool->save($item); 33 | return $value; 34 | } 35 | 36 | /** 37 | * Generates the cache key for this request. 38 | * 39 | * @param array $scopes 40 | * The scopes for which this analysis should run. 41 | */ 42 | private function buildKey(object|string $class, string $attribute, array $scopes): string 43 | { 44 | $parts = [ 45 | is_object($class) ? $class::class : $class, 46 | $attribute, 47 | implode(',', $scopes), 48 | ]; 49 | 50 | return str_replace('\\', '_', \implode('-', $parts)); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Psr6FunctionCacheAnalyzer.php: -------------------------------------------------------------------------------- 1 | analyzer->analyze($function, $attribute, $scopes); 21 | } 22 | 23 | $key = $this->buildKey($function, $attribute, $scopes); 24 | 25 | $item = $this->pool->getItem($key); 26 | if ($item->isHit()) { 27 | return $item->get(); 28 | } 29 | 30 | // No expiration; the cached data would only need to change 31 | // if the source code changes. 32 | $value = $this->analyzer->analyze($function, $attribute, $scopes); 33 | $item->set($value); 34 | $this->pool->save($item); 35 | return $value; 36 | } 37 | 38 | /** 39 | * Generates the cache key for this request. 40 | * 41 | * @param array $scopes 42 | * The scopes for which this analysis should run. 43 | */ 44 | private function buildKey(string $function, string $attribute, array $scopes): string 45 | { 46 | $parts = [ 47 | $function, 48 | $attribute, 49 | implode(',', $scopes), 50 | ]; 51 | 52 | return str_replace('\\', '_', \implode('-', $parts)); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/ReadsClass.php: -------------------------------------------------------------------------------- 1 | 34 | * An array of attributes across all items of the applicable type. 35 | */ 36 | public function getDefinitions(array $reflections, callable $deriver): array 37 | { 38 | return pipe($reflections, 39 | // The Reflector interface is insufficient, but getName() is defined 40 | // on all types we care about. This is a reflection API limitation. 41 | indexBy(method('getName')), 42 | amap($deriver), 43 | afilter(static fn (?object $attr): bool => $attr && !($attr instanceof Excludable && $attr->exclude())), 44 | ); 45 | } 46 | 47 | /** 48 | * Returns the attribute definition for a class component. 49 | */ 50 | public function getComponentDefinition(\Reflector $reflection, string $attributeType, bool $includeByDefault, string $reflectionInterface, object $classDef): ?object 51 | { 52 | // @todo This is a problem. IF an attribute supports scopes, and is excluded, 53 | // then we do NOT want to have a default empty added, regardless of $includeByDefault. 54 | // I think? But we don't know about scopes at this point, which means we don't know 55 | // what we should do here. I don't know how to solve this. 56 | $def = $this->parser->getInheritedAttribute($reflection, $attributeType) 57 | ?? ($includeByDefault ? new $attributeType() : null); 58 | 59 | if ($def instanceof $reflectionInterface) { 60 | // This is just too dynamic for PHPstan to handle. 61 | // @phpstan-ignore-next-line 62 | $def->fromReflection($reflection); 63 | } 64 | 65 | $this->loadSubAttributes($def, $reflection); 66 | 67 | if ($def instanceof CustomAnalysis && $this->analyzer) { 68 | $def->customAnalysis($this->analyzer); 69 | } 70 | 71 | if ($def instanceof ReadsClass) { 72 | $def->fromClassAttribute($classDef); 73 | } 74 | 75 | if ($def instanceof Finalizable) { 76 | $def->finalize(); 77 | } 78 | 79 | return $def; 80 | } 81 | 82 | /** 83 | * Returns the attribute definition for a method. 84 | * 85 | * Methods can't just reuse getComponentDefinition() because they 86 | * also have parameters of their own to parse. 87 | */ 88 | public function getMethodDefinition(\ReflectionMethod $reflection, string $attributeType, bool $includeByDefault, object $classDef): ?object 89 | { 90 | $def = $this->parser->getInheritedAttribute($reflection, $attributeType) 91 | ?? ($includeByDefault ? new $attributeType() : null); 92 | 93 | if ($def instanceof FromReflectionMethod) { 94 | $def->fromReflection($reflection); 95 | } 96 | 97 | $this->loadSubAttributes($def, $reflection); 98 | 99 | if ($def instanceof ParseParameters) { 100 | $parameters = $this->getDefinitions( 101 | $reflection->getParameters(), 102 | fn (\ReflectionParameter $p) 103 | => $this->getComponentDefinition($p, $def->parameterAttribute(), $def->includeParametersByDefault(), FromReflectionParameter::class, $classDef) 104 | ); 105 | $def->setParameters($parameters); 106 | } 107 | 108 | if ($def instanceof CustomAnalysis && $this->analyzer) { 109 | $def->customAnalysis($this->analyzer); 110 | } 111 | 112 | if ($def instanceof ReadsClass) { 113 | $def->fromClassAttribute($classDef); 114 | } 115 | 116 | if ($def instanceof Finalizable) { 117 | $def->finalize(); 118 | } 119 | 120 | return $def; 121 | } 122 | 123 | /** 124 | * Loads sub-attributes onto an attribute, if appropriate. 125 | */ 126 | public function loadSubAttributes(?object $attribute, \Reflector $reflection): void 127 | { 128 | if ($attribute instanceof HasSubAttributes) { 129 | foreach ($attribute->subAttributes() as $type => $callback) { 130 | if ($this->isMultivalueAttribute($type)) { 131 | $subs = $this->parser->getInheritedAttributes($reflection, $type); 132 | foreach ($subs as $sub) { 133 | if ($sub instanceof Finalizable) { 134 | $sub->finalize(); 135 | } 136 | $this->loadSubAttributes($sub, $reflection); 137 | } 138 | if ($callback instanceof \Closure) { 139 | $callback($subs); 140 | } else { 141 | $attribute->$callback($subs); 142 | } 143 | } else { 144 | $sub = $this->parser->getInheritedAttribute($reflection, $type); 145 | if ($sub instanceof Finalizable) { 146 | $sub->finalize(); 147 | } 148 | $this->loadSubAttributes($sub, $reflection); 149 | if ($callback instanceof \Closure) { 150 | $callback($sub); 151 | } else { 152 | $attribute->$callback($sub); 153 | } 154 | } 155 | } 156 | } 157 | } 158 | 159 | /** 160 | * Determines if a given attribute class allows repeating. 161 | * 162 | * This is only meaningful for attributes used as sub-attributes. 163 | */ 164 | protected function isMultivalueAttribute(string $attributeType): bool 165 | { 166 | return is_a($attributeType, Multivalue::class, true); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/RequiredAttributeArgumentsMissing.php: -------------------------------------------------------------------------------- 1 | getCode(), $previous); 20 | 21 | $new->attributeType = $attributeType; 22 | 23 | return $new; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/SupportsScopes.php: -------------------------------------------------------------------------------- 1 | scope]; 25 | * } 26 | * } 27 | * 28 | * If the intent is to allow multiple scopes on the same 29 | * attribute instance, you would do this (assuming you want 30 | * it included it unscoped requests): 31 | * 32 | * class Attr implements SupportsScopes 33 | * { 34 | * public function __construct(private array $scopes = [null]) {} 35 | * 36 | * public function scopes(): array 37 | * { 38 | * return $this->scopes; 39 | * } 40 | * } 41 | * 42 | * Returning an empty array means this attribute will never be 43 | * included, so that is most likely never what you want. 44 | * 45 | * To explicitly exclude an attribute in unscoped or scoped requests, implement 46 | * `Excludable` and mark it excluded in the appropriate scope. 47 | * 48 | * @return array 49 | * An array of scope names in which this attribute should 50 | * be included. Include a value of `null` to have it 51 | * present in the "none requested" scope. 52 | */ 53 | public function scopes(): array; 54 | } 55 | -------------------------------------------------------------------------------- /src/TransitiveProperty.php: -------------------------------------------------------------------------------- 1 | > 23 | */ 24 | private array $type = [[]]; 25 | 26 | public readonly bool $allowsNull; 27 | 28 | public readonly TypeComplexity $complexity; 29 | 30 | /** 31 | * @todo Unclear if self and static should resolve to their actual classes. Right now they do not. 32 | * 33 | * @param ?\ReflectionType $type 34 | */ 35 | public function __construct(?\ReflectionType $type) 36 | { 37 | if (is_null($type)) { 38 | $this->allowsNull = true; 39 | $this->type = [['mixed']]; 40 | $this->complexity = TypeComplexity::Simple; 41 | return; 42 | } 43 | 44 | $this->allowsNull = $type->allowsNull(); 45 | 46 | // PHPStan thinks this property is already assigned, despite 47 | // the return statement above. This is a bug in PHPStan. 48 | // @phpstan-ignore-next-line 49 | $this->type = match ($type::class) { 50 | \ReflectionNamedType::class => [[$type->getName()]], 51 | \ReflectionUnionType::class => $this->parseUnionType($type), 52 | \ReflectionIntersectionType::class => [$this->parseIntersectionType($type)], 53 | }; 54 | 55 | $this->complexity = $this->deriveComplexity($this->type); 56 | } 57 | 58 | public function isSimple(): bool 59 | { 60 | return $this->complexity === TypeComplexity::Simple; 61 | } 62 | 63 | /** 64 | * Returns the simple type for this definition, or null if it's not simple. 65 | * 66 | * @return string|null 67 | * The simple type as a string, or null if it's not a simple type. 68 | */ 69 | public function getSimpleType(): ?string 70 | { 71 | return $this->isSimple() ? $this->type[0][0] : null; 72 | } 73 | 74 | /** 75 | * Determines if this type definition will accept a value of the specified type. 76 | * 77 | * @todo Not sure what to do with static, self, etc. 78 | * 79 | * @param string $type 80 | * A simple string type, like "int", "float", "SomeClass", etc. 81 | * Classes should include their full namespace. 82 | * @return bool 83 | */ 84 | public function accepts(string $type): bool 85 | { 86 | if ($type === 'null') { 87 | return $this->allowsNull; 88 | } 89 | 90 | $typeAccepts = fn ($defType): bool => match(true) { 91 | $defType === 'mixed' => true, 92 | class_exists($defType) || interface_exists($defType) => is_a($type, $defType, true), 93 | default => $type === $defType, 94 | }; 95 | 96 | $intersectionAccepts = fn (array $segment): bool => all($typeAccepts)($segment); 97 | 98 | return any($intersectionAccepts)($this->type); 99 | } 100 | 101 | /** 102 | * @return array> 103 | */ 104 | protected function parseUnionType(\ReflectionUnionType $type): array 105 | { 106 | $translate = fn (\ReflectionType $innerType): array => match($innerType::class) { 107 | \ReflectionNamedType::class => [$innerType->getName()], 108 | \ReflectionIntersectionType::class => $this->parseIntersectionType($innerType), 109 | }; 110 | return array_map($translate, $type->getTypes()); 111 | } 112 | 113 | /** 114 | * @return array 115 | */ 116 | protected function parseIntersectionType(\ReflectionIntersectionType $type): array 117 | { 118 | return array_map(method('getName'), $type->getTypes()); 119 | } 120 | 121 | /** 122 | * 123 | * 124 | * @param array> $type 125 | */ 126 | protected function deriveComplexity(array $type): TypeComplexity 127 | { 128 | if (count($type) === 1 && count($type[0]) === 1) { 129 | return TypeComplexity::Simple; 130 | } 131 | 132 | if (count($type) > 1) { 133 | // It's either a union or a compound. 134 | return any(static fn (array $s):bool => count($s) > 1)($type) 135 | ? TypeComplexity::Compound 136 | : TypeComplexity::Union; 137 | } 138 | 139 | return TypeComplexity::Intersection; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/Visibility.php: -------------------------------------------------------------------------------- 1 |