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 952d970..5774504 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 || ^13.0", "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 b2a2acf..9fcfd7c 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": "2901c81bdccde9a6f74c0a80a6ac8c93", + "content-hash": "15015687767bd6ae57a1c99542d091f3", "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", @@ -1293,16 +1293,16 @@ }, { "name": "respect/fluent", - "version": "2.0.1", + "version": "dev-ide-narrowing", "source": { "type": "git", "url": "https://github.com/Respect/Fluent.git", - "reference": "f32c76e37a82a9e63d6fe700a27201534f72da60" + "reference": "8469e6e8d30d28f36c0b79a381703ea9b26b0ecd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Respect/Fluent/zipball/f32c76e37a82a9e63d6fe700a27201534f72da60", - "reference": "f32c76e37a82a9e63d6fe700a27201534f72da60", + "url": "https://api.github.com/repos/Respect/Fluent/zipball/8469e6e8d30d28f36c0b79a381703ea9b26b0ecd", + "reference": "8469e6e8d30d28f36c0b79a381703ea9b26b0ecd", "shasum": "" }, "require": { @@ -1313,10 +1313,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/" @@ -1340,9 +1345,9 @@ ], "support": { "issues": "https://github.com/Respect/Fluent/issues", - "source": "https://github.com/Respect/Fluent/tree/2.0.1" + "source": "https://github.com/Respect/Fluent/tree/ide-narrowing" }, - "time": "2026-03-26T04:24:51+00:00" + "time": "2026-06-26T19:36:25+00:00" }, { "name": "sebastian/cli-parser", @@ -2453,20 +2458,20 @@ }, { "name": "slevomat/coding-standard", - "version": "8.28.1", + "version": "8.30.1", "source": { "type": "git", "url": "https://github.com/slevomat/coding-standard.git", - "reference": "66151cfbd25b50e8becd9f809fb704f01fd4d6f2" + "reference": "70a3b2150600122f87c59cae1c1b5902ba73798b" }, "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/70a3b2150600122f87c59cae1c1b5902ba73798b", + "reference": "70a3b2150600122f87c59cae1c1b5902ba73798b", "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" @@ -2474,11 +2479,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.2.2", "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.30" }, "type": "phpcodesniffer-standard", "extra": { @@ -2502,7 +2507,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.30.1" }, "funding": [ { @@ -2514,7 +2519,7 @@ "type": "tidelift" } ], - "time": "2026-03-22T17:22:38+00:00" + "time": "2026-06-27T11:26:35+00:00" }, { "name": "squizlabs/php_codesniffer", @@ -2699,9 +2704,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(