From 518208d2a690ad0229c05ae6f001348dc6936da0 Mon Sep 17 00:00:00 2001 From: Alexandre Gomes Gaigalas Date: Fri, 26 Jun 2026 03:23:07 -0300 Subject: [PATCH] Add AssuranceTypeMapper for IDE-readable type narrowing Generate IDE-readable narrowing PHPDoc from a node's #[Assurance] / fluent mixins narrow types after assert()/check() without a PHPStan extension. - New AssuranceTypeMapper maps each assurance shape to a Chain generic: concrete types, the argument-derived forms (from: Value -> @param T; from: TypeString -> @param class-string), element forms, Container subjects, and the Wrap/Container/Elements prefix compositions (with union de-duplication). #[AssuranceParameter] selects which argument carries the type (any position, default first) -- it no longer implies class-string; `from` decides the derivation. Argument-wrapping forms stay Chain to avoid retyping Validator parameters to Chain (which would reject raw, non-fluent validators). - New NarrowingDoc and TerminalMethod value objects; InterfaceConfig gains emitNarrowing / chainType / templateParam / terminalMethods, threaded through MethodBuilder and MixinGenerator. - Unit coverage per bucket plus a MixinGenerator narrowing integration test. --- .gitattributes | 11 + composer.json | 9 +- composer.lock | 287 ++++++++-------- src/Fluent/AssuranceTypeMapper.php | 322 ++++++++++++++++++ src/Fluent/InterfaceConfig.php | 5 + src/Fluent/MethodBuilder.php | 34 +- src/Fluent/MixinGenerator.php | 44 +++ src/Fluent/NarrowingDoc.php | 26 ++ src/Fluent/TerminalMethod.php | 33 ++ tests/Fixtures/Assurance/AllOfHandler.php | 24 ++ tests/Fixtures/Assurance/AllPrefixHandler.php | 27 ++ tests/Fixtures/Assurance/AnyOfHandler.php | 24 ++ .../Fixtures/Assurance/ComparisonHandler.php | 22 ++ tests/Fixtures/Assurance/EachHandler.php | 22 ++ tests/Fixtures/Assurance/ExcludeHandler.php | 25 ++ tests/Fixtures/Assurance/FileTypeHandler.php | 18 + .../Assurance/IndexedValueHandler.php | 25 ++ tests/Fixtures/Assurance/InstanceHandler.php | 25 ++ tests/Fixtures/Assurance/IntTypeHandler.php | 17 + tests/Fixtures/Assurance/KeyHandler.php | 26 ++ tests/Fixtures/Assurance/MemberHandler.php | 22 ++ tests/Fixtures/Assurance/NamedHandler.php | 23 ++ tests/Fixtures/Assurance/NullOrHandler.php | 24 ++ .../Assurance/NullOrPrefixHandler.php | 26 ++ tests/Fixtures/Assurance/NullTypeHandler.php | 17 + tests/Fixtures/Assurance/Validator.php | 19 ++ tests/Unit/Fluent/AssuranceTypeMapperTest.php | 283 +++++++++++++++ tests/Unit/Fluent/MixinGeneratorTest.php | 52 +++ 28 files changed, 1343 insertions(+), 149 deletions(-) create mode 100644 .gitattributes create mode 100644 src/Fluent/AssuranceTypeMapper.php create mode 100644 src/Fluent/NarrowingDoc.php create mode 100644 src/Fluent/TerminalMethod.php create mode 100644 tests/Fixtures/Assurance/AllOfHandler.php create mode 100644 tests/Fixtures/Assurance/AllPrefixHandler.php create mode 100644 tests/Fixtures/Assurance/AnyOfHandler.php create mode 100644 tests/Fixtures/Assurance/ComparisonHandler.php create mode 100644 tests/Fixtures/Assurance/EachHandler.php create mode 100644 tests/Fixtures/Assurance/ExcludeHandler.php create mode 100644 tests/Fixtures/Assurance/FileTypeHandler.php create mode 100644 tests/Fixtures/Assurance/IndexedValueHandler.php create mode 100644 tests/Fixtures/Assurance/InstanceHandler.php create mode 100644 tests/Fixtures/Assurance/IntTypeHandler.php create mode 100644 tests/Fixtures/Assurance/KeyHandler.php create mode 100644 tests/Fixtures/Assurance/MemberHandler.php create mode 100644 tests/Fixtures/Assurance/NamedHandler.php create mode 100644 tests/Fixtures/Assurance/NullOrHandler.php create mode 100644 tests/Fixtures/Assurance/NullOrPrefixHandler.php create mode 100644 tests/Fixtures/Assurance/NullTypeHandler.php create mode 100644 tests/Fixtures/Assurance/Validator.php create mode 100644 tests/Unit/Fluent/AssuranceTypeMapperTest.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..52e3575 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +/* export-ignore + +# Project files +/README.md -export-ignore +/composer.json -export-ignore +/src -export-ignore + +# SBOM information +/LICENSE -export-ignore +/LICENSES -export-ignore +/REUSE.toml -export-ignore diff --git a/composer.json b/composer.json index 471a82e..93f417b 100644 --- a/composer.json +++ b/composer.json @@ -4,6 +4,8 @@ "keywords": ["respect", "fluentgen", "mixin", "fluent"], "type": "library", "license": "ISC", + "minimum-stability": "dev", + "prefer-stable": true, "authors": [ { "name": "Respect/FluentGen Contributors", @@ -20,7 +22,7 @@ "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^12.5", "respect/coding-standard": "^5.0", - "respect/fluent": "^2.0" + "respect/fluent": "3.0.x-dev" }, "suggest": { "respect/fluent": "Enables #[Composable] prefix composition support" @@ -50,5 +52,10 @@ "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true } + }, + "extra": { + "branch-alias": { + "dev-ide-narrowing": "2.1.x-dev" + } } } diff --git a/composer.lock b/composer.lock index b62beb7..f63af36 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e9d9262f524a4418d039b3a4c200ee05", + "content-hash": "8037cd0017afd7607bc04a5a81c0404b", "packages": [ { "name": "nette/php-generator", @@ -82,16 +82,16 @@ }, { "name": "nette/utils", - "version": "v4.1.3", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe" + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/bb3ea637e3d131d72acc033cfc2746ee893349fe", - "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe", + "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", "shasum": "" }, "require": { @@ -167,24 +167,24 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.3" + "source": "https://github.com/nette/utils/tree/v4.1.4" }, - "time": "2026-02-13T03:05:33+00:00" + "time": "2026-05-11T20:49:54+00:00" } ], "packages-dev": [ { "name": "dealerdirect/phpcodesniffer-composer-installer", - "version": "v1.2.0", + "version": "v1.2.1", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/composer-installer.git", - "reference": "845eb62303d2ca9b289ef216356568ccc075ffd1" + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/845eb62303d2ca9b289ef216356568ccc075ffd1", - "reference": "845eb62303d2ca9b289ef216356568ccc075ffd1", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd", "shasum": "" }, "require": { @@ -267,7 +267,7 @@ "type": "thanks_dev" } ], - "time": "2025-11-11T04:32:07+00:00" + "time": "2026-05-06T08:26:05+00:00" }, { "name": "doctrine/coding-standard", @@ -610,11 +610,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.43", + "version": "2.2.2", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/d01bebe3edfd4d49b9666ee5b8271ddca561042f", - "reference": "d01bebe3edfd4d49b9666ee5b8271ddca561042f", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e5cc34d491a90e79c216d824f60fe21fd4d93bd6", + "reference": "e5cc34d491a90e79c216d824f60fe21fd4d93bd6", "shasum": "" }, "require": { @@ -637,6 +637,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -659,7 +670,7 @@ "type": "github" } ], - "time": "2026-03-24T20:40:50+00:00" + "time": "2026-06-05T09:00:01+00:00" }, { "name": "phpstan/phpstan-deprecation-rules", @@ -713,16 +724,16 @@ }, { "name": "phpstan/phpstan-strict-rules", - "version": "2.0.10", + "version": "2.0.11", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-strict-rules.git", - "reference": "1aba28b697c1e3b6bbec8a1725f8b11b6d3e5a5f" + "reference": "9b000a578b85b32945b358b172c7b20e91189024" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/1aba28b697c1e3b6bbec8a1725f8b11b6d3e5a5f", - "reference": "1aba28b697c1e3b6bbec8a1725f8b11b6d3e5a5f", + "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/9b000a578b85b32945b358b172c7b20e91189024", + "reference": "9b000a578b85b32945b358b172c7b20e91189024", "shasum": "" }, "require": { @@ -758,22 +769,22 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", - "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.10" + "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.11" }, - "time": "2026-02-11T14:17:32+00:00" + "time": "2026-05-02T06:54:10+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.5.3", + "version": "12.5.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" + "reference": "186dab580576598076de6818596d12b61801880e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", - "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/186dab580576598076de6818596d12b61801880e", + "reference": "186dab580576598076de6818596d12b61801880e", "shasum": "" }, "require": { @@ -782,16 +793,15 @@ "ext-xmlwriter": "*", "nikic/php-parser": "^5.7.0", "php": ">=8.3", - "phpunit/php-file-iterator": "^6.0", "phpunit/php-text-template": "^5.0", "sebastian/complexity": "^5.0", - "sebastian/environment": "^8.0.3", - "sebastian/lines-of-code": "^4.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", "sebastian/version": "^6.0", "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.5.1" + "phpunit/phpunit": "^12.5.28" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -829,7 +839,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.7" }, "funding": [ { @@ -849,7 +859,7 @@ "type": "tidelift" } ], - "time": "2026-02-06T06:01:44+00:00" + "time": "2026-06-01T13:24:19+00:00" }, { "name": "phpunit/php-file-iterator", @@ -1110,16 +1120,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.14", + "version": "12.5.30", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0" + "reference": "900400a5b616d6fb306f9549f6da33ba615d3fbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/47283cfd98d553edcb1353591f4e255dc1bb61f0", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/900400a5b616d6fb306f9549f6da33ba615d3fbb", + "reference": "900400a5b616d6fb306f9549f6da33ba615d3fbb", "shasum": "" }, "require": { @@ -1133,20 +1143,20 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.3", + "phpunit/php-code-coverage": "^12.5.7", "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", - "sebastian/cli-parser": "^4.2.0", - "sebastian/comparator": "^7.1.4", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.0.3", - "sebastian/exporter": "^7.0.2", - "sebastian/global-state": "^8.0.2", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.3", "sebastian/object-enumerator": "^7.0.0", "sebastian/recursion-context": "^7.0.1", - "sebastian/type": "^6.0.3", + "sebastian/type": "^6.0.4", "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" }, @@ -1188,31 +1198,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.14" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.30" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-02-18T12:38:40+00:00" + "time": "2026-06-15T13:12:30+00:00" }, { "name": "respect/coding-standard", @@ -1259,16 +1253,16 @@ }, { "name": "respect/fluent", - "version": "2.0.0", + "version": "dev-ide-narrowing", "source": { "type": "git", "url": "https://github.com/Respect/Fluent.git", - "reference": "21e9936c4ae753691e895bd4b68ab3380f482141" + "reference": "8469e6e8d30d28f36c0b79a381703ea9b26b0ecd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Respect/Fluent/zipball/21e9936c4ae753691e895bd4b68ab3380f482141", - "reference": "21e9936c4ae753691e895bd4b68ab3380f482141", + "url": "https://api.github.com/repos/Respect/Fluent/zipball/8469e6e8d30d28f36c0b79a381703ea9b26b0ecd", + "reference": "8469e6e8d30d28f36c0b79a381703ea9b26b0ecd", "shasum": "" }, "require": { @@ -1279,10 +1273,15 @@ "phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^12.5", + "phpunit/phpunit": "^12.5 || ^13.0", "respect/coding-standard": "^5.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-ide-narrowing": "3.0.x-dev" + } + }, "autoload": { "psr-4": { "Respect\\Fluent\\": "src/" @@ -1306,29 +1305,29 @@ ], "support": { "issues": "https://github.com/Respect/Fluent/issues", - "source": "https://github.com/Respect/Fluent/tree/2.0.0" + "source": "https://github.com/Respect/Fluent/tree/ide-narrowing" }, - "time": "2026-03-25T05:08:46+00:00" + "time": "2026-06-26T19:36:25+00:00" }, { "name": "sebastian/cli-parser", - "version": "4.2.0", + "version": "4.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -1357,7 +1356,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" }, "funding": [ { @@ -1377,20 +1376,20 @@ "type": "tidelift" } ], - "time": "2025-09-14T09:36:45+00:00" + "time": "2026-05-17T05:29:34+00:00" }, { "name": "sebastian/comparator", - "version": "7.1.4", + "version": "7.1.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6" + "reference": "7c65c1e79836812819705b473a90c12399542485" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", "shasum": "" }, "require": { @@ -1398,10 +1397,10 @@ "ext-mbstring": "*", "php": ">=8.3", "sebastian/diff": "^7.0", - "sebastian/exporter": "^7.0" + "sebastian/exporter": "^7.0.3" }, "require-dev": { - "phpunit/phpunit": "^12.2" + "phpunit/phpunit": "^12.5.25" }, "suggest": { "ext-bcmath": "For comparing BcMath\\Number objects" @@ -1449,7 +1448,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.4" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" }, "funding": [ { @@ -1469,7 +1468,7 @@ "type": "tidelift" } ], - "time": "2026-01-24T09:28:48+00:00" + "time": "2026-05-21T04:45:25+00:00" }, { "name": "sebastian/complexity", @@ -1598,23 +1597,23 @@ }, { "name": "sebastian/environment", - "version": "8.0.4", + "version": "8.1.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11" + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/7b8842c2d8e85d0c3a5831236bf5869af6ab2a11", - "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.26" }, "suggest": { "ext-posix": "*" @@ -1622,7 +1621,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -1650,7 +1649,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.0.4" + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" }, "funding": [ { @@ -1670,29 +1669,29 @@ "type": "tidelift" } ], - "time": "2026-03-15T07:05:40+00:00" + "time": "2026-05-25T13:40:20+00:00" }, { "name": "sebastian/exporter", - "version": "7.0.2", + "version": "7.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7" + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", "shasum": "" }, "require": { "ext-mbstring": "*", "php": ">=8.3", - "sebastian/recursion-context": "^7.0" + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -1740,7 +1739,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2" + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" }, "funding": [ { @@ -1760,30 +1759,30 @@ "type": "tidelift" } ], - "time": "2025-09-24T06:16:11+00:00" + "time": "2026-05-20T04:37:17+00:00" }, { "name": "sebastian/global-state", - "version": "8.0.2", + "version": "8.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d" + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", "shasum": "" }, "require": { "php": ">=8.3", "sebastian/object-reflector": "^5.0", - "sebastian/recursion-context": "^7.0" + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.28" }, "type": "library", "extra": { @@ -1814,7 +1813,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2" + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" }, "funding": [ { @@ -1834,28 +1833,28 @@ "type": "tidelift" } ], - "time": "2025-08-29T11:29:25+00:00" + "time": "2026-06-01T15:10:33+00:00" }, { "name": "sebastian/lines-of-code", - "version": "4.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f" + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", "shasum": "" }, "require": { - "nikic/php-parser": "^5.0", + "nikic/php-parser": "^5.7.0", "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -1884,15 +1883,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" } ], - "time": "2025-02-07T04:57:28+00:00" + "time": "2026-05-19T16:22:07+00:00" }, { "name": "sebastian/object-enumerator", @@ -2086,23 +2097,23 @@ }, { "name": "sebastian/type", - "version": "6.0.3", + "version": "6.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d" + "reference": "82ff822c2edc46724be9f7411d3163021f602773" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -2131,7 +2142,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/6.0.3" + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" }, "funding": [ { @@ -2151,7 +2162,7 @@ "type": "tidelift" } ], - "time": "2025-08-09T06:57:12+00:00" + "time": "2026-05-20T06:45:45+00:00" }, { "name": "sebastian/version", @@ -2209,20 +2220,20 @@ }, { "name": "slevomat/coding-standard", - "version": "8.28.1", + "version": "8.29.0", "source": { "type": "git", "url": "https://github.com/slevomat/coding-standard.git", - "reference": "66151cfbd25b50e8becd9f809fb704f01fd4d6f2" + "reference": "81fce13c4ef4b53a03e5cfa6ce36afc191c1598e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/66151cfbd25b50e8becd9f809fb704f01fd4d6f2", - "reference": "66151cfbd25b50e8becd9f809fb704f01fd4d6f2", + "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/81fce13c4ef4b53a03e5cfa6ce36afc191c1598e", + "reference": "81fce13c4ef4b53a03e5cfa6ce36afc191c1598e", "shasum": "" }, "require": { - "dealerdirect/phpcodesniffer-composer-installer": "^0.7 || ^1.2.0", + "dealerdirect/phpcodesniffer-composer-installer": "^0.7 || ^1.2.1", "php": "^7.4 || ^8.0", "phpstan/phpdoc-parser": "^2.3.2", "squizlabs/php_codesniffer": "^4.0.1" @@ -2230,11 +2241,11 @@ "require-dev": { "phing/phing": "3.0.1|3.1.2", "php-parallel-lint/php-parallel-lint": "1.4.0", - "phpstan/phpstan": "2.1.42", + "phpstan/phpstan": "2.1.54", "phpstan/phpstan-deprecation-rules": "2.0.4", "phpstan/phpstan-phpunit": "2.0.16", - "phpstan/phpstan-strict-rules": "2.0.10", - "phpunit/phpunit": "9.6.34|10.5.63|11.4.4|11.5.50|12.5.14" + "phpstan/phpstan-strict-rules": "2.0.11", + "phpunit/phpunit": "9.6.34|10.5.63|11.4.4|11.5.55|12.5.24" }, "type": "phpcodesniffer-standard", "extra": { @@ -2258,7 +2269,7 @@ ], "support": { "issues": "https://github.com/slevomat/coding-standard/issues", - "source": "https://github.com/slevomat/coding-standard/tree/8.28.1" + "source": "https://github.com/slevomat/coding-standard/tree/8.29.0" }, "funding": [ { @@ -2270,7 +2281,7 @@ "type": "tidelift" } ], - "time": "2026-03-22T17:22:38+00:00" + "time": "2026-05-07T05:48:08+00:00" }, { "name": "squizlabs/php_codesniffer", @@ -2455,9 +2466,11 @@ } ], "aliases": [], - "minimum-stability": "stable", - "stability-flags": {}, - "prefer-stable": false, + "minimum-stability": "dev", + "stability-flags": { + "respect/fluent": 20 + }, + "prefer-stable": true, "prefer-lowest": false, "platform": { "php": "^8.5" diff --git a/src/Fluent/AssuranceTypeMapper.php b/src/Fluent/AssuranceTypeMapper.php new file mode 100644 index 0000000..94c4136 --- /dev/null +++ b/src/Fluent/AssuranceTypeMapper.php @@ -0,0 +1,322 @@ + + */ + +declare(strict_types=1); + +namespace Respect\FluentGen\Fluent; + +use ReflectionClass; +use Respect\Fluent\Attributes\Assurance; +use Respect\Fluent\Attributes\AssuranceFrom; +use Respect\Fluent\Attributes\AssuranceModifier; +use Respect\Fluent\Attributes\AssuranceParameter; +use Respect\Fluent\Attributes\AssuranceSubject; +use Respect\Fluent\Attributes\AssuranceSubjectMode; + +use function array_map; +use function ctype_digit; +use function explode; +use function implode; +use function in_array; +use function is_array; +use function ltrim; +use function str_contains; +use function trim; + +/** + * Derives the IDE-readable narrowing PHPDoc for a generated method from a node's + * #[Assurance] / #[AssuranceSubject] / #[AssuranceParameter] attributes. + */ +final readonly class AssuranceTypeMapper +{ + private const array BUILTINS = [ + 'int', + 'float', + 'string', + 'bool', + 'array', + 'object', + 'callable', + 'iterable', + 'mixed', + 'null', + 'void', + 'false', + 'true', + 'resource', + 'scalar', + 'numeric-string', + 'non-empty-string', + 'class-string', + 'positive-int', + 'negative-int', + ]; + + public function __construct( + private string $chainType = 'Chain', + private string $templateParam = 'TSure', + ) { + } + + /** + * @param ReflectionClass $rule + * @param ReflectionClass|null $prefix the composing prefix when building a prefixed method + */ + public function for(ReflectionClass $rule, bool $static, ReflectionClass|null $prefix = null): NarrowingDoc + { + if (!$static) { + $element = $prefix === null ? $this->elementDoc($rule) : null; + if ($element !== null) { + return $element; + } + + return $this->ret($prefix !== null ? 'mixed' : $this->templateParam); + } + + if ($prefix !== null) { + return $this->forPrefixed($rule, $prefix); + } + + return $this->forBase($rule); + } + + /** + * The element-extraction form for an each()/all()-style rule (from: Elements), or null. + * + * @param ReflectionClass $rule + */ + private function elementDoc(ReflectionClass $rule): NarrowingDoc|null + { + if ($this->assuranceOf($rule)?->from !== AssuranceFrom::Elements) { + return null; + } + + return new NarrowingDoc([ + '@template T', + '@param ' . $this->chainType . ' $' . $this->sourceParameterName($rule), + '@return ' . $this->chainType . '>', + ], suppressConstructorDoc: true); + } + + /** @param ReflectionClass $rule */ + private function forBase(ReflectionClass $rule): NarrowingDoc + { + $assurance = $this->assuranceOf($rule); + if ($assurance === null) { + return $this->ret('mixed'); + } + + $subject = $this->subjectOf($rule); + if ($subject?->mode === AssuranceSubjectMode::Wrap) { + return $this->ret('mixed'); + } + + // The argument carrying the type info: #[AssuranceParameter] selects it (any + // position), defaulting to the first. `from` decides how it maps to the type. + $sourceParam = $this->sourceParameterName($rule); + + if ($assurance->from === AssuranceFrom::TypeString) { + // The argument is a class-string; the input narrows to an instance of it. + return new NarrowingDoc([ + '@template T of object', + '@param class-string $' . $sourceParam, + '@return ' . $this->chainType . '', + ], suppressConstructorDoc: true); + } + + if ($assurance->from === AssuranceFrom::Value) { + // The argument's own type is the narrowed type. + return new NarrowingDoc([ + '@template T', + '@param T $' . $sourceParam, + '@return ' . $this->chainType . '', + ], suppressConstructorDoc: true); + } + + $element = $this->elementDoc($rule); + if ($element !== null) { + return $element; + } + + if ( + $assurance->from === AssuranceFrom::Member + || $assurance->compose !== null + || $assurance->modifier !== null + ) { + return $this->ret('mixed'); + } + + if ($assurance->type !== null) { + return $this->ret($this->typeString($assurance->type)); + } + + return $this->ret('mixed'); + } + + /** + * @param ReflectionClass $rule + * @param ReflectionClass $prefix + */ + private function forPrefixed(ReflectionClass $rule, ReflectionClass $prefix): NarrowingDoc + { + $subject = $this->subjectOf($prefix); + if ($subject?->mode === AssuranceSubjectMode::Elements) { + $inner = $this->concreteTypeOf($rule); + + return $this->ret($inner !== null ? 'iterable<' . $inner . '>' : 'iterable'); + } + + if ($subject?->mode === AssuranceSubjectMode::Wrap) { + $prefixAssurance = $this->assuranceOf($prefix); + if ($prefixAssurance?->modifier === AssuranceModifier::Exclude) { + return $this->ret('mixed'); + } + + $bypass = $prefixAssurance?->type; + $inner = $this->concreteTypeOf($rule); + if ($inner !== null && $bypass !== null) { + return $this->ret($this->union($inner, $this->typeString($bypass))); + } + + return $this->ret('mixed'); + } + + if ($subject?->mode === AssuranceSubjectMode::Container) { + $type = $this->assuranceOf($prefix)?->type; + + return $type !== null ? $this->ret($this->typeString($type)) : $this->ret('mixed'); + } + + return $this->ret('mixed'); + } + + /** + * The plain concrete type of a rule, or null when it is not a pure type rule. + * + * @param ReflectionClass $rule + */ + private function concreteTypeOf(ReflectionClass $rule): string|null + { + $assurance = $this->assuranceOf($rule); + if ($assurance?->type === null) { + return null; + } + + if ( + $assurance->from !== null + || $assurance->compose !== null + || $assurance->modifier !== null + || $this->subjectOf($rule) !== null + || $this->assuranceParameterName($rule) !== null + ) { + return null; + } + + return $this->typeString($assurance->type); + } + + private function ret(string $inner): NarrowingDoc + { + return new NarrowingDoc(['@return ' . $this->chainType . '<' . $inner . '>']); + } + + /** + * Join one or more pipe-separated type strings into a single union, preserving order + * and dropping duplicate members. + */ + private function union(string ...$types): string + { + $parts = []; + foreach ($types as $type) { + foreach (explode('|', $type) as $part) { + $part = trim($part); + if ($part === '' || in_array($part, $parts, true)) { + continue; + } + + $parts[] = $part; + } + } + + return implode('|', $parts); + } + + /** @param ReflectionClass $rule */ + private function assuranceOf(ReflectionClass $rule): Assurance|null + { + $attributes = $rule->getAttributes(Assurance::class); + + return $attributes === [] ? null : $attributes[0]->newInstance(); + } + + /** @param ReflectionClass $rule */ + private function subjectOf(ReflectionClass $rule): AssuranceSubject|null + { + $attributes = $rule->getAttributes(AssuranceSubject::class); + + return $attributes === [] ? null : $attributes[0]->newInstance(); + } + + /** @param ReflectionClass $rule */ + private function assuranceParameterName(ReflectionClass $rule): string|null + { + foreach ($rule->getConstructor()?->getParameters() ?? [] as $param) { + if ($param->getAttributes(AssuranceParameter::class) !== []) { + return $param->getName(); + } + } + + return null; + } + + /** @param ReflectionClass $rule */ + private function firstParameterName(ReflectionClass $rule): string + { + $parameters = $rule->getConstructor()?->getParameters() ?? []; + + return $parameters === [] ? 'input' : $parameters[0]->getName(); + } + + /** + * The argument that carries the assurance type info: the #[AssuranceParameter]-marked + * one, or the first parameter when none is marked. + * + * @param ReflectionClass $rule + */ + private function sourceParameterName(ReflectionClass $rule): string + { + return $this->assuranceParameterName($rule) ?? $this->firstParameterName($rule); + } + + /** @param string|list $type */ + private function typeString(string|array $type): string + { + $parts = is_array($type) ? $type : explode('|', $type); + + return implode('|', array_map($this->qualify(...), $parts)); + } + + private function qualify(string $segment): string + { + $segment = trim($segment); + + if ($segment === '' || $segment[0] === "'" || $segment[0] === '-' || ctype_digit($segment[0])) { + return $segment; + } + + if (str_contains($segment, '\\')) { + return '\\' . ltrim($segment, '\\'); + } + + if (in_array($segment, self::BUILTINS, true)) { + return $segment; + } + + return '\\' . $segment; + } +} diff --git a/src/Fluent/InterfaceConfig.php b/src/Fluent/InterfaceConfig.php index fb2ede6..2f33178 100644 --- a/src/Fluent/InterfaceConfig.php +++ b/src/Fluent/InterfaceConfig.php @@ -15,6 +15,7 @@ /** * @param array $rootExtends * @param array $rootUses + * @param array $terminalMethods methods injected verbatim into the root interface */ public function __construct( public string $suffix, @@ -23,6 +24,10 @@ public function __construct( public array $rootExtends = [], public string|null $rootComment = null, public array $rootUses = [], + public bool $emitNarrowing = false, + public string $chainType = 'Chain', + public string|null $templateParam = null, + public array $terminalMethods = [], ) { } } diff --git a/src/Fluent/MethodBuilder.php b/src/Fluent/MethodBuilder.php index bcc0044..df4f84e 100644 --- a/src/Fluent/MethodBuilder.php +++ b/src/Fluent/MethodBuilder.php @@ -53,7 +53,10 @@ public function classToPrefix(string $shortName): string return lcfirst($shortName); } - /** @param ReflectionClass $nodeReflection */ + /** + * @param ReflectionClass $nodeReflection + * @param ReflectionClass|null $prefixReflection composing prefix class, for narrowing + */ public function build( PhpNamespace $namespace, ReflectionClass $nodeReflection, @@ -61,6 +64,8 @@ public function build( string|null $prefix = null, bool $static = false, ReflectionParameter|null $prefixParameter = null, + AssuranceTypeMapper|null $narrowing = null, + ReflectionClass|null $prefixReflection = null, ): Method { $originalName = $nodeReflection->getShortName(); if ($this->classSuffix !== '' && str_ends_with($originalName, $this->classSuffix)) { @@ -80,21 +85,28 @@ public function build( $this->addPrefixParameter($method, $prefixParameter); } + $narrowingDoc = $narrowing?->for($nodeReflection, $static, $prefixReflection); + $suppressConstructorDoc = $narrowingDoc !== null && $narrowingDoc->suppressConstructorDoc; + $constructor = $nodeReflection->getConstructor(); - if ($constructor === null) { - return $method; - } + if ($constructor !== null) { + $comment = $constructor->getDocComment(); + if ($comment !== false && !$suppressConstructorDoc) { + $cleaned = preg_replace('@(/\*\* *| +\* +| +\*/)@', '', $comment); + if ($cleaned !== null) { + $method->addComment($cleaned); + } + } - $comment = $constructor->getDocComment(); - if ($comment !== false) { - $cleaned = preg_replace('@(/\*\* *| +\* +| +\*/)@', '', $comment); - if ($cleaned !== null) { - $method->addComment($cleaned); + foreach ($constructor->getParameters() as $reflectionParameter) { + $this->addParameter($method, $reflectionParameter, $namespace); } } - foreach ($constructor->getParameters() as $reflectionParameter) { - $this->addParameter($method, $reflectionParameter, $namespace); + if ($narrowingDoc !== null) { + foreach ($narrowingDoc->comments as $line) { + $method->addComment($line); + } } return $method; diff --git a/src/Fluent/MixinGenerator.php b/src/Fluent/MixinGenerator.php index 81d9f8a..c948bd8 100644 --- a/src/Fluent/MixinGenerator.php +++ b/src/Fluent/MixinGenerator.php @@ -10,6 +10,7 @@ namespace Respect\FluentGen\Fluent; +use Nette\PhpGenerator\Method; use Nette\PhpGenerator\PhpNamespace; use ReflectionClass; use ReflectionParameter; @@ -30,6 +31,7 @@ * optIn: bool, * fqcn: class-string, * prefixParameter: ReflectionParameter|null, + * reflection: ReflectionClass, * } */ final readonly class MixinGenerator implements CodeGenerator @@ -126,6 +128,7 @@ private function discoverPrefixesAndFilters(array $nodes): array 'optIn' => $attr->optIn, 'fqcn' => $reflection->getName(), 'prefixParameter' => $prefixParameter, + 'reflection' => $reflection, ]; } @@ -169,6 +172,8 @@ private function generateInterface( $prefix['prefix'], $config->static, $prefix['prefixParameter'], + $this->mapperFor($config), + $prefix['reflection'], ); $interface->addMember($method); @@ -177,6 +182,15 @@ private function generateInterface( $this->addFile($interfaceName, $namespace, $files); } + private function mapperFor(InterfaceConfig $config): AssuranceTypeMapper|null + { + if (!$config->emitNarrowing) { + return null; + } + + return new AssuranceTypeMapper($config->chainType, $config->templateParam ?? 'TSure'); + } + /** * @param array $prefixInterfaceNames * @param array> $nodes @@ -204,6 +218,10 @@ private function generateRootInterface( $interface->addExtend($prefixInterfaceName); } + if ($config->templateParam !== null) { + $interface->addComment('@template-covariant ' . $config->templateParam); + } + if ($config->rootComment !== null) { $interface->addComment($config->rootComment); } @@ -219,14 +237,40 @@ private function generateRootInterface( $config->returnType, null, $config->static, + null, + $this->mapperFor($config), ); $interface->addMember($method); } + foreach ($config->terminalMethods as $terminal) { + $interface->addMember($this->buildTerminalMethod($terminal)); + } + $this->addFile($interfaceName, $namespace, $files); } + private function buildTerminalMethod(TerminalMethod $terminal): Method + { + $method = new Method($terminal->name); + $method->setPublic()->setReturnType($terminal->returnType); + + foreach ($terminal->parameters as $parameterName => $parameterType) { + $method->addParameter($parameterName)->setType($parameterType); + } + + foreach ($terminal->optionalParameters as $parameterName => $parameterType) { + $method->addParameter($parameterName, null)->setType($parameterType); + } + + foreach ($terminal->comments as $line) { + $method->addComment($line); + } + + return $method; + } + /** @param array $files */ private function addFile(string $interfaceName, PhpNamespace $namespace, array &$files): void { diff --git a/src/Fluent/NarrowingDoc.php b/src/Fluent/NarrowingDoc.php new file mode 100644 index 0000000..3664bce --- /dev/null +++ b/src/Fluent/NarrowingDoc.php @@ -0,0 +1,26 @@ + + */ + +declare(strict_types=1); + +namespace Respect\FluentGen\Fluent; + +/** + * PHPDoc lines describing how a generated method narrows its subject, plus whether + * those lines replace the constructor-derived doc comment (needed when the narrowing + * introduces its own @param override, e.g. argument- or element-derived rules). + */ +final readonly class NarrowingDoc +{ + /** @param list $comments */ + public function __construct( + public array $comments, + public bool $suppressConstructorDoc = false, + ) { + } +} diff --git a/src/Fluent/TerminalMethod.php b/src/Fluent/TerminalMethod.php new file mode 100644 index 0000000..883a46e --- /dev/null +++ b/src/Fluent/TerminalMethod.php @@ -0,0 +1,33 @@ + + */ + +declare(strict_types=1); + +namespace Respect\FluentGen\Fluent; + +/** + * A method injected verbatim into a generated root interface (carrying + * the @phpstan-assert narrowing PHPDoc). + * These are not derived from scanned node classes. + */ +final readonly class TerminalMethod +{ + /** + * @param array $parameters name => PHP type + * @param list $comments PHPDoc lines, e.g. '@phpstan-assert TSure $input' + * @param array $optionalParameters name => PHP type, each defaulting to null + */ + public function __construct( + public string $name, + public string $returnType, + public array $parameters = [], + public array $comments = [], + public array $optionalParameters = [], + ) { + } +} diff --git a/tests/Fixtures/Assurance/AllOfHandler.php b/tests/Fixtures/Assurance/AllOfHandler.php new file mode 100644 index 0000000..8b2cb05 --- /dev/null +++ b/tests/Fixtures/Assurance/AllOfHandler.php @@ -0,0 +1,24 @@ +for(new ReflectionClass($rule), true); + } + + /** @param class-string $rule */ + private function instance(string $rule): NarrowingDoc + { + return (new AssuranceTypeMapper())->for(new ReflectionClass($rule), false); + } + + #[Test] + public function itShouldEmitConcreteTypeForPlainTypeRule(): void + { + $doc = $this->base(IntTypeHandler::class); + + self::assertSame(['@return Chain'], $doc->comments); + self::assertFalse($doc->suppressConstructorDoc); + } + + #[Test] + public function itShouldFullyQualifyClassNamesInUnionTypes(): void + { + self::assertSame( + ['@return Chain'], + $this->base(FileTypeHandler::class)->comments, + ); + } + + #[Test] + public function itShouldEmitArgumentDerivedTemplateForFromValue(): void + { + $doc = $this->base(ComparisonHandler::class); + + self::assertSame([ + '@template T', + '@param T $compareTo', + '@return Chain', + ], $doc->comments); + self::assertTrue($doc->suppressConstructorDoc); + } + + #[Test] + public function itShouldEmitClassStringTemplateForTypeStringFrom(): void + { + // from: TypeString -> the class-string argument narrows to an instance of it. + $doc = $this->base(InstanceHandler::class); + + self::assertSame([ + '@template T of object', + '@param class-string $class', + '@return Chain', + ], $doc->comments); + self::assertTrue($doc->suppressConstructorDoc); + } + + #[Test] + public function itShouldIndexTheValueParameterByAssuranceParameter(): void + { + // #[AssuranceParameter] selects the argument (here the second, $value), not the first. + self::assertSame([ + '@template T', + '@param T $value', + '@return Chain', + ], $this->base(IndexedValueHandler::class)->comments); + } + + #[Test] + public function itShouldEmitIterableTemplateForElementsArgumentForm(): void + { + $doc = $this->base(EachHandler::class); + + self::assertSame([ + '@template T', + '@param Chain $validator', + '@return Chain>', + ], $doc->comments); + self::assertTrue($doc->suppressConstructorDoc); + } + + #[Test] + public function itShouldResetForMemberDerivedRule(): void + { + self::assertSame(['@return Chain'], $this->base(MemberHandler::class)->comments); + } + + #[Test] + public function itShouldResetForExcludeModifierRule(): void + { + self::assertSame(['@return Chain'], $this->base(ExcludeHandler::class)->comments); + } + + #[Test] + public function itShouldResetUnannotatedStaticEntryMethod(): void + { + self::assertSame(['@return Chain'], $this->base(FooHandler::class)->comments); + } + + #[Test] + public function itShouldPreserveReceiverTypeOnInstanceMethods(): void + { + // Instance (chain) methods preserve the first rule's type regardless of their own + // assurance: the static entry sets the type, later calls only constrain it. + self::assertSame(['@return Chain'], $this->instance(IntTypeHandler::class)->comments); + self::assertSame(['@return Chain'], $this->instance(FooHandler::class)->comments); + } + + #[Test] + public function itShouldRefineElementTypeEvenOnInstanceMethods(): void + { + // each()/all() add expressible element-type info, so they refine mid-chain too. + self::assertSame([ + '@template T', + '@param Chain $validator', + '@return Chain>', + ], $this->instance(EachHandler::class)->comments); + } + + #[Test] + public function itShouldComposeIterableForElementsPrefix(): void + { + $mapper = new AssuranceTypeMapper(); + + $doc = $mapper->for( + new ReflectionClass(IntTypeHandler::class), + true, + new ReflectionClass(AllPrefixHandler::class), + ); + + self::assertSame(['@return Chain>'], $doc->comments); + } + + #[Test] + public function itShouldResetForNonElementsPrefix(): void + { + $mapper = new AssuranceTypeMapper(); + + $doc = $mapper->for( + new ReflectionClass(IntTypeHandler::class), + true, + new ReflectionClass(ExcludeHandler::class), + ); + + self::assertSame(['@return Chain'], $doc->comments); + } + + #[Test] + public function itShouldEmitContainerTypeForContainerSubject(): void + { + // key/property/length/max/min: the container type is a sound narrowing of the input. + self::assertSame( + ['@return Chain'], + $this->base(KeyHandler::class)->comments, + ); + } + + #[Test] + public function itShouldNotNarrowArgumentWrappingForms(): void + { + // Wrap arg-form (nullOr) and compose forms (allOf/named/anyOf) would have to retype + // their Validator parameter to Chain, which breaks raw Validator arguments, so the + // static entry stays mixed. (Their PREFIX forms narrow safely; see below.) + self::assertSame(['@return Chain'], $this->base(NullOrHandler::class)->comments); + self::assertSame(['@return Chain'], $this->base(AllOfHandler::class)->comments); + self::assertSame(['@return Chain'], $this->base(NamedHandler::class)->comments); + self::assertSame(['@return Chain'], $this->base(AnyOfHandler::class)->comments); + } + + #[Test] + public function itShouldComposeBypassUnionForWrapPrefix(): void + { + $mapper = new AssuranceTypeMapper(); + + $doc = $mapper->for( + new ReflectionClass(IntTypeHandler::class), + true, + new ReflectionClass(NullOrPrefixHandler::class), + ); + + self::assertSame(['@return Chain'], $doc->comments); + } + + #[Test] + public function itShouldDedupeBypassWhenInnerAlreadyAdmitsIt(): void + { + // nullOrNullType(): inner 'null' unioned with the 'null' bypass must collapse to + // Chain, not Chain. + $mapper = new AssuranceTypeMapper(); + + $doc = $mapper->for( + new ReflectionClass(NullTypeHandler::class), + true, + new ReflectionClass(NullOrPrefixHandler::class), + ); + + self::assertSame(['@return Chain'], $doc->comments); + } + + #[Test] + public function itShouldComposeContainerTypeForContainerPrefix(): void + { + // keyIntType() narrows the INPUT to the container type, like base key() does. + $mapper = new AssuranceTypeMapper(); + + $doc = $mapper->for( + new ReflectionClass(IntTypeHandler::class), + true, + new ReflectionClass(KeyHandler::class), + ); + + self::assertSame(['@return Chain'], $doc->comments); + } + + #[Test] + public function itShouldPreserveReceiverTypeForNewBucketsOnInstanceMethods(): void + { + // First-rule-wins: container/wrap/compose rules narrow only on the static entry; + // mid-chain they preserve the accumulated TSure (only each()/all() refine). + self::assertSame(['@return Chain'], $this->instance(KeyHandler::class)->comments); + self::assertSame(['@return Chain'], $this->instance(NullOrHandler::class)->comments); + self::assertSame(['@return Chain'], $this->instance(AllOfHandler::class)->comments); + self::assertSame(['@return Chain'], $this->instance(AnyOfHandler::class)->comments); + } + + #[Test] + public function itShouldHonourCustomChainAndTemplateNames(): void + { + $mapper = new AssuranceTypeMapper('Validator', 'TValue'); + + self::assertSame( + ['@return Validator'], + $mapper->for(new ReflectionClass(IntTypeHandler::class), true)->comments, + ); + self::assertSame( + ['@return Validator'], + $mapper->for(new ReflectionClass(FooHandler::class), false)->comments, + ); + } +} diff --git a/tests/Unit/Fluent/MixinGeneratorTest.php b/tests/Unit/Fluent/MixinGeneratorTest.php index 30d8f76..49aa40c 100644 --- a/tests/Unit/Fluent/MixinGeneratorTest.php +++ b/tests/Unit/Fluent/MixinGeneratorTest.php @@ -16,6 +16,7 @@ use Respect\FluentGen\Fluent\InterfaceConfig; use Respect\FluentGen\Fluent\MethodBuilder; use Respect\FluentGen\Fluent\MixinGenerator; +use Respect\FluentGen\Fluent\TerminalMethod; use Respect\FluentGen\NamespaceScanner; use Respect\FluentGen\Test\Fixtures\Handler; @@ -155,6 +156,57 @@ interfaces: [ self::assertStringContainsString('function empty(', $content); } + #[Test] + public function itShouldEmitNarrowingPhpDocAndTerminalMethodsWhenEnabled(): void + { + $generator = new MixinGenerator( + config: new Config( + sourceDir: self::FIXTURES_DIR . '/Assurance', + sourceNamespace: self::FIXTURES_NS . '\\Assurance', + outputDir: '/tmp/fluentgen-test', + outputNamespace: 'App\\Mixins', + ), + scanner: new NamespaceScanner(), + methodBuilder: new MethodBuilder(classSuffix: 'Handler'), + interfaces: [ + new InterfaceConfig( + suffix: 'Builder', + returnType: 'Chain', + static: true, + emitNarrowing: true, + ), + new InterfaceConfig( + suffix: 'Chain', + returnType: 'Chain', + emitNarrowing: true, + templateParam: 'TSure', + terminalMethods: [ + new TerminalMethod( + name: 'assert', + returnType: 'void', + parameters: ['input' => 'mixed'], + comments: ['@phpstan-assert TSure $input'], + ), + ], + ), + ], + ); + + $files = $generator->generate(); + $builder = $files['/tmp/fluentgen-test/Builder.php']; + $chain = $files['/tmp/fluentgen-test/Chain.php']; + + // Concrete + container narrowing reaches the generated static entry methods. + self::assertStringContainsString('@return Chain', $builder); + self::assertStringContainsString('@return Chain', $builder); + // The instance chain carries the generic header, threads TSure, and gets the + // injected terminal method with its assertion docblock. + self::assertStringContainsString('@template-covariant TSure', $chain); + self::assertStringContainsString('@return Chain', $chain); + self::assertStringContainsString('@phpstan-assert TSure $input', $chain); + self::assertStringContainsString('public function assert(mixed $input): void;', $chain); + } + private function config(): Config { return new Config(