Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
parameters:
stubFiles:
- stubs/Option.stub
- stubs/optional.stub
- stubs/OptionalType.stub
- stubs/Type.stub
Expand All @@ -20,3 +21,8 @@ services:
class: Psl\PHPStan\Type\MatchesTypeSpecifyingExtension
tags:
- phpstan.typeSpecifier.methodTypeSpecifyingExtension

-
class: Psl\PHPStan\Option\OptionFilterReturnTypeExtension
tags:
- phpstan.broker.dynamicMethodReturnTypeExtension
72 changes: 72 additions & 0 deletions src/Option/OptionFilterReturnTypeExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php declare(strict_types = 1);

namespace Psl\PHPStan\Option;

use PhpParser\Node\Arg;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Name;
use PHPStan\Analyser\Scope;
use PHPStan\Node\Expr\TypeExpr;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Type\ArrayType;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\Generic\GenericObjectType;
use PHPStan\Type\IntegerType;
use PHPStan\Type\Type;

class OptionFilterReturnTypeExtension implements DynamicMethodReturnTypeExtension
{

public function getClass(): string
{
/** @var class-string */
return 'Psl\Option\Option'; // @phpstan-ignore varTag.nativeType

Check failure on line 25 in src/Option/OptionFilterReturnTypeExtension.php

View workflow job for this annotation

GitHub Actions / PHPStan (8.1, highest)

No error with identifier varTag.nativeType is reported on line 25.
}

public function isMethodSupported(MethodReflection $methodReflection): bool
{
return $methodReflection->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

Check failure on line 42 in src/Option/OptionFilterReturnTypeExtension.php

View workflow job for this annotation

GitHub Actions / PHPStan (8.1, highest)

No error with identifier argument.type is reported on line 42.

$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;
}

}
16 changes: 16 additions & 0 deletions stubs/Option.stub
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Psl\Option;

use Closure;
use Psl\Comparison;
use Psl\Type;

/**
* @template T
*
* @readonly
*/
final class Option
{
}
47 changes: 47 additions & 0 deletions tests/Option/PslTypeSpecifyingExtensionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php declare(strict_types = 1);

namespace Psl\PHPStan\Option;

use Composer\InstalledVersions;
use Composer\Semver\VersionParser;
use PHPStan\Testing\TypeInferenceTestCase;
use PHPUnit\Framework\Assert;
use PHPUnit\Runner\PhptTestCase;

class PslTypeSpecifyingExtensionTest extends TypeInferenceTestCase
{

/**
* @return iterable<mixed>
*/
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'];
}

}
86 changes: 86 additions & 0 deletions tests/Option/data/filter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

declare(strict_types=1);

namespace PslOptionTest;

use Closure;
use Psl\Option;

use function PHPStan\Testing\assertType;


function positive_int(int $value): void
{
$option = Option\some($value);
$option = $option->filter(fn($value) => $value > 0);
assertType('Psl\Option\Option<int<1, max>>', $option);

$option = Option\some($value);
$option = $option->filter(Closure::fromCallable([\Psl\Type\positive_int(), 'matches']));
assertType('Psl\Option\Option<int<1, max>>', $option);
}

function non_empty_string(string $value): void
{
$option = Option\some($value);
$option = $option->filter(fn($value) => '' !== $value);
assertType('Psl\Option\Option<non-empty-string>', $option);

$option = Option\some($value);
$option = $option->filter(Closure::fromCallable([\Psl\Type\non_empty_string(), 'matches']));
assertType('Psl\Option\Option<non-empty-string>', $option);
}


function numeric_string(string $value): void
{
$option = Option\some($value);
$option = $option->filter(fn($value) => is_numeric($value));
assertType('Psl\Option\Option<numeric-string>', $option);

$option = Option\some($value);
$option = $option->filter(is_numeric(...));
assertType('Psl\Option\Option<numeric-string>', $option);

$option = Option\some($value);
$option = $option->filter(Closure::fromCallable([\Psl\Type\numeric_string(), 'matches']));
assertType('Psl\Option\Option<numeric-string>', $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<float> $value
*/
function filter_list(array $value): void
{
$option = Option\some($value);
assertType('Psl\Option\Option<list<float>>', $option);
$option = $option->filter(fn($value) => [] !== $value);
assertType('Psl\Option\Option<non-empty-list<float>>', $option);

$option = Option\some($value);
assertType('Psl\Option\Option<list<float>>', $option);
$option = $option->filter(Closure::fromCallable([\Psl\Type\non_empty_vec(), 'matches']));
assertType('Psl\Option\Option<non-empty-list<float>>', $option);
}

Loading