diff --git a/src/Dependency/ExportedNodeResolver.php b/src/Dependency/ExportedNodeResolver.php index c9a0c52154..8e6eb17f61 100644 --- a/src/Dependency/ExportedNodeResolver.php +++ b/src/Dependency/ExportedNodeResolver.php @@ -22,10 +22,10 @@ use PHPStan\Dependency\ExportedNode\ExportedTraitNode; use PHPStan\Dependency\ExportedNode\ExportedTraitUseAdaptation; use PHPStan\Node\Printer\ExprPrinter; +use PHPStan\Node\Printer\NodeTypePrinter; use PHPStan\ShouldNotHappenException; use PHPStan\Type\FileTypeMapper; use function array_map; -use function implode; use function is_string; final class ExportedNodeResolver @@ -165,7 +165,7 @@ public function resolve(string $fileName, Node $node): ?RootExportedNode $docComment !== null ? $docComment->getText() : null, ), $node->byRef, - $this->printType($node->returnType), + NodeTypePrinter::printType($node->returnType), $this->exportParameterNodes($node->params), $this->exportAttributeNodes($node->attrGroups), ); @@ -174,48 +174,6 @@ public function resolve(string $fileName, Node $node): ?RootExportedNode return null; } - /** - * @param Node\Identifier|Node\Name|Node\ComplexType|null $type - */ - private function printType($type): ?string - { - if ($type === null) { - return null; - } - - if ($type instanceof Node\NullableType) { - return '?' . $this->printType($type->type); - } - - if ($type instanceof Node\UnionType) { - return implode('|', array_map(function ($innerType): string { - $printedType = $this->printType($innerType); - if ($printedType === null) { - throw new ShouldNotHappenException(); - } - - return $printedType; - }, $type->types)); - } - - if ($type instanceof Node\IntersectionType) { - return implode('&', array_map(function ($innerType): string { - $printedType = $this->printType($innerType); - if ($printedType === null) { - throw new ShouldNotHappenException(); - } - - return $printedType; - }, $type->types)); - } - - if ($type instanceof Node\Identifier || $type instanceof Name) { - return $type->toString(); - } - - throw new ShouldNotHappenException(); - } - /** * @param Node\Param[] $params * @return ExportedParameterNode[] @@ -243,7 +201,7 @@ private function exportParameterNodes(array $params): array } $nodes[] = new ExportedParameterNode( $param->var->name, - $this->printType($type), + NodeTypePrinter::printType($type), $param->byRef, $param->variadic, $param->default !== null, @@ -321,7 +279,7 @@ private function exportClassStatement(Node\Stmt $node, string $fileName, string $node->isAbstract(), $node->isFinal(), $node->isStatic(), - $this->printType($node->returnType), + NodeTypePrinter::printType($node->returnType), $this->exportParameterNodes($node->params), $this->exportAttributeNodes($node->attrGroups), ); @@ -343,7 +301,7 @@ private function exportClassStatement(Node\Stmt $node, string $fileName, string null, $docComment !== null ? $docComment->getText() : null, ), - $this->printType($node->type), + NodeTypePrinter::printType($node->type), $node->isPublic(), $node->isPrivate(), $node->isStatic(), diff --git a/src/Node/Printer/NodeTypePrinter.php b/src/Node/Printer/NodeTypePrinter.php new file mode 100644 index 0000000000..f2a110f048 --- /dev/null +++ b/src/Node/Printer/NodeTypePrinter.php @@ -0,0 +1,52 @@ +type); + } + + if ($type instanceof Node\UnionType) { + return implode('|', array_map(static function ($innerType): string { + $printedType = self::printType($innerType); + if ($printedType === null) { + throw new ShouldNotHappenException(); + } + + return $printedType; + }, $type->types)); + } + + if ($type instanceof Node\IntersectionType) { + return implode('&', array_map(static function ($innerType): string { + $printedType = self::printType($innerType); + if ($printedType === null) { + throw new ShouldNotHappenException(); + } + + return $printedType; + }, $type->types)); + } + + if ($type instanceof Node\Identifier || $type instanceof Node\Name) { + return $type->toString(); + } + + throw new ShouldNotHappenException(); + } + +} diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 817316d16f..4eae1e59f9 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -343,4 +343,9 @@ public function highlightStringDoesNotReturnFalse(): bool return $this->versionId >= 80400; } + public function deprecatesImplicitlyNullableParameterTypes(): bool + { + return $this->versionId >= 80400; + } + } diff --git a/src/Rules/FunctionDefinitionCheck.php b/src/Rules/FunctionDefinitionCheck.php index 19a76e042c..e50021d7ed 100644 --- a/src/Rules/FunctionDefinitionCheck.php +++ b/src/Rules/FunctionDefinitionCheck.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules; use PhpParser\Node; +use PhpParser\Node\ComplexType; use PhpParser\Node\Expr\ConstFetch; use PhpParser\Node\Expr\Variable; use PhpParser\Node\FunctionLike; @@ -15,6 +16,7 @@ use PhpParser\Node\Stmt\Function_; use PhpParser\Node\UnionType; use PHPStan\Analyser\Scope; +use PHPStan\Node\Printer\NodeTypePrinter; use PHPStan\Php\PhpVersion; use PHPStan\Reflection\FunctionReflection; use PHPStan\Reflection\ParameterReflection; @@ -41,6 +43,7 @@ use function in_array; use function is_string; use function sprintf; +use function strtolower; final class FunctionDefinitionCheck { @@ -103,7 +106,7 @@ public function checkAnonymousFunction( { $errors = []; $unionTypeReported = false; - foreach ($parameters as $param) { + foreach ($parameters as $i => $param) { if ($param->type === null) { continue; } @@ -123,6 +126,18 @@ public function checkAnonymousFunction( if (!$param->var instanceof Variable || !is_string($param->var->name)) { throw new ShouldNotHappenException(); } + + $implicitlyNullableTypeError = $this->checkImplicitlyNullableType( + $param->type, + $param->default, + $i + 1, + $param->getStartLine(), + $param->var->name, + ); + if ($implicitlyNullableTypeError !== null) { + $errors[] = $implicitlyNullableTypeError; + } + $type = $scope->getFunctionType($param->type, false, false); if ($type->isVoid()->yes()) { $errors[] = RuleErrorBuilder::message(sprintf($parameterMessage, $param->var->name, 'void')) @@ -333,6 +348,18 @@ private function checkParametersAcceptor( } } + foreach ($parameterNodes as $i => $parameterNode) { + if (!$parameterNode->var instanceof Variable || !is_string($parameterNode->var->name)) { + throw new ShouldNotHappenException(); + } + $implicitlyNullableTypeError = $this->checkImplicitlyNullableType($parameterNode->type, $parameterNode->default, $i + 1, $parameterNode->getStartLine(), $parameterNode->var->name); + if ($implicitlyNullableTypeError === null) { + continue; + } + + $errors[] = $implicitlyNullableTypeError; + } + if ($this->phpVersion->deprecatesRequiredParameterAfterOptional()) { $errors = array_merge($errors, $this->checkRequiredParameterAfterOptional($parameterNodes)); } @@ -654,4 +681,60 @@ private function getReturnTypeReferencedClasses(ParametersAcceptor $parametersAc ); } + private function checkImplicitlyNullableType( + Identifier|Name|ComplexType|null $type, + ?Node\Expr $default, + int $order, + int $line, + string $name, + ): ?IdentifierRuleError + { + if (!$default instanceof ConstFetch) { + return null; + } + + if ($default->name->toLowerString() !== 'null') { + return null; + } + + if ($type === null) { + return null; + } + + if ($type instanceof NullableType || $type instanceof IntersectionType) { + return null; + } + + if (!$this->phpVersion->deprecatesImplicitlyNullableParameterTypes()) { + return null; + } + + if ($type instanceof Identifier && strtolower($type->name) === 'mixed') { + return null; + } + if ($type instanceof Name && $type->toLowerString() === 'mixed') { + return null; + } + + if ($type instanceof UnionType) { + foreach ($type->types as $innerType) { + if ($innerType instanceof Identifier && strtolower($innerType->name) === 'null') { + return null; + } + if ($innerType instanceof Name && $innerType->toLowerString() === 'null') { + return null; + } + } + } + + return RuleErrorBuilder::message(sprintf( + 'Deprecated in PHP 8.4: Parameter #%d $%s (%s) is implicitly nullable via default value null.', + $order, + $name, + NodeTypePrinter::printType($type), + ))->line($line) + ->identifier('parameter.implicitlyNullable') + ->build(); + } + } diff --git a/tests/PHPStan/Rules/Functions/ExistingClassesInClosureTypehintsRuleTest.php b/tests/PHPStan/Rules/Functions/ExistingClassesInClosureTypehintsRuleTest.php index 86f8725573..aa026a0f07 100644 --- a/tests/PHPStan/Rules/Functions/ExistingClassesInClosureTypehintsRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ExistingClassesInClosureTypehintsRuleTest.php @@ -330,4 +330,26 @@ public function testIntersectionTypes(int $phpVersion, array $errors): void $this->analyse([__DIR__ . '/data/closure-intersection-types.php'], $errors); } + public function testDeprecatedImplicitlyNullableParameterType(): void + { + if (PHP_VERSION_ID < 80400) { + self::markTestSkipped('Test requires PHP 8.4.'); + } + + $this->analyse([__DIR__ . '/data/closure-implicitly-nullable.php'], [ + [ + 'Deprecated in PHP 8.4: Parameter #3 $c (int) is implicitly nullable via default value null.', + 13, + ], + [ + 'Deprecated in PHP 8.4: Parameter #5 $e (int|string) is implicitly nullable via default value null.', + 15, + ], + [ + 'Deprecated in PHP 8.4: Parameter #7 $g (stdClass) is implicitly nullable via default value null.', + 17, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/closure-implicitly-nullable.php b/tests/PHPStan/Rules/Functions/data/closure-implicitly-nullable.php new file mode 100644 index 0000000000..f513b60f61 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/closure-implicitly-nullable.php @@ -0,0 +1,24 @@ += 8.0 + +namespace ClosureImplicitNullable; + +class Foo +{ + + public function doFoo(): void + { + $c = function ( + $a = null, + int $b = 1, + int $c = null, + mixed $d = null, + int|string $e = null, + int|string|null $f = null, + \stdClass $g = null, + ?\stdClass $h = null, + ): void { + + }; + } + +} diff --git a/tests/PHPStan/Rules/Methods/ExistingClassesInTypehintsRuleTest.php b/tests/PHPStan/Rules/Methods/ExistingClassesInTypehintsRuleTest.php index b86302f453..f95708eda3 100644 --- a/tests/PHPStan/Rules/Methods/ExistingClassesInTypehintsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ExistingClassesInTypehintsRuleTest.php @@ -526,4 +526,26 @@ public function testSelfOut(): void ]); } + public function testDeprecatedImplicitlyNullableParameterType(): void + { + if (PHP_VERSION_ID < 80400) { + self::markTestSkipped('Test requires PHP 8.4.'); + } + + $this->analyse([__DIR__ . '/data/method-implicitly-nullable.php'], [ + [ + 'Deprecated in PHP 8.4: Parameter #3 $c (int) is implicitly nullable via default value null.', + 13, + ], + [ + 'Deprecated in PHP 8.4: Parameter #5 $e (int|string) is implicitly nullable via default value null.', + 15, + ], + [ + 'Deprecated in PHP 8.4: Parameter #7 $g (stdClass) is implicitly nullable via default value null.', + 17, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/method-implicitly-nullable.php b/tests/PHPStan/Rules/Methods/data/method-implicitly-nullable.php new file mode 100644 index 0000000000..f5690b2c72 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/method-implicitly-nullable.php @@ -0,0 +1,23 @@ +