diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 85d9184..ecf19cc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -113,6 +113,11 @@ jobs: if: matrix.php-version == '7.1' || matrix.php-version == '7.2' || matrix.php-version == '7.3' run: "composer require --dev phpunit/phpunit:^7.5.20 --update-with-dependencies" + # in PHP 7.1, the json_decode() 2nd parameter requires bool, while PHP 7.2 it's null|int + - name: "Downgrade nette/utils" + if: matrix.php-version == '7.1' + run: "composer require --dev nette/utils:^2.3 nette/forms:^2.4 --update-with-dependencies" + - name: "Tests" run: "make tests" diff --git a/composer.json b/composer.json index dd310ac..2dd00e9 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,8 @@ "phpstan/phpstan-php-parser": "^1.1", "phpstan/phpstan-phpunit": "^1.0", "phpstan/phpstan-strict-rules": "^1.0", - "phpunit/phpunit": "^9.5" + "phpunit/phpunit": "^9.5", + "tracy/tracy": "^2.9" }, "config": { "platform": { @@ -50,6 +51,9 @@ } }, "autoload-dev": { + "psr-4": { + "PHPStan\\": "tests/" + }, "classmap": [ "tests/" ] diff --git a/src/Type/Nette/NetteUtilsJsonDecodeDynamicReturnTypeExtension.php b/src/Type/Nette/NetteUtilsJsonDecodeDynamicReturnTypeExtension.php new file mode 100644 index 0000000..8dbcd43 --- /dev/null +++ b/src/Type/Nette/NetteUtilsJsonDecodeDynamicReturnTypeExtension.php @@ -0,0 +1,124 @@ +getName() === 'decode'; + } + + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): Type + { + $args = $methodCall->getArgs(); + + $isForceArray = $this->isForceArray($args); + + $firstArgValue = $args[0]->value; + $firstValueType = $scope->getType($firstArgValue); + + if ($firstValueType instanceof ConstantStringType) { + $resolvedType = $this->resolveConstantStringType($firstValueType, $isForceArray); + } else { + $resolvedType = new MixedType(); + } + + if (! $resolvedType instanceof MixedType) { + return $resolvedType; + } + + // fallback type + if ($isForceArray) { + return new UnionType([ + new ArrayType(new MixedType(), new MixedType()), + new StringType(), + new FloatType(), + new IntegerType(), + new BooleanType(), + ]); + } + + // scalar types with stdClass + return new UnionType([ + new ObjectType(stdClass::class), + new StringType(), + new FloatType(), + new IntegerType(), + new BooleanType(), + ]); + } + + /** + * @param Arg[] $args + */ + private function isForceArray(array $args): bool + { + if (!isset($args[1])) { + return false; + } + + $secondArg = $args[1]; + + // is second arg force array? + if ($secondArg->value instanceof ClassConstFetch) { + $classConstFetch = $secondArg->value; + + if ($classConstFetch->class instanceof Name) { + if (! $classConstFetch->name instanceof Identifier) { + return false; + } + + if ($classConstFetch->class->toString() !== 'Nette\Utils\Json') { + return false; + } + + if ($classConstFetch->name->toString() === 'FORCE_ARRAY') { + return true; + } + } + } + + return false; + } + + private function resolveConstantStringType(ConstantStringType $constantStringType, bool $isForceArray): Type + { + if ($isForceArray) { + $decodedValue = Json::decode($constantStringType->getValue(), Json::FORCE_ARRAY); + } else { + $decodedValue = Json::decode($constantStringType->getValue()); + } + + return ConstantTypeHelper::getTypeFromValue($decodedValue); + } + +} diff --git a/src/Type/Php/JsonDecodeDynamicReturnTypeExtension.php b/src/Type/Php/JsonDecodeDynamicReturnTypeExtension.php new file mode 100644 index 0000000..673a01d --- /dev/null +++ b/src/Type/Php/JsonDecodeDynamicReturnTypeExtension.php @@ -0,0 +1,86 @@ +getName()); + + return $functionReflection->getName() === 'json_decode'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $funcCall, Scope $scope): Type + { + $args = $funcCall->getArgs(); + + dump('___'); + die; + + $isForceArray = $this->isForceArray($funcCall); + + $firstArgValue = $args[0]->value; + $firstValueType = $scope->getType($firstArgValue); + + if ($firstValueType instanceof ConstantStringType) { + $resolvedType = $this->resolveConstantStringType($firstValueType, $isForceArray); + } else { + $resolvedType = new MixedType(); + } + + if (! $resolvedType instanceof MixedType) { + return $resolvedType; + } + + // fallback type + if ($isForceArray) { + return new UnionType([ + new ArrayType(new MixedType(), new MixedType()), + new StringType(), + new FloatType(), + new IntegerType(), + new BooleanType(), + ]); + } + + // scalar types with stdClass + return new UnionType([ + new ObjectType(stdClass::class), + new StringType(), + new FloatType(), + new IntegerType(), + new BooleanType(), + ]); + } + + private function resolveConstantStringType(ConstantStringType $constantStringType, bool $isForceArray): Type + { + if ($isForceArray) { + $decodedValue = Json::decode($constantStringType->getValue(), Json::FORCE_ARRAY); + } else { + $decodedValue = Json::decode($constantStringType->getValue()); + } + + return ConstantTypeHelper::getTypeFromValue($decodedValue); + } +} diff --git a/tests/Type/Nette/NetteUtilsJsonDecodeDynamicReturnTypeExtensionTest.php b/tests/Type/Nette/NetteUtilsJsonDecodeDynamicReturnTypeExtensionTest.php new file mode 100644 index 0000000..b2bbf75 --- /dev/null +++ b/tests/Type/Nette/NetteUtilsJsonDecodeDynamicReturnTypeExtensionTest.php @@ -0,0 +1,41 @@ + + */ + public function dataAsserts(): iterable + { + yield from $this->gatherAssertTypes(__DIR__ . '/data/json_decode.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/json_decode_force_array.php'); + + yield from $this->gatherAssertTypes(__DIR__ . '/data/json_decode_unknown_type.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/json_decode_force_array_unknown_type.php'); + } + + /** + * @dataProvider dataAsserts() + * @param string $assertType + * @param string $file + * @param mixed ...$args + */ + public function testAsserts(string $assertType, string $file, ...$args): void + { + $this->assertFileAsserts($assertType, $file, ...$args); + } + + /** + * @return string[] + */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/config/nette_json_decode_extension.neon']; + } + +} diff --git a/tests/Type/Nette/config/nette_json_decode_extension.neon b/tests/Type/Nette/config/nette_json_decode_extension.neon new file mode 100644 index 0000000..283fe30 --- /dev/null +++ b/tests/Type/Nette/config/nette_json_decode_extension.neon @@ -0,0 +1,5 @@ +services: + - + class: PHPStan\Type\Nette\NetteUtilsJsonDecodeDynamicReturnTypeExtension + tags: + - phpstan.broker.dynamicStaticMethodReturnTypeExtension diff --git a/tests/Type/Nette/data/json_decode.php b/tests/Type/Nette/data/json_decode.php new file mode 100644 index 0000000..328a9e1 --- /dev/null +++ b/tests/Type/Nette/data/json_decode.php @@ -0,0 +1,22 @@ + + */ + public function dataAsserts(): iterable + { + yield from $this->gatherAssertTypes(__DIR__ . '/data/json_decode.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/json_decode_force_array.php'); + } + + /** + * @dataProvider dataAsserts() + * @param string $assertType + * @param string $file + * @param mixed ...$args + */ + public function testAsserts(string $assertType, string $file, ...$args): void + { + $this->assertFileAsserts($assertType, $file, ...$args); + } + + /** + * @return string[] + */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/config/json_decode_extension.neon']; + } + + + +} diff --git a/tests/Type/Php/config/json_decode_extension.neon b/tests/Type/Php/config/json_decode_extension.neon new file mode 100644 index 0000000..5f18ab6 --- /dev/null +++ b/tests/Type/Php/config/json_decode_extension.neon @@ -0,0 +1,5 @@ +services: + - + class: PHPStan\Type\Php\JsonDecodeDynamicReturnTypeExtension + tags: + - phpstan.broker.dynamicFunctionReturnTypeExtension diff --git a/tests/Type/Php/data/json_decode.php b/tests/Type/Php/data/json_decode.php new file mode 100644 index 0000000..f02b790 --- /dev/null +++ b/tests/Type/Php/data/json_decode.php @@ -0,0 +1,21 @@ +