diff --git a/Makefile b/Makefile index d996514c8b7..9748dea055a 100644 --- a/Makefile +++ b/Makefile @@ -140,6 +140,7 @@ lint: --exclude tests/PHPStan/Rules/Variables/data/bug-14349.php \ --exclude tests/PHPStan/Rules/Variables/data/bug-14352.php \ --exclude tests/PHPStan/Rules/Variables/data/bug-14351.php \ + --exclude tests/PHPStan/Rules/Variables/data/globals-modification.php \ --exclude tests/PHPStan/Rules/Properties/data/bug-14457.php \ src tests diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 6667a5916d9..ef154f0f739 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -543,4 +543,69 @@ public function throwsOnStringCast(): bool return $this->versionId >= 70400; } + public function warnsOnContinueTargetingSwitch(): bool + { + return $this->versionId >= 70300; + } + + public function restrictsGlobalsModification(): bool + { + return $this->versionId >= 80100; + } + + public function deprecatesArrayPointerFunctionsOnObjects(): bool + { + return $this->versionId >= 80100; + } + + public function deprecatesStaticTraitMemberAccess(): bool + { + return $this->versionId >= 80100; + } + + public function deprecatesMbstringPseudoEncodings(): bool + { + return $this->versionId >= 80200; + } + + public function deprecatesGetClassWithoutArguments(): bool + { + return $this->versionId >= 80300; + } + + public function deprecatesTriggerErrorWithUserError(): bool + { + return $this->versionId >= 80400; + } + + public function deprecatesPowerOfZeroWithNegativeExponent(): bool + { + return $this->versionId >= 80400; + } + + public function deprecatesCsvEscapeParameterDefault(): bool + { + return $this->versionId >= 80400; + } + + public function deprecatesUnderscoreClassName(): bool + { + return $this->versionId >= 80400; + } + + public function deprecatesDebugInfoReturningNull(): bool + { + return $this->versionId >= 80500; + } + + public function deprecatesOrdWithoutSingleByte(): bool + { + return $this->versionId >= 80500; + } + + public function deprecatesFinfoBufferContext(): bool + { + return $this->versionId >= 80500; + } + } diff --git a/src/Rules/Classes/AccessStaticTraitMemberRule.php b/src/Rules/Classes/AccessStaticTraitMemberRule.php new file mode 100644 index 00000000000..57894c8e8ef --- /dev/null +++ b/src/Rules/Classes/AccessStaticTraitMemberRule.php @@ -0,0 +1,122 @@ + + */ +#[RegisteredRule(level: 0)] +final class AccessStaticTraitMemberRule implements MultipleNodeTypesRule +{ + + public function __construct( + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeTypes(): array + { + return [Expr\StaticCall::class, StaticMethodCallableNode::class, Expr\StaticPropertyFetch::class]; + } + + public function getNodeType(): string + { + return Expr::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->deprecatesStaticTraitMemberAccess()) { + return []; + } + + if ($node instanceof Expr\StaticCall) { + if ($node->isFirstClassCallable()) { + return []; + } + $class = $node->class; + $name = $node->name; + } elseif ($node instanceof StaticMethodCallableNode) { + $class = $node->getClass(); + $name = $node->getName(); + } elseif ($node instanceof Expr\StaticPropertyFetch) { + $class = $node->class; + $name = $node->name; + } else { + return []; + } + + if (!$name instanceof Node\Identifier) { + return []; + } + + $trait = $this->findTrait($class, $scope); + if ($trait === null) { + return []; + } + + if ($node instanceof Expr\StaticPropertyFetch) { + if (!$trait->hasStaticProperty($name->toString())) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Accessing static trait property %s::$%s is deprecated in PHP 8.1, it should only be accessed on a class using the trait.', + $trait->getDisplayName(), + $name->toString(), + )) + ->identifier('staticProperty.onTrait') + ->build(), + ]; + } + + if (!$trait->hasNativeMethod($name->toString())) { + return []; + } + + $method = $trait->getNativeMethod($name->toString()); + if (!$method->isStatic()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Calling static trait method %s::%s() is deprecated in PHP 8.1, it should only be called on a class using the trait.', + $trait->getDisplayName(), + $method->getName(), + )) + ->identifier('staticMethod.onTrait') + ->build(), + ]; + } + + private function findTrait(Name|Expr $class, Scope $scope): ?ClassReflection + { + $classType = $class instanceof Name + ? $scope->resolveTypeByName($class) + : $scope->getType($class)->getObjectTypeOrClassStringObjectType(); + + $classReflections = $classType->getObjectClassReflections(); + if (count($classReflections) !== 1 || !$classReflections[0]->isTrait()) { + return null; + } + + return $classReflections[0]; + } + +} diff --git a/src/Rules/Classes/UnderscoreClassNameRule.php b/src/Rules/Classes/UnderscoreClassNameRule.php new file mode 100644 index 00000000000..acc2ced2cbe --- /dev/null +++ b/src/Rules/Classes/UnderscoreClassNameRule.php @@ -0,0 +1,59 @@ + + */ +#[RegisteredRule(level: 0)] +final class UnderscoreClassNameRule implements Rule +{ + + public function __construct( + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeType(): string + { + return Stmt\ClassLike::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->deprecatesUnderscoreClassName()) { + return []; + } + + if ($node->name === null || $node->name->toString() !== '_') { + return []; + } + + if ($node instanceof Stmt\Interface_) { + $kind = 'an interface'; + } elseif ($node instanceof Stmt\Trait_) { + $kind = 'a trait'; + } elseif ($node instanceof Stmt\Enum_) { + $kind = 'an enum'; + } else { + $kind = 'a class'; + } + + return [ + RuleErrorBuilder::message(sprintf('Using "_" as %s name is deprecated in PHP 8.4.', $kind)) + ->identifier('classLike.underscoreName') + ->build(), + ]; + } + +} diff --git a/src/Rules/Functions/ArrayPointerFunctionOnObjectRule.php b/src/Rules/Functions/ArrayPointerFunctionOnObjectRule.php new file mode 100644 index 00000000000..ce644f64c76 --- /dev/null +++ b/src/Rules/Functions/ArrayPointerFunctionOnObjectRule.php @@ -0,0 +1,89 @@ + + */ +#[RegisteredRule(level: 5)] +final class ArrayPointerFunctionOnObjectRule implements Rule +{ + + private const FUNCTIONS = ['current', 'pos', 'key', 'next', 'prev', 'reset', 'end']; + + public function __construct( + private ReflectionProvider $reflectionProvider, + private RuleLevelHelper $ruleLevelHelper, + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->deprecatesArrayPointerFunctionsOnObjects()) { + return []; + } + + if (!$node->name instanceof Node\Name) { + return []; + } + + $args = $node->getArgs(); + if (count($args) !== 1 || $args[0]->unpack) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $functionName = $this->reflectionProvider->getFunction($node->name, $scope)->getName(); + if (!in_array($functionName, self::FUNCTIONS, true)) { + return []; + } + + $type = $scope->getType($args[0]->value); + $arrayType = new ArrayType(new MixedType(), new MixedType()); + if ( + !TypeCombinator::union($arrayType, new ObjectWithoutClassType())->isSuperTypeOf($type)->yes() + || $this->ruleLevelHelper->accepts($arrayType, $type, $scope->isDeclareStrictTypes())->result + ) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Calling %s() on %s is deprecated in PHP 8.1.', + $functionName, + $type->describe(VerbosityLevel::typeOnly()), + )) + ->identifier('argument.deprecatedObject') + ->build(), + ]; + } + +} diff --git a/src/Rules/Functions/CsvEscapeParameterRule.php b/src/Rules/Functions/CsvEscapeParameterRule.php new file mode 100644 index 00000000000..306fcadb6b6 --- /dev/null +++ b/src/Rules/Functions/CsvEscapeParameterRule.php @@ -0,0 +1,132 @@ + + */ +#[RegisteredRule(level: 0)] +final class CsvEscapeParameterRule implements MultipleNodeTypesRule +{ + + private const FUNCTIONS = ['fgetcsv', 'fputcsv', 'str_getcsv']; + + public function __construct( + private ReflectionProvider $reflectionProvider, + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeTypes(): array + { + return [Expr\FuncCall::class, Expr\MethodCall::class]; + } + + public function getNodeType(): string + { + return Expr::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->deprecatesCsvEscapeParameterDefault()) { + return []; + } + + if ($node instanceof Expr\FuncCall) { + if (!$node->name instanceof Node\Name || $node->isFirstClassCallable()) { + return []; + } + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $reflection = $this->reflectionProvider->getFunction($node->name, $scope); + if (!in_array($reflection->getName(), self::FUNCTIONS, true)) { + return []; + } + $callName = sprintf('%s()', $reflection->getName()); + } elseif ($node instanceof Expr\MethodCall) { + // SplFileObject::fgetcsv() and fputcsv() use the escape set by setCsvControl() + if (!$node->name instanceof Node\Identifier || $node->name->toLowerString() !== 'setcsvcontrol' || $node->isFirstClassCallable()) { + return []; + } + + $reflection = $scope->getMethodReflection($scope->getType($node->var), $node->name->toString()); + if ($reflection === null || $reflection->getDeclaringClass()->getName() !== SplFileObject::class) { + return []; + } + $callName = sprintf('%s::%s()', SplFileObject::class, $reflection->getName()); + } else { + return []; + } + + $args = $node->getArgs(); + $parameterPositions = []; + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $args, $reflection->getVariants(), $reflection->getNamedArgumentsVariants()); + foreach ($parametersAcceptor->getParameters() as $i => $parameter) { + $parameterPositions[$parameter->getName()] = $i; + } + if (!isset($parameterPositions['escape'])) { + return []; + } + $escapePosition = $parameterPositions['escape']; + + $positionalArgsCount = 0; + foreach ($args as $arg) { + if ($arg->unpack) { + return []; + } + if ($arg->name === null) { + $positionalArgsCount++; + continue; + } + // a named argument after $escape makes PHP pass its default value explicitly, + // an unknown one is an error reported by the argument check + $position = $parameterPositions[$arg->name->toString()] ?? null; + if ($position === null || $position >= $escapePosition) { + return []; + } + } + + if ($positionalArgsCount > $escapePosition) { + return []; + } + + $appendPositionally = $positionalArgsCount === count($args) && $positionalArgsCount === $escapePosition; + + return [ + RuleErrorBuilder::message(sprintf( + 'Calling %s without the $escape parameter is deprecated in PHP 8.4.', + $callName, + )) + ->tip('Pass \'\\\\\' to keep the current behaviour, or an empty string to disable the proprietary escape mechanism.') + ->identifier('argument.missingEscape') + ->fixNode($node, static function (Expr\FuncCall|Expr\MethodCall $node) use ($appendPositionally): Expr\FuncCall|Expr\MethodCall { + $node->args[] = new Node\Arg( + new Node\Scalar\String_('\\'), + name: $appendPositionally ? null : new Node\Identifier('escape'), + ); + + return $node; + }) + ->build(), + ]; + } + +} diff --git a/src/Rules/Functions/FinfoBufferContextRule.php b/src/Rules/Functions/FinfoBufferContextRule.php new file mode 100644 index 00000000000..bd12e76b9f4 --- /dev/null +++ b/src/Rules/Functions/FinfoBufferContextRule.php @@ -0,0 +1,119 @@ + + */ +#[RegisteredRule(level: 0)] +final class FinfoBufferContextRule implements MultipleNodeTypesRule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeTypes(): array + { + return [Expr\FuncCall::class, Expr\MethodCall::class]; + } + + public function getNodeType(): string + { + return Expr::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->deprecatesFinfoBufferContext()) { + return []; + } + + if ($node instanceof Expr\FuncCall) { + if (!$node->name instanceof Node\Name || $node->isFirstClassCallable()) { + return []; + } + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $reflection = $this->reflectionProvider->getFunction($node->name, $scope); + if ($reflection->getName() !== 'finfo_buffer') { + return []; + } + $callName = 'finfo_buffer()'; + } elseif ($node instanceof Expr\MethodCall) { + if (!$node->name instanceof Node\Identifier || $node->name->toLowerString() !== 'buffer' || $node->isFirstClassCallable()) { + return []; + } + + $reflection = $scope->getMethodReflection($scope->getType($node->var), $node->name->toString()); + if ($reflection === null || $reflection->getDeclaringClass()->getName() !== finfo::class) { + return []; + } + $callName = sprintf('%s::%s()', finfo::class, $reflection->getName()); + } else { + return []; + } + + $args = $node->getArgs(); + $contextPosition = null; + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $args, $reflection->getVariants(), $reflection->getNamedArgumentsVariants()); + foreach ($parametersAcceptor->getParameters() as $i => $parameter) { + if ($parameter->getName() === 'context') { + $contextPosition = $i; + break; + } + } + if ($contextPosition === null) { + return []; + } + + foreach ($args as $i => $arg) { + if ($arg->unpack) { + return []; + } + + $isContext = $arg->name === null ? $i === $contextPosition : $arg->name->toString() === 'context'; + if (!$isContext) { + continue; + } + + $errorBuilder = RuleErrorBuilder::message(sprintf( + 'Passing the $context parameter to %s is deprecated in PHP 8.5.', + $callName, + )) + ->tip('The parameter has no effect, remove it.') + ->identifier('argument.unused'); + + // removing other expressions would drop their side effects + if ( + $arg->value instanceof Expr\ConstFetch + || $arg->value instanceof Expr\Variable + || $arg->value instanceof Node\Scalar + ) { + $errorBuilder->removeNode($arg); + } + + return [$errorBuilder->build()]; + } + + return []; + } + +} diff --git a/src/Rules/Functions/GetClassWithoutArgumentsRule.php b/src/Rules/Functions/GetClassWithoutArgumentsRule.php new file mode 100644 index 00000000000..9c747e6c1a7 --- /dev/null +++ b/src/Rules/Functions/GetClassWithoutArgumentsRule.php @@ -0,0 +1,92 @@ + + */ +#[RegisteredRule(level: 0)] +final class GetClassWithoutArgumentsRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Name || $node->isFirstClassCallable() || count($node->getArgs()) > 0) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $functionName = $this->reflectionProvider->getFunction($node->name, $scope)->getName(); + if (!in_array($functionName, ['get_class', 'get_parent_class'], true)) { + return []; + } + + if ($functionName === 'get_class' && !$scope->isInClass()) { + return [ + RuleErrorBuilder::message('Function get_class() cannot be called without arguments outside of a class.') + ->identifier('outOfClass.getClass') + ->build(), + ]; + } + + if (!$this->phpVersion->deprecatesGetClassWithoutArguments()) { + return []; + } + + $keyword = $functionName === 'get_class' ? 'self' : 'parent'; + $errorBuilder = RuleErrorBuilder::message(sprintf( + 'Calling %s() without arguments is deprecated in PHP 8.3.', + $functionName, + ))->identifier('function.deprecatedWithoutArguments'); + + if ($keyword === 'parent') { + if (!$scope->isInClass() || $scope->isInTrait()) { + return [$errorBuilder->tip('Pass the object or class name to get_parent_class().')->build()]; + } + + if ($scope->getClassReflection()->getParentClass() === null) { + return [ + $errorBuilder + ->tip(sprintf('Class %s does not extend any class, so this call always returns false.', $scope->getClassReflection()->getDisplayName())) + ->build(), + ]; + } + } + + return [ + $errorBuilder + ->tip(sprintf('Use %s::class instead.', $keyword)) + ->fixNode($node, static fn () => new ClassConstFetch(new Node\Name($keyword), 'class')) + ->build(), + ]; + } + +} diff --git a/src/Rules/Functions/MbstringDeprecatedEncodingRule.php b/src/Rules/Functions/MbstringDeprecatedEncodingRule.php new file mode 100644 index 00000000000..3ca6a9f11f1 --- /dev/null +++ b/src/Rules/Functions/MbstringDeprecatedEncodingRule.php @@ -0,0 +1,140 @@ + + */ +#[RegisteredRule(level: 0)] +final class MbstringDeprecatedEncodingRule implements Rule +{ + + /** Functions configuring mbstring do not report the deprecation. */ + private const IGNORED_FUNCTIONS = [ + 'mb_internal_encoding', + 'mb_http_output', + 'mb_detect_order', + 'mb_preferred_mime_name', + 'mb_regex_encoding', + ]; + + private const ENCODINGS = [ + 'HTML-ENTITIES' => 'HTML entities', + 'HTML' => 'HTML entities', + 'BASE64' => 'Base64', + 'UUENCODE' => 'Uuencode', + 'QUOTED-PRINTABLE' => 'QPrint', + 'QPRINT' => 'QPrint', + ]; + + private const REPLACEMENTS = [ + 'HTML entities' => 'htmlspecialchars(), htmlentities(), or mb_encode_numericentity()/mb_decode_numericentity()', + 'Base64' => 'base64_encode()/base64_decode()', + 'Uuencode' => 'convert_uuencode()/convert_uudecode()', + 'QPrint' => 'quoted_printable_encode()/quoted_printable_decode()', + ]; + + public function __construct( + private ReflectionProvider $reflectionProvider, + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->deprecatesMbstringPseudoEncodings()) { + return []; + } + + if (!$node->name instanceof Node\Name || count($node->getArgs()) === 0) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $functionReflection = $this->reflectionProvider->getFunction($node->name, $scope); + $functionName = $functionReflection->getName(); + if (!str_starts_with($functionName, 'mb_') || in_array($functionName, self::IGNORED_FUNCTIONS, true)) { + return []; + } + + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $node->getArgs(), + $functionReflection->getVariants(), + $functionReflection->getNamedArgumentsVariants(), + ); + $normalizedFuncCall = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $node); + if ($normalizedFuncCall === null) { + return []; + } + + $args = $normalizedFuncCall->getArgs(); + $errors = []; + foreach ($parametersAcceptor->getParameters() as $i => $parameter) { + if (!in_array($parameter->getName(), ['encoding', 'to_encoding'], true) || !array_key_exists($i, $args)) { + continue; + } + + foreach ($this->getDeprecatedEncodings($scope->getType($args[$i]->value)->getConstantStrings()) as $encoding) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Handling %s via mbstring in %s() is deprecated in PHP 8.2.', + $encoding, + $functionName, + )) + ->tip(sprintf('Use %s instead.', self::REPLACEMENTS[$encoding])) + ->identifier('argument.deprecatedEncoding') + ->build(); + } + } + + return $errors; + } + + /** + * @param list $constantStrings + * @return list> + */ + private function getDeprecatedEncodings(array $constantStrings): array + { + $encodings = []; + foreach ($constantStrings as $constantString) { + $encoding = self::ENCODINGS[strtoupper($constantString->getValue())] ?? null; + if ($encoding === null) { + return []; + } + + $encodings[$encoding] = $encoding; + } + + return array_values($encodings); + } + +} diff --git a/src/Rules/Functions/OrdSingleByteRule.php b/src/Rules/Functions/OrdSingleByteRule.php new file mode 100644 index 00000000000..222937ecc8a --- /dev/null +++ b/src/Rules/Functions/OrdSingleByteRule.php @@ -0,0 +1,113 @@ + + */ +#[RegisteredRule(level: 5)] +final class OrdSingleByteRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->deprecatesOrdWithoutSingleByte()) { + return []; + } + + if (!$node->name instanceof Node\Name || $node->isFirstClassCallable()) { + return []; + } + + $args = $node->getArgs(); + if (count($args) !== 1 || $args[0]->unpack) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + if ($this->reflectionProvider->getFunction($node->name, $scope)->getName() !== 'ord') { + return []; + } + + $argType = $scope->getType($args[0]->value); + if (!$argType->isString()->yes() && $scope->isDeclareStrictTypes()) { + // a TypeError reported by the argument type check + return []; + } + + $stringType = $argType->toString(); + if ($stringType instanceof ErrorType) { + return []; + } + + if ($stringType->isNonEmptyString()->no()) { + return [ + RuleErrorBuilder::message('Providing an empty string to ord() is deprecated in PHP 8.5.') + ->identifier('argument.ordEmptyString') + ->build(), + ]; + } + + $constantStrings = $stringType->getConstantStrings(); + if (count($constantStrings) === 0) { + return []; + } + + $minLength = null; + foreach ($constantStrings as $constantString) { + $length = strlen($constantString->getValue()); + if ($length === 1) { + return []; + } + + $minLength = $minLength === null ? $length : min($minLength, $length); + } + + $errorBuilder = RuleErrorBuilder::message('Providing a string that is not one byte long to ord() is deprecated in PHP 8.5.') + ->tip('Use ord($str[0]) instead.') + ->identifier('argument.ordMultiByte'); + + // ord() reads the first byte, which the fix passes explicitly + if ($minLength > 1 && $argType->isString()->yes()) { + $errorBuilder->fixNode($args[0]->value, static function (Node\Expr $expr): Node\Expr { + if ($expr instanceof Node\Scalar\String_ && ord($expr->value[0]) >= 0x20 && ord($expr->value[0]) <= 0x7E) { + return new Node\Scalar\String_($expr->value[0]); + } + + return new Node\Expr\ArrayDimFetch($expr, new Node\Scalar\Int_(0)); + }); + } + + return [$errorBuilder->build()]; + } + +} diff --git a/src/Rules/Functions/TriggerErrorUserErrorRule.php b/src/Rules/Functions/TriggerErrorUserErrorRule.php new file mode 100644 index 00000000000..6b41e9ec2f0 --- /dev/null +++ b/src/Rules/Functions/TriggerErrorUserErrorRule.php @@ -0,0 +1,88 @@ + + */ +#[RegisteredRule(level: 0)] +final class TriggerErrorUserErrorRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->deprecatesTriggerErrorWithUserError()) { + return []; + } + + if (!$node->name instanceof Node\Name || count($node->getArgs()) < 2) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $functionReflection = $this->reflectionProvider->getFunction($node->name, $scope); + if (!in_array($functionReflection->getName(), ['trigger_error', 'user_error'], true)) { + return []; + } + + $normalizedFuncCall = ArgumentsNormalizer::reorderFuncArguments( + ParametersAcceptorSelector::selectFromArgs( + $scope, + $node->getArgs(), + $functionReflection->getVariants(), + $functionReflection->getNamedArgumentsVariants(), + ), + $node, + ); + if ($normalizedFuncCall === null) { + return []; + } + + $args = $normalizedFuncCall->getArgs(); + if (!isset($args[1]) || !(new ConstantIntegerType(E_USER_ERROR))->isSuperTypeOf($scope->getType($args[1]->value))->yes()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Passing E_USER_ERROR to %s() is deprecated in PHP 8.4.', + $functionReflection->getName(), + )) + ->tip('Throw an exception or call exit() with a string message instead.') + ->identifier('argument.deprecatedUserError') + ->build(), + ]; + } + +} diff --git a/src/Rules/Keywords/ContinueTargetingSwitchRule.php b/src/Rules/Keywords/ContinueTargetingSwitchRule.php new file mode 100644 index 00000000000..a60e9ac19ec --- /dev/null +++ b/src/Rules/Keywords/ContinueTargetingSwitchRule.php @@ -0,0 +1,119 @@ + + */ +#[RegisteredRule(level: 0)] +final class ContinueTargetingSwitchRule implements Rule +{ + + private const LOOP_TYPES = [ + Stmt\For_::class, + Stmt\Foreach_::class, + Stmt\Do_::class, + Stmt\While_::class, + ]; + + private const SCOPE_BOUNDARY_TYPES = [ + Node\Expr\Closure::class, + Stmt\ClassMethod::class, + Stmt\Function_::class, + ]; + + public function __construct( + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeType(): string + { + return Stmt\Continue_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->warnsOnContinueTargetingSwitch()) { + return []; + } + + $depth = $node->num instanceof Node\Scalar\Int_ ? $node->num->value : 1; + + $remaining = $depth; + $targetsSwitch = false; + $nearestLoopDepth = null; + foreach (array_reverse($node->getAttribute(ParentStmtTypesVisitor::ATTRIBUTE_NAME, [])) as $parentStmtType) { + if (in_array($parentStmtType, self::SCOPE_BOUNDARY_TYPES, true)) { + break; + } + + $isLoop = in_array($parentStmtType, self::LOOP_TYPES, true); + if (!$isLoop && $parentStmtType !== Stmt\Switch_::class) { + continue; + } + + $remaining--; + if ($remaining === 0) { + if (!$isLoop) { + $targetsSwitch = true; + continue; + } + + return []; + } + + if ($remaining >= 0 || !$isLoop) { + continue; + } + + $nearestLoopDepth = $depth - $remaining; + break; + } + + if (!$targetsSwitch) { + return []; + } + + $continueKeyword = $depth > 1 ? sprintf('continue %d', $depth) : 'continue'; + $breakKeyword = $depth > 1 ? sprintf('break %d', $depth) : 'break'; + $errorBuilder = RuleErrorBuilder::message(sprintf( + 'Keyword %s targeting switch is equivalent to %s.', + $continueKeyword, + $breakKeyword, + ))->identifier('continue.switch'); + + if ($nearestLoopDepth !== null) { + // the intention was most likely to continue the enclosing loop, so the fix is not obvious + return [ + $errorBuilder + ->tip(sprintf( + 'Did you mean to use continue %d to continue the enclosing loop? Use %s to keep the current behaviour.', + $nearestLoopDepth, + $breakKeyword, + )) + ->build(), + ]; + } + + return [ + $errorBuilder + ->fixNode($node, static fn (Stmt\Continue_ $node) => new Stmt\Break_($node->num, ['comments' => $node->getComments()])) + ->build(), + ]; + } + +} diff --git a/src/Rules/Methods/DebugInfoReturnNullRule.php b/src/Rules/Methods/DebugInfoReturnNullRule.php new file mode 100644 index 00000000000..9241c4815d9 --- /dev/null +++ b/src/Rules/Methods/DebugInfoReturnNullRule.php @@ -0,0 +1,96 @@ + + */ +#[RegisteredRule(level: 0)] +final class DebugInfoReturnNullRule implements Rule +{ + + public function __construct( + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeType(): string + { + return MethodReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->deprecatesDebugInfoReturningNull()) { + return []; + } + + if (strtolower($node->getMethodName()) !== '__debuginfo' || $node->isGenerator()) { + return []; + } + + $method = $node->getMethodReflection(); + if ($method->isAbstract()->yes()) { + return []; + } + + $message = sprintf( + 'Returning null from %s::%s() is deprecated in PHP 8.5.', + $node->getClassReflection()->getDisplayName(), + $method->getName(), + ); + + $errors = []; + foreach ($node->getReturnStatements() as $returnStatement) { + $returnNode = $returnStatement->getReturnNode(); + if ( + $returnNode->expr !== null + && !$returnStatement->getScope()->getType($returnNode->expr)->isNull()->yes() + ) { + continue; + } + + $errorBuilder = RuleErrorBuilder::message($message) + ->line($returnNode->getStartLine()) + ->tip('Return an empty array instead.') + ->identifier('method.debugInfoNull'); + + // replacing other expressions would drop their side effects + if ( + $returnNode->expr === null + || $returnNode->expr instanceof Node\Expr\ConstFetch + || $returnNode->expr instanceof Node\Expr\Variable + ) { + $errorBuilder->fixNode($returnNode, static function (Return_ $returnNode): Return_ { + $returnNode->expr = new Node\Expr\Array_(); + + return $returnNode; + }); + } + + $errors[] = $errorBuilder->build(); + } + + if (!$node->getStatementResult()->isAlwaysTerminating()) { + $errors[] = RuleErrorBuilder::message($message) + ->tip('The method returns null implicitly. Return an empty array instead.') + ->identifier('method.debugInfoNull') + ->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Operators/PowerOfZeroWithNegativeExponentRule.php b/src/Rules/Operators/PowerOfZeroWithNegativeExponentRule.php new file mode 100644 index 00000000000..bfae8666e19 --- /dev/null +++ b/src/Rules/Operators/PowerOfZeroWithNegativeExponentRule.php @@ -0,0 +1,148 @@ + + */ +#[RegisteredRule(level: 2)] +final class PowerOfZeroWithNegativeExponentRule implements MultipleNodeTypesRule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeTypes(): array + { + return [Expr\BinaryOp\Pow::class, Expr\AssignOp\Pow::class, Expr\FuncCall::class]; + } + + public function getNodeType(): string + { + return Expr::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->deprecatesPowerOfZeroWithNegativeExponent()) { + return []; + } + + if ($node instanceof Expr\BinaryOp\Pow) { + $base = $node->left; + $exponent = $node->right; + } elseif ($node instanceof Expr\AssignOp\Pow) { + $base = $node->var; + $exponent = $node->expr; + } elseif ($node instanceof Expr\FuncCall) { + $args = $this->getPowArgs($node, $scope); + if ($args === null) { + return []; + } + [$base, $exponent] = $args; + } else { + return []; + } + + $baseType = $scope->getType($base); + $exponentType = $scope->getType($exponent); + if (!$baseType->isObject()->no() || !$exponentType->isObject()->no()) { + return []; + } + + if ( + !$scope->getScopeType(new Expr\BinaryOp\Equal(new TypeExpr($baseType), new Node\Scalar\Int_(0)))->isTrue()->yes() + || !$scope->getScopeType(new Expr\BinaryOp\Smaller(new TypeExpr($exponentType), new Node\Scalar\Int_(0)))->isTrue()->yes() + ) { + return []; + } + + $errorBuilder = RuleErrorBuilder::message('Power of base 0 and negative exponent is deprecated in PHP 8.4.') + ->tip('Use fpow() to get the IEEE 754 result.') + ->identifier('pow.zeroBaseNegativeExponent'); + + // fpow() has float parameters, so only int and float operands keep working with strict types + if ( + !$node instanceof Expr\AssignOp\Pow + && $this->isIntOrFloat($baseType) + && $this->isIntOrFloat($exponentType) + ) { + if ($node instanceof Expr\FuncCall) { + $errorBuilder->fixNode($node, static fn (Expr\FuncCall $node) => new Expr\FuncCall(new Node\Name('fpow'), $node->args)); + } else { + $errorBuilder->fixNode($node, static fn (Expr\BinaryOp\Pow $node) => new Expr\FuncCall(new Node\Name('fpow'), [ + new Node\Arg($node->left), + new Node\Arg($node->right), + ])); + } + } + + return [$errorBuilder->build()]; + } + + /** + * @return array{Expr, Expr}|null + */ + private function getPowArgs(Expr\FuncCall $node, Scope $scope): ?array + { + if (!$node->name instanceof Node\Name || count($node->getArgs()) !== 2) { + return null; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return null; + } + + $functionReflection = $this->reflectionProvider->getFunction($node->name, $scope); + if ($functionReflection->getName() !== 'pow') { + return null; + } + + $normalizedFuncCall = ArgumentsNormalizer::reorderFuncArguments( + ParametersAcceptorSelector::selectFromArgs( + $scope, + $node->getArgs(), + $functionReflection->getVariants(), + $functionReflection->getNamedArgumentsVariants(), + ), + $node, + ); + if ($normalizedFuncCall === null) { + return null; + } + + $args = $normalizedFuncCall->getArgs(); + if (count($args) !== 2) { + return null; + } + + return [$args[0]->value, $args[1]->value]; + } + + private function isIntOrFloat(Type $type): bool + { + return (new UnionType([new IntegerType(), new FloatType()]))->isSuperTypeOf($type)->yes(); + } + +} diff --git a/src/Rules/Variables/GlobalsModificationRule.php b/src/Rules/Variables/GlobalsModificationRule.php new file mode 100644 index 00000000000..66be06631e6 --- /dev/null +++ b/src/Rules/Variables/GlobalsModificationRule.php @@ -0,0 +1,125 @@ + + */ +#[RegisteredRule(level: 0)] +final class GlobalsModificationRule implements MultipleNodeTypesRule +{ + + public function __construct( + private PhpVersion $phpVersion, + ) + { + } + + public function getNodeTypes(): array + { + return [ + Expr\Assign::class, + Expr\AssignOp::class, + Expr\AssignRef::class, + Expr\PreInc::class, + Expr\PostInc::class, + Expr\PreDec::class, + Expr\PostDec::class, + Stmt\Foreach_::class, + Stmt\Unset_::class, + ]; + } + + public function getNodeType(): string + { + return Node::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->restrictsGlobalsModification()) { + return []; + } + + if ($node instanceof Stmt\Unset_) { + $targets = $node->vars; + } elseif ($node instanceof Stmt\Foreach_) { + $targets = [$node->valueVar]; + if ($node->keyVar !== null) { + $targets[] = $node->keyVar; + } + } elseif ( + $node instanceof Expr\Assign + || $node instanceof Expr\AssignOp + || $node instanceof Expr\AssignRef + || $node instanceof Expr\PreInc + || $node instanceof Expr\PostInc + || $node instanceof Expr\PreDec + || $node instanceof Expr\PostDec + ) { + $targets = [$node->var]; + } else { + return []; + } + + foreach ($targets as $target) { + if ($this->containsGlobals($target)) { + return [ + RuleErrorBuilder::message('$GLOBALS can only be modified using the $GLOBALS[$name] = $value syntax.') + ->identifier('globals.modification') + ->nonIgnorable() + ->build(), + ]; + } + } + + if ($node instanceof Expr\AssignRef && $this->isGlobals($node->expr)) { + return [ + RuleErrorBuilder::message('Cannot acquire reference to $GLOBALS.') + ->identifier('globals.reference') + ->nonIgnorable() + ->build(), + ]; + } + + return []; + } + + private function containsGlobals(Expr $expr): bool + { + if ($this->isGlobals($expr)) { + return true; + } + + if (!$expr instanceof Expr\List_ && !$expr instanceof Expr\Array_) { + return false; + } + + foreach ($expr->items as $item) { + if ($item === null) { + continue; + } + + if ($this->containsGlobals($item->value)) { + return true; + } + } + + return false; + } + + private function isGlobals(Expr $expr): bool + { + return $expr instanceof Expr\Variable && $expr->name === 'GLOBALS'; + } + +} diff --git a/tests/PHPStan/Rules/Classes/AccessStaticTraitMemberRuleTest.php b/tests/PHPStan/Rules/Classes/AccessStaticTraitMemberRuleTest.php new file mode 100644 index 00000000000..3ff90f92470 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/AccessStaticTraitMemberRuleTest.php @@ -0,0 +1,46 @@ + + */ +#[RequiresPhp('>= 8.1.0')] +class AccessStaticTraitMemberRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 80100; + + protected function getRule(): Rule + { + return new AccessStaticTraitMemberRule(new PhpVersion($this->phpVersionId)); + } + + public function testRule(): void + { + $method = 'Calling static trait method AccessStaticTraitMember\FooTrait::create() is deprecated in PHP 8.1, it should only be called on a class using the trait.'; + $property = 'Accessing static trait property AccessStaticTraitMember\FooTrait::$counter is deprecated in PHP 8.1, it should only be accessed on a class using the trait.'; + $this->analyse([__DIR__ . '/data/access-static-trait-member.php'], [ + [$method, 16], + [$property, 17], + [$method, 49], + [$property, 50], + [$property, 51], + [$method, 52], + [$method, 54], + [$property, 55], + ]); + } + + public function testBeforePhp81(): void + { + $this->phpVersionId = 80000; + $this->analyse([__DIR__ . '/data/access-static-trait-member.php'], []); + } + +} diff --git a/tests/PHPStan/Rules/Classes/UnderscoreClassNameRuleTest.php b/tests/PHPStan/Rules/Classes/UnderscoreClassNameRuleTest.php new file mode 100644 index 00000000000..379cf65ba90 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/UnderscoreClassNameRuleTest.php @@ -0,0 +1,54 @@ + + */ +#[RequiresPhp('>= 8.1.0')] +class UnderscoreClassNameRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 80400; + + protected function getRule(): Rule + { + return new UnderscoreClassNameRule(new PhpVersion($this->phpVersionId)); + } + + /** + * @return list + */ + private function getFiles(): array + { + return [ + __DIR__ . '/data/underscore-class-name/class.php', + __DIR__ . '/data/underscore-class-name/interface.php', + __DIR__ . '/data/underscore-class-name/trait.php', + __DIR__ . '/data/underscore-class-name/enum.php', + __DIR__ . '/data/underscore-class-name/anonymous.php', + ]; + } + + public function testRule(): void + { + $this->analyse($this->getFiles(), [ + ['Using "_" as a class name is deprecated in PHP 8.4.', 5], + ['Using "_" as an interface name is deprecated in PHP 8.4.', 5], + ['Using "_" as a trait name is deprecated in PHP 8.4.', 5], + ['Using "_" as an enum name is deprecated in PHP 8.4.', 5], + ]); + } + + public function testBeforePhp84(): void + { + $this->phpVersionId = 80300; + $this->analyse($this->getFiles(), []); + } + +} diff --git a/tests/PHPStan/Rules/Classes/data/access-static-trait-member.php b/tests/PHPStan/Rules/Classes/data/access-static-trait-member.php new file mode 100644 index 00000000000..8c34ce934e8 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/access-static-trait-member.php @@ -0,0 +1,61 @@ += 8.1 + +namespace AccessStaticTraitMember; + +trait FooTrait +{ + + public static int $counter = 0; + + public static function create(): void + { + } + + public function instanceMethod(): void + { + FooTrait::create(); + FooTrait::$counter++; + self::create(); + static::create(); + self::$counter++; + static::$counter++; + } + +} + +class Foo +{ + + use FooTrait; + +} + +class Bar extends Foo +{ + + public function doBar(): void + { + parent::create(); + parent::$counter++; + } + +} + +/** + * @param class-string $className + */ +function doFoo(string $className): void +{ + FooTrait::create(); + echo FooTrait::$counter; + FooTrait::$counter = 5; + $callable = FooTrait::create(...); + $traitName = 'AccessStaticTraitMember\FooTrait'; + $traitName::create(); + $traitName::$counter++; + + Foo::create(); + Foo::$counter++; + $className::create(); + $className::$counter++; +} diff --git a/tests/PHPStan/Rules/Classes/data/underscore-class-name/anonymous.php b/tests/PHPStan/Rules/Classes/data/underscore-class-name/anonymous.php new file mode 100644 index 00000000000..be2cf3d03a7 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/underscore-class-name/anonymous.php @@ -0,0 +1,10 @@ += 8.1 + +namespace UnderscoreClassName\EnumDeclaration; + +enum _ +{ + +} + +enum __ +{ + +} diff --git a/tests/PHPStan/Rules/Classes/data/underscore-class-name/interface.php b/tests/PHPStan/Rules/Classes/data/underscore-class-name/interface.php new file mode 100644 index 00000000000..f1e6107f8e9 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/underscore-class-name/interface.php @@ -0,0 +1,13 @@ + + */ +#[RequiresPhp('>= 8.0.0')] +class ArrayPointerFunctionOnObjectRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 80100; + + private bool $checkUnionTypes = false; + + protected function getRule(): Rule + { + $reflectionProvider = self::createReflectionProvider(); + return new ArrayPointerFunctionOnObjectRule( + $reflectionProvider, + new RuleLevelHelper( + $reflectionProvider, + checkNullables: true, + checkThisOnly: false, + checkUnionTypes: $this->checkUnionTypes, + checkExplicitMixed: false, + checkImplicitMixed: false, + checkBenevolentUnionTypes: false, + discoveringSymbolsTip: true, + ), + new PhpVersion($this->phpVersionId), + ); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/array-pointer-function-on-object.php'], $this->getObjectErrors()); + } + + public function testRuleWithUnionTypes(): void + { + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/array-pointer-function-on-object.php'], array_merge($this->getObjectErrors(), [ + [ + 'Calling current() on array|stdClass is deprecated in PHP 8.1.', + 36, + ], + [ + 'Calling current() on iterable is deprecated in PHP 8.1.', + 37, + ], + ])); + } + + public function testBeforePhp81(): void + { + $this->phpVersionId = 80000; + $this->analyse([__DIR__ . '/data/array-pointer-function-on-object.php'], []); + } + + /** + * @return list + */ + private function getObjectErrors(): array + { + return [ + ['Calling current() on stdClass is deprecated in PHP 8.1.', 27], + ['Calling pos() on stdClass is deprecated in PHP 8.1.', 28], + ['Calling key() on ArrayObject is deprecated in PHP 8.1.', 29], + ['Calling next() on stdClass is deprecated in PHP 8.1.', 30], + ['Calling prev() on stdClass is deprecated in PHP 8.1.', 31], + ['Calling reset() on stdClass is deprecated in PHP 8.1.', 32], + ['Calling end() on stdClass is deprecated in PHP 8.1.', 33], + ['Calling current() on stdClass is deprecated in PHP 8.1.', 34], + ]; + } + +} diff --git a/tests/PHPStan/Rules/Functions/CsvEscapeParameterRuleTest.php b/tests/PHPStan/Rules/Functions/CsvEscapeParameterRuleTest.php new file mode 100644 index 00000000000..64a6e2a499d --- /dev/null +++ b/tests/PHPStan/Rules/Functions/CsvEscapeParameterRuleTest.php @@ -0,0 +1,49 @@ + + */ +#[RequiresPhp('>= 8.0.0')] +class CsvEscapeParameterRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 80400; + + protected function getRule(): Rule + { + return new CsvEscapeParameterRule(self::createReflectionProvider(), new PhpVersion($this->phpVersionId)); + } + + public function testRule(): void + { + $tip = 'Pass \'\\\\\' to keep the current behaviour, or an empty string to disable the proprietary escape mechanism.'; + $this->analyse([__DIR__ . '/data/csv-escape-parameter.php'], [ + ['Calling fgetcsv() without the $escape parameter is deprecated in PHP 8.4.', 15, $tip], + ['Calling fgetcsv() without the $escape parameter is deprecated in PHP 8.4.', 16, $tip], + ['Calling fputcsv() without the $escape parameter is deprecated in PHP 8.4.', 17, $tip], + ['Calling str_getcsv() without the $escape parameter is deprecated in PHP 8.4.', 18, $tip], + ['Calling str_getcsv() without the $escape parameter is deprecated in PHP 8.4.', 19, $tip], + ['Calling SplFileObject::setCsvControl() without the $escape parameter is deprecated in PHP 8.4.', 20, $tip], + ['Calling SplFileObject::setCsvControl() without the $escape parameter is deprecated in PHP 8.4.', 21, $tip], + ]); + } + + public function testBeforePhp84(): void + { + $this->phpVersionId = 80300; + $this->analyse([__DIR__ . '/data/csv-escape-parameter.php'], []); + } + + public function testFix(): void + { + $this->fix(__DIR__ . '/data/csv-escape-parameter.php', __DIR__ . '/data/csv-escape-parameter.php.fixed'); + } + +} diff --git a/tests/PHPStan/Rules/Functions/FinfoBufferContextRuleTest.php b/tests/PHPStan/Rules/Functions/FinfoBufferContextRuleTest.php new file mode 100644 index 00000000000..012accd0c65 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/FinfoBufferContextRuleTest.php @@ -0,0 +1,47 @@ + + */ +#[RequiresPhp('>= 8.0.0')] +class FinfoBufferContextRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 80500; + + protected function getRule(): Rule + { + return new FinfoBufferContextRule(self::createReflectionProvider(), new PhpVersion($this->phpVersionId)); + } + + public function testRule(): void + { + $tip = 'The parameter has no effect, remove it.'; + $this->analyse([__DIR__ . '/data/finfo-buffer-context.php'], [ + ['Passing the $context parameter to finfo_buffer() is deprecated in PHP 8.5.', 13, $tip], + ['Passing the $context parameter to finfo_buffer() is deprecated in PHP 8.5.', 14, $tip], + ['Passing the $context parameter to finfo_buffer() is deprecated in PHP 8.5.', 15, $tip], + ['Passing the $context parameter to finfo::buffer() is deprecated in PHP 8.5.', 16, $tip], + ['Passing the $context parameter to finfo::buffer() is deprecated in PHP 8.5.', 17, $tip], + ]); + } + + public function testBeforePhp85(): void + { + $this->phpVersionId = 80400; + $this->analyse([__DIR__ . '/data/finfo-buffer-context.php'], []); + } + + public function testFix(): void + { + $this->fix(__DIR__ . '/data/finfo-buffer-context.php', __DIR__ . '/data/finfo-buffer-context.php.fixed'); + } + +} diff --git a/tests/PHPStan/Rules/Functions/GetClassWithoutArgumentsRuleTest.php b/tests/PHPStan/Rules/Functions/GetClassWithoutArgumentsRuleTest.php new file mode 100644 index 00000000000..cc3927e90a2 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/GetClassWithoutArgumentsRuleTest.php @@ -0,0 +1,90 @@ + + */ +#[RequiresPhp('>= 8.1.0')] +class GetClassWithoutArgumentsRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 80300; + + protected function getRule(): Rule + { + return new GetClassWithoutArgumentsRule(self::createReflectionProvider(), new PhpVersion($this->phpVersionId)); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/get-class-without-arguments.php'], [ + [ + 'Calling get_class() without arguments is deprecated in PHP 8.3.', + 15, + 'Use self::class instead.', + ], + [ + 'Calling get_parent_class() without arguments is deprecated in PHP 8.3.', + 16, + 'Pass the object or class name to get_parent_class().', + ], + [ + 'Calling get_class() without arguments is deprecated in PHP 8.3.', + 28, + 'Use self::class instead.', + ], + [ + 'Calling get_class() without arguments is deprecated in PHP 8.3.', + 29, + 'Use self::class instead.', + ], + [ + 'Calling get_parent_class() without arguments is deprecated in PHP 8.3.', + 30, + 'Use parent::class instead.', + ], + [ + 'Calling get_class() without arguments is deprecated in PHP 8.3.', + 31, + 'Use self::class instead.', + ], + [ + 'Calling get_parent_class() without arguments is deprecated in PHP 8.3.', + 46, + 'Class GetClassWithoutArguments\WithoutParent does not extend any class, so this call always returns false.', + ], + [ + 'Function get_class() cannot be called without arguments outside of a class.', + 53, + ], + [ + 'Calling get_parent_class() without arguments is deprecated in PHP 8.3.', + 54, + 'Pass the object or class name to get_parent_class().', + ], + ]); + } + + public function testBeforePhp83(): void + { + $this->phpVersionId = 80200; + $this->analyse([__DIR__ . '/data/get-class-without-arguments.php'], [ + [ + 'Function get_class() cannot be called without arguments outside of a class.', + 53, + ], + ]); + } + + public function testFix(): void + { + $this->fix(__DIR__ . '/data/get-class-without-arguments.php', __DIR__ . '/data/get-class-without-arguments.php.fixed'); + } + +} diff --git a/tests/PHPStan/Rules/Functions/MbstringDeprecatedEncodingRuleTest.php b/tests/PHPStan/Rules/Functions/MbstringDeprecatedEncodingRuleTest.php new file mode 100644 index 00000000000..eb6c56225c0 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/MbstringDeprecatedEncodingRuleTest.php @@ -0,0 +1,47 @@ + + */ +#[RequiresPhp('>= 8.0.0')] +class MbstringDeprecatedEncodingRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 80200; + + protected function getRule(): Rule + { + return new MbstringDeprecatedEncodingRule(self::createReflectionProvider(), new PhpVersion($this->phpVersionId)); + } + + public function testRule(): void + { + $htmlTip = 'Use htmlspecialchars(), htmlentities(), or mb_encode_numericentity()/mb_decode_numericentity() instead.'; + $this->analyse([__DIR__ . '/data/mbstring-deprecated-encoding.php'], [ + ['Handling HTML entities via mbstring in mb_convert_encoding() is deprecated in PHP 8.2.', 7, $htmlTip], + ['Handling HTML entities via mbstring in mb_convert_encoding() is deprecated in PHP 8.2.', 8, $htmlTip], + ['Handling Base64 via mbstring in mb_strlen() is deprecated in PHP 8.2.', 9, 'Use base64_encode()/base64_decode() instead.'], + ['Handling Uuencode via mbstring in mb_substr() is deprecated in PHP 8.2.', 10, 'Use convert_uuencode()/convert_uudecode() instead.'], + ['Handling QPrint via mbstring in mb_strtoupper() is deprecated in PHP 8.2.', 11, 'Use quoted_printable_encode()/quoted_printable_decode() instead.'], + ['Handling QPrint via mbstring in mb_check_encoding() is deprecated in PHP 8.2.', 12, 'Use quoted_printable_encode()/quoted_printable_decode() instead.'], + ['Handling HTML entities via mbstring in mb_str_pad() is deprecated in PHP 8.2.', 13, $htmlTip], + ['Handling HTML entities via mbstring in mb_strwidth() is deprecated in PHP 8.2.', 14, $htmlTip], + ['Handling HTML entities via mbstring in mb_strlen() is deprecated in PHP 8.2.', 19, $htmlTip], + ['Handling Base64 via mbstring in mb_strlen() is deprecated in PHP 8.2.', 19, 'Use base64_encode()/base64_decode() instead.'], + ]); + } + + public function testBeforePhp82(): void + { + $this->phpVersionId = 80100; + $this->analyse([__DIR__ . '/data/mbstring-deprecated-encoding.php'], []); + } + +} diff --git a/tests/PHPStan/Rules/Functions/OrdSingleByteRuleTest.php b/tests/PHPStan/Rules/Functions/OrdSingleByteRuleTest.php new file mode 100644 index 00000000000..777d59992b0 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/OrdSingleByteRuleTest.php @@ -0,0 +1,54 @@ + + */ +class OrdSingleByteRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 80500; + + protected function getRule(): Rule + { + return new OrdSingleByteRule(self::createReflectionProvider(), new PhpVersion($this->phpVersionId)); + } + + public function testRule(): void + { + $message = 'Providing a string that is not one byte long to ord() is deprecated in PHP 8.5.'; + $tip = 'Use ord($str[0]) instead.'; + $this->analyse([__DIR__ . '/data/ord-single-byte.php'], [ + [$message, 13, $tip], + [$message, 14, $tip], + [$message, 15, $tip], + [$message, 17, $tip], + ['Providing an empty string to ord() is deprecated in PHP 8.5.', 18], + ]); + } + + public function testCoercion(): void + { + $this->analyse([__DIR__ . '/data/ord-single-byte-coercion.php'], [ + ['Providing a string that is not one byte long to ord() is deprecated in PHP 8.5.', 7, 'Use ord($str[0]) instead.'], + ['Providing an empty string to ord() is deprecated in PHP 8.5.', 10], + ]); + } + + public function testBeforePhp85(): void + { + $this->phpVersionId = 80400; + $this->analyse([__DIR__ . '/data/ord-single-byte.php'], []); + } + + public function testFix(): void + { + $this->fix(__DIR__ . '/data/ord-single-byte.php', __DIR__ . '/data/ord-single-byte.php.fixed'); + } + +} diff --git a/tests/PHPStan/Rules/Functions/TriggerErrorUserErrorRuleTest.php b/tests/PHPStan/Rules/Functions/TriggerErrorUserErrorRuleTest.php new file mode 100644 index 00000000000..14c5b94cae8 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/TriggerErrorUserErrorRuleTest.php @@ -0,0 +1,41 @@ + + */ +#[RequiresPhp('>= 8.0.0')] +class TriggerErrorUserErrorRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 80400; + + protected function getRule(): Rule + { + return new TriggerErrorUserErrorRule(self::createReflectionProvider(), new PhpVersion($this->phpVersionId)); + } + + public function testRule(): void + { + $tip = 'Throw an exception or call exit() with a string message instead.'; + $this->analyse([__DIR__ . '/data/trigger-error-user-error.php'], [ + ['Passing E_USER_ERROR to trigger_error() is deprecated in PHP 8.4.', 8, $tip], + ['Passing E_USER_ERROR to trigger_error() is deprecated in PHP 8.4.', 11, $tip], + ['Passing E_USER_ERROR to trigger_error() is deprecated in PHP 8.4.', 14, $tip], + ['Passing E_USER_ERROR to user_error() is deprecated in PHP 8.4.', 17, $tip], + ]); + } + + public function testBeforePhp84(): void + { + $this->phpVersionId = 80300; + $this->analyse([__DIR__ . '/data/trigger-error-user-error.php'], []); + } + +} diff --git a/tests/PHPStan/Rules/Functions/data/array-pointer-function-on-object.php b/tests/PHPStan/Rules/Functions/data/array-pointer-function-on-object.php new file mode 100644 index 00000000000..e26f2746a23 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/array-pointer-function-on-object.php @@ -0,0 +1,44 @@ += 8.0 + +namespace ArrayPointerFunctionOnObject; + +use ArrayObject; +use stdClass; + +/** + * @param array $array + * @param ArrayObject $arrayObject + * @param array|stdClass $arrayOrObject + * @param iterable $iterable + */ +function doFoo( + array $array, + stdClass $object, + ArrayObject $arrayObject, + array|stdClass $arrayOrObject, + iterable $iterable, + mixed $mixed, +): void +{ + current($array); + key($array); + next($array); + + current($object); + pos($object); + key($arrayObject); + next($object); + prev($object); + reset($object); + end($object); + \current(array: $object); + + current($arrayOrObject); + current($iterable); + current($mixed); +} + +function intOrObject(int|stdClass $intOrObject): void +{ + current($intOrObject); +} diff --git a/tests/PHPStan/Rules/Functions/data/csv-escape-parameter.php b/tests/PHPStan/Rules/Functions/data/csv-escape-parameter.php new file mode 100644 index 00000000000..3e85725a72a --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/csv-escape-parameter.php @@ -0,0 +1,31 @@ += 8.0 + +namespace CsvEscapeParameter; + +use SplFileObject; +use SplTempFileObject; + +/** + * @param resource $stream + * @param list $fields + * @param list $args + */ +function doFoo($stream, array $fields, string $line, SplFileObject $file, SplTempFileObject $tempFile, array $args): void +{ + fgetcsv($stream); + fgetcsv($stream, null, ';', '"'); + fputcsv($stream, $fields, enclosure: "'"); + \str_getcsv($line); + str_getcsv($line, ';', '"'); + $file->setCsvControl(); + $tempFile->setCsvControl(';'); + + fgetcsv($stream, null, ',', '"', '\\'); + fgetcsv($stream, escape: ''); + fputcsv($stream, $fields, ',', '"', '', "\n"); + fputcsv($stream, $fields, eol: "\r\n"); + str_getcsv($line, ...$args); + $file->setCsvControl(';', '"', ''); + $file->fgetcsv(); + $file->fputcsv($fields); +} diff --git a/tests/PHPStan/Rules/Functions/data/csv-escape-parameter.php.fixed b/tests/PHPStan/Rules/Functions/data/csv-escape-parameter.php.fixed new file mode 100644 index 00000000000..f40c9a43e7a --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/csv-escape-parameter.php.fixed @@ -0,0 +1,31 @@ += 8.0 + +namespace CsvEscapeParameter; + +use SplFileObject; +use SplTempFileObject; + +/** + * @param resource $stream + * @param list $fields + * @param list $args + */ +function doFoo($stream, array $fields, string $line, SplFileObject $file, SplTempFileObject $tempFile, array $args): void +{ + fgetcsv($stream, escape: '\\'); + fgetcsv($stream, null, ';', '"', '\\'); + fputcsv($stream, $fields, enclosure: "'", escape: '\\'); + \str_getcsv($line, escape: '\\'); + str_getcsv($line, ';', '"', '\\'); + $file->setCsvControl(escape: '\\'); + $tempFile->setCsvControl(';', escape: '\\'); + + fgetcsv($stream, null, ',', '"', '\\'); + fgetcsv($stream, escape: ''); + fputcsv($stream, $fields, ',', '"', '', "\n"); + fputcsv($stream, $fields, eol: "\r\n"); + str_getcsv($line, ...$args); + $file->setCsvControl(';', '"', ''); + $file->fgetcsv(); + $file->fputcsv($fields); +} diff --git a/tests/PHPStan/Rules/Functions/data/finfo-buffer-context.php b/tests/PHPStan/Rules/Functions/data/finfo-buffer-context.php new file mode 100644 index 00000000000..7469d9a6a6a --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/finfo-buffer-context.php @@ -0,0 +1,24 @@ += 8.0 + +namespace FinfoBufferContext; + +use finfo; + +/** + * @param resource $context + * @param list $args + */ +function doFoo(finfo $finfo, string $data, $context, array $args): void +{ + finfo_buffer($finfo, $data, FILEINFO_NONE, null); + \finfo_buffer($finfo, $data, FILEINFO_MIME, $context); + finfo_buffer($finfo, $data, context: null); + $finfo->buffer($data, FILEINFO_NONE, null); + $finfo->buffer($data, context: stream_context_create()); + + finfo_buffer($finfo, $data); + finfo_buffer($finfo, $data, FILEINFO_MIME); + $finfo->buffer($data, FILEINFO_NONE); + finfo_buffer($finfo, ...$args); + finfo_file($finfo, __FILE__, FILEINFO_NONE, $context); +} diff --git a/tests/PHPStan/Rules/Functions/data/finfo-buffer-context.php.fixed b/tests/PHPStan/Rules/Functions/data/finfo-buffer-context.php.fixed new file mode 100644 index 00000000000..77c374dcbe6 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/finfo-buffer-context.php.fixed @@ -0,0 +1,24 @@ += 8.0 + +namespace FinfoBufferContext; + +use finfo; + +/** + * @param resource $context + * @param list $args + */ +function doFoo(finfo $finfo, string $data, $context, array $args): void +{ + finfo_buffer($finfo, $data, FILEINFO_NONE); + \finfo_buffer($finfo, $data, FILEINFO_MIME); + finfo_buffer($finfo, $data); + $finfo->buffer($data, FILEINFO_NONE); + $finfo->buffer($data, context: stream_context_create()); + + finfo_buffer($finfo, $data); + finfo_buffer($finfo, $data, FILEINFO_MIME); + $finfo->buffer($data, FILEINFO_NONE); + finfo_buffer($finfo, ...$args); + finfo_file($finfo, __FILE__, FILEINFO_NONE, $context); +} diff --git a/tests/PHPStan/Rules/Functions/data/get-class-without-arguments.php b/tests/PHPStan/Rules/Functions/data/get-class-without-arguments.php new file mode 100644 index 00000000000..fedb09c82dd --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/get-class-without-arguments.php @@ -0,0 +1,55 @@ += 8.1 + +namespace GetClassWithoutArguments; + +class ParentClass +{ + +} + +trait FooTrait +{ + + public function traitMethod(): void + { + echo get_class(); + echo get_parent_class(); + } + +} + +class Foo extends ParentClass +{ + + use FooTrait; + + public function doFoo(object $object): void + { + echo get_class(); + echo \get_class(); + echo get_parent_class(); + $closure = static fn (): string => get_class(); + + echo get_class($object); + echo get_class($this); + echo get_parent_class($this); + $callable = get_class(...); + } + +} + +class WithoutParent +{ + + public function doFoo(): void + { + echo get_parent_class(); + } + +} + +function doFoo(): void +{ + echo get_class(); + echo get_parent_class(); +} diff --git a/tests/PHPStan/Rules/Functions/data/get-class-without-arguments.php.fixed b/tests/PHPStan/Rules/Functions/data/get-class-without-arguments.php.fixed new file mode 100644 index 00000000000..9e3d7d05011 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/get-class-without-arguments.php.fixed @@ -0,0 +1,55 @@ += 8.1 + +namespace GetClassWithoutArguments; + +class ParentClass +{ + +} + +trait FooTrait +{ + + public function traitMethod(): void + { + echo self::class; + echo get_parent_class(); + } + +} + +class Foo extends ParentClass +{ + + use FooTrait; + + public function doFoo(object $object): void + { + echo self::class; + echo self::class; + echo parent::class; + $closure = static fn (): string => self::class; + + echo get_class($object); + echo get_class($this); + echo get_parent_class($this); + $callable = get_class(...); + } + +} + +class WithoutParent +{ + + public function doFoo(): void + { + echo get_parent_class(); + } + +} + +function doFoo(): void +{ + echo get_class(); + echo get_parent_class(); +} diff --git a/tests/PHPStan/Rules/Functions/data/mbstring-deprecated-encoding.php b/tests/PHPStan/Rules/Functions/data/mbstring-deprecated-encoding.php new file mode 100644 index 00000000000..02dd83209ac --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/mbstring-deprecated-encoding.php @@ -0,0 +1,26 @@ += 8.0 + +namespace MbstringDeprecatedEncoding; + +function doFoo(string $s, bool $b, string $encoding): void +{ + mb_convert_encoding($s, 'HTML-ENTITIES', 'UTF-8'); + mb_convert_encoding($s, 'html'); + mb_strlen($s, 'base64'); + mb_substr($s, 0, 1, 'UUENCODE'); + mb_strtoupper($s, 'Quoted-Printable'); + mb_check_encoding($s, 'qprint'); + mb_str_pad($s, 10, encoding: 'HTML-ENTITIES'); + \mb_strwidth($s, $b ? 'HTML' : 'HTML-ENTITIES'); + + mb_convert_encoding($s, 'UTF-8', 'HTML-ENTITIES'); + mb_convert_encoding($s, 'UTF-8', ['UTF-8', 'BASE64']); + mb_strlen($s, $b ? 'UTF-8' : 'BASE64'); + mb_strlen($s, $b ? 'HTML' : 'BASE64'); + mb_strlen($s, 'UTF-8'); + mb_strlen($s, $encoding); + mb_strlen($s); + mb_internal_encoding('HTML-ENTITIES'); + mb_http_output('BASE64'); + htmlspecialchars($s, ENT_QUOTES, 'HTML-ENTITIES'); +} diff --git a/tests/PHPStan/Rules/Functions/data/ord-single-byte-coercion.php b/tests/PHPStan/Rules/Functions/data/ord-single-byte-coercion.php new file mode 100644 index 00000000000..4de82ee7de3 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/ord-single-byte-coercion.php @@ -0,0 +1,11 @@ += 8.0 + +namespace TriggerErrorUserError; + +function doFoo(int $level, bool $b): void +{ + if ($b) { + trigger_error('Something went wrong', E_USER_ERROR); + } + if ($b) { + \trigger_error('Something went wrong', 256); + } + if ($b) { + trigger_error(error_level: E_USER_ERROR, message: 'Something went wrong'); + } + if ($b) { + user_error('Something went wrong', E_USER_ERROR); + } + + trigger_error('Something went wrong'); + trigger_error('Something went wrong', E_USER_WARNING); + trigger_error('Something went wrong', E_USER_DEPRECATED); + trigger_error('Something went wrong', $b ? E_USER_ERROR : E_USER_WARNING); + trigger_error('Something went wrong', $level); +} diff --git a/tests/PHPStan/Rules/Keywords/ContinueTargetingSwitchRuleTest.php b/tests/PHPStan/Rules/Keywords/ContinueTargetingSwitchRuleTest.php new file mode 100644 index 00000000000..5bba08d5e02 --- /dev/null +++ b/tests/PHPStan/Rules/Keywords/ContinueTargetingSwitchRuleTest.php @@ -0,0 +1,66 @@ + + */ +class ContinueTargetingSwitchRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 70300; + + protected function getRule(): Rule + { + return new ContinueTargetingSwitchRule(new PhpVersion($this->phpVersionId)); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/continue-targeting-switch.php'], [ + [ + 'Keyword continue targeting switch is equivalent to break.', + 10, + ], + [ + 'Keyword continue 2 targeting switch is equivalent to break 2.', + 14, + ], + [ + 'Keyword continue targeting switch is equivalent to break.', + 27, + 'Did you mean to use continue 2 to continue the enclosing loop? Use break to keep the current behaviour.', + ], + [ + 'Keyword continue 2 targeting switch is equivalent to break 2.', + 32, + 'Did you mean to use continue 3 to continue the enclosing loop? Use break 2 to keep the current behaviour.', + ], + [ + 'Keyword continue targeting switch is equivalent to break.', + 46, + 'Did you mean to use continue 3 to continue the enclosing loop? Use break to keep the current behaviour.', + ], + [ + 'Keyword continue targeting switch is equivalent to break.', + 64, + ], + ]); + } + + public function testOlderPhp(): void + { + $this->phpVersionId = 70200; + $this->analyse([__DIR__ . '/data/continue-targeting-switch.php'], []); + } + + public function testFix(): void + { + $this->fix(__DIR__ . '/data/continue-targeting-switch.php', __DIR__ . '/data/continue-targeting-switch.php.fixed'); + } + +} diff --git a/tests/PHPStan/Rules/Keywords/data/continue-targeting-switch.php b/tests/PHPStan/Rules/Keywords/data/continue-targeting-switch.php new file mode 100644 index 00000000000..137ab3666b7 --- /dev/null +++ b/tests/PHPStan/Rules/Keywords/data/continue-targeting-switch.php @@ -0,0 +1,69 @@ + $items + */ +function insideLoop(array $items): void +{ + foreach ($items as $item) { + switch ($item) { + case 1: + continue; + case 2: + continue 2; + case 3: + foreach ($items as $inner) { + continue 2; + } + break; + case 4: + while (true) { + continue; + } + } + + for ($i = 0; $i < $item; $i++) { + switch ($i) { + case 0: + switch ($item) { + case 0: + continue; + } + } + } + + echo $item; + } +} + +/** + * @param list $items + */ +function closureIsBoundary(array $items): void +{ + foreach ($items as $item) { + $f = static function () use ($item): void { + switch ($item) { + case 1: + continue; + } + }; + $f(); + } +} diff --git a/tests/PHPStan/Rules/Keywords/data/continue-targeting-switch.php.fixed b/tests/PHPStan/Rules/Keywords/data/continue-targeting-switch.php.fixed new file mode 100644 index 00000000000..b13626821a4 --- /dev/null +++ b/tests/PHPStan/Rules/Keywords/data/continue-targeting-switch.php.fixed @@ -0,0 +1,69 @@ + $items + */ +function insideLoop(array $items): void +{ + foreach ($items as $item) { + switch ($item) { + case 1: + continue; + case 2: + continue 2; + case 3: + foreach ($items as $inner) { + continue 2; + } + break; + case 4: + while (true) { + continue; + } + } + + for ($i = 0; $i < $item; $i++) { + switch ($i) { + case 0: + switch ($item) { + case 0: + continue; + } + } + } + + echo $item; + } +} + +/** + * @param list $items + */ +function closureIsBoundary(array $items): void +{ + foreach ($items as $item) { + $f = static function () use ($item): void { + switch ($item) { + case 1: + break; + } + }; + $f(); + } +} diff --git a/tests/PHPStan/Rules/Methods/DebugInfoReturnNullRuleTest.php b/tests/PHPStan/Rules/Methods/DebugInfoReturnNullRuleTest.php new file mode 100644 index 00000000000..a531353720c --- /dev/null +++ b/tests/PHPStan/Rules/Methods/DebugInfoReturnNullRuleTest.php @@ -0,0 +1,48 @@ + + */ +#[RequiresPhp('>= 8.2.0')] +class DebugInfoReturnNullRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 80500; + + protected function getRule(): Rule + { + return new DebugInfoReturnNullRule(new PhpVersion($this->phpVersionId)); + } + + public function testRule(): void + { + $tip = 'Return an empty array instead.'; + $this->analyse([__DIR__ . '/data/debug-info-return-null.php'], [ + ['Returning null from DebugInfoReturnNull\ReturnsNull::__debugInfo() is deprecated in PHP 8.5.', 10, $tip], + ['Returning null from DebugInfoReturnNull\ReturnsNullConditionally::__DebugInfo() is deprecated in PHP 8.5.', 26, $tip], + ['Returning null from DebugInfoReturnNull\ReturnsNullConditionally::__DebugInfo() is deprecated in PHP 8.5.', 31, $tip], + ['Returning null from DebugInfoReturnNull\ReturnsNullFromCall::__debugInfo() is deprecated in PHP 8.5.', 44, $tip], + ['Returning null from DebugInfoReturnNull\EmptyReturn::__debugInfo() is deprecated in PHP 8.5.', 62, $tip], + ['Returning null from DebugInfoReturnNull\ImplicitReturn::__debugInfo() is deprecated in PHP 8.5.', 73, 'The method returns null implicitly. Return an empty array instead.'], + ]); + } + + public function testBeforePhp85(): void + { + $this->phpVersionId = 80400; + $this->analyse([__DIR__ . '/data/debug-info-return-null.php'], []); + } + + public function testFix(): void + { + $this->fix(__DIR__ . '/data/debug-info-return-null.php', __DIR__ . '/data/debug-info-return-null.php.fixed'); + } + +} diff --git a/tests/PHPStan/Rules/Methods/data/debug-info-return-null.php b/tests/PHPStan/Rules/Methods/data/debug-info-return-null.php new file mode 100644 index 00000000000..65ae721c600 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/debug-info-return-null.php @@ -0,0 +1,109 @@ += 8.2 + +namespace DebugInfoReturnNull; + +class ReturnsNull +{ + + public function __debugInfo() + { + return null; + } + +} + +class ReturnsNullConditionally +{ + + private bool $debug = false; + + /** + * @return array|null + */ + public function __DebugInfo(): ?array + { + if (!$this->debug) { + return null; + } + + $value = null; + if ($this->debug) { + return $value; + } + + return ['debug' => $this->debug]; + } + +} + +class ReturnsNullFromCall +{ + + public function __debugInfo() + { + return $this->log(); + } + + private function log(): null + { + echo 'debugging'; + + return null; + } + +} + +class EmptyReturn +{ + + public function __debugInfo() + { + if (rand(0, 1) === 0) { + return; + } + + return []; + } + +} + +class ImplicitReturn +{ + + public function __debugInfo() + { + echo 'debugging'; + } + +} + +class MaybeReturnsNull +{ + + /** @var array|null */ + private ?array $info = null; + + /** + * @return array|null + */ + public function __debugInfo(): ?array + { + return $this->info; + } + + public function getInfo(): ?string + { + return null; + } + +} + +interface HasDebugInfo +{ + + /** + * @return array + */ + public function __debugInfo(): array; + +} diff --git a/tests/PHPStan/Rules/Methods/data/debug-info-return-null.php.fixed b/tests/PHPStan/Rules/Methods/data/debug-info-return-null.php.fixed new file mode 100644 index 00000000000..431bb32ac13 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/debug-info-return-null.php.fixed @@ -0,0 +1,109 @@ += 8.2 + +namespace DebugInfoReturnNull; + +class ReturnsNull +{ + + public function __debugInfo() + { + return []; + } + +} + +class ReturnsNullConditionally +{ + + private bool $debug = false; + + /** + * @return array|null + */ + public function __DebugInfo(): ?array + { + if (!$this->debug) { + return []; + } + + $value = null; + if ($this->debug) { + return []; + } + + return ['debug' => $this->debug]; + } + +} + +class ReturnsNullFromCall +{ + + public function __debugInfo() + { + return $this->log(); + } + + private function log(): null + { + echo 'debugging'; + + return null; + } + +} + +class EmptyReturn +{ + + public function __debugInfo() + { + if (rand(0, 1) === 0) { + return []; + } + + return []; + } + +} + +class ImplicitReturn +{ + + public function __debugInfo() + { + echo 'debugging'; + } + +} + +class MaybeReturnsNull +{ + + /** @var array|null */ + private ?array $info = null; + + /** + * @return array|null + */ + public function __debugInfo(): ?array + { + return $this->info; + } + + public function getInfo(): ?string + { + return null; + } + +} + +interface HasDebugInfo +{ + + /** + * @return array + */ + public function __debugInfo(): array; + +} diff --git a/tests/PHPStan/Rules/Operators/PowerOfZeroWithNegativeExponentRuleTest.php b/tests/PHPStan/Rules/Operators/PowerOfZeroWithNegativeExponentRuleTest.php new file mode 100644 index 00000000000..4e202608401 --- /dev/null +++ b/tests/PHPStan/Rules/Operators/PowerOfZeroWithNegativeExponentRuleTest.php @@ -0,0 +1,51 @@ + + */ +#[RequiresPhp('>= 8.0.0')] +class PowerOfZeroWithNegativeExponentRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 80400; + + protected function getRule(): Rule + { + return new PowerOfZeroWithNegativeExponentRule(self::createReflectionProvider(), new PhpVersion($this->phpVersionId)); + } + + public function testRule(): void + { + $message = 'Power of base 0 and negative exponent is deprecated in PHP 8.4.'; + $tip = 'Use fpow() to get the IEEE 754 result.'; + $this->analyse([__DIR__ . '/data/power-of-zero-with-negative-exponent.php'], [ + [$message, 13, $tip], + [$message, 14, $tip], + [$message, 15, $tip], + [$message, 16, $tip], + [$message, 17, $tip], + [$message, 18, $tip], + [$message, 19, $tip], + [$message, 21, $tip], + ]); + } + + public function testBeforePhp84(): void + { + $this->phpVersionId = 80300; + $this->analyse([__DIR__ . '/data/power-of-zero-with-negative-exponent.php'], []); + } + + public function testFix(): void + { + $this->fix(__DIR__ . '/data/power-of-zero-with-negative-exponent.php', __DIR__ . '/data/power-of-zero-with-negative-exponent.php.fixed'); + } + +} diff --git a/tests/PHPStan/Rules/Operators/data/power-of-zero-with-negative-exponent.php b/tests/PHPStan/Rules/Operators/data/power-of-zero-with-negative-exponent.php new file mode 100644 index 00000000000..22b4425c558 --- /dev/null +++ b/tests/PHPStan/Rules/Operators/data/power-of-zero-with-negative-exponent.php @@ -0,0 +1,31 @@ += 8.0 + +namespace PowerOfZeroWithNegativeExponent; + +/** + * @param 0|0.0 $zero + * @param int $negative + * @param int<-1, 1> $maybeNegative + * @param int<0, 1> $maybeZero + */ +function doFoo(int|float $zero, int $negative, int $maybeNegative, int $maybeZero, float $float, string $numericZero, ?int $null): void +{ + var_dump(0 ** -1); + var_dump(0.0 ** -0.5); + var_dump($zero ** $negative); + var_dump(pow(0, -2)); + var_dump(\pow(exponent: -2, num: 0)); + var_dump('0' ** -1); + var_dump(null ** -1); + $i = 0; + $i **= -1; + + var_dump(0 ** 1); + var_dump(0 ** 0); + var_dump(0.0 ** -0.0); + var_dump(2 ** -1); + var_dump($zero ** $maybeNegative); + var_dump($maybeZero ** $negative); + var_dump($float ** -1); + var_dump(fpow(0, -1)); +} diff --git a/tests/PHPStan/Rules/Operators/data/power-of-zero-with-negative-exponent.php.fixed b/tests/PHPStan/Rules/Operators/data/power-of-zero-with-negative-exponent.php.fixed new file mode 100644 index 00000000000..2c79af0dd7d --- /dev/null +++ b/tests/PHPStan/Rules/Operators/data/power-of-zero-with-negative-exponent.php.fixed @@ -0,0 +1,31 @@ += 8.0 + +namespace PowerOfZeroWithNegativeExponent; + +/** + * @param 0|0.0 $zero + * @param int $negative + * @param int<-1, 1> $maybeNegative + * @param int<0, 1> $maybeZero + */ +function doFoo(int|float $zero, int $negative, int $maybeNegative, int $maybeZero, float $float, string $numericZero, ?int $null): void +{ + var_dump(fpow(0, -1)); + var_dump(fpow(0.0, -0.5)); + var_dump(fpow($zero, $negative)); + var_dump(fpow(0, -2)); + var_dump(fpow(exponent: -2, num: 0)); + var_dump('0' ** -1); + var_dump(null ** -1); + $i = 0; + $i **= -1; + + var_dump(0 ** 1); + var_dump(0 ** 0); + var_dump(0.0 ** -0.0); + var_dump(2 ** -1); + var_dump($zero ** $maybeNegative); + var_dump($maybeZero ** $negative); + var_dump($float ** -1); + var_dump(fpow(0, -1)); +} diff --git a/tests/PHPStan/Rules/Variables/GlobalsModificationRuleTest.php b/tests/PHPStan/Rules/Variables/GlobalsModificationRuleTest.php new file mode 100644 index 00000000000..86f4b23e418 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/GlobalsModificationRuleTest.php @@ -0,0 +1,47 @@ + + */ +class GlobalsModificationRuleTest extends RuleTestCase +{ + + private int $phpVersionId = 80100; + + protected function getRule(): Rule + { + return new GlobalsModificationRule(new PhpVersion($this->phpVersionId)); + } + + public function testRule(): void + { + $message = '$GLOBALS can only be modified using the $GLOBALS[$name] = $value syntax.'; + $this->analyse([__DIR__ . '/data/globals-modification.php'], [ + [$message, 10], + [$message, 11], + [$message, 12], + [$message, 13], + [$message, 14], + [$message, 15], + [$message, 17], + [$message, 19], + [$message, 20], + [$message, 21], + [$message, 22], + ['Cannot acquire reference to $GLOBALS.', 23], + ]); + } + + public function testBeforePhp81(): void + { + $this->phpVersionId = 80000; + $this->analyse([__DIR__ . '/data/globals-modification.php'], []); + } + +} diff --git a/tests/PHPStan/Rules/Variables/data/globals-modification.php b/tests/PHPStan/Rules/Variables/data/globals-modification.php new file mode 100644 index 00000000000..1afb10f33d6 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/globals-modification.php @@ -0,0 +1,42 @@ + $values + */ +function doFoo(array $values): void +{ + $GLOBALS = $values; + $GLOBALS += $values; + $GLOBALS ??= []; + [$GLOBALS] = [$values]; + ['a' => [, $GLOBALS]] = ['a' => [1, $values]]; + foreach ($values as $GLOBALS) { + } + foreach ($values as $GLOBALS => $value) { + } + $GLOBALS++; + --$GLOBALS; + unset($values, $GLOBALS); + $GLOBALS = &$values; + $reference = &$GLOBALS; +} + +/** + * @param array $values + */ +function allowed(array $values): void +{ + $GLOBALS['foo'] = $values; + $GLOBALS['foo']['bar'] = 1; + $GLOBALS['foo'] ??= 1; + $GLOBALS['foo']++; + [$GLOBALS['foo']] = [1]; + unset($GLOBALS['foo']); + foreach ($GLOBALS as $name => &$value) { + } + $copy = $GLOBALS; + $reference = &$GLOBALS['foo']; + var_dump($copy, $reference); +}