Skip to content

Commit 1b231d1

Browse files
authored
Merge pull request #61 from Khartir/add-json_decode-return-type-extension
Add json decode return type extension
2 parents f84cf63 + a5bb3e8 commit 1b231d1

File tree

6 files changed

+92
-1
lines changed

6 files changed

+92
-1
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\\Php\\JsonThrowOnErrorDynamicReturnTypeExtension\:\:getTypeFromFunctionCall\(\) 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: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php declare(strict_types=1);
2+
3+
4+
namespace TheCodingMachine\Safe\PHPStan\Type\Php;
5+
6+
use PhpParser\Node\Expr\FuncCall;
7+
use PhpParser\Node\Name;
8+
use PHPStan\Analyser\Scope;
9+
use PHPStan\Reflection\FunctionReflection;
10+
use PHPStan\Reflection\ReflectionProvider;
11+
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
12+
use PHPStan\Type\NeverType;
13+
use PHPStan\Type\Php\JsonThrowOnErrorDynamicReturnTypeExtension;
14+
use PHPStan\Type\Type;
15+
16+
/**
17+
* @see \PHPStan\Type\Php\JsonThrowOnErrorDynamicReturnTypeExtension
18+
*/
19+
final class JsonDecodeDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension
20+
{
21+
private FunctionReflection $nativeJsonDecodeReflection;
22+
23+
public function __construct(
24+
private readonly JsonThrowOnErrorDynamicReturnTypeExtension $phpstanCheck,
25+
ReflectionProvider $reflectionProvider,
26+
) {
27+
$this->nativeJsonDecodeReflection = $reflectionProvider->getFunction(new Name('json_decode'), null);
28+
}
29+
30+
public function isFunctionSupported(FunctionReflection $functionReflection): bool
31+
{
32+
return strtolower($functionReflection->getName()) === 'safe\json_decode';
33+
}
34+
35+
public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type
36+
{
37+
$result = $this->phpstanCheck->getTypeFromFunctionCall($this->nativeJsonDecodeReflection, $functionCall, $scope);
38+
39+
// if PHPStan reports null and there is a json error, then an invalid constant string was passed
40+
if ($result->isNull()->yes() && JSON_ERROR_NONE !== json_last_error()) {
41+
return new NeverType();
42+
}
43+
44+
return $result;
45+
}
46+
}

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: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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+
};

tests/Type/Php/data/preg_match_unchecked.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
$string = 'Hello World';
88

99
// when return value isn't checked, we may-or-may-not have matches
10-
$type = "array{0?: string, 1?: non-empty-string, 2?: 'o', 3?: 'World'}";
10+
$type = "list{0?: string, 1?: non-empty-string, 2?: 'o', 3?: 'World'}";
1111

1212
// @phpstan-ignore-next-line - use of unsafe is intentional
1313
\preg_match($pattern, $string, $matches);

0 commit comments

Comments
 (0)