From b9894fa84cebc3fa0e2fce926eeb7eb86bf7abb9 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Thu, 16 Jan 2025 15:33:55 +0100 Subject: [PATCH] Add ArrayChangeKeyCaseFunctionReturnTypeExtension --- conf/config.neon | 5 + ...gumentBasedFunctionReturnTypeExtension.php | 1 - ...angeKeyCaseFunctionReturnTypeExtension.php | 159 ++++++++++++++++++ .../Analyser/LegacyNodeScopeResolverTest.php | 2 +- .../Analyser/nsrt/array-change-key-case.php | 98 +++++++++++ .../Rules/Functions/ReturnTypeRuleTest.php | 7 + .../Rules/Functions/data/bug-10960.php | 26 +++ 7 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 src/Type/Php/ArrayChangeKeyCaseFunctionReturnTypeExtension.php create mode 100644 tests/PHPStan/Analyser/nsrt/array-change-key-case.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-10960.php diff --git a/conf/config.neon b/conf/config.neon index 19b6388858..a605093fcb 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -1177,6 +1177,11 @@ services: tags: - phpstan.broker.dynamicFunctionReturnTypeExtension + - + class: PHPStan\Type\Php\ArrayChangeKeyCaseFunctionReturnTypeExtension + tags: + - phpstan.broker.dynamicFunctionReturnTypeExtension + - class: PHPStan\Type\Php\ArrayIntersectKeyFunctionReturnTypeExtension tags: diff --git a/src/Type/Php/ArgumentBasedFunctionReturnTypeExtension.php b/src/Type/Php/ArgumentBasedFunctionReturnTypeExtension.php index b2508600f4..6e3c75b9a1 100644 --- a/src/Type/Php/ArgumentBasedFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArgumentBasedFunctionReturnTypeExtension.php @@ -18,7 +18,6 @@ final class ArgumentBasedFunctionReturnTypeExtension implements DynamicFunctionR private const FUNCTION_NAMES = [ 'array_unique' => 0, - 'array_change_key_case' => 0, 'array_diff_assoc' => 0, 'array_diff_key' => 0, 'array_diff_uassoc' => 0, diff --git a/src/Type/Php/ArrayChangeKeyCaseFunctionReturnTypeExtension.php b/src/Type/Php/ArrayChangeKeyCaseFunctionReturnTypeExtension.php new file mode 100644 index 0000000000..391974231d --- /dev/null +++ b/src/Type/Php/ArrayChangeKeyCaseFunctionReturnTypeExtension.php @@ -0,0 +1,159 @@ +getName() === 'array_change_key_case'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (!isset($functionCall->getArgs()[0])) { + return null; + } + + $arrayType = $scope->getType($functionCall->getArgs()[0]->value); + if (!isset($functionCall->getArgs()[1])) { + $case = CASE_LOWER; + } else { + $caseType = $scope->getType($functionCall->getArgs()[1]->value); + $scalarValues = $caseType->getConstantScalarValues(); + if (count($scalarValues) === 1) { + $case = (int) $scalarValues[0]; + } else { + $case = null; + } + } + + $constantArrays = $arrayType->getConstantArrays(); + if (count($constantArrays) > 0) { + $arrayTypes = []; + foreach ($constantArrays as $constantArray) { + $newConstantArrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + $valueTypes = $constantArray->getValueTypes(); + foreach ($constantArray->getKeyTypes() as $i => $keyType) { + $valueType = $valueTypes[$i]; + + $constantStrings = $keyType->getConstantStrings(); + if (count($constantStrings) > 0) { + $keyType = TypeCombinator::union( + ...array_map( + fn (ConstantStringType $type): Type => $this->mapConstantString($type, $case), + $constantStrings, + ), + ); + } + + $newConstantArrayBuilder->setOffsetValueType( + $keyType, + $valueType, + $constantArray->isOptionalKey($i), + ); + } + $newConstantArrayType = $newConstantArrayBuilder->getArray(); + if ($constantArray->isList()->yes()) { + $newConstantArrayType = AccessoryArrayListType::intersectWith($newConstantArrayType); + } + $arrayTypes[] = $newConstantArrayType; + } + + $newArrayType = TypeCombinator::union(...$arrayTypes); + } else { + $keysType = $arrayType->getIterableKeyType(); + + $keysType = TypeTraverser::map($keysType, function (Type $type, callable $traverse) use ($case): Type { + if ($type instanceof UnionType) { + return $traverse($type); + } + + $constantStrings = $type->getConstantStrings(); + if (count($constantStrings) > 0) { + return TypeCombinator::union( + ...array_map( + fn (ConstantStringType $type): Type => $this->mapConstantString($type, $case), + $constantStrings, + ), + ); + } + + if ($type->isString()->yes()) { + $types = [new StringType()]; + if ($type->isNonFalsyString()->yes()) { + $types[] = new AccessoryNonFalsyStringType(); + } elseif ($type->isNonEmptyString()->yes()) { + $types[] = new AccessoryNonEmptyStringType(); + } + if ($type->isNumericString()->yes()) { + $types[] = new AccessoryNumericStringType(); + } + if ($case === CASE_LOWER) { + $types[] = new AccessoryLowercaseStringType(); + } elseif ($case === CASE_UPPER) { + $types[] = new AccessoryUppercaseStringType(); + } + + return TypeCombinator::intersect(...$types); + } + + return $type; + }); + + $newArrayType = TypeCombinator::intersect(new ArrayType( + $keysType, + $arrayType->getIterableValueType(), + ), ...TypeUtils::getAccessoryTypes($arrayType)); + } + + if ($arrayType->isIterableAtLeastOnce()->yes()) { + $newArrayType = TypeCombinator::intersect($newArrayType, new NonEmptyArrayType()); + } + + return $newArrayType; + } + + private function mapConstantString(ConstantStringType $type, ?int $case): Type + { + if ($case === CASE_LOWER) { + return new ConstantStringType(strtolower($type->getValue())); + } elseif ($case === CASE_UPPER) { + return new ConstantStringType(strtoupper($type->getValue())); + } + + return TypeCombinator::union( + new ConstantStringType(strtolower($type->getValue())), + new ConstantStringType(strtoupper($type->getValue())), + ); + } + +} diff --git a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php index 42b0d24959..64bb226b0f 100644 --- a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php @@ -4577,7 +4577,7 @@ public function dataArrayFunctions(): array '$reducedToInt', ], [ - 'array<0|1|2, 1|2|3>', + 'array{1, 2, 3}', 'array_change_key_case($integers)', ], [ diff --git a/tests/PHPStan/Analyser/nsrt/array-change-key-case.php b/tests/PHPStan/Analyser/nsrt/array-change-key-case.php new file mode 100644 index 0000000000..aef6c0ac75 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-change-key-case.php @@ -0,0 +1,98 @@ + $arr1 + * @param array $arr2 + * @param array $arr3 + * @param array $arr4 + * @param array $arr5 + * @param array $arr6 + * @param array $arr7 + * @param array $arr8 + * @param array{foo: 1, bar?: 2} $arr9 + * @param array<'foo'|'bar', string> $arr10 + * @param list $list + * @param non-empty-array $nonEmpty + */ + public function sayHello( + array $arr1, + array $arr2, + array $arr3, + array $arr4, + array $arr5, + array $arr6, + array $arr7, + array $arr8, + array $arr9, + array $arr10, + array $list, + array $nonEmpty, + int $case + ): void { + assertType('array', array_change_key_case($arr1)); + assertType('array', array_change_key_case($arr1, CASE_LOWER)); + assertType('array', array_change_key_case($arr1, CASE_UPPER)); + assertType('array', array_change_key_case($arr1, $case)); + + assertType('array', array_change_key_case($arr2)); + assertType('array', array_change_key_case($arr2, CASE_LOWER)); + assertType('array', array_change_key_case($arr2, CASE_UPPER)); + assertType('array', array_change_key_case($arr2, $case)); + + assertType('array', array_change_key_case($arr3)); + assertType('array', array_change_key_case($arr3, CASE_LOWER)); + assertType('array', array_change_key_case($arr3, CASE_UPPER)); + assertType('array', array_change_key_case($arr3, $case)); + + assertType('array', array_change_key_case($arr4)); + assertType('array', array_change_key_case($arr4, CASE_LOWER)); + assertType('array', array_change_key_case($arr4, CASE_UPPER)); + assertType('array', array_change_key_case($arr4, $case)); + + assertType('array', array_change_key_case($arr5)); + assertType('array', array_change_key_case($arr5, CASE_LOWER)); + assertType('array', array_change_key_case($arr5, CASE_UPPER)); + assertType('array', array_change_key_case($arr5, $case)); + + assertType('array', array_change_key_case($arr6)); + assertType('array', array_change_key_case($arr6, CASE_LOWER)); + assertType('array', array_change_key_case($arr6, CASE_UPPER)); + assertType('array', array_change_key_case($arr6, $case)); + + assertType('array', array_change_key_case($arr7)); + assertType('array', array_change_key_case($arr7, CASE_LOWER)); + assertType('array', array_change_key_case($arr7, CASE_UPPER)); + assertType('array', array_change_key_case($arr7, $case)); + + assertType('array', array_change_key_case($arr8)); + assertType('array', array_change_key_case($arr8, CASE_LOWER)); + assertType('array', array_change_key_case($arr8, CASE_UPPER)); + assertType('array', array_change_key_case($arr8, $case)); + + assertType('array{foo: 1, bar?: 2}', array_change_key_case($arr9)); + assertType('array{foo: 1, bar?: 2}', array_change_key_case($arr9, CASE_LOWER)); + assertType('array{FOO: 1, BAR?: 2}', array_change_key_case($arr9, CASE_UPPER)); + assertType("non-empty-array<'BAR'|'bar'|'FOO'|'foo', 1|2>", array_change_key_case($arr9, $case)); + + assertType("array<'bar'|'foo', string>", array_change_key_case($arr10)); + assertType("array<'bar'|'foo', string>", array_change_key_case($arr10, CASE_LOWER)); + assertType("array<'BAR'|'FOO', string>", array_change_key_case($arr10, CASE_UPPER)); + assertType("array<'BAR'|'bar'|'FOO'|'foo', string>", array_change_key_case($arr10, $case)); + + assertType('list', array_change_key_case($list)); + assertType('list', array_change_key_case($list, CASE_LOWER)); + assertType('list', array_change_key_case($list, CASE_UPPER)); + assertType('list', array_change_key_case($list, $case)); + + assertType('non-empty-array', array_change_key_case($nonEmpty)); + assertType('non-empty-array', array_change_key_case($nonEmpty, CASE_LOWER)); + assertType('non-empty-array', array_change_key_case($nonEmpty, CASE_UPPER)); + assertType('non-empty-array', array_change_key_case($nonEmpty, $case)); + } +} diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index 28d766512f..d8a9be4200 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -283,6 +283,13 @@ public function testBug10732(): void $this->analyse([__DIR__ . '/data/bug-10732.php'], []); } + public function testBug10960(): void + { + $this->checkExplicitMixed = true; + $this->checkNullables = true; + $this->analyse([__DIR__ . '/data/bug-10960.php'], []); + } + public function testBug11518(): void { $this->checkExplicitMixed = true; diff --git a/tests/PHPStan/Rules/Functions/data/bug-10960.php b/tests/PHPStan/Rules/Functions/data/bug-10960.php new file mode 100644 index 0000000000..4b64fae830 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-10960.php @@ -0,0 +1,26 @@ + 'bar']); +lowerCaseKey(['FOO' => 'bar']);