├── stubs ├── TypeInterface.stub ├── Type.stub ├── OptionalType.stub ├── optional.stub └── Option.stub ├── .editorconfig ├── phpstan-baseline-psl-1.neon ├── extension.neon ├── composer.json ├── src ├── Type │ ├── AssertTypeSpecifyingExtension.php │ ├── MatchesTypeSpecifyingExtension.php │ └── TypeShapeReturnTypeExtension.php └── Option │ └── OptionFilterReturnTypeExtension.php └── README.md /stubs/TypeInterface.stub: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | abstract class Type implements TypeInterface 11 | { 12 | 13 | } 14 | -------------------------------------------------------------------------------- /stubs/OptionalType.stub: -------------------------------------------------------------------------------- 1 | 11 | * 12 | * @internal 13 | */ 14 | final class OptionalType extends Type\Type 15 | { 16 | 17 | } 18 | -------------------------------------------------------------------------------- /stubs/optional.stub: -------------------------------------------------------------------------------- 1 | $inner_type 11 | * 12 | * @return TypeInterface> 13 | */ 14 | function optional(TypeInterface $inner_type): TypeInterface 15 | { 16 | 17 | } 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | 9 | [*.{php,phpt}] 10 | indent_style = tab 11 | indent_size = 4 12 | 13 | [*.xml] 14 | indent_style = tab 15 | indent_size = 4 16 | 17 | [*.neon] 18 | indent_style = tab 19 | indent_size = 4 20 | 21 | [*.{yaml,yml}] 22 | indent_style = space 23 | indent_size = 2 24 | 25 | [composer.json] 26 | indent_style = tab 27 | indent_size = 4 28 | -------------------------------------------------------------------------------- /phpstan-baseline-psl-1.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - 4 | message: '#^Class Psl\\Option\\Option not found\.$#' 5 | identifier: class.notFound 6 | count: 1 7 | path: src/Option/OptionFilterReturnTypeExtension.php 8 | 9 | - 10 | message: '#^Parameter \#1 \$ancestorClassName of method PHPStan\\Type\\Type\:\:getTemplateType\(\) expects class\-string, string given\.$#' 11 | identifier: argument.type 12 | count: 1 13 | path: src/Option/OptionFilterReturnTypeExtension.php 14 | -------------------------------------------------------------------------------- /stubs/Option.stub: -------------------------------------------------------------------------------- 1 | ::some() if `$predicate` returns true (where t is the wrapped value), and 20 | * - Option::none() if `$predicate` returns false. 21 | * 22 | * @param (Closure(T): bool) $predicate 23 | * 24 | * @param-immediately-invoked-callable $predicate 25 | * 26 | * @return Option 27 | */ 28 | public function filter(Closure $predicate): Option 29 | { 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /extension.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | stubFiles: 3 | - stubs/Option.stub 4 | - stubs/optional.stub 5 | - stubs/OptionalType.stub 6 | - stubs/Type.stub 7 | - stubs/TypeInterface.stub 8 | 9 | services: 10 | - 11 | class: Psl\PHPStan\Type\TypeShapeReturnTypeExtension 12 | tags: 13 | - phpstan.broker.dynamicFunctionReturnTypeExtension 14 | 15 | - 16 | class: Psl\PHPStan\Type\AssertTypeSpecifyingExtension 17 | tags: 18 | - phpstan.typeSpecifier.methodTypeSpecifyingExtension 19 | 20 | - 21 | class: Psl\PHPStan\Type\MatchesTypeSpecifyingExtension 22 | tags: 23 | - phpstan.typeSpecifier.methodTypeSpecifyingExtension 24 | 25 | - 26 | class: Psl\PHPStan\Option\OptionFilterReturnTypeExtension 27 | tags: 28 | - phpstan.broker.dynamicMethodReturnTypeExtension 29 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "php-standard-library/phpstan-extension", 3 | "type": "phpstan-extension", 4 | "description": "PHPStan PSL extension", 5 | "license": [ 6 | "MIT" 7 | ], 8 | "require": { 9 | "php": "^7.4 || ^8.0", 10 | "phpstan/phpstan": "^2.0" 11 | }, 12 | "conflict": { 13 | "azjezz/psl": "<1.6||>=5.0" 14 | }, 15 | "require-dev": { 16 | "azjezz/psl": "^1.6||^2.0||^3.0||^4.0", 17 | "composer/semver": "^3.3", 18 | "nikic/php-parser": "^4.14.0", 19 | "php-parallel-lint/php-parallel-lint": "^1.2", 20 | "phpstan/phpstan-phpunit": "^2.0", 21 | "phpstan/phpstan-strict-rules": "^2.0", 22 | "phpunit/phpunit": "^9.6" 23 | }, 24 | "config": { 25 | "sort-packages": true 26 | }, 27 | "extra": { 28 | "phpstan": { 29 | "includes": [ 30 | "extension.neon" 31 | ] 32 | } 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "Psl\\PHPStan\\": "src/" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "classmap": [ 41 | "tests/" 42 | ] 43 | }, 44 | "minimum-stability": "dev", 45 | "prefer-stable": true 46 | } 47 | -------------------------------------------------------------------------------- /src/Type/AssertTypeSpecifyingExtension.php: -------------------------------------------------------------------------------- 1 | typeSpecifier = $typeSpecifier; 23 | } 24 | 25 | public function getClass(): string 26 | { 27 | return TypeInterface::class; 28 | } 29 | 30 | public function isMethodSupported(MethodReflection $methodReflection, MethodCall $node, TypeSpecifierContext $context): bool 31 | { 32 | return $context->null() && $methodReflection->getName() === 'assert'; 33 | } 34 | 35 | public function specifyTypes(MethodReflection $methodReflection, MethodCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes 36 | { 37 | $args = $node->getArgs(); 38 | if (!isset($args[0])) { 39 | return new SpecifiedTypes(); 40 | } 41 | 42 | return $this->typeSpecifier->create( 43 | $args[0]->value, 44 | $scope->getType($node), 45 | TypeSpecifierContext::createTruthy(), 46 | $scope 47 | ); 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/Type/MatchesTypeSpecifyingExtension.php: -------------------------------------------------------------------------------- 1 | typeSpecifier = $typeSpecifier; 23 | } 24 | 25 | public function getClass(): string 26 | { 27 | return TypeInterface::class; 28 | } 29 | 30 | public function isMethodSupported(MethodReflection $methodReflection, MethodCall $node, TypeSpecifierContext $context): bool 31 | { 32 | return !$context->null() && $methodReflection->getName() === 'matches'; 33 | } 34 | 35 | public function specifyTypes(MethodReflection $methodReflection, MethodCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes 36 | { 37 | $args = $node->getArgs(); 38 | if (!isset($args[0])) { 39 | return new SpecifiedTypes(); 40 | } 41 | 42 | $specType = $scope->getType($node->var); 43 | 44 | return $this->typeSpecifier->create( 45 | $args[0]->value, 46 | $specType->getTemplateType(TypeInterface::class, 'T'), 47 | $context, 48 | $scope 49 | ); 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/Option/OptionFilterReturnTypeExtension.php: -------------------------------------------------------------------------------- 1 | getName() === 'filter'; 31 | } 32 | 33 | public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type 34 | { 35 | $args = $methodCall->getArgs(); 36 | if (!isset($args[0])) { 37 | return null; 38 | } 39 | $filterCallback = $args[0]->value; 40 | 41 | $optionType = $scope->getType($methodCall->var); 42 | $originalType = $optionType->getTemplateType('Psl\Option\Option', 'T'); 43 | 44 | $refinedType = $this->analyzeFilterCallback($filterCallback, $originalType, $scope); 45 | 46 | return new GenericObjectType( 47 | 'Psl\Option\Option', 48 | [$refinedType] 49 | ); 50 | } 51 | 52 | private function analyzeFilterCallback(Expr $filterCallback, Type $originalType, Scope $scope): Type 53 | { 54 | $arrayType = new ArrayType(new IntegerType(), $originalType); 55 | 56 | $refinedType = $scope 57 | ->getType( 58 | new FuncCall( 59 | new Name('array_filter'), 60 | [new Arg(new TypeExpr($arrayType)), new Arg($filterCallback)] 61 | ) 62 | ) 63 | ->getIterableValueType(); 64 | 65 | if (!$refinedType->equals($originalType)) { 66 | return $refinedType; 67 | } 68 | 69 | return $originalType; 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHPStan PSL extension 2 | 3 | [![Build](https://github.com/php-standard-library/phpstan-extension/workflows/Build/badge.svg)](https://github.com/php-standard-library/phpstan-extension/actions) 4 | [![Latest Stable Version](https://poser.pugx.org/php-standard-library/phpstan-extension/v/stable)](https://packagist.org/packages/php-standard-library/phpstan-extension) 5 | [![License](https://poser.pugx.org/php-standard-library/phpstan-extension/license)](https://packagist.org/packages/php-standard-library/phpstan-extension) 6 | 7 | * [PHPStan](https://phpstan.org/) 8 | * [PSL](https://github.com/azjezz/psl) 9 | 10 | ## Description 11 | 12 | The main goal of this extension is to help PHPStan to detect the types after using `Psl\Type\shape`. 13 | 14 | Given the following example: 15 | 16 | ```php 17 | use Psl\Type; 18 | 19 | $specification = Type\shape([ 20 | 'name' => Type\string(), 21 | 'age' => Type\int(), 22 | 'location' => Type\optional(Type\shape([ 23 | 'city' => Type\string(), 24 | 'state' => Type\string(), 25 | 'country' => Type\string(), 26 | ])) 27 | ]); 28 | 29 | $input = $specification->coerce($_GET['user']); 30 | ``` 31 | 32 | PHPStan assumes that `$input` is of type `array<"age"|"location"|"name", array<"city"|"country"|"state", string>|int|string>`. 33 | 34 | If we enable the extension, you will get a more specific and correct type of `array{name: string, age: int, location?: array{city: string, state: string, country: string}}`. 35 | 36 | Besides coerce, this extension also supports `matches()` and `assert()` methods. 37 | 38 | ## Installation 39 | 40 | To use this extension, require it in [Composer](https://getcomposer.org/): 41 | 42 | ``` 43 | composer require --dev php-standard-library/phpstan-extension 44 | ``` 45 | 46 | If you also install [phpstan/extension-installer](https://github.com/phpstan/extension-installer) then you're all set! 47 | 48 |
49 | Manual installation 50 | 51 | If you don't want to use `phpstan/extension-installer`, include extension.neon in your project's PHPStan config: 52 | 53 | ``` 54 | includes: 55 | - vendor/php-standard-library/phpstan-extension/extension.neon 56 | ``` 57 |
58 | -------------------------------------------------------------------------------- /src/Type/TypeShapeReturnTypeExtension.php: -------------------------------------------------------------------------------- 1 | getName() === 'Psl\Type\shape'; 25 | } 26 | 27 | public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type 28 | { 29 | $args = $functionCall->getArgs(); 30 | if (!isset($args[0])) { 31 | return null; 32 | } 33 | 34 | $arg = $scope->getType($args[0]->value); 35 | $arrays = $arg->getConstantArrays(); 36 | if (count($arrays) === 0) { 37 | return null; 38 | } 39 | 40 | $results = []; 41 | foreach ($arrays as $array) { 42 | $results[] = $this->createResult($array); 43 | } 44 | 45 | return new GenericObjectType( 46 | TypeInterface::class, 47 | [ 48 | TypeCombinator::union(...$results), 49 | ] 50 | ); 51 | } 52 | 53 | private function createResult(ConstantArrayType $arrayType): Type 54 | { 55 | $builder = ConstantArrayTypeBuilder::createEmpty(); 56 | foreach ($arrayType->getKeyTypes() as $key) { 57 | $valueType = $arrayType->getOffsetValueType($key); 58 | [$type, $optional] = $this->extractOptional($valueType->getTemplateType(TypeInterface::class, 'T')); 59 | 60 | $builder->setOffsetValueType($key, $type, $optional); 61 | } 62 | 63 | return $builder->getArray(); 64 | } 65 | 66 | /** 67 | * @return array{Type, bool} 68 | */ 69 | private function extractOptional(Type $type): array 70 | { 71 | $optionalType = $type->getTemplateType(OptionalType::class, 'T'); 72 | if ($optionalType instanceof ErrorType) { 73 | return [$type, false]; 74 | } 75 | 76 | return [$optionalType, true]; 77 | } 78 | 79 | } 80 | --------------------------------------------------------------------------------