diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7d03ce0..3cd56fa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,6 +19,10 @@ jobs: - "7.4" - "8.0" - "8.1" + - "8.2" + - "8.3" + - "8.4" + - "8.5" steps: - name: "Checkout" @@ -77,6 +81,10 @@ jobs: - "7.4" - "8.0" - "8.1" + - "8.2" + - "8.3" + - "8.4" + - "8.5" dependencies: - "lowest" - "highest" @@ -113,6 +121,10 @@ jobs: - "7.4" - "8.0" - "8.1" + - "8.2" + - "8.3" + - "8.4" + - "8.5" dependencies: - "lowest" - "highest" diff --git a/extension.neon b/extension.neon index 10a01cf..8f3a510 100644 --- a/extension.neon +++ b/extension.neon @@ -1,6 +1,8 @@ parameters: stubFiles: - stubs/Option.stub + - stubs/nullish.stub + - stubs/NullishType.stub - stubs/optional.stub - stubs/OptionalType.stub - stubs/Type.stub diff --git a/phpstan-baseline-psl-1.neon b/phpstan-baseline-psl-1.neon index b8b48ee..7c045e0 100644 --- a/phpstan-baseline-psl-1.neon +++ b/phpstan-baseline-psl-1.neon @@ -11,3 +11,15 @@ parameters: identifier: argument.type count: 1 path: src/Option/OptionFilterReturnTypeExtension.php + + - + message: '#^Class Psl\\Type\\Internal\\NullishType not found\.$#' + identifier: class.notFound + count: 1 + path: src/Type/TypeShapeReturnTypeExtension.php + + - + message: '#^Parameter \#1 \$ancestorClassName of method PHPStan\\Type\\Type\:\:getTemplateType\(\) expects class\-string, string given\.$#' + identifier: argument.type + count: 1 + path: src/Type/TypeShapeReturnTypeExtension.php diff --git a/src/Type/TypeShapeReturnTypeExtension.php b/src/Type/TypeShapeReturnTypeExtension.php index 4163a21..44422b5 100644 --- a/src/Type/TypeShapeReturnTypeExtension.php +++ b/src/Type/TypeShapeReturnTypeExtension.php @@ -12,6 +12,7 @@ use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use Psl\Type\Internal\NullishType; use Psl\Type\Internal\OptionalType; use Psl\Type\TypeInterface; use function count; @@ -55,7 +56,9 @@ private function createResult(ConstantArrayType $arrayType): Type $builder = ConstantArrayTypeBuilder::createEmpty(); foreach ($arrayType->getKeyTypes() as $key) { $valueType = $arrayType->getOffsetValueType($key); - [$type, $optional] = $this->extractOptional($valueType->getTemplateType(TypeInterface::class, 'T')); + $templateType = $valueType->getTemplateType(TypeInterface::class, 'T'); + [$type, $optional] = $this->extractOptional($templateType); + [$type] = $this->extractNullish($type); $builder->setOffsetValueType($key, $type, $optional); } @@ -63,6 +66,19 @@ private function createResult(ConstantArrayType $arrayType): Type return $builder->getArray(); } + /** + * @return array{Type, bool} + */ + private function extractNullish(Type $type): array + { + $nullishType = $type->getTemplateType(NullishType::class, 'T'); + if ($nullishType instanceof ErrorType) { + return [$type, false]; + } + + return [TypeCombinator::addNull($nullishType), false]; + } + /** * @return array{Type, bool} */ diff --git a/stubs/NullishType.stub b/stubs/NullishType.stub new file mode 100644 index 0000000..9b9ca7f --- /dev/null +++ b/stubs/NullishType.stub @@ -0,0 +1,17 @@ + + * + * @internal + */ +final class NullishType extends Type\Type +{ + +} diff --git a/stubs/nullish.stub b/stubs/nullish.stub new file mode 100644 index 0000000..10254f2 --- /dev/null +++ b/stubs/nullish.stub @@ -0,0 +1,17 @@ + $inner_type + * + * @return TypeInterface> + */ +function nullish(TypeInterface $inner_type): TypeInterface +{ + +} diff --git a/tests/Type/PslTypeSpecifyingExtensionTest.php b/tests/Type/PslTypeSpecifyingExtensionTest.php index cfcb288..85cca59 100644 --- a/tests/Type/PslTypeSpecifyingExtensionTest.php +++ b/tests/Type/PslTypeSpecifyingExtensionTest.php @@ -5,6 +5,8 @@ use Composer\InstalledVersions; use Composer\Semver\VersionParser; use PHPStan\Testing\TypeInferenceTestCase; +use Psl\Type\Internal\NullishType; +use function class_exists; class PslTypeSpecifyingExtensionTest extends TypeInferenceTestCase { @@ -17,6 +19,11 @@ public function dataFileAsserts(): iterable yield from $this->gatherAssertTypes(__DIR__ . '/data/coerce.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/assert.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/matches.php'); + if (class_exists(NullishType::class)) { + yield from $this->gatherAssertTypes(__DIR__ . '/data/nullishCoerce.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/nullishAssert.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/nullishMatches.php'); + } if (InstalledVersions::satisfies(new VersionParser(), 'azjezz/psl', '<2.0.0')) { yield from $this->gatherAssertTypes(__DIR__ . '/data/complexTypev1.php'); } else { diff --git a/tests/Type/data/nullishAssert.php b/tests/Type/data/nullishAssert.php new file mode 100644 index 0000000..eda8958 --- /dev/null +++ b/tests/Type/data/nullishAssert.php @@ -0,0 +1,41 @@ + $a + */ + public function assertNullishShape(array $a): void + { + $specification = Type\shape([ + 'name' => Type\string(), + 'bio' => Type\nullish(Type\string()), + ]); + + $b = $specification->assert($a); + + assertType('array{name: string, bio: string|null}', $a); + assertType('array{name: string, bio: string|null}', $b); + } + + /** + * @param array $a + */ + public function assertOptionalNullishShape(array $a): void + { + $specification = Type\shape([ + 'bio' => Type\optional(Type\nullish(Type\string())), + ]); + + $b = $specification->assert($a); + + assertType('array{bio?: string|null}', $a); + assertType('array{bio?: string|null}', $b); + } +} diff --git a/tests/Type/data/nullishCoerce.php b/tests/Type/data/nullishCoerce.php new file mode 100644 index 0000000..c7b13e4 --- /dev/null +++ b/tests/Type/data/nullishCoerce.php @@ -0,0 +1,39 @@ + $input + */ + public function coerceNullishShape(array $input): void + { + $specification = Type\shape([ + 'name' => Type\string(), + 'bio' => Type\nullish(Type\string()), + ]); + + $output = $specification->coerce($input); + + assertType('array{name: string, bio: string|null}', $output); + } + + /** + * @param array $input + */ + public function coerceOptionalNullishShape(array $input): void + { + $specification = Type\shape([ + 'bio' => Type\optional(Type\nullish(Type\string())), + ]); + + $output = $specification->coerce($input); + + assertType('array{bio?: string|null}', $output); + } +} diff --git a/tests/Type/data/nullishMatches.php b/tests/Type/data/nullishMatches.php new file mode 100644 index 0000000..2fc6167 --- /dev/null +++ b/tests/Type/data/nullishMatches.php @@ -0,0 +1,43 @@ + $a + */ + public function matchesNullishShape(array $a): void + { + $specification = Type\shape([ + 'name' => Type\string(), + 'bio' => Type\nullish(Type\string()), + ]); + + if ($specification->matches($a)) { + assertType('array{name: string, bio: string|null}', $a); + } else { + assertType('array', $a); + } + } + + /** + * @param array $a + */ + public function matchesOptionalNullishShape(array $a): void + { + $specification = Type\shape([ + 'bio' => Type\optional(Type\nullish(Type\string())), + ]); + + if ($specification->matches($a)) { + assertType('array{bio?: string|null}', $a); + } else { + assertType('non-empty-array', $a); + } + } +}