├── .github └── workflows │ ├── ci.yml │ └── shepherd.yml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── docker-compose.yml ├── phpunit.xml ├── psalm-cases ├── 00-random-casts.php ├── 01-maps-narrowing-array-keys.php ├── 02-object-like-array.php └── classes │ ├── Address.php │ ├── Foo.php │ ├── TestClass.php │ ├── UglyNestedStructure.php │ ├── User.php │ └── UserWithHobby.php ├── psalm.xml ├── resources ├── build.php └── grammar.pp ├── src ├── ArrayKeyType.php ├── ArrayType.php ├── BoolType.php ├── CallableType.php ├── CastException.php ├── ClassType.php ├── FloatType.php ├── IntType.php ├── ListType.php ├── LiteralType.php ├── MapType.php ├── MixedType.php ├── NullType.php ├── NumericStringType.php ├── NumericType.php ├── ObjectLikeType.php ├── ObjectType.php ├── Parser │ ├── Cache.php │ ├── DeclarationReader.php │ ├── DocBlockAstTraverser.php │ ├── DocBlockParser.php │ └── PropertyVisitor.php ├── Resolvers │ ├── DefaultResolver.php │ ├── MappingUtils.php │ ├── Resolver.php │ ├── SubTypeResolution.php │ └── ValueResolution.php ├── ResourceType.php ├── ScalarType.php ├── StringType.php ├── Type.php ├── Union2Type.php ├── Union3Type.php ├── Union4Type.php ├── Union5Type.php ├── Union6Type.php ├── Union7Type.php ├── Union8Type.php └── types.php ├── test.php └── tests ├── CacheTest.php ├── CastTest.php ├── JsonMappingTest.php ├── MappingUtilsTest.php ├── Parser └── ParserTest.php ├── TrackUnmappedPropertiesTest.php ├── TypeDeclarationTest.php ├── TypedPropertiesTest.php └── Types ├── ArrayKeyTypeTest.php ├── ArrayTypeTest.php ├── BoolTypeTest.php ├── CallableTypeTest.php ├── ClassTypeTest.php ├── ListTypeTest.php ├── MixedTypeTest.php ├── NullTypeTest.php ├── ResourceTypeTest.php └── UnionTypeTest.php /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | jobs: 5 | lint: 6 | strategy: 7 | matrix: 8 | php: [ 8.3 ] 9 | name: Check PHP syntax 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: shivammathur/setup-php@v2 13 | with: 14 | php-version: ${{ matrix.php }} 15 | tools: composer:v2 16 | - uses: actions/checkout@v2 17 | - run: composer update 18 | - run: vendor/bin/parallel-lint src 19 | - run: vendor/bin/phpcs --standard=PSR2 -n src 20 | unit-tests-static-analysis: 21 | name: Check PHP syntax 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: shivammathur/setup-php@v2 25 | with: 26 | php-version: '8.3' 27 | ini-values: zend.assertions = 1 28 | tools: composer:v2 29 | - uses: actions/checkout@v2 30 | - run: composer update 31 | - run: vendor/bin/psalm 32 | - run: vendor/bin/phpstan analyze src 33 | - run: vendor/bin/phpunit --coverage-clover build/logs/clover.xml 34 | - env: 35 | COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | run: | 37 | composer global require php-coveralls/php-coveralls 38 | php-coveralls --coverage_clover=build/logs/clover.xml -v 39 | -------------------------------------------------------------------------------- /.github/workflows/shepherd.yml: -------------------------------------------------------------------------------- 1 | name: Shepherd 2 | 3 | on: [push, pull_request] 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: shivammathur/setup-php@v2 9 | - uses: actions/checkout@v2 10 | - run: composer install --prefer-dist --no-progress --no-suggest 11 | - run: vendor/bin/psalm --threads=2 --output-format=github --shepherd 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | composer.lock 3 | .idea/ 4 | .phpunit.cache/ 5 | composer.phar 6 | INTERNAL.md 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | {description} 294 | Copyright (C) {year} {fullname} 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | {signature of Ty Coon}, 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Hamlet Type 2 | === 3 | 4 | ![CI Status](https://github.com/hamlet-framework/type/workflows/CI/badge.svg?branch=master&event=push) 5 | [![Packagist](https://img.shields.io/packagist/v/hamlet-framework/type.svg)](https://packagist.org/packages/hamlet-framework/type) 6 | [![Packagist](https://img.shields.io/packagist/dt/hamlet-framework/type.svg)](https://packagist.org/packages/hamlet-framework/type) 7 | [![Coverage Status](https://coveralls.io/repos/github/hamlet-framework/type/badge.svg?branch=master)](https://coveralls.io/github/hamlet-framework/type?branch=master) 8 | ![Psalm coverage](https://shepherd.dev/github/hamlet-framework/type/coverage.svg?) 9 | 10 | There are few aspects of specifying type of expression in PHP: 11 | 12 | 1. The most exact specification of the type (we assume it's in psalm syntax): `array` 13 | 2. The sequence of run time assertions: `assert($records instanceof array)` 14 | 3. The type hint for static analysers (which is _psalm_ at the moment): `(array) $records` 15 | 4. The ability to derive types without string manipulation: `array || null` 16 | 5. The ability to cast when it's safe, i.e. falsey should cast to false, etc. 17 | 18 | This library provides the basic building blocks for type specifications. For example, the following expression: 19 | 20 | ```php 21 | $type = _map( 22 | _int(), 23 | _union( 24 | _class(DateTime::class), 25 | _null() 26 | ) 27 | ) 28 | ``` 29 | 30 | creates an object of type `Type>`. 31 | 32 | Asserting at run time, that the type of `$records` is `array>`: 33 | ```php 34 | $type->assert($records); 35 | ``` 36 | 37 | Cast `$records` to `array>` and throw an exception when `$records` cannot be cast to `array>`: 38 | ```php 39 | return $type->cast($records); 40 | ``` 41 | 42 | Combine type with other types, for example, making it nullable `array>|null`: 43 | ```php 44 | _union($type, _null()) 45 | ``` 46 | 47 | ## Object like arrays 48 | 49 | Object like arrays require more leg work. For example the type `array{id:int,name:string,valid?:bool}` 50 | corresponds to this construct: 51 | 52 | ```php 53 | /** @var Type */ 54 | $type = _object_like([ 55 | 'id' => _int(), 56 | 'name' => _string(), 57 | 'valid?' => _bool() 58 | ]); 59 | ``` 60 | 61 | Quite a mouthful. Also, note the required `@var` as psalm currently have no support for dependent types. 62 | 63 | If you want to assert types matching your PHPDoc you can use the type parser (WiP): 64 | 65 | ```php 66 | /** @var Type */ 67 | $type = Type::of('array{id:int}'); 68 | 69 | assert($type->matches($record)); 70 | ``` 71 | 72 | ## Background 73 | 74 | - Move completely to PHPStan parsed including docblock 75 | - Add union and intersection types 76 | - Support for enums 77 | - Support for non-empty-* types 78 | - Support for tuples as int `array{int,string}` 79 | - Support for iterable|self|static|class-string 80 | - Better support for callables 81 | - Add PHPStan to QA pipeline -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hamlet-framework/type", 3 | "type": "library", 4 | "description": "Hamlet Framework / Type", 5 | "keywords": ["library", "smart cast", "psalm"], 6 | "license": "Apache-2.0", 7 | "authors": [ 8 | { 9 | "name": "Vasily Kartashov", 10 | "email": "info@kartashov.com" 11 | } 12 | ], 13 | "config": { 14 | "optimize-autoloader": true, 15 | "classmap-authoritative": true, 16 | "sort-packages": true, 17 | "allow-plugins": { 18 | "composer/package-versions-deprecated": true 19 | } 20 | }, 21 | "require": { 22 | "php": ">=8.3", 23 | "nikic/php-parser": "^4", 24 | "phpstan/phpdoc-parser": "@stable" 25 | }, 26 | "require-dev": { 27 | "ext-json": "*", 28 | "php-parallel-lint/php-parallel-lint": "@stable", 29 | "phpunit/phpunit": "@stable", 30 | "squizlabs/php_codesniffer": "@stable", 31 | "vimeo/psalm": "@stable", 32 | "phpstan/phpstan": "@stable" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "Hamlet\\Type\\": "src" 37 | }, 38 | "files": [ 39 | "src/types.php" 40 | ] 41 | }, 42 | "autoload-dev": { 43 | "psr-4": { 44 | "Hamlet\\Type\\": "tests" 45 | } 46 | }, 47 | "archive": { 48 | "exclude": [ 49 | "psalm-cases", 50 | "tests" 51 | ] 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | php: 5 | image: php:8.3-cli 6 | volumes: 7 | - .:/app 8 | working_dir: /app 9 | composer: 10 | image: composer 11 | volumes: 12 | - .:/app 13 | working_dir: /app -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | tests 6 | 7 | 8 | 9 | 10 | src 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /psalm-cases/00-random-casts.php: -------------------------------------------------------------------------------- 1 | cast(true); 26 | } 27 | 28 | public function callable(): callable 29 | { 30 | return _callable()->cast(function (): int { 31 | return 1; 32 | }); 33 | } 34 | 35 | public function stdClass(): stdClass 36 | { 37 | return _class(stdClass::class)->cast(new stdClass()); 38 | } 39 | 40 | public function float(): float 41 | { 42 | return _float()->cast(1.1); 43 | } 44 | 45 | public function int(): float 46 | { 47 | return _int()->cast(1); 48 | } 49 | 50 | /** 51 | * @return array 52 | */ 53 | public function list(): array 54 | { 55 | return _list(_int())->cast([1, 2, 3]); 56 | } 57 | 58 | /** 59 | * @return (1|null|'java') 60 | */ 61 | public function literal(): mixed 62 | { 63 | return _literal(1, null, 'java')->cast(1); 64 | } 65 | 66 | /** 67 | * @return array 68 | */ 69 | public function map(): array 70 | { 71 | return _map(_string(), _int())->cast(['a' => 1]); 72 | } 73 | 74 | public function mixed(): mixed 75 | { 76 | return _mixed()->cast(null); 77 | } 78 | 79 | /** 80 | * @return null 81 | */ 82 | public function null() 83 | { 84 | return _null()->cast(0); 85 | } 86 | 87 | public function object(): object 88 | { 89 | return _object()->cast(new stdClass()); 90 | } 91 | 92 | /** 93 | * @return array{id:int} 94 | */ 95 | public function object_like(): array 96 | { 97 | /** @var Type $type */ 98 | $type = _object_like([ 99 | 'id' => _int() 100 | ]); 101 | 102 | return $type->cast(['id' => 1]); 103 | } 104 | 105 | public function string(): string 106 | { 107 | return _string()->cast('hello'); 108 | } 109 | 110 | public function union(): ?string 111 | { 112 | return _union(_string(), _null())->cast(null); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /psalm-cases/01-maps-narrowing-array-keys.php: -------------------------------------------------------------------------------- 1 | $a 12 | * @return void 13 | */ 14 | function f(array $a) {} 15 | 16 | /** 17 | * @param array $a 18 | * @return void 19 | */ 20 | function g(array $a) {} 21 | 22 | /** 23 | * @var array $b 24 | */ 25 | $b = []; 26 | f(_map(_string(), _mixed())->cast($b)); 27 | g(_map(_int(), _mixed())->cast($b)); 28 | -------------------------------------------------------------------------------- /psalm-cases/02-object-like-array.php: -------------------------------------------------------------------------------- 1 | $type */ 16 | $type = Type::of("array{x:string,y:int,z:('a'|'b')}"); 17 | 18 | f($type->cast($b)); 19 | -------------------------------------------------------------------------------- /psalm-cases/classes/Address.php: -------------------------------------------------------------------------------- 1 | city; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /psalm-cases/classes/Foo.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | private $a; 15 | 16 | /** 17 | * @var string|object|null 18 | * @psalm-var 'x'|'y'|'z'|CastException|\DateTime|null 19 | */ 20 | private $b; 21 | } 22 | -------------------------------------------------------------------------------- /psalm-cases/classes/UglyNestedStructure.php: -------------------------------------------------------------------------------- 1 | id; 19 | } 20 | 21 | public function name(): string 22 | { 23 | return $this->name; 24 | } 25 | 26 | public function address() 27 | { 28 | return $this->address; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /psalm-cases/classes/UserWithHobby.php: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /resources/build.php: -------------------------------------------------------------------------------- 1 | string 13 | %token string:string [^']+ 14 | %token string:_quote ' -> default 15 | 16 | 17 | %token parenthesis_ \( 18 | %token _parenthesis \) 19 | 20 | %token brace_ \{ 21 | %token _brace \} 22 | 23 | %token bracket_ \[ 24 | %token _bracket \] 25 | 26 | %token angular_ < 27 | %token _angular > 28 | 29 | 30 | %token question \? 31 | %token namespace :: 32 | %token colon : 33 | %token comma , 34 | %token or \| 35 | %token and & 36 | %token float_number (\d+)?\.\d+ 37 | %token int_number \d+ 38 | %token id [a-zA-Z_][a-zA-Z0-9_]* 39 | %token backslash \\ 40 | %token word [^\s]+ 41 | 42 | expression: 43 | type() ::word::* 44 | 45 | type: 46 | basic_type() 47 | | derived_type() 48 | 49 | derived_type: 50 | union() 51 | | intersection() 52 | 53 | #basic_type: 54 | ( escaped_type() | | literal() | object_like_array() | array() | generic() | callable() | class_name() ) brackets()* 55 | 56 | quoted_string: 57 | ::quote_:: ::_quote:: 58 | 59 | #literal: 60 | quoted_string() 61 | | 62 | | 63 | | 64 | | 65 | | 66 | | const() 67 | 68 | #const: 69 | class_name() ::namespace:: 70 | 71 | #array: 72 | ::angular_:: type() ::comma:: type() ::_angular:: 73 | | ::angular_:: type() ::_angular:: 74 | | 75 | 76 | #object_like_array: 77 | ::brace_:: property() (::comma:: property())* ::_brace:: 78 | 79 | #property_name: 80 | 81 | | 82 | | quoted_string() 83 | 84 | #property: 85 | property_name() property_option()? ::colon:: type() 86 | | type() 87 | 88 | #property_option: 89 | ::question:: 90 | 91 | escaped_type: 92 | ::parenthesis_:: type() ::_parenthesis:: 93 | 94 | #union: 95 | basic_type() ::or:: type() 96 | 97 | #intersection: 98 | basic_type() ::and:: type() 99 | 100 | #generic: 101 | class_name() ::angular_:: type() (::comma:: type())* ::_angular:: 102 | 103 | #callable: 104 | ::parenthesis_:: callable_parameters()? ::_parenthesis:: (::colon:: type())? 105 | 106 | callable_parameters: 107 | type() (::comma:: type())* 108 | 109 | #class_name: 110 | backslash()? (backslash() )* 111 | 112 | #backslash: 113 | ::backslash:: 114 | 115 | #brackets: 116 | ::bracket_:: ::_bracket:: 117 | -------------------------------------------------------------------------------- /src/ArrayKeyType.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | readonly class ArrayKeyType extends Union2Type 9 | { 10 | public function __construct() 11 | { 12 | parent::__construct(new IntType, new StringType); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/ArrayType.php: -------------------------------------------------------------------------------- 1 | > 10 | */ 11 | readonly class ArrayType extends Type 12 | { 13 | /** 14 | * @var Type 15 | */ 16 | private Type $elementType; 17 | 18 | /** 19 | * @param Type $elementType 20 | */ 21 | public function __construct(Type $elementType) 22 | { 23 | $this->elementType = $elementType; 24 | } 25 | 26 | /** 27 | * @psalm-assert-if-true array $value 28 | */ 29 | public function matches(mixed $value): bool 30 | { 31 | if (!is_array($value)) { 32 | return false; 33 | } 34 | /** 35 | * @psalm-suppress MixedAssignment 36 | */ 37 | foreach ($value as $v) { 38 | if (!$this->elementType->matches($v)) { 39 | return false; 40 | } 41 | } 42 | return true; 43 | } 44 | 45 | /** 46 | * @return array 47 | */ 48 | public function resolveAndCast(mixed $value, Resolver $resolver): array 49 | { 50 | if (!is_array($value)) { 51 | throw new CastException($value, $this); 52 | } 53 | $result = []; 54 | /** 55 | * @psalm-suppress MixedAssignment 56 | */ 57 | foreach ($value as $v) { 58 | $result[] = $this->elementType->resolveAndCast($v, $resolver); 59 | } 60 | return $result; 61 | } 62 | 63 | public function __toString(): string 64 | { 65 | return 'array<' . $this->elementType . '>'; 66 | } 67 | 68 | public function serialize(): string 69 | { 70 | return 'new ' . static::class . '(' . $this->elementType->serialize() . ')'; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/BoolType.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | readonly class BoolType extends Type 9 | { 10 | /** 11 | * @param mixed $value 12 | * @return bool 13 | * @psalm-assert-if-true bool $value 14 | */ 15 | public function matches(mixed $value): bool 16 | { 17 | return is_bool($value); 18 | } 19 | 20 | public function cast(mixed $value): bool 21 | { 22 | return (bool) $value; 23 | } 24 | 25 | public function __toString(): string 26 | { 27 | return 'bool'; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/CallableType.php: -------------------------------------------------------------------------------- 1 | 8 | * 9 | * @todo add more logic into return type and argument types 10 | */ 11 | readonly class CallableType extends Type 12 | { 13 | private string $tag; 14 | 15 | private ?Type $returnType; 16 | 17 | /** 18 | * @var array 19 | */ 20 | private array $argumentTypes; 21 | 22 | /** 23 | * @param string $tag 24 | * @param ?Type $returnType 25 | * @param array $argumentTypes 26 | */ 27 | public function __construct(string $tag, ?Type $returnType = null, array $argumentTypes = []) 28 | { 29 | $this->tag = $tag; 30 | $this->returnType = $returnType; 31 | $this->argumentTypes = $argumentTypes; 32 | } 33 | 34 | /** 35 | * @psalm-assert-if-true callable $value 36 | */ 37 | public function matches(mixed $value): bool 38 | { 39 | return is_callable($value); 40 | } 41 | 42 | /** 43 | * @return T 44 | * @todo think about wrapping the value into an asserting closure here and in the assert methods 45 | * @psalm-suppress InvalidReturnStatement not sure we can do more than that 46 | * @psalm-suppress InvalidReturnType 47 | */ 48 | public function cast(mixed $value): callable 49 | { 50 | if (!is_callable($value)) { 51 | throw new CastException($value, $this); 52 | } 53 | return $value; 54 | } 55 | 56 | public function __toString(): string 57 | { 58 | $arguments = []; 59 | foreach ($this->argumentTypes as $argumentType) { 60 | if ($argumentType instanceof Union2Type) { 61 | $arguments[] = '(' . $argumentType . ')'; 62 | } else { 63 | $arguments[] = (string) $argumentType; 64 | } 65 | } 66 | if ($this->returnType) { 67 | if ($this->returnType instanceof Union2Type) { 68 | $return = ':(' . $this->returnType . ')'; 69 | } else { 70 | $return = ':' . $this->returnType; 71 | } 72 | } else { 73 | $return = ''; 74 | } 75 | 76 | return $this->tag . '(' . join(',', $arguments) . ')' . $return; 77 | } 78 | 79 | public function serialize(): string 80 | { 81 | $line = var_export($this->tag, true); 82 | if ($this->returnType) { 83 | $line .= ', ' . $this->returnType->serialize(); 84 | if ($this->argumentTypes) { 85 | $arguments = []; 86 | foreach ($this->argumentTypes as $argumentType) { 87 | $arguments[] = $argumentType->serialize(); 88 | } 89 | $line .= ', [' . join(', ', $arguments) . ']'; 90 | } 91 | } 92 | return 'new ' . static::class . '(' . $line . ')'; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/CastException.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | protected Type $targetType; 22 | 23 | /** 24 | * @param T $value 25 | * @param Type $targetType 26 | * @param string $details 27 | */ 28 | public function __construct(mixed $value, Type $targetType, string $details = '') 29 | { 30 | $message = 'Cannot convert [' . var_export($value, true) . '] to ' . $targetType; 31 | if ($details) { 32 | $message .= '. ' . $details; 33 | } 34 | parent::__construct($message); 35 | $this->value = $value; 36 | $this->targetType = $targetType; 37 | } 38 | 39 | /** 40 | * @return T 41 | */ 42 | public function value(): mixed 43 | { 44 | return $this->value; 45 | } 46 | 47 | /** 48 | * @return Type 49 | */ 50 | public function targetType(): Type 51 | { 52 | return $this->targetType; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/ClassType.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | readonly class ClassType extends Type 15 | { 16 | /** 17 | * @var class-string 18 | */ 19 | private string $type; 20 | 21 | /** 22 | * @param class-string $type 23 | */ 24 | public function __construct(string $type) 25 | { 26 | if ($type[0] == '\\') { 27 | /** @psalm-suppress PropertyTypeCoercion */ 28 | $this->type = substr($type, 1); 29 | } else { 30 | $this->type = $type; 31 | } 32 | } 33 | 34 | /** 35 | * @psalm-assert-if-true T $value 36 | */ 37 | public function matches(mixed $value): bool 38 | { 39 | return is_object($value) && is_a($value, $this->type); 40 | } 41 | 42 | /** 43 | * @return T 44 | * @psalm-suppress InvalidReturnType 45 | * @psalm-suppress InvalidReturnStatement 46 | * @psalm-suppress NoValue 47 | * @throws ReflectionException 48 | */ 49 | public function resolveAndCast(mixed $value, Resolver $resolver): object 50 | { 51 | if ($this->matches($value)) { 52 | return $value; 53 | } 54 | 55 | if (!(is_object($value) && is_a($value, stdClass::class) || is_array($value))) { 56 | throw new CastException($value, $this); 57 | } 58 | 59 | $subTypeResolution = $resolver->resolveSubType($this->type, $value); 60 | $reflectionClass = $subTypeResolution->reflectionClass(); 61 | $subTreeResolver = $subTypeResolution->subTreeResolver(); 62 | 63 | $validateUnmappedProperties = !$resolver->ignoreUnmappedProperties(); 64 | $mappedProperties = []; 65 | 66 | $result = $reflectionClass->newInstanceWithoutConstructor(); 67 | foreach ($reflectionClass->getProperties() as $reflectionProperty) { 68 | $propertyName = $reflectionProperty->getName(); 69 | $valueResolution = $subTreeResolver->getValue($this->type, $propertyName, $value); 70 | $propertyType = $subTreeResolver->getPropertyType($reflectionClass, $reflectionProperty); 71 | 72 | if ($valueResolution->successful()) { 73 | if ($validateUnmappedProperties) { 74 | $sourceFieldName = $valueResolution->sourceFieldName(); 75 | if ($sourceFieldName !== null) { 76 | $mappedProperties[$sourceFieldName] = 1; 77 | } 78 | } 79 | } elseif ($propertyType->matches(null)) { 80 | $mappedProperties[$propertyName] = 1; 81 | } else { 82 | throw new CastException($value, $this); 83 | } 84 | 85 | $result = $resolver->setValue( 86 | $result, 87 | $propertyName, 88 | $propertyType->resolveAndCast($valueResolution->value(), $subTreeResolver) 89 | ); 90 | } 91 | if ($validateUnmappedProperties) { 92 | MappingUtils::checkMapping($value, $mappedProperties, $this); 93 | } 94 | return $result; 95 | } 96 | 97 | public function __toString(): string 98 | { 99 | return $this->type; 100 | } 101 | 102 | public function serialize(): string 103 | { 104 | return 'new ' . static::class . '(' . $this->type . '::class)'; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/FloatType.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | readonly class FloatType extends Type 9 | { 10 | public function matches(mixed $value): bool 11 | { 12 | return is_float($value); 13 | } 14 | 15 | public function cast(mixed $value): float 16 | { 17 | if (is_object($value)) { 18 | throw new CastException($value, $this); 19 | } 20 | return (float) $value; 21 | } 22 | 23 | public function __toString(): string 24 | { 25 | return 'float'; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/IntType.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | readonly class IntType extends Type 9 | { 10 | /** 11 | * @psalm-assert-if-true int $value 12 | */ 13 | public function matches(mixed $value): bool 14 | { 15 | return is_int($value); 16 | } 17 | 18 | public function cast(mixed $value): int 19 | { 20 | if (is_object($value)) { 21 | throw new CastException($value, $this); 22 | } 23 | return (int) $value; 24 | } 25 | 26 | public function __toString(): string 27 | { 28 | return 'int'; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/ListType.php: -------------------------------------------------------------------------------- 1 | > 10 | */ 11 | readonly class ListType extends Type 12 | { 13 | /** 14 | * @var Type 15 | */ 16 | private Type $elementType; 17 | 18 | /** 19 | * @param Type $elementType 20 | */ 21 | public function __construct(Type $elementType) 22 | { 23 | $this->elementType = $elementType; 24 | } 25 | 26 | /** 27 | * @psalm-assert-if-true list $value 28 | */ 29 | public function matches(mixed $value): bool 30 | { 31 | if (!is_array($value)) { 32 | return false; 33 | } 34 | $i = 0; 35 | foreach ($value as $k => $v) { 36 | if ($i !== $k) { 37 | return false; 38 | } 39 | if (!$this->elementType->matches($v)) { 40 | return false; 41 | } 42 | $i++; 43 | } 44 | return true; 45 | } 46 | 47 | /** 48 | * @return list 49 | */ 50 | public function resolveAndCast(mixed $value, Resolver $resolver): array 51 | { 52 | if (!is_array($value)) { 53 | throw new CastException($value, $this); 54 | } 55 | $result = []; 56 | /** 57 | * @psalm-suppress MixedAssignment 58 | */ 59 | foreach ($value as $v) { 60 | $result[] = $this->elementType->resolveAndCast($v, $resolver); 61 | } 62 | return $result; 63 | } 64 | 65 | public function __toString(): string 66 | { 67 | return 'list<' . $this->elementType . '>'; 68 | } 69 | 70 | public function serialize(): string 71 | { 72 | return 'new ' . static::class . '(' . $this->elementType->serialize() . ')'; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/LiteralType.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | readonly class LiteralType extends Type 10 | { 11 | /** 12 | * @var array 13 | */ 14 | private array $values; 15 | 16 | /** 17 | * @param T ...$values 18 | */ 19 | public function __construct(mixed ...$values) 20 | { 21 | $this->values = $values; 22 | } 23 | 24 | /** 25 | * @psalm-assert-if-true T $value 26 | */ 27 | public function matches(mixed $value): bool 28 | { 29 | foreach ($this->values as $v) { 30 | if ($value === $v) { 31 | return true; 32 | } 33 | } 34 | return false; 35 | } 36 | 37 | /** 38 | * @return T 39 | */ 40 | public function cast(mixed $value): mixed 41 | { 42 | if ($this->matches($value)) { 43 | return $value; 44 | } 45 | foreach ($this->values as $v) { 46 | if (is_scalar($value) && $v == $value || $v === $value) { 47 | return $v; 48 | } 49 | } 50 | throw new CastException($value, $this); 51 | } 52 | 53 | public function __toString(): string 54 | { 55 | $escape = 56 | function (mixed $a): string { 57 | if (is_string($a)) { 58 | return "'$a'"; 59 | } 60 | if (is_null($a)) { 61 | return 'null'; 62 | } 63 | if (is_bool($a)) { 64 | return $a ? 'true' : 'false'; 65 | } 66 | return (string) $a; 67 | }; 68 | 69 | if (count($this->values) > 1) { 70 | return '(' . join('|', array_map($escape, $this->values)) . ')'; 71 | } else { 72 | return $escape($this->values[0]); 73 | } 74 | } 75 | 76 | public function serialize(): string 77 | { 78 | $properties = []; 79 | foreach ($this->values as $value) { 80 | $properties[] = var_export($value, true); 81 | } 82 | return 'new ' . static::class . '(' . join(', ', $properties) . ')'; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/MapType.php: -------------------------------------------------------------------------------- 1 | > 12 | */ 13 | readonly class MapType extends Type 14 | { 15 | /** 16 | * @var Type 17 | */ 18 | private Type $keyType; 19 | 20 | /** 21 | * @var Type 22 | */ 23 | private Type $valueType; 24 | 25 | /** 26 | * @param Type $keyType 27 | * @param Type $valueType 28 | */ 29 | public function __construct(Type $keyType, Type $valueType) 30 | { 31 | $this->keyType = $keyType; 32 | $this->valueType = $valueType; 33 | } 34 | 35 | /** 36 | * @psalm-assert-if-true array $value 37 | */ 38 | public function matches(mixed $value): bool 39 | { 40 | if (!is_array($value)) { 41 | return false; 42 | } 43 | /** 44 | * @psalm-suppress MixedAssignment 45 | * @psalm-suppress TypeDoesNotContainType 46 | */ 47 | foreach ($value as $k => $v) { 48 | if (!$this->keyType->matches($k) || !$this->valueType->matches($v)) { 49 | return false; 50 | } 51 | } 52 | return true; 53 | } 54 | 55 | /** 56 | * @return array 57 | */ 58 | public function resolveAndCast(mixed $value, Resolver $resolver): array 59 | { 60 | if (!(is_array($value) || is_object($value) && is_a($value, stdClass::class))) { 61 | throw new CastException($value, $this); 62 | } 63 | $result = []; 64 | /** 65 | * @psalm-suppress MixedAssignment 66 | */ 67 | foreach (((array) $value) as $k => $v) { 68 | $result[$this->keyType->resolveAndCast($k, $resolver)] = $this->valueType->resolveAndCast($v, $resolver); 69 | } 70 | return $result; 71 | } 72 | 73 | public function __toString(): string 74 | { 75 | return 'array<' . $this->keyType . ',' . $this->valueType . '>'; 76 | } 77 | 78 | public function serialize(): string 79 | { 80 | return 'new ' . static::class . '(' . $this->keyType->serialize() . ', ' . $this->valueType->serialize() . ')'; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/MixedType.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | readonly class MixedType extends Type 9 | { 10 | public function matches(mixed $value): bool 11 | { 12 | return true; 13 | } 14 | 15 | /** 16 | * @psalm-suppress MixedReturnStatement 17 | */ 18 | public function cast(mixed $value): mixed 19 | { 20 | return $value; 21 | } 22 | 23 | public function __toString(): string 24 | { 25 | return 'mixed'; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/NullType.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | readonly class NullType extends Type 9 | { 10 | /** 11 | * @psalm-assert-if-true null $value 12 | */ 13 | public function matches(mixed $value): bool 14 | { 15 | return is_null($value); 16 | } 17 | 18 | /** 19 | * @return null 20 | */ 21 | public function cast(mixed $value): mixed 22 | { 23 | if ($value != null) { 24 | throw new CastException($value, $this); 25 | } 26 | return null; 27 | } 28 | 29 | public function __toString(): string 30 | { 31 | return 'null'; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/NumericStringType.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | readonly class NumericStringType extends Type 9 | { 10 | public function matches(mixed $value): bool 11 | { 12 | return is_string($value) && is_numeric($value); 13 | } 14 | 15 | /** 16 | * @return numeric-string 17 | */ 18 | public function cast(mixed $value): string 19 | { 20 | if (is_array($value)) { 21 | throw new CastException($value, $this); 22 | } 23 | if ($value === true) { 24 | return '1'; 25 | } elseif ($value === false || $value === null) { 26 | return '0'; 27 | } else { 28 | if (is_object($value) && !method_exists($value, '__toString')) { 29 | throw new CastException($value, $this); 30 | } 31 | $string = (string) $value; 32 | } 33 | if (!is_numeric($string)) { 34 | throw new CastException($value, $this); 35 | } 36 | return $string; 37 | } 38 | 39 | public function __toString(): string 40 | { 41 | return 'numeric-string'; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/NumericType.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | readonly class NumericType extends Union3Type 9 | { 10 | public function __construct() 11 | { 12 | parent::__construct(new IntType, new FloatType, new NumericStringType); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/ObjectLikeType.php: -------------------------------------------------------------------------------- 1 | > 12 | */ 13 | readonly class ObjectLikeType extends Type 14 | { 15 | /** 16 | * @var array> 17 | */ 18 | private array $fields; 19 | 20 | /** 21 | * @param array> $fields 22 | */ 23 | public function __construct(array $fields) 24 | { 25 | $this->fields = $fields; 26 | } 27 | 28 | /** 29 | * @psalm-assert-if-true array $value 30 | */ 31 | public function matches(mixed $value): bool 32 | { 33 | if (!is_array($value)) { 34 | return false; 35 | } 36 | foreach ($this->fields as $field => $type) { 37 | $tokens = explode('?', $field, 2); 38 | if (!array_key_exists($tokens[0], $value)) { 39 | if (count($tokens) == 1) { 40 | return false; 41 | } else { 42 | continue; 43 | } 44 | } 45 | if (!$type->matches($value[$tokens[0]])) { 46 | return false; 47 | } 48 | } 49 | return true; 50 | } 51 | 52 | /** 53 | * @return array 54 | */ 55 | public function resolveAndCast(mixed $value, Resolver $resolver): array 56 | { 57 | if (!(is_array($value) || is_object($value) && is_a($value, stdClass::class))) { 58 | throw new CastException($value, $this); 59 | } 60 | $validateUnmappedProperties = !$resolver->ignoreUnmappedProperties(); 61 | $mappedProperties = []; 62 | 63 | foreach ($this->fields as $field => $fieldType) { 64 | $tokens = explode('?', $field, 2); 65 | $fieldName = $tokens[0]; 66 | $required = count($tokens) == 1; 67 | 68 | /** 69 | * @psalm-suppress ArgumentTypeCoercion 70 | */ 71 | $resolution = $resolver->getValue(null, $fieldName, $value); 72 | if ($resolution->successful()) { 73 | if ($validateUnmappedProperties) { 74 | $sourceFieldName = $resolution->sourceFieldName(); 75 | if ($sourceFieldName !== null) { 76 | $mappedProperties[$sourceFieldName] = 1; 77 | } 78 | } 79 | } elseif (!$required) { 80 | $mappedProperties[$fieldName] = 1; 81 | continue; 82 | } else { 83 | throw new CastException($value, $this); 84 | } 85 | 86 | $fieldValue = $fieldType->resolveAndCast($resolution->value(), $resolver); 87 | $value = $resolver->setValue($value, $fieldName, $fieldValue); 88 | } 89 | if ($validateUnmappedProperties) { 90 | MappingUtils::checkMapping($value, $mappedProperties, $this); 91 | } 92 | return (array) $value; 93 | } 94 | 95 | public function __toString(): string 96 | { 97 | $keys = []; 98 | foreach ($this->fields as $name => $type) { 99 | $keys[] = $name . ':' . $type; 100 | } 101 | return 'array{' . join(',', $keys) . '}'; 102 | } 103 | 104 | public function serialize(): string 105 | { 106 | $arguments = []; 107 | foreach ($this->fields as $name => $type) { 108 | $arguments[] = var_export($name, true) . ' => ' . $type->serialize(); 109 | } 110 | return 'new ' . static::class . '([' . join(', ', $arguments) . '])'; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/ObjectType.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | readonly class ObjectType extends Type 9 | { 10 | /** 11 | * @psalm-assert-if-true object $value 12 | */ 13 | public function matches(mixed $value): bool 14 | { 15 | return is_object($value); 16 | } 17 | 18 | public function cast(mixed $value): object 19 | { 20 | return (object) $value; 21 | } 22 | 23 | public function __toString(): string 24 | { 25 | return 'object'; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Parser/Cache.php: -------------------------------------------------------------------------------- 1 | serialize(); 15 | $tempFileName = $fileName . '.tmp'; 16 | file_put_contents($tempFileName, 'getTraceAsString()); 36 | unlink($fileName); 37 | return null; 38 | } 39 | 40 | /** 41 | * @noinspection PhpExpressionAlwaysNullInspection 42 | * @psalm-suppress UndefinedVariable 43 | */ 44 | return $value ?? null; 45 | } 46 | 47 | public static function remove(string $key): void 48 | { 49 | $safeKey = __CLASS__ . '::' . $key; 50 | $fileName = sys_get_temp_dir() . '/type-cache.' . md5($safeKey); 51 | unlink($fileName); 52 | } 53 | 54 | public static function purge(): void 55 | { 56 | foreach (glob(sys_get_temp_dir() . '/type-cache.*') as $fileName) { 57 | unlink($fileName); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Parser/DeclarationReader.php: -------------------------------------------------------------------------------- 1 | tokenize($declaration)); 32 | $node = $typeParser->parse($tokens); 33 | 34 | return (new DocBlockAstTraverser)->traverse($node, $nameContext); 35 | } 36 | 37 | // public function parse_(TreeNode $node, ?NameContext $nameContext = null): Type 38 | // { 39 | // if ($node->isToken() && $node->getValueToken() == 'built_in') { 40 | // return $this->fromBuiltIn($node); 41 | // } 42 | // switch ($node->getId()) { 43 | // case '#literal': 44 | // return $this->fromLiteral($node); 45 | // case '#class_name': 46 | // return $this->fromClassName($node); 47 | // case '#union': 48 | // $subTypes = []; 49 | // for ($i = 0; $i < $node->getChildrenNumber(); $i++) { 50 | // $child = $node->getChild($i); 51 | // assert($child !== null); 52 | // $subTypes[] = $this->parse($child); 53 | // } 54 | // return _union(...$subTypes); 55 | // case '#array': 56 | // return $this->fromArray($node); 57 | // case '#object_like_array': 58 | // return $this->fromObjectLikeArray($node); 59 | // case '#basic_type': 60 | // $firstChild = $node->getChild(0); 61 | // assert($firstChild !== null); 62 | // $type = $this->parse($firstChild); 63 | // for ($i = 1; $i < $node->getChildrenNumber(); $i++) { 64 | // $child = $node->getChild($i); 65 | // assert($child !== null); 66 | // if ($child->getId() == '#brackets') { 67 | // $type = new ArrayType($type); 68 | // } 69 | // } 70 | // return $type; 71 | // case '#callable': 72 | // return $this->fromCallable($node); 73 | // case '#generic': 74 | // return $this->fromGeneric($node); 75 | // } 76 | // throw new RuntimeException('Cannot convert node ' . print_r($node, true)); 77 | // } 78 | // 79 | // private function fromLiteral(TreeNode $node): Type 80 | // { 81 | // $firstChild = $node->getChild(0); 82 | // assert($firstChild !== null); 83 | // if ($firstChild->isToken()) { 84 | // switch ($firstChild->getValueToken()) { 85 | // case 'true': 86 | // return new LiteralType(true); 87 | // case 'false': 88 | // return new LiteralType(false); 89 | // case 'null': 90 | // return new NullType; 91 | // case 'int_number': 92 | // return new LiteralType((int) $firstChild->getValueValue()); 93 | // case 'float_number': 94 | // return new LiteralType((float) $firstChild->getValueValue()); 95 | // case 'string': 96 | // return new LiteralType($firstChild->getValueValue()); 97 | // } 98 | // } 99 | // throw new RuntimeException('Cannot convert node ' . print_r($node, true)); 100 | // } 101 | // 102 | // private function fromBuiltIn(TreeNode $node): Type 103 | // { 104 | // switch ($node->getValueValue()) { 105 | // case 'boolean': 106 | // case 'bool': 107 | // return new BoolType; 108 | // case 'integer': 109 | // case 'int': 110 | // return new IntType; 111 | // case 'float': 112 | // case 'double': 113 | // return new FloatType; 114 | // case 'numeric-string': 115 | // return new NumericStringType; 116 | // case 'numeric': 117 | // return new NumericType; 118 | // case 'array-key': 119 | // return new ArrayKeyType; 120 | // case 'string': 121 | // return new StringType; 122 | // case 'object': 123 | // return new ObjectType; 124 | // case 'mixed': 125 | // return new MixedType; 126 | // case 'resource': 127 | // return new ResourceType; 128 | // } 129 | // throw new RuntimeException('Cannot convert node ' . print_r($node, true)); 130 | // } 131 | // 132 | // /** 133 | // * @param TreeNode $node 134 | // * @return Type 135 | // * @psalm-suppress RedundantCondition 136 | // */ 137 | // private function fromClassName(TreeNode $node): Type 138 | // { 139 | // $path = ''; 140 | // for ($i = 0; $i < $node->getChildrenNumber(); $i++) { 141 | // $child = $node->getChild($i); 142 | // assert($child !== null); 143 | // if ($child->getId() === '#backslash') { 144 | // $path .= '\\'; 145 | // } elseif ($child->getId() === 'token' && $child->getValueToken() === 'id') { 146 | // $valueValue = $child->getValueValue(); 147 | // assert($valueValue !== null); 148 | // $path .= $valueValue; 149 | // } else { 150 | // throw new RuntimeException('Unexpected ID ' . print_r($child, true)); 151 | // } 152 | // } 153 | // 154 | // if ($this->nameContext) { 155 | // if ($path[0] == '\\') { 156 | // $className = new Name\FullyQualified($path); 157 | // } else { 158 | // $className = new Name($path); 159 | // } 160 | // $resolvedClassName = $this->nameContext->getResolvedClassName($className)->toString(); 161 | // } else { 162 | // $resolvedClassName = $path; 163 | // } 164 | // if (!class_exists($resolvedClassName) && !interface_exists($resolvedClassName)) { 165 | // throw new RuntimeException('Unknown type ' . $path); 166 | // } 167 | // return new ClassType($resolvedClassName); 168 | // } 169 | // 170 | // private function fromArray(TreeNode $node): Type 171 | // { 172 | // $firstChild = $node->getChild(0); 173 | // assert($firstChild !== null); 174 | // $tag = $firstChild->getValueValue(); 175 | // $list = $tag == 'list' || $tag == 'non-empty-list'; 176 | // 177 | // /** 178 | // * @psalm-suppress MixedArgumentTypeCoercion 179 | // */ 180 | // switch ($node->getChildrenNumber()) { 181 | // case 1: 182 | // if ($list) { 183 | // return new ListType(new MixedType()); 184 | // } else { 185 | // return new MapType(new ArrayKeyType(), new MixedType()); 186 | // } 187 | // case 2: 188 | // $secondChild = $node->getChild(1); 189 | // assert($secondChild !== null); 190 | // if ($list) { 191 | // return new ListType($this->parse($secondChild)); 192 | // } else { 193 | // return new MapType(new ArrayKeyType(), $this->parse($secondChild)); 194 | // } 195 | // case 3: 196 | // if (!$list) { 197 | // $secondChild = $node->getChild(1); 198 | // $thirdChild = $node->getChild(2); 199 | // assert($secondChild !== null && $thirdChild !== null); 200 | // return new MapType($this->parse($secondChild), $this->parse($thirdChild)); 201 | // } 202 | // } 203 | // throw new RuntimeException('Cannot convert node ' . print_r($node, true)); 204 | // } 205 | // 206 | // /** 207 | // * @param TreeNode $node 208 | // * @return array{0:string,1:bool,2:Type} 209 | // */ 210 | // private function fromProperty(TreeNode $node): array 211 | // { 212 | // switch ($node->getChildrenNumber()) { 213 | // case 1: 214 | // $firstChild = $node->getChild(0); 215 | // assert($firstChild !== null); 216 | // return ['', true, $this->parse($firstChild)]; 217 | // case 2: 218 | // $name = $node->getChild(0)?->getChild(0)?->getValueValue(); 219 | // assert($name !== null); 220 | // $secondChild = $node->getChild(1); 221 | // assert($secondChild !== null); 222 | // return [$name, true, $this->parse($secondChild)]; 223 | // case 3: 224 | // $name = $node->getChild(0)?->getChild(0)?->getValueValue(); 225 | // assert($name !== null); 226 | // $thirdChild = $node->getChild(2); 227 | // assert($thirdChild !== null); 228 | // return [$name, false, $this->parse($thirdChild)]; 229 | // } 230 | // throw new RuntimeException('Cannot convert node ' . print_r($node, true)); 231 | // } 232 | // 233 | // private function fromObjectLikeArray(TreeNode $node): Type 234 | // { 235 | // $properties = []; 236 | // for ($i = 1; $i < $node->getChildrenNumber(); $i++) { 237 | // $child = $node->getChild($i); 238 | // assert($child !== null); 239 | // if ($child->getId() == '#property') { 240 | // list($name, $required, $type) = $this->fromProperty($child); 241 | // $properties[$name . ($required ? '' : '?')] = $type; 242 | // } else { 243 | // throw new RuntimeException('Cannot convert node ' . print_r($child, true)); 244 | // } 245 | // } 246 | // return _object_like($properties); 247 | // } 248 | // 249 | // private function fromCallable(TreeNode $node): Type 250 | // { 251 | // $firstChild = $node->getChild(0); 252 | // assert($firstChild !== null); 253 | // $tag = $firstChild->getValueValue(); 254 | // $types = []; 255 | // for ($i = 1; $i < $node->getChildrenNumber(); $i++) { 256 | // $child = $node->getChild($i); 257 | // assert($child !== null); 258 | // $types[] = $this->parse($child); 259 | // } 260 | // if (empty($types)) { 261 | // $returnType = null; 262 | // } else { 263 | // $returnType = array_pop($types); 264 | // } 265 | // assert($tag !== null); 266 | // return new CallableType($tag, $returnType, $types); 267 | // } 268 | // 269 | // private function fromGeneric(TreeNode $node): Type 270 | // { 271 | // $firstChild = $node->getChild(0); 272 | // assert($firstChild !== null); 273 | // return $this->parse($firstChild); 274 | // } 275 | } 276 | -------------------------------------------------------------------------------- /src/Parser/DocBlockAstTraverser.php: -------------------------------------------------------------------------------- 1 | type->name == 'array') { 52 | return match (count($node->genericTypes)) { 53 | 1 => new ArrayType($this->traverse($node->genericTypes[0], $nameContext)), 54 | 2 => new MapType( 55 | $this->traverse($node->genericTypes[0], $nameContext), 56 | $this->traverse($node->genericTypes[1], $nameContext), 57 | ), 58 | default => throw new RuntimeException('Unknown array type: ' . count($node->genericTypes)), 59 | }; 60 | } elseif ($node->type->name == 'list') { 61 | return match (count($node->genericTypes)) { 62 | 1 => new ListType($this->traverse($node->genericTypes[0], $nameContext)), 63 | default => throw new RuntimeException('Unknown list type: ' . count($node->genericTypes)), 64 | }; 65 | } 66 | throw new RuntimeException('Unknown generic type: ' . print_r($node, true)); 67 | } elseif ($node instanceof IdentifierTypeNode) { 68 | return match ($node->name) { 69 | 'string' => new StringType, 70 | 'int' => new IntType, 71 | 'float', 'double' => new FloatType, 72 | 'bool' => new BoolType, 73 | 'mixed' => new MixedType, 74 | 'resource' => new ResourceType, 75 | 'array-key' => new ArrayKeyType, 76 | 'numeric' => new NumericType, 77 | 'numeric-string' => new NumericStringType, 78 | 'array' => new ArrayType(new MixedType), 79 | 'object' => new ObjectType, 80 | 'null' => new NullType, 81 | 'true' => new LiteralType(true), 82 | 'false' => new LiteralType(false), 83 | default => $this->traverseUserTypeNode($node, $nameContext), 84 | }; 85 | } elseif ($node instanceof UnionTypeNode) { 86 | $types = []; 87 | foreach ($node->types as $type) { 88 | $types[] = $this->traverse($type, $nameContext); 89 | } 90 | return match (count($types)) { 91 | 2 => new Union2Type($types[0], $types[1]), 92 | 3 => new Union3Type($types[0], $types[1], $types[2]), 93 | 4 => new Union4Type($types[0], $types[1], $types[2], $types[3]), 94 | 5 => new Union5Type($types[0], $types[1], $types[2], $types[3], $types[4]), 95 | 6 => new Union6Type($types[0], $types[1], $types[2], $types[3], $types[4], $types[5]), 96 | 7 => new Union7Type($types[0], $types[1], $types[2], $types[3], $types[4], $types[5], $types[6]), 97 | 8 => new Union8Type($types[0], $types[1], $types[2], $types[3], $types[4], $types[5], $types[6], $types[7]), 98 | default => throw new RuntimeException('Unsupported union type: ' . count($types)), 99 | }; 100 | } elseif ($node instanceof ConstTypeNode) { 101 | if ($node->constExpr instanceof QuoteAwareConstExprStringNode) { 102 | return new LiteralType($node->constExpr->value); 103 | } elseif ($node->constExpr instanceof ConstExprIntegerNode) { 104 | return new LiteralType($node->constExpr->value); 105 | } elseif ($node->constExpr instanceof ConstExprFloatNode) { 106 | return new LiteralType($node->constExpr->value); 107 | } 108 | throw new RuntimeException('Unknown const type: ' . print_r($node, true)); 109 | } elseif ($node instanceof ArrayTypeNode) { 110 | return new ArrayType($this->traverse($node->type, $nameContext)); 111 | } elseif ($node instanceof ArrayShapeNode) { 112 | return $this->traverseObjectLikeArrayNode($node, $nameContext); 113 | } elseif ($node instanceof CallableTypeNode) { 114 | return $this->traverseCallableNode($node, $nameContext); 115 | } 116 | throw new RuntimeException('Unknown type: ' . print_r($node, true)); 117 | } 118 | 119 | private function resolveClassName(String $name, ?NameContext $nameContext): string 120 | { 121 | if ($nameContext !== null) { 122 | if ($name[0] == '\\') { 123 | $className = new Name\FullyQualified($name); 124 | } else { 125 | $className = new Name($name); 126 | } 127 | return $nameContext->getResolvedClassName($className)->toString(); 128 | } else { 129 | return $name; 130 | } 131 | } 132 | 133 | private function traverseUserTypeNode(IdentifierTypeNode $node, ?NameContext $nameContext): Type 134 | { 135 | $resolvedClassName = $this->resolveClassName($node->name, $nameContext); 136 | 137 | if (!class_exists($resolvedClassName) && !interface_exists($resolvedClassName)) { 138 | throw new RuntimeException('Unknown user type ' . $resolvedClassName); 139 | } 140 | return new ClassType($resolvedClassName); 141 | } 142 | 143 | private function traverseObjectLikeArrayNode(ArrayShapeNode $node, ?NameContext $nameContext): Type 144 | { 145 | $properties = []; 146 | foreach ($node->items as $item) { 147 | $key = $item->keyName->name ?? '0'; 148 | $optional = $item->optional; 149 | $type = $this->traverse($item->valueType, $nameContext); 150 | 151 | $properties[$key . ($optional ? '?' : '')] = $type; 152 | } 153 | return new ObjectLikeType($properties); 154 | } 155 | 156 | private function traverseCallableNode(CallableTypeNode $node, ?NameContext $nameContext): Type 157 | { 158 | $tag = $node->identifier->name; 159 | $parameterTypes = []; 160 | foreach ($node->parameters as $parameter) { 161 | $parameterTypes[] = $this->traverse($parameter->type, $nameContext); 162 | } 163 | $returnType = $this->traverse($node->returnType, $nameContext); 164 | 165 | return new CallableType($tag, $returnType, $parameterTypes); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/Parser/DocBlockParser.php: -------------------------------------------------------------------------------- 1 | getDeclaringClass(); 19 | $cacheKey = $declaringReflectionClass->getName() . '::' . $reflectionProperty->getName(); 20 | $fileName = $declaringReflectionClass->getFileName(); 21 | if ($fileName === false) { 22 | throw new RuntimeException('Cannot find declaring file name'); 23 | } 24 | 25 | try { 26 | $propertyType = Cache::get($cacheKey, filemtime($fileName)); 27 | if ($propertyType !== null) { 28 | return $propertyType; 29 | } 30 | } catch (Exception $exception) { 31 | error_log($exception->getTraceAsString()); 32 | Cache::remove($cacheKey); 33 | } 34 | 35 | 36 | $body = file_get_contents($fileName); 37 | 38 | $parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7); 39 | $traverser = new NodeTraverser; 40 | $visitor = new PropertyVisitor($reflectionProperty->getDeclaringClass()); 41 | $traverser->addVisitor($visitor); 42 | $statements = $parser->parse($body); 43 | if ($statements !== null) { 44 | $traverser->traverse($statements); 45 | } 46 | 47 | $result = null; 48 | foreach ($visitor->properties() as $key => $propertyType) { 49 | Cache::set($key, $propertyType); 50 | if ($cacheKey == $key) { 51 | $result = $propertyType; 52 | } 53 | } 54 | if ($result === null) { 55 | $result = new MixedType; 56 | } 57 | return $result; 58 | } 59 | 60 | public static function varTypeDeclarationFrom(string $doc): ?string 61 | { 62 | $fields = self::parseDoc($doc); 63 | foreach ($fields as $field) { 64 | if ($field['tag'] == '@psalm-var') { 65 | return $field['type']; 66 | } 67 | } 68 | foreach ($fields as $field) { 69 | if ($field['tag'] == '@var') { 70 | return $field['type']; 71 | } 72 | } 73 | return null; 74 | } 75 | 76 | /** 77 | * @return array 78 | */ 79 | public static function parseDoc(string $doc): array 80 | { 81 | $lines = preg_split( 82 | '|$\R?^|m', 83 | preg_replace( 84 | '|^\s*[*]|m', 85 | '', 86 | preg_replace( 87 | '|[*]+/\s*$|', 88 | '', 89 | preg_replace( 90 | '|^/[*]+\s*|', 91 | '', 92 | trim( 93 | $doc 94 | ) 95 | ) 96 | ) 97 | ) 98 | ); 99 | 100 | $sections = []; 101 | $section = null; 102 | foreach ($lines as $line) { 103 | if (preg_match('|\s*(@[a-zA-Z0-9_-]+)\s+(.*)|', $line, $matches)) { 104 | if ($section) { 105 | $sections[] = $section; 106 | } 107 | $section = [$matches[1], trim($matches[2])]; 108 | } elseif ($section) { 109 | $trimmedLine = trim($line); 110 | if ($trimmedLine) { 111 | $section[1] .= PHP_EOL . $trimmedLine; 112 | } 113 | } 114 | } 115 | if ($section) { 116 | $sections[] = $section; 117 | } 118 | 119 | /** 120 | * @var array $sections 121 | */ 122 | $entries = []; 123 | foreach ($sections as [$tag, $body]) { 124 | if (preg_match('|(.*)\s+(\$[_a-zA-Z][_a-zA-Z0-9]*)|', $body, $matches)) { 125 | $entries[] = [ 126 | 'tag' => $tag, 127 | 'variable' => trim($matches[2]), 128 | 'type' => $matches[1] 129 | ]; 130 | } else { 131 | $entries[] = [ 132 | 'tag' => $tag, 133 | 'type' => $body 134 | ]; 135 | } 136 | } 137 | 138 | return $entries; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Parser/PropertyVisitor.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | private static array $reflectionClasses = []; 19 | 20 | /** 21 | * @var array 22 | */ 23 | private array $properties = []; 24 | 25 | /** 26 | * @var class-string|null 27 | */ 28 | private ?string $currentClass = null; 29 | 30 | private Type|bool|null $currentProperty = null; 31 | 32 | public function __construct(ReflectionClass $reflectionClass) 33 | { 34 | parent::__construct(null, [ 35 | 'preserveOriginalNames' => false, 36 | 'replaceNodes' => false, 37 | ]); 38 | self::$reflectionClasses[$reflectionClass->getName()] = $reflectionClass; 39 | } 40 | 41 | /** 42 | * @throws ReflectionException 43 | */ 44 | public function enterNode(Node $node): Node|int|null 45 | { 46 | if ($node instanceof Node\Stmt\Class_) { 47 | $className = (string) $node->name; 48 | /** @psalm-suppress PropertyTypeCoercion */ 49 | $this->currentClass = $this->getNameContext()->getResolvedClassName(new Node\Name($className))->toString(); 50 | } elseif ($node instanceof Node\Stmt\Property) { 51 | $this->currentProperty = true; 52 | $docComment = $node->getDocComment(); 53 | if ($docComment) { 54 | $typeDeclaration = DocBlockParser::varTypeDeclarationFrom($docComment->getText()); 55 | if ($typeDeclaration !== null) { 56 | $this->currentProperty = Type::of($typeDeclaration, $this->getNameContext()); 57 | } 58 | } 59 | } elseif ($node instanceof VarLikeIdentifier) { 60 | if ($this->currentClass === null) { 61 | throw new RuntimeException('Invalid class context'); 62 | } 63 | $key = $this->currentClass . '::' . $node->name; 64 | if ($this->currentProperty === true) { 65 | $currentReflectionClass = $this->reflectionClassByName($this->currentClass); 66 | $reflectionType = $currentReflectionClass->getProperty($node->name)->getType(); 67 | if ($reflectionType !== null) { 68 | /** 69 | * @psalm-suppress UndefinedMethod 70 | */ 71 | $typeDeclaration = $reflectionType->getName(); 72 | if ($reflectionType->allowsNull()) { 73 | $typeDeclaration .= '|null'; 74 | } 75 | $this->properties[$key] = Type::of($typeDeclaration); 76 | } 77 | $this->currentProperty = null; 78 | } elseif ($this->currentProperty) { 79 | $this->properties[$key] = $this->currentProperty; 80 | $this->currentProperty = null; 81 | } 82 | } 83 | return parent::enterNode($node); 84 | } 85 | 86 | /** 87 | * @return array 88 | */ 89 | public function properties(): array 90 | { 91 | return $this->properties; 92 | } 93 | 94 | /** 95 | * @param class-string|null $type 96 | */ 97 | private function reflectionClassByName(?string $type): ReflectionClass 98 | { 99 | if ($type === null) { 100 | throw new RuntimeException('Type information missing'); 101 | } 102 | if (isset(self::$reflectionClasses[$type])) { 103 | return self::$reflectionClasses[$type]; 104 | } else { 105 | try { 106 | return self::$reflectionClasses[$type] = new ReflectionClass($type); 107 | } catch (ReflectionException $exception) { 108 | throw new RuntimeException('Cannot load class ' . $type, 0, $exception); 109 | } 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Resolvers/DefaultResolver.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | private static array $reflectionClasses = []; 19 | 20 | /** 21 | * @var array> 22 | */ 23 | private static array $propertyTypes = []; 24 | 25 | /** 26 | * @psalm-suppress RedundantConditionGivenDocblockType 27 | */ 28 | public function getValue($type, string $propertyName, $source): ValueResolution 29 | { 30 | if (is_array($source) && array_key_exists($propertyName, $source)) { 31 | return ValueResolution::success($source[$propertyName], $propertyName); 32 | } elseif (is_object($source) && is_a($source, stdClass::class) && property_exists($source, $propertyName)) { 33 | return ValueResolution::success($source->{$propertyName}, $propertyName); 34 | } 35 | return ValueResolution::failure(); 36 | } 37 | 38 | /** 39 | * @throws ReflectionException 40 | */ 41 | public function setValue(object|array $object, string $propertyName, mixed $value): object|array 42 | { 43 | if (is_array($object)) { 44 | $object[$propertyName] = $value; 45 | } else { 46 | if (is_a($object, stdClass::class)) { 47 | $object->{$propertyName} = $value; 48 | } else { 49 | $reflectionClass = $this->getReflectionClass(get_class($object)); 50 | $property = $reflectionClass->getProperty($propertyName); 51 | $property->setAccessible(true); 52 | $property->setValue($object, $value); 53 | } 54 | } 55 | return $object; 56 | } 57 | 58 | /** 59 | * @template T 60 | * @param class-string $type 61 | * @return ReflectionClass 62 | * @psalm-suppress InvalidReturnType 63 | * @psalm-suppress InvalidReturnStatement 64 | */ 65 | protected function getReflectionClass(string $type): ReflectionClass 66 | { 67 | if (!isset(self::$reflectionClasses[$type])) { 68 | assert(class_exists($type)); 69 | self::$reflectionClasses[$type] = new ReflectionClass($type); 70 | } 71 | return self::$reflectionClasses[$type]; 72 | } 73 | 74 | /** 75 | * @template T 76 | * @param class-string $type 77 | * @param mixed $value 78 | * @return SubTypeResolution 79 | */ 80 | public function resolveSubType(string $type, mixed $value): SubTypeResolution 81 | { 82 | assert(class_exists($type)); 83 | return new SubTypeResolution($this->getReflectionClass($type), $this); 84 | } 85 | 86 | /** 87 | * @param ReflectionClass $reflectionClass 88 | * @param ReflectionProperty $reflectionProperty 89 | * @return Type 90 | */ 91 | public function getPropertyType(ReflectionClass $reflectionClass, ReflectionProperty $reflectionProperty): Type 92 | { 93 | $className = $reflectionClass->getName(); 94 | $propertyName = $reflectionProperty->getName(); 95 | 96 | if (!isset(self::$propertyTypes[$className][$propertyName])) { 97 | self::$propertyTypes[$className][$propertyName] = 98 | DocBlockParser::fromProperty($reflectionClass, $reflectionProperty); 99 | } 100 | return self::$propertyTypes[$className][$propertyName]; 101 | } 102 | 103 | public function ignoreUnmappedProperties(): bool 104 | { 105 | return true; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Resolvers/MappingUtils.php: -------------------------------------------------------------------------------- 1 | > 16 | */ 17 | private static array $properties = []; 18 | 19 | /** 20 | * @param array $mappedProperties 21 | */ 22 | public static function checkMapping(mixed $value, array $mappedProperties, Type $context): void 23 | { 24 | if (is_array($value)) { 25 | foreach ($value as $property => $_) { 26 | /** 27 | * @psalm-suppress MixedArrayTypeCoercion 28 | */ 29 | if (!isset($mappedProperties[$property])) { 30 | throw new CastException($value, $context, 'Property [' . $property . '] not mapped'); 31 | } 32 | } 33 | } elseif (is_object($value)) { 34 | if (is_a($value, stdClass::class)) { 35 | $properties = array_keys(get_object_vars($value)); 36 | } else { 37 | $className = get_class($value); 38 | if (isset(self::$properties[$className])) { 39 | $properties = self::$properties[$className]; 40 | } else { 41 | $properties = []; 42 | try { 43 | $reflectionClass = new ReflectionClass($className); 44 | } catch (ReflectionException $e) { 45 | throw new RuntimeException('Cannot read reflection information for ' . $className, 0, $e); 46 | } 47 | 48 | foreach ($reflectionClass->getProperties() as $property) { 49 | $properties[] = $property->getName(); 50 | } 51 | self::$properties[$className] = $properties; 52 | } 53 | } 54 | 55 | foreach ($properties as $property) { 56 | if (!isset($mappedProperties[$property])) { 57 | throw new CastException($value, $context, 'Property [' . $property . '] not mapped'); 58 | } 59 | } 60 | } else { 61 | throw new RuntimeException('Unexpected value type: ' . gettype($value)); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Resolvers/Resolver.php: -------------------------------------------------------------------------------- 1 | |null $type 15 | * @param string $propertyName 16 | * @param stdClass|array $source 17 | * @return ValueResolution 18 | */ 19 | public function getValue(?string $type, string $propertyName, stdClass|array $source): ValueResolution; 20 | 21 | /** 22 | * @template T 23 | * @param object|array $object 24 | * @param string $propertyName 25 | * @param T $value 26 | * @return object|array updated object 27 | */ 28 | public function setValue(object|array $object, string $propertyName, mixed $value): object|array; 29 | 30 | /** 31 | * @template T 32 | * @param class-string $type 33 | * @param mixed $value 34 | * @return SubTypeResolution 35 | */ 36 | public function resolveSubType(string $type, mixed $value): SubTypeResolution; 37 | 38 | /** 39 | * @param ReflectionClass $reflectionClass 40 | * @param ReflectionProperty $reflectionProperty 41 | * @return Type 42 | */ 43 | public function getPropertyType(ReflectionClass $reflectionClass, ReflectionProperty $reflectionProperty): Type; 44 | 45 | public function ignoreUnmappedProperties(): bool; 46 | } 47 | -------------------------------------------------------------------------------- /src/Resolvers/SubTypeResolution.php: -------------------------------------------------------------------------------- 1 | $reflectionClass 14 | * @param Resolver $subTreeResolver 15 | */ 16 | public function __construct( 17 | private ReflectionClass $reflectionClass, 18 | private Resolver $subTreeResolver 19 | ) { 20 | } 21 | 22 | /** 23 | * @return ReflectionClass 24 | */ 25 | public function reflectionClass(): ReflectionClass 26 | { 27 | return $this->reflectionClass; 28 | } 29 | 30 | public function subTreeResolver(): Resolver 31 | { 32 | return $this->subTreeResolver; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Resolvers/ValueResolution.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | public static function success(mixed $value, string $sourceFieldName): self 27 | { 28 | return new self(true, $value, $sourceFieldName); 29 | } 30 | 31 | /** 32 | * @return self 33 | */ 34 | public static function failure(): self 35 | { 36 | return new self(false, null, null); 37 | } 38 | 39 | public function successful(): bool 40 | { 41 | return $this->successful; 42 | } 43 | 44 | /** 45 | * @return T 46 | */ 47 | public function value(): mixed 48 | { 49 | return $this->value; 50 | } 51 | 52 | public function sourceFieldName(): ?string 53 | { 54 | return $this->sourceFieldName; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/ResourceType.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | readonly class ResourceType extends Type 9 | { 10 | /** 11 | * @psalm-assert-if-true resource $type 12 | */ 13 | public function matches(mixed $value): bool 14 | { 15 | return is_resource($value); 16 | } 17 | 18 | /** 19 | * @return resource 20 | */ 21 | public function cast(mixed $value): mixed 22 | { 23 | if (!is_resource($value)) { 24 | throw new CastException($value, $this); 25 | } 26 | return $value; 27 | } 28 | 29 | public function __toString(): string 30 | { 31 | return 'resource'; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/ScalarType.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | readonly class ScalarType extends Union4Type 9 | { 10 | public function __construct() 11 | { 12 | parent::__construct(new IntType, new BoolType, new FloatType, new StringType); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/StringType.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | readonly class StringType extends Type 9 | { 10 | /** 11 | * @psalm-assert-if-true string $value 12 | */ 13 | public function matches(mixed $value): bool 14 | { 15 | return is_string($value); 16 | } 17 | 18 | public function cast(mixed $value): string 19 | { 20 | if (is_array($value)) { 21 | throw new CastException($value, $this); 22 | } 23 | if (is_object($value) && !method_exists($value, '__toString')) { 24 | throw new CastException($value, $this); 25 | } 26 | return (string) $value; 27 | } 28 | 29 | public function __toString(): string 30 | { 31 | return 'string'; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Type.php: -------------------------------------------------------------------------------- 1 | matches($value), new CastException($value, $this)); 27 | return $value; 28 | } 29 | 30 | /** 31 | * @return T 32 | */ 33 | public function cast(mixed $value): mixed 34 | { 35 | $resolver = new DefaultResolver; 36 | return $this->resolveAndCast($value, $resolver); 37 | } 38 | 39 | /** 40 | * @return T 41 | */ 42 | public function resolveAndCast(mixed $value, Resolver $resolver): mixed 43 | { 44 | return $this->cast($value); 45 | } 46 | 47 | abstract public function __toString(): string; 48 | 49 | public function serialize(): string 50 | { 51 | return 'new ' . static::class; 52 | } 53 | 54 | public static function of(string $declaration, ?NameContext $nameContext = null): Type 55 | { 56 | return match ($declaration) { 57 | 'string' => new StringType, 58 | 'int' => new IntType, 59 | 'float' => new FloatType, 60 | 'bool' => new BoolType, 61 | 'mixed' => new MixedType, 62 | 'resource' => new ResourceType, 63 | default => DeclarationReader::instance()->read($declaration, $nameContext), 64 | }; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Union2Type.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | readonly class Union2Type extends Type 13 | { 14 | /** 15 | * @var Type 16 | */ 17 | protected Type $a; 18 | 19 | /** 20 | * @var Type 21 | */ 22 | protected Type $b; 23 | 24 | /** 25 | * @param Type $a 26 | * @param Type $b 27 | */ 28 | public function __construct(Type $a, Type $b) 29 | { 30 | $this->a = $a; 31 | $this->b = $b; 32 | } 33 | 34 | /** 35 | * @return list 36 | */ 37 | protected function types(): array 38 | { 39 | return [$this->a, $this->b]; 40 | } 41 | 42 | /** 43 | * @psalm-assert-if-true A|B $value 44 | */ 45 | public function matches(mixed $value): bool 46 | { 47 | foreach ($this->types() as $a) { 48 | if ($a->matches($value)) { 49 | return true; 50 | } 51 | } 52 | return false; 53 | } 54 | 55 | /** 56 | * @return A|B 57 | */ 58 | public function resolveAndCast(mixed $value, Resolver $resolver): mixed 59 | { 60 | foreach ($this->types() as $a) { 61 | if ($a->matches($value)) { 62 | return $value; 63 | } 64 | } 65 | foreach ($this->types() as $a) { 66 | try { 67 | return $a->resolveAndCast($value, $resolver); 68 | } catch (CastException $_) { 69 | } 70 | } 71 | throw new CastException($value, $this); 72 | } 73 | 74 | public function __toString(): string 75 | { 76 | $tokens = []; 77 | foreach ($this->types() as $a) { 78 | $tokens[] = (string) $a; 79 | } 80 | return join('|', $tokens); 81 | } 82 | 83 | public function serialize(): string 84 | { 85 | $arguments = []; 86 | foreach ($this->types() as $a) { 87 | $arguments[] = $a->serialize(); 88 | } 89 | return 'new ' . static::class . '(' . join(', ', $arguments) . ')'; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Union3Type.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | readonly class Union3Type extends Union2Type 12 | { 13 | /** 14 | * @var Type 15 | */ 16 | protected Type $c; 17 | 18 | /** 19 | * @param Type $a 20 | * @param Type $b 21 | * @param Type $c 22 | */ 23 | public function __construct(Type $a, Type $b, Type $c) 24 | { 25 | parent::__construct($a, $b); 26 | $this->c = $c; 27 | } 28 | 29 | /** 30 | * @return list 31 | */ 32 | protected function types(): array 33 | { 34 | return [$this->a, $this->b, $this->c]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Union4Type.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | readonly class Union4Type extends Union3Type 13 | { 14 | /** 15 | * @var Type 16 | */ 17 | protected Type $d; 18 | 19 | /** 20 | * @param Type $a 21 | * @param Type $b 22 | * @param Type $c 23 | * @param Type $d 24 | */ 25 | public function __construct(Type $a, Type $b, Type $c, Type $d) 26 | { 27 | parent::__construct($a, $b, $c); 28 | $this->d = $d; 29 | } 30 | 31 | /** 32 | * @return list 33 | */ 34 | protected function types(): array 35 | { 36 | return [$this->a, $this->b, $this->c, $this->d]; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Union5Type.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | readonly class Union5Type extends Union4Type 14 | { 15 | /** 16 | * @var Type 17 | */ 18 | protected Type $e; 19 | 20 | /** 21 | * @param Type $a 22 | * @param Type $b 23 | * @param Type $c 24 | * @param Type $d 25 | * @param Type $e 26 | */ 27 | public function __construct(Type $a, Type $b, Type $c, Type $d, Type $e) 28 | { 29 | parent::__construct($a, $b, $c, $d); 30 | $this->e = $e; 31 | } 32 | 33 | /** 34 | * @return list 35 | */ 36 | protected function types(): array 37 | { 38 | return [$this->a, $this->b, $this->c, $this->d, $this->e]; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Union6Type.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | readonly class Union6Type extends Union5Type 15 | { 16 | /** 17 | * @var Type 18 | */ 19 | protected Type $f; 20 | 21 | /** 22 | * @param Type $a 23 | * @param Type $b 24 | * @param Type $c 25 | * @param Type $d 26 | * @param Type $e 27 | * @param Type $f 28 | */ 29 | public function __construct(Type $a, Type $b, Type $c, Type $d, Type $e, Type $f) 30 | { 31 | parent::__construct($a, $b, $c, $d, $e); 32 | $this->f = $f; 33 | } 34 | 35 | /** 36 | * @return list 37 | */ 38 | protected function types(): array 39 | { 40 | return [$this->a, $this->b, $this->c, $this->d, $this->e, $this->f]; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Union7Type.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | readonly class Union7Type extends Union6Type 16 | { 17 | /** 18 | * @var Type 19 | */ 20 | protected Type $g; 21 | 22 | /** 23 | * @param Type $a 24 | * @param Type $b 25 | * @param Type $c 26 | * @param Type $d 27 | * @param Type $e 28 | * @param Type $f 29 | * @param Type $g 30 | */ 31 | public function __construct(Type $a, Type $b, Type $c, Type $d, Type $e, Type $f, Type $g) 32 | { 33 | parent::__construct($a, $b, $c, $d, $e, $f); 34 | $this->g = $g; 35 | } 36 | 37 | /** 38 | * @return list 39 | */ 40 | protected function types(): array 41 | { 42 | return [$this->a, $this->b, $this->c, $this->d, $this->e, $this->f, $this->g]; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Union8Type.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | readonly class Union8Type extends Union7Type 17 | { 18 | /** 19 | * @var Type 20 | */ 21 | protected Type $h; 22 | 23 | /** 24 | * @param Type $a 25 | * @param Type $b 26 | * @param Type $c 27 | * @param Type $d 28 | * @param Type $e 29 | * @param Type $f 30 | * @param Type $g 31 | * @param Type $h 32 | */ 33 | public function __construct(Type $a, Type $b, Type $c, Type $d, Type $e, Type $f, Type $g, Type $h) 34 | { 35 | parent::__construct($a, $b, $c, $d, $e, $f, $g); 36 | $this->h = $h; 37 | } 38 | 39 | /** 40 | * @return list 41 | */ 42 | protected function types(): array 43 | { 44 | return [$this->a, $this->b, $this->c, $this->d, $this->e, $this->f, $this->g, $this->h]; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/types.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | function _bool(): Type 11 | { 12 | return new BoolType; 13 | } 14 | 15 | /** 16 | * @template T 17 | * @param class-string $type 18 | * @return Type 19 | */ 20 | function _class(string $type): Type 21 | { 22 | return new ClassType($type); 23 | } 24 | 25 | /** 26 | * @return Type 27 | */ 28 | function _callable(): Type 29 | { 30 | return new CallableType('callable'); 31 | } 32 | 33 | /** 34 | * @return Type 35 | */ 36 | function _float(): Type 37 | { 38 | return new FloatType; 39 | } 40 | 41 | /** 42 | * @return Type 43 | */ 44 | function _int(): Type 45 | { 46 | return new IntType; 47 | } 48 | 49 | /** 50 | * @return Type 51 | */ 52 | function _numeric_string(): Type 53 | { 54 | return new NumericStringType; 55 | } 56 | 57 | /** 58 | * @return Type 59 | * @psalm-suppress InvalidReturnStatement 60 | * @psalm-suppress InvalidReturnType 61 | */ 62 | function _numeric(): Type 63 | { 64 | return new NumericType; 65 | } 66 | 67 | /** 68 | * @return Type 69 | * @psalm-suppress InvalidReturnStatement 70 | * @psalm-suppress InvalidReturnType 71 | */ 72 | function _scalar(): Type 73 | { 74 | return new ScalarType; 75 | } 76 | 77 | /** 78 | * @return Type 79 | */ 80 | function _array_key(): Type 81 | { 82 | return new ArrayKeyType; 83 | } 84 | 85 | /** 86 | * @template A 87 | * @param Type $type 88 | * @return Type> 89 | */ 90 | function _list(Type $type): Type 91 | { 92 | return new ListType($type); 93 | } 94 | 95 | /** 96 | * @template A 97 | * @param Type $type 98 | * @return Type> 99 | */ 100 | function _array(Type $type): Type 101 | { 102 | return new ArrayType($type); 103 | } 104 | 105 | /** 106 | * @template A 107 | * @param array $as 108 | * @return Type 109 | */ 110 | function _literal(...$as): Type 111 | { 112 | return new LiteralType(...$as); 113 | } 114 | 115 | /** 116 | * @template A as array-key 117 | * @template B 118 | * @param Type $keyType 119 | * @param Type $valueType 120 | * @return Type> 121 | */ 122 | function _map(Type $keyType, Type $valueType): Type 123 | { 124 | return new MapType($keyType, $valueType); 125 | } 126 | 127 | /** 128 | * @return Type 129 | */ 130 | function _mixed(): Type 131 | { 132 | return new MixedType; 133 | } 134 | 135 | /** 136 | * @return Type 137 | */ 138 | function _null(): Type 139 | { 140 | return new NullType; 141 | } 142 | 143 | /** 144 | * @return Type 145 | */ 146 | function _object(): Type 147 | { 148 | return new ObjectType; 149 | } 150 | 151 | /** 152 | * @template T 153 | * @param array> $properties 154 | * @return ObjectLikeType 155 | */ 156 | function _object_like(array $properties): Type 157 | { 158 | return new ObjectLikeType($properties); 159 | } 160 | 161 | /** 162 | * @return Type 163 | */ 164 | function _resource(): Type 165 | { 166 | return new ResourceType; 167 | } 168 | 169 | /** 170 | * @return Type 171 | */ 172 | function _string(): Type 173 | { 174 | return new StringType; 175 | } 176 | 177 | /** 178 | * @template A 179 | * @template B 180 | * @template C 181 | * @template D 182 | * @template E 183 | * @template F 184 | * @template G 185 | * @template H 186 | * @param Type $a 187 | * @param Type $b 188 | * @param Type|null $c 189 | * @param Type|null $d 190 | * @param Type|null $e 191 | * @param Type|null $f 192 | * @param Type|null $g 193 | * @param Type|null $h 194 | * @return Type 195 | * @psalm-return (func_num_args() is 2 ? Type : (func_num_args() is 3 ? Type : (func_num_args() is 4 ? Type : (func_num_args() is 5 ? Type : (func_num_args() is 6 ? Type : (func_num_args() is 7 ? Type : Type)))))) 196 | */ 197 | function _union(Type $a, Type $b, Type $c = null, Type $d = null, Type $e = null, Type $f = null, Type $g = null, Type $h = null): Type 198 | { 199 | switch (func_num_args()) { 200 | case 2: 201 | return new Union2Type($a, $b); 202 | case 3: 203 | if ($c === null) { 204 | throw new RuntimeException('Type cannot be null'); 205 | } 206 | return new Union3Type($a, $b, $c); 207 | case 4: 208 | if ($c === null || $d === null) { 209 | throw new RuntimeException('Type cannot be null'); 210 | } 211 | return new Union4Type($a, $b, $c, $d); 212 | case 5: 213 | if ($c === null || $d === null || $e === null) { 214 | throw new RuntimeException('Type cannot be null'); 215 | } 216 | return new Union5Type($a, $b, $c, $d, $e); 217 | case 6: 218 | if ($c === null || $d === null || $e === null || $f === null) { 219 | throw new RuntimeException('Type cannot be null'); 220 | } 221 | return new Union6Type($a, $b, $c, $d, $e, $f); 222 | case 7: 223 | if ($c === null || $d === null || $e === null || $f === null || $g === null) { 224 | throw new RuntimeException('Type cannot be null'); 225 | } 226 | return new Union7Type($a, $b, $c, $d, $e, $f, $g); 227 | case 8: 228 | if ($c === null || $d === null || $e === null || $f === null || $g === null || $h === null) { 229 | throw new RuntimeException('Type cannot be null'); 230 | } 231 | return new Union8Type($a, $b, $c, $d, $e, $f, $g, $h); 232 | default: 233 | throw new RuntimeException('Unsupported number of arguments'); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /test.php: -------------------------------------------------------------------------------- 1 | read('array|false>'); 8 | 9 | print_r($type->serialize()); -------------------------------------------------------------------------------- /tests/CacheTest.php: -------------------------------------------------------------------------------- 1 | >'); 13 | $key = md5(random_bytes(15)); 14 | 15 | Cache::set($key, $type); 16 | $copy = Cache::get($key, time() - 1); 17 | 18 | $this->assertEquals((string) $copy, (string) $type); 19 | } 20 | 21 | public function testSetThenRemoveAndThenGet() 22 | { 23 | $type = Type::of('array>'); 24 | $key = md5(random_bytes(15)); 25 | 26 | Cache::set($key, $type); 27 | Cache::remove($key); 28 | $copy = Cache::get($key, time() - 1); 29 | 30 | $this->assertNull($copy); 31 | } 32 | 33 | public function testSetThenGetWithFutureTimestamp() 34 | { 35 | $type = Type::of('array>'); 36 | $key = md5(random_bytes(15)); 37 | 38 | Cache::set($key, $type); 39 | $copy = Cache::get($key, time() + 5); 40 | 41 | $this->assertNull($copy); 42 | } 43 | 44 | public function testCacheReturnsNullOnError() 45 | { 46 | $type = Type::of('array>'); 47 | $key = md5(random_bytes(15)); 48 | $safeKey = Cache::class . '::' . $key; 49 | $fileName = sys_get_temp_dir() . '/type-cache.' . md5($safeKey); 50 | 51 | $this->assertFileDoesNotExist($fileName); 52 | Cache::set($key, $type); 53 | $copy1 = Cache::get($key, time() - 1); 54 | $this->assertNotNull($copy1); 55 | 56 | $this->assertFileExists($fileName); 57 | file_put_contents($fileName, 'assertNull($copy2); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/CastTest.php: -------------------------------------------------------------------------------- 1 | cast($value); 53 | Assert::assertSame((bool) $value, $result); 54 | } 55 | 56 | #[DataProvider('values')] public function testFloatCast(mixed $value): void 57 | { 58 | try { 59 | $result = _float()->cast($value); 60 | Assert::assertEquals((float) $value, $result); 61 | } catch (CastException) { 62 | // @todo need to check that the warning would have been thrown 63 | Assert::assertTrue(true); 64 | } 65 | } 66 | 67 | #[DataProvider('values')] public function testIntegerCast(mixed $value): void 68 | { 69 | try { 70 | $result = _int()->cast($value); 71 | Assert::assertSame((int) $value, $result); 72 | } catch (CastException) { 73 | // @todo need to check that the warning would have been thrown 74 | Assert::assertTrue(true); 75 | } 76 | } 77 | 78 | #[DataProvider('values')] public function testStringCast(mixed $value): void 79 | { 80 | try { 81 | $result = _string()->cast($value); 82 | Assert::assertSame((string) $value, $result); 83 | } catch (CastException) { 84 | // @todo need to check that the warning would have been thrown 85 | Assert::assertTrue(true); 86 | } 87 | } 88 | 89 | #[DataProvider('values')] public function testObjectCast(mixed $value): void 90 | { 91 | $result = _object()->cast($value); 92 | Assert::assertEquals((object) $value, $result); 93 | } 94 | 95 | #[DataProvider('values')] public function testNumericCast(mixed $value): void 96 | { 97 | try { 98 | $result = _numeric()->cast($value); 99 | Assert::assertTrue(is_numeric($result)); 100 | } catch (CastException) { 101 | Assert::assertTrue(is_object($value) && !method_exists($value, '__toString')); 102 | } 103 | } 104 | 105 | #[DataProvider('values')] public function testScalarCast(mixed $value): void 106 | { 107 | try { 108 | $result = _scalar()->cast($value); 109 | Assert::assertTrue(is_scalar($result)); 110 | } catch (CastException) { 111 | Assert::assertTrue(is_object($value) && !method_exists($value, '__toString')); 112 | } 113 | } 114 | 115 | public function testObjectMatch(): void 116 | { 117 | $this->assertTrue(_object()->matches(new stdClass)); 118 | $this->assertTrue(_object()->matches(new DateTime)); 119 | $this->assertTrue(_object()->matches(function () { 120 | })); 121 | 122 | $this->assertFalse(_object()->matches(null)); 123 | $this->assertFalse(_object()->matches(false)); 124 | $this->assertFalse(_object()->matches(true)); 125 | $this->assertFalse(_object()->matches(1)); 126 | $this->assertFalse(_object()->matches(1.1)); 127 | $this->assertFalse(_object()->matches('s')); 128 | } 129 | 130 | public function testIllegalFloatCastWithinList(): void 131 | { 132 | $this->expectException(CastException::class); 133 | $value = [1.1, null, new stdClass]; 134 | $type = _list(_float()); 135 | 136 | $type->cast($value); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /tests/JsonMappingTest.php: -------------------------------------------------------------------------------- 1 | '); 13 | $json = ' 14 | [ 15 | { 16 | "id": 1, 17 | "name": "Vanya", 18 | "age": 10 19 | }, 20 | { 21 | "id": 2, 22 | "name": "Masha" 23 | } 24 | ] 25 | '; 26 | $value1 = $type->cast(json_decode($json)); 27 | 28 | Assert::assertCount(2, $value1); 29 | Assert::assertEquals(10, $value1[0]['age']); 30 | Assert::assertArrayNotHasKey('age', $value1[1]); 31 | 32 | $value2 = $type->cast(json_decode($json, true)); 33 | 34 | Assert::assertCount(2, $value2); 35 | Assert::assertEquals(10, $value2[0]['age']); 36 | Assert::assertArrayNotHasKey('age', $value2[1]); 37 | } 38 | 39 | public function testObjectMapping() 40 | { 41 | require_once __DIR__ . '/../psalm-cases/classes/User.php'; 42 | require_once __DIR__ . '/../psalm-cases/classes/Address.php'; 43 | 44 | $json = ' 45 | [ 46 | { 47 | "id": 1, 48 | "name": "Vanya", 49 | "address": { 50 | "city": "Moscow" 51 | } 52 | }, 53 | { 54 | "id": 2, 55 | "name": "Masha", 56 | "address": { 57 | "city": "Vladivostok" 58 | } 59 | }, 60 | { 61 | "id": 3, 62 | "name": "Misha" 63 | } 64 | ] 65 | '; 66 | $users = _list(_class(User::class))->cast(json_decode($json)); 67 | 68 | Assert::assertCount(3, $users); 69 | Assert::assertEquals('Vladivostok', $users[1]->address()->city()); 70 | Assert::assertNull($users[2]->address()); 71 | } 72 | 73 | public function testJsonEmbeddedCasting() 74 | { 75 | $json = ' 76 | { 77 | "Lyuba": "2.34", 78 | "Sveta": "17.01" 79 | } 80 | '; 81 | $weights = _map(_string(), _float())->cast(json_decode($json)); 82 | 83 | Assert::assertCount(2, $weights); 84 | Assert::assertArrayHasKey('Lyuba', $weights); 85 | Assert::assertArrayHasKey('Sveta', $weights); 86 | Assert::assertSame(2.34, $weights['Lyuba']); 87 | Assert::assertSame(17.01, $weights['Sveta']); 88 | } 89 | 90 | public function testMapOfMaps() 91 | { 92 | $json = ' 93 | { 94 | "Dostoyevsky": { 95 | "Crime and Punishment": 1866, 96 | "Idiot": 1869 97 | }, 98 | "Tolstoy": { 99 | "War and Peace": 1869 100 | }, 101 | "Bulgakov": { 102 | "The Master and Margarita": "1940" 103 | } 104 | } 105 | '; 106 | $writers = _map(_string(), _map(_string(), _int()))->cast(json_decode($json)); 107 | 108 | Assert::assertCount(3, $writers); 109 | Assert::assertSame(1866, $writers['Dostoyevsky']['Crime and Punishment']); 110 | Assert::assertSame(1869, $writers['Dostoyevsky']['Idiot']); 111 | Assert::assertSame(1869, $writers['Tolstoy']['War and Peace']); 112 | Assert::assertSame(1940, $writers['Bulgakov']['The Master and Margarita']); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /tests/MappingUtilsTest.php: -------------------------------------------------------------------------------- 1 | 1, 15 | 'b' => 1 16 | ]; 17 | 18 | MappingUtils::checkMapping($a, ['a' => 1, 'b' => 1], _mixed()); 19 | 20 | $this->expectException(CastException::class); 21 | $this->expectExceptionMessage('Property [b] not mapped'); 22 | MappingUtils::checkMapping($a, ['a' => 1], _mixed()); 23 | } 24 | 25 | public function testCheckMappingOnStdClass() 26 | { 27 | $a = (object) [ 28 | 'a' => 1, 29 | 'b' => 1 30 | ]; 31 | 32 | MappingUtils::checkMapping($a, ['a' => 1, 'b' => 1], _mixed()); 33 | 34 | $this->expectException(CastException::class); 35 | $this->expectExceptionMessage('Property [b] not mapped'); 36 | MappingUtils::checkMapping($a, ['a' => 1], _mixed()); 37 | } 38 | 39 | public function testCheckMappingOnClasses() 40 | { 41 | require_once __DIR__ . '/../psalm-cases/classes/User.php'; 42 | require_once __DIR__ . '/../psalm-cases/classes/UserWithHobby.php'; 43 | $a = new UserWithHobby; 44 | 45 | MappingUtils::checkMapping($a, ['id' => 1, 'hobby' => 1, 'address' => 1, 'name' => 1], _mixed()); 46 | 47 | $this->expectException(CastException::class); 48 | $this->expectExceptionMessage('Property [address] not mapped'); 49 | MappingUtils::checkMapping($a, ['id' => 1, 'hobby' => 1, 'name' => 1], _mixed()); 50 | } 51 | 52 | public function testExceptionIsThrownOnInvalidInput() 53 | { 54 | $this->expectException(RuntimeException::class); 55 | $this->expectExceptionMessage('Unexpected value type: boolean'); 56 | MappingUtils::checkMapping(false, [], _mixed()); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/Parser/ParserTest.php: -------------------------------------------------------------------------------- 1 | '], 41 | ['array'], 42 | ['array'], 43 | ['array'], 44 | ['array|false>'], 45 | ["'a'|'b'"], 46 | ['Hamlet\\Type\\Type'], 47 | ["array>"], 48 | ['array|null|false|1|1.1'], 49 | ["('a'|'b'|'c')"], 50 | ['string[][]'], 51 | ['(1|false)[]'], 52 | ['int[]|string'], 53 | ['array[]'], 54 | ['int[]'], 55 | ['array|array{id:int}'], 56 | ['array>'], 57 | ['array{id:int|null,name?:string|null}'], 58 | ['array{0: string, 1: string, foo: stdClass, 28: false}'], 59 | // ['non-empty-array{0:string,1:string,foo:non-empty-array,23:boolean}'], 60 | ['array'], 61 | ["callable(('a'|'b'), int):(string|array{DateTime}|callable():int)"], 62 | ['Closure(bool):int'], 63 | // ['Generator'], 64 | ['callable(array{0:int}[]):(int|null)'], 65 | ['list>|bool|null>'], 66 | ]; 67 | } 68 | 69 | #[DataProvider('typeDeclarations')] public function testTypeParser(string $specification): void 70 | { 71 | $type = Type::of($specification); 72 | Assert::assertNotNull($type); 73 | } 74 | 75 | public static function phpDocDeclarations(): array 76 | { 77 | return [ 78 | [' 79 | /** @var string $a */ 80 | '], 81 | [' 82 | /*********** 83 | * @var int|string|null 84 | ***********/ 85 | '], 86 | [" 87 | /* 88 | * 89 | * 90 | * This is the set of objects 91 | * @psalm-var object|array{'*': int} 92 | */ 93 | "], 94 | [' 95 | /** 96 | * Check if a given lexeme is matched at the beginning of the text. 97 | * 98 | * @param string $lexeme Name of the lexeme. 99 | * @param string $regex Regular expression describing the lexeme. 100 | * @param int $offset Offset. 101 | * @return array 102 | * @throws \Hoa\Compiler\Exception\Lexer 103 | */ 104 | '], 105 | [' 106 | /** 107 | * A summary informing the user what the associated element does. 108 | * 109 | * A *description*, that can span multiple lines, to go _in-depth_ into the details of this element 110 | * and to provide some background information or textual references. 111 | * 112 | * @param string $myArgument With a *description* of this argument, these may also 113 | * span multiple lines. 114 | * 115 | * @return void 116 | */ 117 | '] 118 | ]; 119 | } 120 | 121 | #[DataProvider('phpDocDeclarations')] public function testPhpDocParser(string $specification) 122 | { 123 | $data = DocBlockParser::parseDoc($specification); 124 | Assert::assertNotNull($data); 125 | } 126 | 127 | /** 128 | * @throws ReflectionException 129 | */ 130 | public function testNameResolver(): void 131 | { 132 | require_once __DIR__ . '/../../psalm-cases/classes/TestClass.php'; 133 | 134 | $type = new ReflectionClass(TestClass::class); 135 | $type->getProperty('a'); 136 | 137 | $typeA = DocBlockParser::fromProperty($type, $type->getProperty('a')); 138 | $typeB = DocBlockParser::fromProperty($type, $type->getProperty('b')); 139 | 140 | Assert::assertEquals('array>', (string) $typeA); 141 | Assert::assertEquals("'x'|'y'|'z'|Hamlet\Type\CastException|DateTime|null", (string) $typeB); 142 | } 143 | 144 | #[DataProvider('typeDeclarations')] public function testSerialization(string $specification): void 145 | { 146 | $type = Type::of($specification); 147 | 148 | $copy = eval('return ' . $type->serialize() . ';'); 149 | $this->assertEquals((string) $type, (string) $copy, 'Failed on ' . $specification); 150 | } 151 | 152 | public function testParsingOfUglyNestedStructures(): void 153 | { 154 | if (version_compare(phpversion(), '7.4') < 0) { 155 | $this->assertTrue(true); 156 | return; 157 | } 158 | 159 | require_once __DIR__ . '/../../psalm-cases/classes/UglyNestedStructure.php'; 160 | $typeA = new ReflectionClass(\Hamlet\Type\Parser\A::class); 161 | $typeB = new ReflectionClass(\Hamlet\Type\Parser\N0\N1\B::class); 162 | $typeC = new ReflectionClass(\C::class); 163 | 164 | $this->assertEquals( 165 | \DateTime::class, 166 | (string) DocBlockParser::fromProperty($typeA, $typeA->getProperty('c')) 167 | ); 168 | $this->assertEquals( 169 | \Hamlet\Type\Parser\A::class, 170 | (string) DocBlockParser::fromProperty($typeB, $typeB->getProperty('a')) 171 | ); 172 | $this->assertEquals( 173 | \Hamlet\Type\Parser\A::class, 174 | (string) DocBlockParser::fromProperty($typeC, $typeC->getProperty('a')) 175 | ); 176 | $this->assertEquals( 177 | \Hamlet\Type\Parser\N0\N1\B::class, 178 | (string) DocBlockParser::fromProperty($typeC, $typeC->getProperty('b')) 179 | ); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /tests/TrackUnmappedPropertiesTest.php: -------------------------------------------------------------------------------- 1 | 1, 14 | 'name' => 'Alexey', 15 | 'hobby' => 'Cats, of course', 16 | ]; 17 | 18 | require_once __DIR__ . '/../psalm-cases/classes/User.php'; 19 | $this->assertInstanceOf(User::class, _class(User::class)->cast($value)); 20 | } 21 | 22 | public function testArrayPropertiesNotMappedToClassPropertiesThrowExceptionWhenEnabled() 23 | { 24 | $resolver = new class() extends DefaultResolver { 25 | public function ignoreUnmappedProperties(): bool 26 | { 27 | return false; 28 | } 29 | }; 30 | 31 | $value = [ 32 | 'id' => 1, 33 | 'name' => 'Alexey', 34 | 'hobby' => 'Cats, of course', 35 | ]; 36 | 37 | require_once __DIR__ . '/../psalm-cases/classes/User.php'; 38 | $this->expectException(CastException::class); 39 | _class(User::class)->resolveAndCast($value, $resolver); 40 | } 41 | 42 | public function testArrayPropertiesNotMappedToObjectLikePropertiesDoNotThrowExceptionByDefault() 43 | { 44 | $value = [ 45 | 'id' => 1, 46 | 'name' => 'Alexey', 47 | 'hobby' => 'Cats, of course', 48 | ]; 49 | 50 | $type = Type::of('array{id:int,name:string}'); 51 | $this->assertEquals($value, $type->cast($value)); 52 | } 53 | 54 | public function testArrayPropertiesNotMappedToObjectLikePropertiesThrowExceptionWhenEnabled() 55 | { 56 | $resolver = new class() extends DefaultResolver { 57 | public function ignoreUnmappedProperties(): bool 58 | { 59 | return false; 60 | } 61 | }; 62 | 63 | $value = [ 64 | 'id' => 1, 65 | 'name' => 'Alexey', 66 | 'hobby' => 'Cats, of course', 67 | ]; 68 | 69 | $this->expectException(CastException::class); 70 | $type = Type::of('array{id:int,name:string}'); 71 | $type->resolveAndCast($value, $resolver); 72 | } 73 | 74 | public function testClassPropertiesNotMappedToClassPropertiesDoNotThrowExceptionByDefault() 75 | { 76 | $value = (object) [ 77 | 'id' => 1, 78 | 'name' => 'Alexey', 79 | 'hobby' => 'Cats, of course', 80 | ]; 81 | 82 | require_once __DIR__ . '/../psalm-cases/classes/User.php'; 83 | $this->assertInstanceOf(User::class, _class(User::class)->cast($value)); 84 | } 85 | 86 | public function testClassPropertiesNotMappedToClassPropertiesThrowExceptionWhenEnabled() 87 | { 88 | $resolver = new class() extends DefaultResolver { 89 | public function ignoreUnmappedProperties(): bool 90 | { 91 | return false; 92 | } 93 | }; 94 | 95 | $value = (object) [ 96 | 'id' => 1, 97 | 'name' => 'Alexey', 98 | 'hobby' => 'Cats, of course', 99 | ]; 100 | 101 | require_once __DIR__ . '/../psalm-cases/classes/User.php'; 102 | $this->expectException(CastException::class); 103 | _class(User::class)->resolveAndCast($value, $resolver); 104 | } 105 | 106 | public function testClassPropertiesNotMappedToObjectLikePropertiesDoNotThrowExceptionByDefault() 107 | { 108 | $value = (object) [ 109 | 'id' => 1, 110 | 'name' => 'Alexey', 111 | 'hobby' => 'Cats, of course', 112 | ]; 113 | 114 | $type = Type::of('array{id:int,name:string}'); 115 | $this->assertEquals((array) $value, $type->cast($value)); 116 | } 117 | 118 | public function testClassPropertiesNotMappedToObjectLikePropertiesThrowExceptionWhenEnabled() 119 | { 120 | $resolver = new class() extends DefaultResolver { 121 | public function ignoreUnmappedProperties(): bool 122 | { 123 | return false; 124 | } 125 | }; 126 | 127 | $value = (object) [ 128 | 'id' => 1, 129 | 'name' => 'Alexey', 130 | 'hobby' => 'Cats, of course', 131 | ]; 132 | 133 | $this->expectException(CastException::class); 134 | $type = Type::of('array{id:int,name:string}'); 135 | $type->resolveAndCast($value, $resolver); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /tests/TypeDeclarationTest.php: -------------------------------------------------------------------------------- 1 | cast("this"); 15 | _union(_string(), _null())->cast("this"); 16 | _map(_int(), _class(DateTime::class))->cast([]); 17 | 18 | $this->assertTrue(true); 19 | } 20 | 21 | public function testTypeString(): void 22 | { 23 | $type = _map( 24 | _int(), 25 | _union( 26 | _null(), 27 | _class(DateTime::class) 28 | ) 29 | ); 30 | $this->assertEquals('array', (string)$type); 31 | } 32 | 33 | public function testLiteralType(): void 34 | { 35 | $type = _literal('a', 1, false, null); 36 | 37 | $this->assertTrue($type->matches('a')); 38 | $this->assertTrue($type->matches(1)); 39 | $this->assertTrue($type->matches(false)); 40 | $this->assertTrue($type->matches(null)); 41 | 42 | $this->assertFalse($type->matches('1')); 43 | $this->assertFalse($type->matches(0)); 44 | $this->assertFalse($type->matches(true)); 45 | 46 | $this->assertEquals('a', $type->cast('a')); 47 | $this->assertEquals(1, $type->cast('1')); 48 | $this->assertFalse($type->cast(false)); 49 | $this->assertFalse($type->cast('0')); 50 | 51 | $this->expectException(CastException::class); 52 | $type->cast(new stdClass); 53 | } 54 | 55 | public function testPropertyType(): void 56 | { 57 | $value = ['id' => 12]; 58 | $type = _object_like([ 59 | 'id' => _int() 60 | ]); 61 | assert($type->matches($value)); 62 | 63 | $this->assertEquals(12, $type->cast($value)['id']); 64 | } 65 | 66 | public function testIntersectionType(): void 67 | { 68 | /** @var Type $type */ 69 | $type = Type::of('array{id:int,name:string,online?:bool}'); 70 | 71 | $this->assertEquals('array{id:int,name:string,online?:bool}', (string)$type); 72 | 73 | $value = ['id' => 12, 'name' => 'hey there']; 74 | $this->assertTrue($type->matches($value)); 75 | 76 | $value = ['id' => 1, 'name' => 'too', 'online' => false]; 77 | $this->assertTrue($type->matches($value)); 78 | 79 | $value = ['id' => 1, 'name' => 'too', 'online' => 2.3]; 80 | $this->assertFalse($type->matches($value)); 81 | 82 | $value = ['id' => 1]; 83 | $this->assertFalse($type->matches($value)); 84 | } 85 | 86 | public function testIntersectionCast(): void 87 | { 88 | /** @var Type $type */ 89 | $type = _object_like([ 90 | 'id' => _int(), 91 | 'name' => _string(), 92 | 'online?' => _bool() 93 | ]); 94 | 95 | $object = new class() { 96 | public function __toString(): string 97 | { 98 | return 'hey there'; 99 | } 100 | }; 101 | $value = ['id' => '12monkeys', 'name' => $object, 'online' => '0']; 102 | 103 | $this->assertEquals(['id' => 12, 'name' => 'hey there', 'online' => false], $type->cast($value)); 104 | } 105 | 106 | public function testPropertyTypeThrowsExceptionOnMissingProperty(): void 107 | { 108 | $this->expectException(CastException::class); 109 | _object_like(['id' => _int()])->cast([]); 110 | } 111 | 112 | public function testNonRequiredPropertyTypeThrowsNoExceptionOnMissingProperty(): void 113 | { 114 | _object_like(['id?' => _int()])->cast([]); 115 | $this->assertTrue(true); 116 | } 117 | 118 | public function testListType(): void 119 | { 120 | $type = _list(_string()); 121 | 122 | $this->assertTrue($type->matches(['a', 'b'])); 123 | $this->assertFalse($type->matches(['a', 2])); 124 | $this->assertFalse($type->matches('a, b, c')); 125 | 126 | $this->assertEquals(['a', '2'], $type->cast(['a', 2])); 127 | 128 | $this->expectException(CastException::class); 129 | $type->cast('a, b, c'); 130 | } 131 | 132 | public function testCastOrFail(): void 133 | { 134 | $type = _union(_class(DateTime::class), _null()); 135 | 136 | $this->expectException(CastException::class); 137 | $type->cast(1.1); 138 | } 139 | 140 | public function testCastable(): void 141 | { 142 | $type = _float(); 143 | $this->assertEquals(2.5, $type->cast("2.5")); 144 | } 145 | 146 | public function testUnionType(): void 147 | { 148 | $type = _union(_int(), _null()); 149 | $this->assertTrue($type->matches(1)); 150 | $this->assertTrue($type->matches(null)); 151 | $this->assertFalse($type->matches(new stdClass)); 152 | } 153 | 154 | public static function invalidNumericStrings(): array 155 | { 156 | return [ 157 | ['hey'], 158 | ['null'], 159 | [[]], 160 | [new stdClass], 161 | [new class() { 162 | public function __toString() 163 | { 164 | return 'sausage'; 165 | } 166 | }], 167 | ]; 168 | } 169 | 170 | #[DataProvider('invalidNumericStrings')] public function testInvalidNumericStrings(mixed $value): void 171 | { 172 | $this->expectException(CastException::class); 173 | _numeric_string()->cast($value); 174 | } 175 | 176 | public function testNumericStringMatchAndCast(): void 177 | { 178 | $type = _numeric_string(); 179 | $this->assertTrue($type->matches('1.2')); 180 | $this->assertTrue($type->matches('1')); 181 | $this->assertFalse($type->matches('')); 182 | $this->assertFalse($type->matches(false)); 183 | $this->assertFalse($type->matches(null)); 184 | $this->assertFalse($type->matches([])); 185 | 186 | $object = new class() 187 | { 188 | public function __toString() 189 | { 190 | return "1.2"; 191 | } 192 | }; 193 | $this->assertEquals('1.2', $type->cast($object)); 194 | $this->assertEquals('1', $type->cast(1)); 195 | $this->assertEquals('1', $type->cast(true)); 196 | $this->assertEquals('0', $type->cast(false)); 197 | $this->assertEquals('0', $type->cast(null)); 198 | } 199 | 200 | public static function values(): array 201 | { 202 | return [ 203 | [null], 204 | [true], 205 | [false], 206 | ['a'], 207 | [1], 208 | [1.0], 209 | [fopen(__FILE__, 'r')], 210 | [new stdClass], 211 | [[]], 212 | [[1]], 213 | [function () { 214 | }], 215 | ]; 216 | } 217 | 218 | #[DataProvider('values')] public function testMixedTypeMatchAndCast(mixed $value): void 219 | { 220 | $this->assertTrue(_mixed()->matches($value)); 221 | $this->assertSame($value, _mixed()->cast($value)); 222 | } 223 | 224 | public function testMapMatch(): void 225 | { 226 | $type = _map(_string(), _string()); 227 | $this->assertTrue($type->matches([])); 228 | $this->assertTrue($type->matches(['a' => 'b'])); 229 | $this->assertFalse($type->matches(['a' => false])); 230 | $this->assertFalse($type->matches(new stdClass)); 231 | $this->assertFalse($type->matches(false)); 232 | $this->assertFalse($type->matches(null)); 233 | } 234 | 235 | public function testMapCast(): void 236 | { 237 | $type = _map(_string(), _int()); 238 | $this->assertEquals(['a' => 1], $type->cast(['a' => 1])); 239 | $this->assertEquals(['a' => 1], $type->cast(['a' => true])); 240 | $this->assertEquals(['a' => 0], $type->cast(['a' => false])); 241 | $this->assertEquals(['0' => 1], $type->cast([0 => 1])); 242 | $this->assertEquals([], $type->cast(new stdClass)); 243 | 244 | $object = new stdClass; 245 | $object->a = 1; 246 | $this->assertEquals(['a' => 1], $type->cast($object)); 247 | } 248 | 249 | public static function invalidMaps(): array 250 | { 251 | return [ 252 | ['hey'], 253 | ['null'], 254 | [[new stdClass]], 255 | [[new DateTime]], 256 | [new class() { 257 | public function __toString() 258 | { 259 | return 'sausage'; 260 | } 261 | }], 262 | ]; 263 | } 264 | 265 | #[DataProvider('invalidMaps')] public function testMapCastFail(mixed $value): void 266 | { 267 | $type = _map(_string(), _int()); 268 | $this->expectException(CastException::class); 269 | $type->cast($value); 270 | } 271 | 272 | public function testObjectLikeType(): void 273 | { 274 | $type = _object_like([ 275 | 'name' => _string(), 276 | 'age?' => _int(), 277 | ]); 278 | $this->assertTrue($type->matches(['name' => 'Ivan'])); 279 | $this->assertTrue($type->matches(['name' => 'Ivan', 'address' => 'Moscow'])); 280 | $this->assertTrue($type->matches(['name' => 'Ivan', 'age' => 22])); 281 | $this->assertFalse($type->matches(['name' => 'Ivan', 'age' => 'unknown'])); 282 | $this->assertFalse($type->matches("user")); 283 | 284 | $this->expectException(CastException::class); 285 | $type->cast("user"); 286 | } 287 | 288 | public function testCast(): void 289 | { 290 | $type = _list(_string()); 291 | $list = $type->cast([0, 1.4, 'test', false, null]); 292 | 293 | $this->assertEquals(['0', '1.4', 'test', '', ''], $list); 294 | } 295 | 296 | public function testCastToNull(): void 297 | { 298 | $this->assertNull(_null()->cast(0)); 299 | $this->assertNull(_null()->cast('')); 300 | $this->assertNull(_null()->cast([])); 301 | $this->assertNull(_null()->cast(false)); 302 | } 303 | 304 | public function testNumericStringCastsToString(): void 305 | { 306 | $value = _list(_numeric_string())->cast([1.0, "2.34", -1]); 307 | $this->assertSame(['1', '2.34', '-1'], $value); 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /tests/TypedPropertiesTest.php: -------------------------------------------------------------------------------- 1 | getProperty('a')); 17 | $this->assertEquals('int', (string) $typeOfA); 18 | 19 | $typeOfB = DocBlockParser::fromProperty($reflectionClass, $reflectionClass->getProperty('b')); 20 | $this->assertEquals('string|null', (string) $typeOfB); 21 | 22 | $typeOfProp = DocBlockParser::fromProperty($reflectionClass, $reflectionClass->getProperty('prop')); 23 | $this->assertEquals('Hamlet\Type\Parser\TestClass', (string) $typeOfProp); 24 | 25 | $typeOfDate = DocBlockParser::fromProperty($reflectionClass, $reflectionClass->getProperty('date')); 26 | $this->assertEquals('DateTime|null', (string) $typeOfDate); 27 | 28 | $typeOfStatic = DocBlockParser::fromProperty($reflectionClass, $reflectionClass->getProperty('static')); 29 | $this->assertEquals('string', (string) $typeOfStatic); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Types/ArrayKeyTypeTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($success, _array_key()->matches($value)); 55 | } 56 | 57 | /** 58 | * @dataProvider matchCases() 59 | * @param mixed $value 60 | * @param bool $success 61 | */ 62 | public function testAssert($value, bool $success) 63 | { 64 | $exceptionThrown = false; 65 | try { 66 | _array_key()->assert($value); 67 | } catch (Exception $error) { 68 | $exceptionThrown = true; 69 | } 70 | $this->assertEquals(!$success, $exceptionThrown); 71 | } 72 | 73 | public static function castCases(): array 74 | { 75 | $resource = fopen(__FILE__, 'r'); 76 | $object = new class () 77 | { 78 | public function __toString() 79 | { 80 | return 'a'; 81 | } 82 | }; 83 | $callable = function () { 84 | }; 85 | 86 | return [ 87 | [true, 1, false], 88 | [false, 0, false], 89 | [0, 0, false], 90 | [1, 1, false], 91 | [-1, -1, false], 92 | ['', '', false], 93 | ['0', '0', false], 94 | ['string', 'string', false], 95 | [[], 0, false], 96 | [[1], 1, false], 97 | [[1, 3], 1, false], 98 | [new stdClass, null, true], 99 | [$object, (string) $object, false], 100 | [new DateTime, null, true], 101 | [$callable, null, true], 102 | [$resource, (int) $resource, false], 103 | [null, 0, false], 104 | ]; 105 | } 106 | 107 | /** 108 | * @dataProvider castCases() 109 | * @param mixed $value 110 | * @param mixed $result 111 | * @param bool $exceptionThrown 112 | */ 113 | public function testCast($value, $result, bool $exceptionThrown) 114 | { 115 | if ($exceptionThrown) { 116 | $this->expectException(CastException::class); 117 | _array_key()->cast($value); 118 | } else { 119 | $this->assertSame($result, _array_key()->cast($value)); 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /tests/Types/ArrayTypeTest.php: -------------------------------------------------------------------------------- 1 | 2], true], 56 | [['a' => 0], true], 57 | ]; 58 | } 59 | 60 | /** 61 | * @dataProvider matchCases() 62 | * @param mixed $value 63 | * @param bool $success 64 | */ 65 | public function testMatch($value, bool $success) 66 | { 67 | $this->assertEquals($success, _array(_mixed())->matches($value), 'Failed on ' . print_r($value, true)); 68 | } 69 | 70 | /** 71 | * @dataProvider matchCases() 72 | * @param mixed $value 73 | * @param bool $success 74 | */ 75 | public function testAssert($value, bool $success) 76 | { 77 | if ($success) { 78 | _array(_mixed())->assert($value); 79 | $this->assertTrue(true, 'Failed to assert that ' . print_r($value, true) . ' is convertible to an array'); 80 | } else { 81 | $this->expectException(CastException::class); 82 | _array(_mixed())->assert($value); 83 | } 84 | } 85 | 86 | public function testArrayOfStrings() 87 | { 88 | $a = [0 => 'a', 1 => 'b']; 89 | $this->assertTrue(_array(_string())->matches($a)); 90 | } 91 | 92 | public function testWrongOrder() 93 | { 94 | $a = [1 => 'a', 0 => 'b']; 95 | $this->assertTrue(_array(_string())->matches($a)); 96 | } 97 | 98 | public function testSkippedIndex() 99 | { 100 | $a = [0 => 'a', 2 => 'b']; 101 | $this->assertTrue(_array(_string())->matches($a)); 102 | } 103 | 104 | public function testInvalidType() 105 | { 106 | $a = [1, 2, 'a']; 107 | $this->assertFalse(_array(_int())->matches($a)); 108 | } 109 | 110 | public function testParsing() 111 | { 112 | $type = Type::of('array'); 113 | $this->assertTrue($type->matches([1, 2, 3])); 114 | $this->assertTrue($type->matches([1 => 2])); 115 | } 116 | 117 | public static function castCases(): array 118 | { 119 | $resource = fopen(__FILE__, 'r'); 120 | $object = new class () 121 | { 122 | public function __toString() 123 | { 124 | return 'a'; 125 | } 126 | }; 127 | $callable = function () { 128 | }; 129 | $invokable = new class() 130 | { 131 | public function __invoke() 132 | { 133 | } 134 | }; 135 | 136 | return [ 137 | [true, null, true], 138 | [false, null, true], 139 | [0, null, true], 140 | [1, null, true], 141 | [-1, null, true], 142 | ['', null, true], 143 | ['0', null, true], 144 | ['x1', null, true], 145 | [[], [], false], 146 | [[false], [false], false], 147 | [[1], [1], false], 148 | [[1, 3], [1, 3], false], 149 | [$object, null, true], 150 | [$callable, null, true], 151 | [$invokable, null, true], 152 | [$resource, null, true], 153 | [null, null, true], 154 | ]; 155 | } 156 | 157 | /** 158 | * @dataProvider castCases() 159 | * @param mixed $value 160 | * @param mixed $result 161 | * @param bool $exceptionThrown 162 | */ 163 | public function testCast($value, $result, bool $exceptionThrown) 164 | { 165 | if ($exceptionThrown) { 166 | $this->expectException(CastException::class); 167 | } 168 | $this->assertSame($result, _array(_mixed())->cast($value), 'Failed on ' . print_r($value, true)); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /tests/Types/BoolTypeTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($success, _bool()->matches($value)); 55 | } 56 | 57 | /** 58 | * @dataProvider matchCases() 59 | * @param mixed $value 60 | * @param bool $success 61 | */ 62 | public function testAssert($value, bool $success) 63 | { 64 | $exceptionThrown = false; 65 | try { 66 | _bool()->assert($value); 67 | } catch (Exception $error) { 68 | $exceptionThrown = true; 69 | } 70 | $this->assertEquals(!$success, $exceptionThrown); 71 | } 72 | 73 | public static function castCases(): array 74 | { 75 | $resource = fopen(__FILE__, 'r'); 76 | $object = new class () 77 | { 78 | public function __toString() 79 | { 80 | return 'a'; 81 | } 82 | }; 83 | $callable = function () { 84 | }; 85 | 86 | return [ 87 | [true, true, false], 88 | [false, false, false], 89 | [0, false, false], 90 | [1, true, false], 91 | [-1, true, false], 92 | ['', false, false], 93 | ['0', false, false], 94 | ['string', true, false], 95 | [[], false, false], 96 | [[false], true, false], 97 | [[1], true, false], 98 | [[1, 3], true, false], 99 | [new stdClass, true, false], 100 | [$object, true, false], 101 | [new DateTime, true, false], 102 | [$callable, true, false], 103 | [$resource, true, false], 104 | [null, false, false], 105 | ]; 106 | } 107 | 108 | /** 109 | * @dataProvider castCases() 110 | * @param mixed $value 111 | * @param mixed $result 112 | * @param bool $exceptionThrown 113 | */ 114 | public function testCast($value, $result, bool $exceptionThrown) 115 | { 116 | if ($exceptionThrown) { 117 | $this->expectException(CastException::class); 118 | _bool()->cast($value); 119 | } else { 120 | $this->assertSame($result, _bool()->cast($value)); 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /tests/Types/CallableTypeTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($success, _callable()->matches($value)); 59 | } 60 | 61 | #[DataProvider('matchCases')] public function testAssert(mixed $value, bool $success): void 62 | { 63 | $exceptionThrown = false; 64 | try { 65 | _callable()->assert($value); 66 | } catch (Exception) { 67 | $exceptionThrown = true; 68 | } 69 | $this->assertEquals(!$success, $exceptionThrown); 70 | } 71 | 72 | public static function castCases(): array 73 | { 74 | $resource = fopen(__FILE__, 'r'); 75 | $object = new class () 76 | { 77 | public function __toString() 78 | { 79 | return 'a'; 80 | } 81 | }; 82 | $callable = function () { 83 | }; 84 | $invokable = new class() 85 | { 86 | public function __invoke() 87 | { 88 | } 89 | }; 90 | 91 | return [ 92 | [true, null, true], 93 | [false, null, true], 94 | [0, null, true], 95 | [1, null, true], 96 | [-1, null, true], 97 | ['', null, true], 98 | ['0', null, true], 99 | ['x1', null, true], 100 | [[], null, true], 101 | [[false], null, true], 102 | [[1], null, true], 103 | [[1, 3], null, true], 104 | [new stdClass, null, true], 105 | [$object, null, true], 106 | [new DateTime, null, true], 107 | ['abs', 'abs', false], 108 | [$callable, $callable, false], 109 | [$invokable, $invokable, false], 110 | [$resource, null, true], 111 | [null, null, true], 112 | ]; 113 | } 114 | 115 | #[DataProvider('castCases')] public function testCast(mixed $value, mixed $result, bool $exceptionThrown): void 116 | { 117 | if ($exceptionThrown) { 118 | $this->expectException(CastException::class); 119 | _callable()->cast($value); 120 | } else { 121 | $this->assertSame($result, _callable()->cast($value)); 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /tests/Types/ClassTypeTest.php: -------------------------------------------------------------------------------- 1 | 2], false], 59 | [['a' => 0], false], 60 | ]; 61 | } 62 | 63 | /** 64 | * @dataProvider matchCases() 65 | * @param mixed $value 66 | * @param bool $success 67 | */ 68 | public function testMatch($value, bool $success) 69 | { 70 | $this->assertEquals($success, _class(stdClass::class)->matches($value), 'Failed on ' . print_r($value, true)); 71 | } 72 | 73 | /** 74 | * @dataProvider matchCases() 75 | * @param mixed $value 76 | * @param bool $success 77 | */ 78 | public function testAssert($value, bool $success) 79 | { 80 | if (!$success) { 81 | $this->expectException(CastException::class); 82 | } 83 | _class(stdClass::class)->assert($value); 84 | $this->assertTrue(true); 85 | } 86 | 87 | public function testParsing() 88 | { 89 | $type = Type::of('\\DateTime'); 90 | $this->assertTrue($type->matches(new DateTime)); 91 | $this->assertFalse($type->matches(new DateTimeImmutable)); 92 | } 93 | 94 | public static function castCases(): array 95 | { 96 | $resource = fopen(__FILE__, 'r'); 97 | $object = new class () 98 | { 99 | public function __toString() 100 | { 101 | return 'a'; 102 | } 103 | }; 104 | $callable = function () { 105 | }; 106 | $invokable = new class() 107 | { 108 | public function __invoke() 109 | { 110 | } 111 | }; 112 | $stdObject = new stdClass; 113 | 114 | return [ 115 | [true, true], 116 | [false, true], 117 | [0, true], 118 | [1, true], 119 | [-1, true], 120 | ['', true], 121 | ['0', true], 122 | ['x1', true], 123 | [[], false], 124 | [[false], false], 125 | [[1], false], 126 | [[1, 3], false], 127 | [$object, true], 128 | [$callable, true], 129 | [$invokable, true], 130 | [$resource, true], 131 | [null, true], 132 | [$stdObject, false] 133 | ]; 134 | } 135 | 136 | /** 137 | * @dataProvider castCases() 138 | * @param mixed $value 139 | * @param bool $exceptionThrown 140 | */ 141 | public function testCast($value, bool $exceptionThrown) 142 | { 143 | if ($exceptionThrown) { 144 | $this->expectException(CastException::class); 145 | } 146 | _class(stdClass::class)->cast($value); 147 | $this->assertTrue(true); 148 | } 149 | 150 | public function testNonNullableField() 151 | { 152 | require_once __DIR__ . '/../../psalm-cases/classes/Address.php'; 153 | try { 154 | _class(Address::class)->cast(['country' => 'Thailand']); 155 | } catch (CastException $e) { 156 | $this->assertSame(['country' => 'Thailand'], $e->value()); 157 | $this->assertEquals(Address::class, (string) $e->targetType()); 158 | return; 159 | } 160 | $this->fail('Exception excepted'); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /tests/Types/ListTypeTest.php: -------------------------------------------------------------------------------- 1 | 2], false], 56 | [['a' => 0], false], 57 | ]; 58 | } 59 | 60 | /** 61 | * @dataProvider matchCases() 62 | * @param mixed $value 63 | * @param bool $success 64 | */ 65 | public function testMatch($value, bool $success) 66 | { 67 | $this->assertEquals($success, _list(_mixed())->matches($value), 'Failed on ' . print_r($value, true)); 68 | } 69 | 70 | /** 71 | * @dataProvider matchCases() 72 | * @param mixed $value 73 | * @param bool $success 74 | */ 75 | public function testAssert($value, bool $success) 76 | { 77 | if (!$success) { 78 | $this->expectException(CastException::class); 79 | } 80 | _list(_mixed())->assert($value); 81 | $this->assertTrue(true); 82 | } 83 | 84 | public function testListOfStrings() 85 | { 86 | $a = [0 => 'a', 1 => 'b']; 87 | $this->assertTrue(_list(_string())->matches($a)); 88 | } 89 | 90 | public function testWrongOrder() 91 | { 92 | $a = [1 => 'a', 0 => 'b']; 93 | $this->assertFalse(_list(_string())->matches($a)); 94 | } 95 | 96 | public function testSkippedIndex() 97 | { 98 | $a = [0 => 'a', 2 => 'b']; 99 | $this->assertFalse(_list(_string())->matches($a)); 100 | } 101 | 102 | public function testInvalidType() 103 | { 104 | $a = [1, 2, 'a']; 105 | $this->assertFalse(_list(_int())->matches($a)); 106 | } 107 | 108 | public function testParsing() 109 | { 110 | $type = Type::of('list'); 111 | $this->assertTrue($type->matches([1, 2, 3])); 112 | $this->assertFalse($type->matches([1 => 2])); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /tests/Types/MixedTypeTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($success, _mixed()->matches($value)); 61 | } 62 | 63 | /** 64 | * @dataProvider matchCases() 65 | * @param mixed $value 66 | * @param bool $success 67 | */ 68 | public function testAssert($value, bool $success) 69 | { 70 | _mixed()->assert($value); 71 | $this->assertTrue(true); 72 | } 73 | 74 | public static function castCases(): array 75 | { 76 | $resource = fopen(__FILE__, 'r'); 77 | $object = new class () 78 | { 79 | public function __toString() 80 | { 81 | return 'a'; 82 | } 83 | }; 84 | $callable = function () { 85 | }; 86 | $invokable = new class() 87 | { 88 | public function __invoke() 89 | { 90 | } 91 | }; 92 | 93 | return [ 94 | [true, true, false], 95 | [false, false, false], 96 | [0, 0, false], 97 | [1, 1, false], 98 | [-1, -1, false], 99 | ['', '', false], 100 | ['0', '0', false], 101 | ['x1', 'x1', false], 102 | [[], [], false], 103 | [[false], [false], false], 104 | [[1], [1], false], 105 | [[1, 3], [1, 3], false], 106 | [$object, $object, false], 107 | [$callable, $callable, false], 108 | [$invokable, $invokable, false], 109 | [$resource, $resource, false], 110 | [null, null, false], 111 | ]; 112 | } 113 | 114 | /** 115 | * @dataProvider castCases() 116 | * @param mixed $value 117 | * @param mixed $result 118 | * @param bool $exceptionThrown 119 | */ 120 | public function testCast($value, $result, bool $exceptionThrown) 121 | { 122 | _mixed()->cast($value); 123 | $this->assertTrue(true); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /tests/Types/NullTypeTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($success, _null()->matches($value)); 63 | } 64 | 65 | /** 66 | * @dataProvider matchCases() 67 | * @param mixed $value 68 | * @param bool $success 69 | */ 70 | public function testAssert($value, bool $success) 71 | { 72 | $exceptionThrown = false; 73 | try { 74 | _null()->assert($value); 75 | } catch (Exception $error) { 76 | $exceptionThrown = true; 77 | } 78 | $this->assertEquals(!$success, $exceptionThrown); 79 | } 80 | 81 | public static function castCases(): array 82 | { 83 | $resource = fopen(__FILE__, 'r'); 84 | $object = new class () 85 | { 86 | public function __toString() 87 | { 88 | return 'a'; 89 | } 90 | }; 91 | $callable = function () { 92 | }; 93 | $invokable = new class() 94 | { 95 | public function __invoke() 96 | { 97 | } 98 | }; 99 | 100 | return [ 101 | [true, null, true], 102 | [false, null, false], 103 | [0, null, false], 104 | [1, null, true], 105 | [-1, null, true], 106 | ['', null, false], 107 | ['0', null, true], 108 | ['x1', null, true], 109 | [[], null, false], 110 | [[false], null, true], 111 | [[1], null, true], 112 | [[1, 3], null, true], 113 | [new stdClass, null, true], 114 | [$object, null, true], 115 | [new DateTime, null, true], 116 | ['abs', null, true], 117 | [$callable, null, true], 118 | [$invokable, null, true], 119 | [$resource, null, true], 120 | [null, null, false], 121 | ]; 122 | } 123 | 124 | /** 125 | * @dataProvider castCases() 126 | * @param mixed $value 127 | * @param mixed $result 128 | * @param bool $exceptionThrown 129 | */ 130 | public function testCast($value, $result, bool $exceptionThrown) 131 | { 132 | if ($exceptionThrown) { 133 | $this->expectException(CastException::class); 134 | _null()->cast($value); 135 | } else { 136 | $this->assertSame($result, _null()->cast($value)); 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /tests/Types/ResourceTypeTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($success, _resource()->matches($value)); 63 | } 64 | 65 | /** 66 | * @dataProvider matchCases() 67 | * @param mixed $value 68 | * @param bool $success 69 | */ 70 | public function testAssert($value, bool $success) 71 | { 72 | $exceptionThrown = false; 73 | try { 74 | _resource()->assert($value); 75 | } catch (Exception $error) { 76 | $exceptionThrown = true; 77 | } 78 | $this->assertEquals(!$success, $exceptionThrown); 79 | } 80 | 81 | public static function castCases(): array 82 | { 83 | $resource = fopen(__FILE__, 'r'); 84 | $object = new class () 85 | { 86 | public function __toString() 87 | { 88 | return 'a'; 89 | } 90 | }; 91 | $callable = function () { 92 | }; 93 | $invokable = new class() 94 | { 95 | public function __invoke() 96 | { 97 | } 98 | }; 99 | 100 | return [ 101 | [true, null, true], 102 | [false, null, true], 103 | [0, null, true], 104 | [1, null, true], 105 | [-1, null, true], 106 | ['', null, true], 107 | ['0', null, true], 108 | ['x1', null, true], 109 | [[], null, true], 110 | [[false], null, true], 111 | [[1], null, true], 112 | [[1, 3], null, true], 113 | [new stdClass, null, true], 114 | [$object, null, true], 115 | [new DateTime, null, true], 116 | ['abs', null, true], 117 | [$callable, null, true], 118 | [$invokable, null, true], 119 | [$resource, $resource, false], 120 | [null, null, true], 121 | ]; 122 | } 123 | 124 | /** 125 | * @dataProvider castCases() 126 | * @param mixed $value 127 | * @param mixed $result 128 | * @param bool $exceptionThrown 129 | */ 130 | public function testCast($value, $result, bool $exceptionThrown) 131 | { 132 | if ($exceptionThrown) { 133 | $this->expectException(CastException::class); 134 | _resource()->cast($value); 135 | } else { 136 | $this->assertSame($result, _resource()->cast($value)); 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /tests/Types/UnionTypeTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($success1, $type1->matches($value)); 82 | 83 | $type2 = _union(_null(), _int(), _string()); 84 | $this->assertEquals($success2, $type2->matches($value)); 85 | 86 | $type3 = _union(_null(), _int(), _string(), _bool()); 87 | $this->assertEquals($success3, $type3->matches($value)); 88 | } 89 | 90 | /** 91 | * @dataProvider matchCases() 92 | * @param mixed $value 93 | * @param bool $success 94 | */ 95 | public function testAssert($value, bool $success) 96 | { 97 | $exceptionThrown = false; 98 | try { 99 | _union(_int(), _null())->assert($value); 100 | } catch (Exception $error) { 101 | $exceptionThrown = true; 102 | } 103 | $this->assertEquals(!$success, $exceptionThrown); 104 | } 105 | 106 | public static function castCases(): array 107 | { 108 | $resource = fopen(__FILE__, 'r'); 109 | $object = new class () 110 | { 111 | public function __toString() 112 | { 113 | return 'a'; 114 | } 115 | }; 116 | $callable = function () { 117 | }; 118 | $invokable = new class() 119 | { 120 | public function __invoke() 121 | { 122 | } 123 | }; 124 | 125 | return [ 126 | [true, null, true ], 127 | [false, null, false], 128 | [0, null, false], 129 | [1, null, true ], 130 | [-1, null, true ], 131 | ['', null, false], 132 | ['0', null, true ], 133 | ['x1', null, true ], 134 | [[], null, false], 135 | [[false], null, true ], 136 | [[1], null, true ], 137 | [[1, 3], null, true ], 138 | [new stdClass, null, true ], 139 | [$object, null, true ], 140 | [new DateTime, null, true ], 141 | ['abs', null, true ], 142 | [$callable, null, true ], 143 | [$invokable, null, true ], 144 | [$resource, $resource, false], 145 | [null, null, false], 146 | ]; 147 | } 148 | 149 | /** 150 | * @dataProvider castCases 151 | * @param mixed $value 152 | * @param mixed $result 153 | * @param bool $exceptionThrown 154 | */ 155 | public function testCast($value, $result, bool $exceptionThrown) 156 | { 157 | $type = _union(_resource(), _null()); 158 | if ($exceptionThrown) { 159 | $this->expectException(CastException::class); 160 | $type->cast($value); 161 | } else { 162 | $this->assertSame($result, $type->cast($value)); 163 | } 164 | } 165 | 166 | public function testFactoryMethod() { 167 | $type2 = _union(_literal(1), _literal(2)); 168 | $this->assertInstanceOf(Union2Type::class, $type2); 169 | $this->assertTrue($type2->matches(2)); 170 | 171 | $type3 = _union(_literal(1), _literal(2), _literal(3)); 172 | $this->assertInstanceOf(Union3Type::class, $type3); 173 | $this->assertTrue($type3->matches(3)); 174 | 175 | $type4 = _union(_literal(1), _literal(2), _literal(3), _literal(4)); 176 | $this->assertInstanceOf(Union4Type::class, $type4); 177 | $this->assertTrue($type4->matches(4)); 178 | 179 | $type5 = _union(_literal(1), _literal(2), _literal(3), _literal(4), _literal(5)); 180 | $this->assertInstanceOf(Union5Type::class, $type5); 181 | $this->assertTrue($type5->matches(5)); 182 | 183 | $type6 = _union(_literal(1), _literal(2), _literal(3), _literal(4), _literal(5), _literal(6)); 184 | $this->assertInstanceOf(Union6Type::class, $type6); 185 | $this->assertTrue($type6->matches(6)); 186 | 187 | $type7 = _union(_literal(1), _literal(2), _literal(3), _literal(4), _literal(5), _literal(6), _literal(7)); 188 | $this->assertInstanceOf(Union7Type::class, $type7); 189 | $this->assertTrue($type7->matches(7)); 190 | 191 | $type8 = _union(_literal(1), _literal(2), _literal(3), _literal(4), _literal(5), _literal(6), _literal(7), _literal(8)); 192 | $this->assertInstanceOf(Union8Type::class, $type8); 193 | $this->assertTrue($type8->matches(8)); 194 | 195 | $this->expectException(RuntimeException::class); 196 | _union(_literal(1), _literal(2), _literal(3), _literal(4), _literal(5), _literal(6), _literal(7), _literal(8), _literal(9)); 197 | } 198 | 199 | public function testNullableTailsFail3() 200 | { 201 | $this->expectException(RuntimeException::class); 202 | _union(_literal(1), _literal(2), null); 203 | } 204 | 205 | public function testNullableTailsFail4() 206 | { 207 | $this->expectException(RuntimeException::class); 208 | _union(_literal(1), _literal(2), _literal(3), null); 209 | } 210 | 211 | public function testNullableTailsFail5() 212 | { 213 | $this->expectException(RuntimeException::class); 214 | _union(_literal(1), _literal(2), _literal(3), _literal(4), null); 215 | } 216 | 217 | public function testNullableTailsFail6() 218 | { 219 | $this->expectException(RuntimeException::class); 220 | _union(_literal(1), _literal(2), _literal(3), _literal(4), _literal(5), null); 221 | } 222 | 223 | public function testNullableTailsFail7() 224 | { 225 | $this->expectException(RuntimeException::class); 226 | _union(_literal(1), _literal(2), _literal(3), _literal(4), _literal(5), _literal(6), null); 227 | } 228 | 229 | public function testNullableTailsFail8() 230 | { 231 | $this->expectException(RuntimeException::class); 232 | _union(_literal(1), _literal(2), _literal(3), _literal(4), _literal(5), _literal(6), _literal(7), null); 233 | } 234 | } 235 | --------------------------------------------------------------------------------