diff --git a/conf/config.neon b/conf/config.neon index cb8c20ad48..b57baff06a 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -82,6 +82,7 @@ parameters: reportWrongPhpDocTypeInVarTag: false reportAnyTypeWideningInVarTag: false reportNonIntStringArrayKey: false + reportUnsafeArrayStringKeyCasting: detect reportPossiblyNonexistentGeneralArrayOffset: false reportPossiblyNonexistentConstantArrayOffset: false checkMissingOverrideMethodAttribute: false diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index bc79fe7c40..300ea5ac34 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -91,6 +91,7 @@ parametersSchema: reportWrongPhpDocTypeInVarTag: bool() reportAnyTypeWideningInVarTag: bool() reportNonIntStringArrayKey: bool() + reportUnsafeArrayStringKeyCasting: schema(string(), pattern('detect|prevent'), nullable()) reportPossiblyNonexistentGeneralArrayOffset: bool() reportPossiblyNonexistentConstantArrayOffset: bool() checkMissingOverrideMethodAttribute: bool() diff --git a/src/DependencyInjection/ContainerFactory.php b/src/DependencyInjection/ContainerFactory.php index cac88d0e39..6e8f0a385e 100644 --- a/src/DependencyInjection/ContainerFactory.php +++ b/src/DependencyInjection/ContainerFactory.php @@ -202,6 +202,7 @@ public static function postInitializeContainer(Container $container): void $container->getService('typeSpecifier'); BleedingEdgeToggle::setBleedingEdge($container->getParameter('featureToggles')['bleedingEdge']); + ReportUnsafeArrayStringKeyCastingToggle::setLevel($container->getParameter('reportUnsafeArrayStringKeyCasting')); } public function getCurrentWorkingDirectory(): string diff --git a/src/DependencyInjection/ReportUnsafeArrayStringKeyCastingToggle.php b/src/DependencyInjection/ReportUnsafeArrayStringKeyCastingToggle.php new file mode 100644 index 0000000000..e2f13563fe --- /dev/null +++ b/src/DependencyInjection/ReportUnsafeArrayStringKeyCastingToggle.php @@ -0,0 +1,34 @@ + */ private array $genericTypeResolvingStack = []; + /** + * @param ReportUnsafeArrayStringKeyCastingLevel $reportUnsafeArrayStringKeyCasting + */ public function __construct( private TypeNodeResolverExtensionRegistryProvider $extensionRegistryProvider, private ReflectionProvider\ReflectionProviderProvider $reflectionProviderProvider, private TypeAliasResolverProvider $typeAliasResolverProvider, private ConstantResolver $constantResolver, private InitializerExprTypeResolver $initializerExprTypeResolver, + #[AutowiredParameter] + private ?string $reportUnsafeArrayStringKeyCasting, ) { } @@ -661,7 +672,7 @@ private function resolveConditionalTypeForParameterNode(ConditionalTypeForParame private function resolveArrayTypeNode(ArrayTypeNode $typeNode, NameScope $nameScope): Type { $itemType = $this->resolve($typeNode->type, $nameScope); - return new ArrayType(new BenevolentUnionType([new IntegerType(), new StringType()]), $itemType); + return new ArrayType((new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(), $itemType); } private function resolveGenericTypeNode(GenericTypeNode $typeNode, NameScope $nameScope): Type @@ -686,9 +697,23 @@ static function (string $variance): TemplateTypeVariance { if (in_array($mainTypeName, ['array', 'non-empty-array'], true)) { if (count($genericTypes) === 1) { // array - $arrayType = new ArrayType(new BenevolentUnionType([new IntegerType(), new StringType()]), $genericTypes[0]); + $arrayType = new ArrayType((new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(), $genericTypes[0]); } elseif (count($genericTypes) === 2) { // array - $keyType = TypeCombinator::intersect($genericTypes[0]->toArrayKey(), new UnionType([ + $originalKey = $genericTypes[0]; + if ($this->reportUnsafeArrayStringKeyCasting === ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { + $originalKey = TypeTraverser::map($originalKey, static function (Type $type, callable $traverse) { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + if ($type instanceof StringType) { + return TypeCombinator::intersect($type, new AccessoryDecimalIntegerStringType(inverse: true)); + } + + return $type; + }); + } + $keyType = TypeCombinator::intersect($originalKey->toArrayKey(), new UnionType([ new IntegerType(), new StringType(), ]))->toArrayKey(); diff --git a/src/Type/Accessory/AccessoryDecimalIntegerStringType.php b/src/Type/Accessory/AccessoryDecimalIntegerStringType.php index b04cbc13ef..93f2eed9f4 100644 --- a/src/Type/Accessory/AccessoryDecimalIntegerStringType.php +++ b/src/Type/Accessory/AccessoryDecimalIntegerStringType.php @@ -246,7 +246,7 @@ public function toArray(): Type public function toArrayKey(): Type { if ($this->inverse) { - return new StringType(); + return $this; } return new IntegerType(); diff --git a/src/Type/Accessory/AccessoryNumericStringType.php b/src/Type/Accessory/AccessoryNumericStringType.php index 62e320e5de..d0a7d65ac0 100644 --- a/src/Type/Accessory/AccessoryNumericStringType.php +++ b/src/Type/Accessory/AccessoryNumericStringType.php @@ -2,6 +2,7 @@ namespace PHPStan\Type\Accessory; +use PHPStan\DependencyInjection\ReportUnsafeArrayStringKeyCastingToggle; use PHPStan\Php\PhpVersion; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; @@ -209,12 +210,20 @@ public function toArray(): Type public function toArrayKey(): Type { + $level = ReportUnsafeArrayStringKeyCastingToggle::getLevel(); + if ($level !== ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { + new UnionType([ + new IntegerType(), + new IntersectionType([ + new StringType(), + new AccessoryNumericStringType(), + ]), + ]); + } + return new UnionType([ new IntegerType(), - new IntersectionType([ - new StringType(), - new AccessoryNumericStringType(), - ]), + new IntersectionType([new StringType(), new AccessoryDecimalIntegerStringType(inverse: true)]), ]); } diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index 288caefdc6..8a82601946 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -2,6 +2,7 @@ namespace PHPStan\Type; +use PHPStan\DependencyInjection\ReportUnsafeArrayStringKeyCastingToggle; use PHPStan\Php\PhpVersion; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; @@ -12,6 +13,7 @@ use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; +use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\Constant\ConstantArrayType; @@ -32,6 +34,7 @@ use PHPStan\Type\Traits\UndecidedComparisonTypeTrait; use function array_merge; use function count; +use function in_array; use function sprintf; /** @api */ @@ -47,14 +50,16 @@ class ArrayType implements Type private Type $keyType; + private ?Type $cachedIterableKeyType = null; + /** @api */ public function __construct(Type $keyType, private Type $itemType) { - if ($keyType->describe(VerbosityLevel::value()) === '(int|string)') { + if (in_array($keyType->describe(VerbosityLevel::value()), ['(int|string)', '(int|non-decimal-int-string)'], true)) { $keyType = new MixedType(); } if ($keyType instanceof StrictMixedType && !$keyType instanceof TemplateStrictMixedType) { - $keyType = new UnionType([new StringType(), new IntegerType()]); + $keyType = (new UnionType([new StringType(), new IntegerType()]))->toArrayKey(); } $this->keyType = $keyType; @@ -198,15 +203,44 @@ public function getArraySize(): Type public function getIterableKeyType(): Type { + if ($this->cachedIterableKeyType !== null) { + return $this->cachedIterableKeyType; + } $keyType = $this->keyType; if ($keyType instanceof MixedType && !$keyType instanceof TemplateMixedType) { - return new BenevolentUnionType([new IntegerType(), new StringType()]); + $keyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); } if ($keyType instanceof StrictMixedType) { - return new BenevolentUnionType([new IntegerType(), new StringType()]); + $keyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); + } + + $level = ReportUnsafeArrayStringKeyCastingToggle::getLevel(); + if ($level === null) { + return $this->cachedIterableKeyType = $keyType; + } + + if ($level === ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { + return $this->cachedIterableKeyType = $keyType; + } + + if ($level !== ReportUnsafeArrayStringKeyCastingToggle::DETECT) { // @phpstan-ignore notIdentical.alwaysFalse + throw new ShouldNotHappenException(); } - return $keyType; + return $this->cachedIterableKeyType = TypeTraverser::map($keyType, static function (Type $type, callable $traverse): Type { + if ($type instanceof UnionType) { + return $traverse($type); + } + + if ($type->isString()->yes() && !$type->isDecimalIntegerString()->no()) { + return TypeCombinator::union( + new IntegerType(), + TypeCombinator::intersect($type, new AccessoryDecimalIntegerStringType(inverse: true)), + ); + } + + return $type; + }); } public function getFirstIterableKeyType(): Type diff --git a/src/Type/StringType.php b/src/Type/StringType.php index 0336130033..afbdcf92a4 100644 --- a/src/Type/StringType.php +++ b/src/Type/StringType.php @@ -2,12 +2,14 @@ namespace PHPStan\Type; +use PHPStan\DependencyInjection\ReportUnsafeArrayStringKeyCastingToggle; use PHPStan\Php\PhpVersion; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\Reflection\ReflectionProviderStaticAccessor; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; +use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantBooleanType; @@ -177,7 +179,15 @@ public function toArray(): Type public function toArrayKey(): Type { - return $this; + $level = ReportUnsafeArrayStringKeyCastingToggle::getLevel(); + if ($level !== ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { + return $this; + } + + return new UnionType([ + new IntegerType(), + TypeCombinator::intersect($this, new AccessoryDecimalIntegerStringType(inverse: true)), + ]); } public function toCoercedArgumentType(bool $strictTypes): Type diff --git a/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeAcceptanceTest.php b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeAcceptanceTest.php new file mode 100644 index 0000000000..02412d5940 --- /dev/null +++ b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeAcceptanceTest.php @@ -0,0 +1,45 @@ + + */ +class ReportUnsafeArrayStringKeyCastingDetectTypeAcceptanceTest extends RuleTestCase +{ + + public function getRule(): Rule + { + return self::getContainer()->getByType(CallMethodsRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/report-unsafe-array-string-key-casting-accepts.php'], [ + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\Foo::doBaz() expects array, non-empty-array given.', + 33, + ], + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\Foo::doBaz() expects array, non-empty-array given.', + 39, + ], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return array_merge( + parent::getAdditionalConfigFiles(), + [ + __DIR__ . '/reportUnsafeArrayStringKeyCastingDetect.neon', + ], + ); + } + +} diff --git a/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeInferenceTest.php b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeInferenceTest.php new file mode 100644 index 0000000000..1ad96508af --- /dev/null +++ b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingDetectTypeInferenceTest.php @@ -0,0 +1,40 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + public static function getAdditionalConfigFiles(): array + { + return array_merge( + parent::getAdditionalConfigFiles(), + [ + __DIR__ . '/reportUnsafeArrayStringKeyCastingDetect.neon', + ], + ); + } + +} diff --git a/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest.php b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest.php new file mode 100644 index 0000000000..846ab7512a --- /dev/null +++ b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest.php @@ -0,0 +1,53 @@ + + */ +class ReportUnsafeArrayStringKeyCastingPreventTypeAcceptanceTest extends RuleTestCase +{ + + public function getRule(): Rule + { + return self::getContainer()->getByType(CallMethodsRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/report-unsafe-array-string-key-casting-accepts.php'], [ + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\Foo::doFoo() expects array, non-empty-array given.', + 31, + ], + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\Foo::doBaz() expects array, non-empty-array given.', + 33, + ], + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\Foo::doFoo() expects array, non-empty-array given.', + 37, + ], + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\Foo::doBaz() expects array, non-empty-array given.', + 39, + ], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return array_merge( + parent::getAdditionalConfigFiles(), + [ + __DIR__ . '/reportUnsafeArrayStringKeyCastingPrevent.neon', + ], + ); + } + +} diff --git a/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeInferenceTest.php b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeInferenceTest.php new file mode 100644 index 0000000000..0f8b64b4b5 --- /dev/null +++ b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingPreventTypeInferenceTest.php @@ -0,0 +1,40 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + public static function getAdditionalConfigFiles(): array + { + return array_merge( + parent::getAdditionalConfigFiles(), + [ + __DIR__ . '/reportUnsafeArrayStringKeyCastingPrevent.neon', + ], + ); + } + +} diff --git a/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingUnsafeTypeAcceptanceTest.php b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingUnsafeTypeAcceptanceTest.php new file mode 100644 index 0000000000..429a6a438a --- /dev/null +++ b/tests/PHPStan/Analyser/ReportUnsafeArrayStringKeyCastingUnsafeTypeAcceptanceTest.php @@ -0,0 +1,34 @@ + + */ +class ReportUnsafeArrayStringKeyCastingUnsafeTypeAcceptanceTest extends RuleTestCase +{ + + public function getRule(): Rule + { + return self::getContainer()->getByType(CallMethodsRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/report-unsafe-array-string-key-casting-accepts.php'], [ + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\Foo::doBaz() expects array, non-empty-array given.', + 33, + ], + [ + 'Parameter #1 $a of method ReportUnsafeArrayStringKeyCastingAccepts\Foo::doBaz() expects array, non-empty-array given.', + 39, + ], + ]); + } + +} diff --git a/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-accepts.php b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-accepts.php new file mode 100644 index 0000000000..d04e42a499 --- /dev/null +++ b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-accepts.php @@ -0,0 +1,42 @@ + $a */ + public function doFoo(array $a): void + { + + } + + /** @param array $a */ + public function doBar(array $a): void + { + + } + + /** @param array $a */ + public function doBaz(array $a): void + { + + } + + public function doTest(string $s): void + { + $a = [$s => new stdClass()]; + $this->doFoo($a); + $this->doBar($a); + $this->doBaz($a); + + $b = []; + $b[$s] = new stdClass(); + $this->doFoo($b); + $this->doBar($b); + $this->doBaz($b); + } + +} diff --git a/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-detect.php b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-detect.php new file mode 100644 index 0000000000..df88c4437d --- /dev/null +++ b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-detect.php @@ -0,0 +1,91 @@ + $a + */ + public function doFoo(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('int|non-decimal-int-string', $k); + } + } + + /** + * @param array $a + */ + public function doBar(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('(int|non-decimal-int-string)', $k); + } + } + + /** + * @param array $a + */ + public function doBaz(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('int|non-decimal-int-string', $k); + } + } + + public function doArrayCreationAndAssign(string $s): void + { + $a = [$s => 1]; + assertType('non-empty-array', $a); + + $b = []; + $b[$s] = 2; + assertType('non-empty-array', $b); + } + +} + +class FooNonDecimalIntString +{ + + /** + * @param array $a + */ + public function doFoo(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('non-decimal-int-string', $k); + } + } + + /** + * @param array $a + */ + public function doBaz(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('int|non-decimal-int-string', $k); + } + } + + /** @param non-decimal-int-string $s */ + public function doArrayCreationAndAssign(string $s): void + { + $a = [$s => 1]; + assertType('non-empty-array', $a); + + $b = []; + $b[$s] = 2; + assertType('non-empty-array', $b); + } + +} diff --git a/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-prevent.php b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-prevent.php new file mode 100644 index 0000000000..163a996bd2 --- /dev/null +++ b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-prevent.php @@ -0,0 +1,91 @@ + $a + */ + public function doFoo(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('non-decimal-int-string', $k); + } + } + + /** + * @param array $a + */ + public function doBar(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('(int|non-decimal-int-string)', $k); + } + } + + /** + * @param array $a + */ + public function doBaz(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('int|non-decimal-int-string', $k); + } + } + + public function doArrayCreationAndAssign(string $s): void + { + $a = [$s => 1]; + assertType('non-empty-array', $a); + + $b = []; + $b[$s] = 2; + assertType('non-empty-array', $b); + } + +} + +class FooNonDecimalIntString +{ + + /** + * @param array $a + */ + public function doFoo(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('non-decimal-int-string', $k); + } + } + + /** + * @param array $a + */ + public function doBaz(array $a): void + { + assertType('array', $a); + foreach ($a as $k => $v) { + assertType('int|non-decimal-int-string', $k); + } + } + + /** @param non-decimal-int-string $s */ + public function doArrayCreationAndAssign(string $s): void + { + $a = [$s => 1]; + assertType('non-empty-array', $a); + + $b = []; + $b[$s] = 2; + assertType('non-empty-array', $b); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/decimal-int-string.php b/tests/PHPStan/Analyser/nsrt/decimal-int-string.php index 6374752502..fe5c5fdd52 100644 --- a/tests/PHPStan/Analyser/nsrt/decimal-int-string.php +++ b/tests/PHPStan/Analyser/nsrt/decimal-int-string.php @@ -35,6 +35,16 @@ public function doBar(string $s): void assertType('float|int', $s + $s); } + public function doBaz(string $s): void + { + $a = [$s => 1]; + assertType('non-empty-array', $a); + + $b = []; + $b[$s] = 2; + assertType('non-empty-array', $b); + } + /** * @param non-decimal-int-string $s */ diff --git a/tests/PHPStan/Analyser/reportUnsafeArrayStringKeyCastingDetect.neon b/tests/PHPStan/Analyser/reportUnsafeArrayStringKeyCastingDetect.neon new file mode 100644 index 0000000000..1ea800ca91 --- /dev/null +++ b/tests/PHPStan/Analyser/reportUnsafeArrayStringKeyCastingDetect.neon @@ -0,0 +1,2 @@ +parameters: + reportUnsafeArrayStringKeyCasting: detect diff --git a/tests/PHPStan/Analyser/reportUnsafeArrayStringKeyCastingPrevent.neon b/tests/PHPStan/Analyser/reportUnsafeArrayStringKeyCastingPrevent.neon new file mode 100644 index 0000000000..f35820e866 --- /dev/null +++ b/tests/PHPStan/Analyser/reportUnsafeArrayStringKeyCastingPrevent.neon @@ -0,0 +1,2 @@ +parameters: + reportUnsafeArrayStringKeyCasting: prevent