diff --git a/extension.neon b/extension.neon index 68ff69b..10a01cf 100644 --- a/extension.neon +++ b/extension.neon @@ -1,5 +1,6 @@ parameters: stubFiles: + - stubs/Option.stub - stubs/optional.stub - stubs/OptionalType.stub - stubs/Type.stub @@ -20,3 +21,8 @@ services: class: Psl\PHPStan\Type\MatchesTypeSpecifyingExtension tags: - phpstan.typeSpecifier.methodTypeSpecifyingExtension + + - + class: Psl\PHPStan\Option\OptionFilterReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension diff --git a/src/Option/OptionFilterReturnTypeExtension.php b/src/Option/OptionFilterReturnTypeExtension.php new file mode 100644 index 0000000..7c2ccc3 --- /dev/null +++ b/src/Option/OptionFilterReturnTypeExtension.php @@ -0,0 +1,72 @@ +getName() === 'filter'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + $args = $methodCall->getArgs(); + if (!isset($args[0])) { + return null; + } + $filterCallback = $args[0]->value; + + $optionType = $scope->getType($methodCall->var); + $originalType = $optionType->getTemplateType('Psl\Option\Option', 'T'); // @phpstan-ignore argument.type + + $refinedType = $this->analyzeFilterCallback($filterCallback, $originalType, $scope); + + return new GenericObjectType( + 'Psl\Option\Option', + [$refinedType] + ); + } + + private function analyzeFilterCallback(Expr $filterCallback, Type $originalType, Scope $scope): Type + { + $arrayType = new ArrayType(new IntegerType(), $originalType); + + $refinedType = $scope + ->getType( + new FuncCall( + new Name('array_filter'), + [new Arg(new TypeExpr($arrayType)), new Arg($filterCallback)] + ) + ) + ->getIterableValueType(); + + if (!$refinedType->equals($originalType)) { + return $refinedType; + } + + return $originalType; + } + +} diff --git a/stubs/Option.stub b/stubs/Option.stub new file mode 100644 index 0000000..776233f --- /dev/null +++ b/stubs/Option.stub @@ -0,0 +1,16 @@ + + */ + public function dataFileAsserts(): iterable + { + yield from $this->gatherAssertTypes(__DIR__ . '/data/filter.php'); + } + + /** + * @dataProvider dataFileAsserts + * @param mixed ...$args + */ + public function testFileAsserts( + string $assertType, + string $file, + ...$args + ): void + { + if (!InstalledVersions::satisfies(new VersionParser(), 'azjezz/psl', '>=2.0.0')) { + Assert::markTestSkipped(sprintf('Option component is not available in current azjezz/psl installed version')); + } + + $this->assertFileAsserts($assertType, $file, ...$args); + } + + /*** + * @return string[] + */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../../extension.neon']; + } + +} diff --git a/tests/Option/data/filter.php b/tests/Option/data/filter.php new file mode 100644 index 0000000..cf2ad0d --- /dev/null +++ b/tests/Option/data/filter.php @@ -0,0 +1,86 @@ +filter(fn($value) => $value > 0); + assertType('Psl\Option\Option>', $option); + + $option = Option\some($value); + $option = $option->filter(Closure::fromCallable([\Psl\Type\positive_int(), 'matches'])); + assertType('Psl\Option\Option>', $option); +} + +function non_empty_string(string $value): void +{ + $option = Option\some($value); + $option = $option->filter(fn($value) => '' !== $value); + assertType('Psl\Option\Option', $option); + + $option = Option\some($value); + $option = $option->filter(Closure::fromCallable([\Psl\Type\non_empty_string(), 'matches'])); + assertType('Psl\Option\Option', $option); +} + + +function numeric_string(string $value): void +{ + $option = Option\some($value); + $option = $option->filter(fn($value) => is_numeric($value)); + assertType('Psl\Option\Option', $option); + + $option = Option\some($value); + $option = $option->filter(is_numeric(...)); + assertType('Psl\Option\Option', $option); + + $option = Option\some($value); + $option = $option->filter(Closure::fromCallable([\Psl\Type\numeric_string(), 'matches'])); + assertType('Psl\Option\Option', $option); +} + +function literal_string(string $value): void +{ + $option = Option\some($value); + $option = $option->filter(fn($value) => 'potato' === $value); + assertType('Psl\Option\Option<\'potato\'>', $option); + + $option = Option\some($value); + $option = $option->filter(fn ($value) => 'potato' === $value || 'tomato' === $value); + assertType('Psl\Option\Option<\'potato\'|\'tomato\'>', $option); + + $option = Option\some($value); + $option = $option->filter(fn ($value) => in_array($value, ['potato', 'tomato'], true)); + assertType('Psl\Option\Option<\'potato\'|\'tomato\'>', $option); + + $option = Option\some($value); + $option = $option->filter(Closure::fromCallable([\Psl\Type\literal_scalar('potato'), 'matches'])); + assertType('Psl\Option\Option<\'potato\'>', $option); +} + + +/** + * @param list $value + */ +function filter_list(array $value): void +{ + $option = Option\some($value); + assertType('Psl\Option\Option>', $option); + $option = $option->filter(fn($value) => [] !== $value); + assertType('Psl\Option\Option>', $option); + + $option = Option\some($value); + assertType('Psl\Option\Option>', $option); + $option = $option->filter(Closure::fromCallable([\Psl\Type\non_empty_vec(), 'matches'])); + assertType('Psl\Option\Option>', $option); +} +