From 98ad59cee3e3c3e8ad2fcc36f12f3dcdec823353 Mon Sep 17 00:00:00 2001 From: Thomas Gnandt Date: Thu, 24 Jul 2025 21:19:07 +0200 Subject: [PATCH 1/4] add JsonDecodeDynamicReturnTypeExtension --- phpstan-safe-rule.neon | 4 + phpstan.neon | 5 + .../JsonDecodeDynamicReturnTypeExtension.php | 108 ++++++++++++++++++ tests/Type/Php/TypeAssertionsTest.php | 1 + tests/Type/Php/data/json_decode_return.php | 44 +++++++ 5 files changed, 162 insertions(+) create mode 100644 src/Type/Php/JsonDecodeDynamicReturnTypeExtension.php create mode 100644 tests/Type/Php/data/json_decode_return.php diff --git a/phpstan-safe-rule.neon b/phpstan-safe-rule.neon index d922803..5c615ca 100644 --- a/phpstan-safe-rule.neon +++ b/phpstan-safe-rule.neon @@ -23,3 +23,7 @@ services: class: TheCodingMachine\Safe\PHPStan\Type\Php\PregMatchTypeSpecifyingExtension tags: - phpstan.typeSpecifier.functionTypeSpecifyingExtension + - + class: TheCodingMachine\Safe\PHPStan\Type\Php\JsonDecodeDynamicReturnTypeExtension + tags: + - phpstan.broker.dynamicFunctionReturnTypeExtension diff --git a/phpstan.neon b/phpstan.neon index 692e10a..85d152c 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -25,5 +25,10 @@ parameters: identifier: phpstanApi.interface count: 1 path: src/Rules/Error/SafeRuleError.php + - + message: '#^Calling PHPStan\\Type\\BitwiseFlagHelper::bitwiseOrContainsConstant\(\) is not covered by backward compatibility promise\. The method might change in a minor PHPStan version\.$#' + identifier: phpstanApi.method + count: 1 + path: src/Type/Php/JsonDecodeDynamicReturnTypeExtension.php includes: - phpstan-safe-rule.neon diff --git a/src/Type/Php/JsonDecodeDynamicReturnTypeExtension.php b/src/Type/Php/JsonDecodeDynamicReturnTypeExtension.php new file mode 100644 index 0000000..d2c2ea1 --- /dev/null +++ b/src/Type/Php/JsonDecodeDynamicReturnTypeExtension.php @@ -0,0 +1,108 @@ +getName()) === 'safe\json_decode'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type + { + $defaultReturnType = ParametersAcceptorSelector::selectFromArgs( + $scope, + $functionCall->getArgs(), + $functionReflection->getVariants(), + )->getReturnType(); + + return $this->narrowTypeForJsonDecode($functionCall, $scope, $defaultReturnType); + } + + private function narrowTypeForJsonDecode(FuncCall $funcCall, Scope $scope, Type $fallbackType): Type + { + $args = $funcCall->getArgs(); + $isForceArray = $this->isForceArray($funcCall, $scope); + if (!isset($args[0])) { + return $fallbackType; + } + + $firstValueType = $scope->getType($args[0]->value); + if ([] !== $firstValueType->getConstantStrings()) { + $types = []; + + foreach ($firstValueType->getConstantStrings() as $constantString) { + $types[] = $this->resolveConstantStringType($constantString, $isForceArray); + } + + return TypeCombinator::union(...$types); + } + + if ($isForceArray) { + return TypeCombinator::remove($fallbackType, new ObjectWithoutClassType()); + } + + return $fallbackType; + } + + /** + * Is "json_decode(..., true)"? + */ + private function isForceArray(FuncCall $funcCall, Scope $scope): bool + { + $args = $funcCall->getArgs(); + if (!isset($args[1])) { + return false; + } + + $secondArgType = $scope->getType($args[1]->value); + $secondArgValue = 1 === \count($secondArgType->getConstantScalarValues()) ? $secondArgType->getConstantScalarValues()[0] : null; + + if (is_bool($secondArgValue)) { + return $secondArgValue; + } + + if ($secondArgValue !== null || !isset($args[3])) { + return false; + } + + // depends on used constants, @see https://www.php.net/manual/en/json.constants.php#constant.json-object-as-array + return $this->bitwiseFlagAnalyser->bitwiseOrContainsConstant($args[3]->value, $scope, 'JSON_OBJECT_AS_ARRAY')->yes(); + } + + private function resolveConstantStringType(ConstantStringType $constantStringType, bool $isForceArray): Type + { + try { + $decodedValue = \Safe\json_decode($constantStringType->getValue(), $isForceArray); + } catch (JsonException) { + return new NeverType(); + } + + return ConstantTypeHelper::getTypeFromValue($decodedValue); + } +} diff --git a/tests/Type/Php/TypeAssertionsTest.php b/tests/Type/Php/TypeAssertionsTest.php index 120b85a..75c1c7b 100644 --- a/tests/Type/Php/TypeAssertionsTest.php +++ b/tests/Type/Php/TypeAssertionsTest.php @@ -14,6 +14,7 @@ public static function dataFileAsserts(): iterable yield from self::gatherAssertTypes(__DIR__ . '/data/preg_match_unchecked.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/preg_match_checked.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/preg_replace_return.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/json_decode_return.php'); } /** diff --git a/tests/Type/Php/data/json_decode_return.php b/tests/Type/Php/data/json_decode_return.php new file mode 100644 index 0000000..d56d1fd --- /dev/null +++ b/tests/Type/Php/data/json_decode_return.php @@ -0,0 +1,44 @@ + Date: Thu, 24 Jul 2025 21:19:45 +0200 Subject: [PATCH 2/4] fix test --- tests/Type/Php/data/preg_match_unchecked.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Type/Php/data/preg_match_unchecked.php b/tests/Type/Php/data/preg_match_unchecked.php index 8a55990..8eda5bb 100644 --- a/tests/Type/Php/data/preg_match_unchecked.php +++ b/tests/Type/Php/data/preg_match_unchecked.php @@ -7,7 +7,7 @@ $string = 'Hello World'; // when return value isn't checked, we may-or-may-not have matches -$type = "array{0?: string, 1?: non-empty-string, 2?: 'o', 3?: 'World'}"; +$type = "list{0?: string, 1?: non-empty-string, 2?: 'o', 3?: 'World'}"; // @phpstan-ignore-next-line - use of unsafe is intentional \preg_match($pattern, $string, $matches); From 4d80fb29d01143df1d07d79107a6b17db1c27775 Mon Sep 17 00:00:00 2001 From: Thomas Gnandt Date: Fri, 25 Jul 2025 11:31:52 +0200 Subject: [PATCH 3/4] call phpstan extension instead of copying it --- phpstan.neon | 2 +- .../JsonDecodeDynamicReturnTypeExtension.php | 84 +++---------------- tests/Type/Php/data/json_decode_return.php | 9 -- 3 files changed, 11 insertions(+), 84 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index 85d152c..faf698f 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -26,7 +26,7 @@ parameters: count: 1 path: src/Rules/Error/SafeRuleError.php - - message: '#^Calling PHPStan\\Type\\BitwiseFlagHelper::bitwiseOrContainsConstant\(\) is not covered by backward compatibility promise\. The method might change in a minor PHPStan version\.$#' + message: '#^Calling PHPStan\\Type\\Php\\JsonThrowOnErrorDynamicReturnTypeExtension\:\:getTypeFromFunctionCall\(\) is not covered by backward compatibility promise\. The method might change in a minor PHPStan version\.$#' identifier: phpstanApi.method count: 1 path: src/Type/Php/JsonDecodeDynamicReturnTypeExtension.php diff --git a/src/Type/Php/JsonDecodeDynamicReturnTypeExtension.php b/src/Type/Php/JsonDecodeDynamicReturnTypeExtension.php index d2c2ea1..c0c6c59 100644 --- a/src/Type/Php/JsonDecodeDynamicReturnTypeExtension.php +++ b/src/Type/Php/JsonDecodeDynamicReturnTypeExtension.php @@ -4,19 +4,14 @@ namespace TheCodingMachine\Safe\PHPStan\Type\Php; use PhpParser\Node\Expr\FuncCall; +use PhpParser\Node\Name; use PHPStan\Analyser\Scope; use PHPStan\Reflection\FunctionReflection; -use PHPStan\Reflection\ParametersAcceptorSelector; -use PHPStan\Type\BitwiseFlagHelper; -use PHPStan\Type\Constant\ConstantStringType; -use PHPStan\Type\ConstantScalarType; -use PHPStan\Type\ConstantTypeHelper; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\Type\DynamicFunctionReturnTypeExtension; use PHPStan\Type\NeverType; -use PHPStan\Type\ObjectWithoutClassType; +use PHPStan\Type\Php\JsonThrowOnErrorDynamicReturnTypeExtension; use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; -use Safe\Exceptions\JsonException; /** * @see \PHPStan\Type\Php\JsonThrowOnErrorDynamicReturnTypeExtension @@ -24,7 +19,8 @@ final class JsonDecodeDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension { public function __construct( - private readonly BitwiseFlagHelper $bitwiseFlagAnalyser, + private readonly JsonThrowOnErrorDynamicReturnTypeExtension $phpstanCheck, + private readonly ReflectionProvider $reflectionProvider, ) { } @@ -35,74 +31,14 @@ public function isFunctionSupported(FunctionReflection $functionReflection): boo public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type { - $defaultReturnType = ParametersAcceptorSelector::selectFromArgs( - $scope, - $functionCall->getArgs(), - $functionReflection->getVariants(), - )->getReturnType(); + $functionReflection = $this->reflectionProvider->getFunction(new Name('json_decode'), null); + $result = $this->phpstanCheck->getTypeFromFunctionCall($functionReflection, $functionCall, $scope); - return $this->narrowTypeForJsonDecode($functionCall, $scope, $defaultReturnType); - } - - private function narrowTypeForJsonDecode(FuncCall $funcCall, Scope $scope, Type $fallbackType): Type - { - $args = $funcCall->getArgs(); - $isForceArray = $this->isForceArray($funcCall, $scope); - if (!isset($args[0])) { - return $fallbackType; - } - - $firstValueType = $scope->getType($args[0]->value); - if ([] !== $firstValueType->getConstantStrings()) { - $types = []; - - foreach ($firstValueType->getConstantStrings() as $constantString) { - $types[] = $this->resolveConstantStringType($constantString, $isForceArray); - } - - return TypeCombinator::union(...$types); - } - - if ($isForceArray) { - return TypeCombinator::remove($fallbackType, new ObjectWithoutClassType()); - } - - return $fallbackType; - } - - /** - * Is "json_decode(..., true)"? - */ - private function isForceArray(FuncCall $funcCall, Scope $scope): bool - { - $args = $funcCall->getArgs(); - if (!isset($args[1])) { - return false; - } - - $secondArgType = $scope->getType($args[1]->value); - $secondArgValue = 1 === \count($secondArgType->getConstantScalarValues()) ? $secondArgType->getConstantScalarValues()[0] : null; - - if (is_bool($secondArgValue)) { - return $secondArgValue; - } - - if ($secondArgValue !== null || !isset($args[3])) { - return false; - } - - // depends on used constants, @see https://www.php.net/manual/en/json.constants.php#constant.json-object-as-array - return $this->bitwiseFlagAnalyser->bitwiseOrContainsConstant($args[3]->value, $scope, 'JSON_OBJECT_AS_ARRAY')->yes(); - } - - private function resolveConstantStringType(ConstantStringType $constantStringType, bool $isForceArray): Type - { - try { - $decodedValue = \Safe\json_decode($constantStringType->getValue(), $isForceArray); - } catch (JsonException) { + // if PHPStan reports null and there is a json error, then an invalid constant string was passed + if ($result->isNull()->yes() && JSON_ERROR_NONE !== json_last_error()) { return new NeverType(); } - return ConstantTypeHelper::getTypeFromValue($decodedValue); + return $result; } } diff --git a/tests/Type/Php/data/json_decode_return.php b/tests/Type/Php/data/json_decode_return.php index d56d1fd..f6c681a 100644 --- a/tests/Type/Php/data/json_decode_return.php +++ b/tests/Type/Php/data/json_decode_return.php @@ -33,12 +33,3 @@ function(string $json): void { $value = \Safe\json_decode($json, true); \PHPStan\Testing\assertType('mixed~object', $value); }; - -function(string $json): void { - /** @var '{}'|'null' $json */ - $value = \Safe\json_decode($json); - \PHPStan\Testing\assertType('stdClass|null', $value); - - $value = \Safe\json_decode($json, true); - \PHPStan\Testing\assertType('array{}|null', $value); -}; From a5bb3e8b6282c419601c4521817760447fe2cf3d Mon Sep 17 00:00:00 2001 From: Thomas Gnandt Date: Fri, 25 Jul 2025 16:05:04 +0200 Subject: [PATCH 4/4] get reflection only once --- src/Type/Php/JsonDecodeDynamicReturnTypeExtension.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Type/Php/JsonDecodeDynamicReturnTypeExtension.php b/src/Type/Php/JsonDecodeDynamicReturnTypeExtension.php index c0c6c59..8ffbe62 100644 --- a/src/Type/Php/JsonDecodeDynamicReturnTypeExtension.php +++ b/src/Type/Php/JsonDecodeDynamicReturnTypeExtension.php @@ -18,10 +18,13 @@ */ final class JsonDecodeDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension { + private FunctionReflection $nativeJsonDecodeReflection; + public function __construct( private readonly JsonThrowOnErrorDynamicReturnTypeExtension $phpstanCheck, - private readonly ReflectionProvider $reflectionProvider, + ReflectionProvider $reflectionProvider, ) { + $this->nativeJsonDecodeReflection = $reflectionProvider->getFunction(new Name('json_decode'), null); } public function isFunctionSupported(FunctionReflection $functionReflection): bool @@ -31,8 +34,7 @@ public function isFunctionSupported(FunctionReflection $functionReflection): boo public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type { - $functionReflection = $this->reflectionProvider->getFunction(new Name('json_decode'), null); - $result = $this->phpstanCheck->getTypeFromFunctionCall($functionReflection, $functionCall, $scope); + $result = $this->phpstanCheck->getTypeFromFunctionCall($this->nativeJsonDecodeReflection, $functionCall, $scope); // if PHPStan reports null and there is a json error, then an invalid constant string was passed if ($result->isNull()->yes() && JSON_ERROR_NONE !== json_last_error()) {