Skip to content

Commit 98ad59c

Browse files
committed
add JsonDecodeDynamicReturnTypeExtension
1 parent 5f9795e commit 98ad59c

File tree

5 files changed

+162
-0
lines changed

5 files changed

+162
-0
lines changed

phpstan-safe-rule.neon

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,7 @@ services:
2323
class: TheCodingMachine\Safe\PHPStan\Type\Php\PregMatchTypeSpecifyingExtension
2424
tags:
2525
- phpstan.typeSpecifier.functionTypeSpecifyingExtension
26+
-
27+
class: TheCodingMachine\Safe\PHPStan\Type\Php\JsonDecodeDynamicReturnTypeExtension
28+
tags:
29+
- phpstan.broker.dynamicFunctionReturnTypeExtension

phpstan.neon

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,10 @@ parameters:
2525
identifier: phpstanApi.interface
2626
count: 1
2727
path: src/Rules/Error/SafeRuleError.php
28+
-
29+
message: '#^Calling PHPStan\\Type\\BitwiseFlagHelper::bitwiseOrContainsConstant\(\) is not covered by backward compatibility promise\. The method might change in a minor PHPStan version\.$#'
30+
identifier: phpstanApi.method
31+
count: 1
32+
path: src/Type/Php/JsonDecodeDynamicReturnTypeExtension.php
2833
includes:
2934
- phpstan-safe-rule.neon
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php declare(strict_types=1);
2+
3+
4+
namespace TheCodingMachine\Safe\PHPStan\Type\Php;
5+
6+
use PhpParser\Node\Expr\FuncCall;
7+
use PHPStan\Analyser\Scope;
8+
use PHPStan\Reflection\FunctionReflection;
9+
use PHPStan\Reflection\ParametersAcceptorSelector;
10+
use PHPStan\Type\BitwiseFlagHelper;
11+
use PHPStan\Type\Constant\ConstantStringType;
12+
use PHPStan\Type\ConstantScalarType;
13+
use PHPStan\Type\ConstantTypeHelper;
14+
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
15+
use PHPStan\Type\NeverType;
16+
use PHPStan\Type\ObjectWithoutClassType;
17+
use PHPStan\Type\Type;
18+
use PHPStan\Type\TypeCombinator;
19+
use Safe\Exceptions\JsonException;
20+
21+
/**
22+
* @see \PHPStan\Type\Php\JsonThrowOnErrorDynamicReturnTypeExtension
23+
*/
24+
final class JsonDecodeDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension
25+
{
26+
public function __construct(
27+
private readonly BitwiseFlagHelper $bitwiseFlagAnalyser,
28+
) {
29+
}
30+
31+
public function isFunctionSupported(FunctionReflection $functionReflection): bool
32+
{
33+
return strtolower($functionReflection->getName()) === 'safe\json_decode';
34+
}
35+
36+
public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type
37+
{
38+
$defaultReturnType = ParametersAcceptorSelector::selectFromArgs(
39+
$scope,
40+
$functionCall->getArgs(),
41+
$functionReflection->getVariants(),
42+
)->getReturnType();
43+
44+
return $this->narrowTypeForJsonDecode($functionCall, $scope, $defaultReturnType);
45+
}
46+
47+
private function narrowTypeForJsonDecode(FuncCall $funcCall, Scope $scope, Type $fallbackType): Type
48+
{
49+
$args = $funcCall->getArgs();
50+
$isForceArray = $this->isForceArray($funcCall, $scope);
51+
if (!isset($args[0])) {
52+
return $fallbackType;
53+
}
54+
55+
$firstValueType = $scope->getType($args[0]->value);
56+
if ([] !== $firstValueType->getConstantStrings()) {
57+
$types = [];
58+
59+
foreach ($firstValueType->getConstantStrings() as $constantString) {
60+
$types[] = $this->resolveConstantStringType($constantString, $isForceArray);
61+
}
62+
63+
return TypeCombinator::union(...$types);
64+
}
65+
66+
if ($isForceArray) {
67+
return TypeCombinator::remove($fallbackType, new ObjectWithoutClassType());
68+
}
69+
70+
return $fallbackType;
71+
}
72+
73+
/**
74+
* Is "json_decode(..., true)"?
75+
*/
76+
private function isForceArray(FuncCall $funcCall, Scope $scope): bool
77+
{
78+
$args = $funcCall->getArgs();
79+
if (!isset($args[1])) {
80+
return false;
81+
}
82+
83+
$secondArgType = $scope->getType($args[1]->value);
84+
$secondArgValue = 1 === \count($secondArgType->getConstantScalarValues()) ? $secondArgType->getConstantScalarValues()[0] : null;
85+
86+
if (is_bool($secondArgValue)) {
87+
return $secondArgValue;
88+
}
89+
90+
if ($secondArgValue !== null || !isset($args[3])) {
91+
return false;
92+
}
93+
94+
// depends on used constants, @see https://www.php.net/manual/en/json.constants.php#constant.json-object-as-array
95+
return $this->bitwiseFlagAnalyser->bitwiseOrContainsConstant($args[3]->value, $scope, 'JSON_OBJECT_AS_ARRAY')->yes();
96+
}
97+
98+
private function resolveConstantStringType(ConstantStringType $constantStringType, bool $isForceArray): Type
99+
{
100+
try {
101+
$decodedValue = \Safe\json_decode($constantStringType->getValue(), $isForceArray);
102+
} catch (JsonException) {
103+
return new NeverType();
104+
}
105+
106+
return ConstantTypeHelper::getTypeFromValue($decodedValue);
107+
}
108+
}

tests/Type/Php/TypeAssertionsTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public static function dataFileAsserts(): iterable
1414
yield from self::gatherAssertTypes(__DIR__ . '/data/preg_match_unchecked.php');
1515
yield from self::gatherAssertTypes(__DIR__ . '/data/preg_match_checked.php');
1616
yield from self::gatherAssertTypes(__DIR__ . '/data/preg_replace_return.php');
17+
yield from self::gatherAssertTypes(__DIR__ . '/data/json_decode_return.php');
1718
}
1819

1920
/**
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
$value = \Safe\json_decode('null');
3+
\PHPStan\Testing\assertType('null', $value);
4+
5+
$value = \Safe\json_decode('false');
6+
\PHPStan\Testing\assertType('false', $value);
7+
8+
$value = \Safe\json_decode('[]');
9+
\PHPStan\Testing\assertType('array{}', $value);
10+
11+
$value = \Safe\json_decode('{}');
12+
\PHPStan\Testing\assertType('stdClass', $value);
13+
14+
$value = \Safe\json_decode('{}', true);
15+
\PHPStan\Testing\assertType('array{}', $value);
16+
17+
$value = \Safe\json_decode('{}', flags: JSON_OBJECT_AS_ARRAY);
18+
\PHPStan\Testing\assertType('array{}', $value);
19+
20+
$value = \Safe\json_decode('{"foo": "bar"}');
21+
\PHPStan\Testing\assertType('stdClass', $value);
22+
23+
$value = \Safe\json_decode('{"foo": "bar"}', true);
24+
\PHPStan\Testing\assertType("array{foo: 'bar'}", $value);
25+
26+
$value = \Safe\json_decode('{', true);
27+
\PHPStan\Testing\assertType('*NEVER*', $value);
28+
29+
function(string $json): void {
30+
$value = \Safe\json_decode($json);
31+
\PHPStan\Testing\assertType('mixed', $value);
32+
33+
$value = \Safe\json_decode($json, true);
34+
\PHPStan\Testing\assertType('mixed~object', $value);
35+
};
36+
37+
function(string $json): void {
38+
/** @var '{}'|'null' $json */
39+
$value = \Safe\json_decode($json);
40+
\PHPStan\Testing\assertType('stdClass|null', $value);
41+
42+
$value = \Safe\json_decode($json, true);
43+
\PHPStan\Testing\assertType('array{}|null', $value);
44+
};

0 commit comments

Comments
 (0)