diff --git a/src/Rules/RuleLevelHelper.php b/src/Rules/RuleLevelHelper.php index 1c1bd926f90..d164a43fb8f 100644 --- a/src/Rules/RuleLevelHelper.php +++ b/src/Rules/RuleLevelHelper.php @@ -74,7 +74,7 @@ private function transformCommonType(Type $type): Type || (!$type->isExplicitMixed() && $this->checkImplicitMixed) ) ) { - return new StrictMixedType(); + return new StrictMixedType($type->getSubtractedType()); } return $traverse($type); diff --git a/src/Type/Generic/TemplateStrictMixedType.php b/src/Type/Generic/TemplateStrictMixedType.php index 6feefd86966..fb935f00329 100644 --- a/src/Type/Generic/TemplateStrictMixedType.php +++ b/src/Type/Generic/TemplateStrictMixedType.php @@ -27,6 +27,8 @@ public function __construct( ?Type $default, ) { + parent::__construct(); + $this->scope = $scope; $this->strategy = $templateTypeStrategy; $this->variance = $templateTypeVariance; diff --git a/src/Type/MixedType.php b/src/Type/MixedType.php index bd91b5286fb..6b73080b95c 100644 --- a/src/Type/MixedType.php +++ b/src/Type/MixedType.php @@ -100,6 +100,20 @@ public function getConstantStrings(): array public function accepts(Type $type, bool $strictTypes): AcceptsResult { + if ( + $this->subtractedType !== null + && !$type instanceof NeverType + && $this->subtractedType->isSuperTypeOf($type)->yes() + ) { + return AcceptsResult::createNo([ + sprintf( + 'Type %s has already been eliminated from %s.', + $this->subtractedType->describe(VerbosityLevel::precise()), + $this->describe(VerbosityLevel::typeOnly()), + ), + ]); + } + return AcceptsResult::createYes(); } diff --git a/src/Type/StrictMixedType.php b/src/Type/StrictMixedType.php index 7477144c93d..063dac76707 100644 --- a/src/Type/StrictMixedType.php +++ b/src/Type/StrictMixedType.php @@ -23,7 +23,9 @@ use PHPStan\Type\Traits\NonGeneralizableTypeTrait; use PHPStan\Type\Traits\NonIterableTypeTrait; use PHPStan\Type\Traits\NonRemoveableTypeTrait; +use PHPStan\Type\Traits\SubstractableTypeTrait; use PHPStan\Type\Traits\UndecidedComparisonCompoundTypeTrait; +use function sprintf; class StrictMixedType implements CompoundType { @@ -33,6 +35,16 @@ class StrictMixedType implements CompoundType use NonIterableTypeTrait; use NonRemoveableTypeTrait; use NonGeneralizableTypeTrait; + use SubstractableTypeTrait; + + public function __construct(private ?Type $subtractedType = null) + { + } + + public function getSubtractedType(): ?Type + { + return $this->subtractedType; + } public function getReferencedClasses(): array { @@ -56,6 +68,20 @@ public function getConstantStrings(): array public function accepts(Type $type, bool $strictTypes): AcceptsResult { + if ( + $this->subtractedType !== null + && !$type instanceof NeverType + && $this->subtractedType->isSuperTypeOf($type)->yes() + ) { + return AcceptsResult::createNo([ + sprintf( + 'Type %s has already been eliminated from %s.', + $this->subtractedType->describe(VerbosityLevel::precise()), + $this->describe(VerbosityLevel::typeOnly()), + ), + ]); + } + return AcceptsResult::createYes(); } @@ -90,7 +116,19 @@ public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult public function equals(Type $type): bool { - return $type instanceof self; + if (!$type instanceof self) { + return false; + } + + if ($this->subtractedType === null) { + return $type->subtractedType === null; + } + + if ($type->subtractedType === null) { + return false; + } + + return $this->subtractedType->equals($type->subtractedType); } public function describe(VerbosityLevel $level): string @@ -98,8 +136,8 @@ public function describe(VerbosityLevel $level): string return $level->handle( static fn () => 'mixed', static fn () => 'mixed', - static fn () => 'mixed', - static fn () => 'strict-mixed', + fn () => 'mixed' . $this->describeSubtractedType($this->subtractedType, $level), + fn () => 'strict-mixed' . $this->describeSubtractedType($this->subtractedType, $level), ); } diff --git a/src/Type/VerbosityLevel.php b/src/Type/VerbosityLevel.php index 5783f649a43..4869b5511c4 100644 --- a/src/Type/VerbosityLevel.php +++ b/src/Type/VerbosityLevel.php @@ -128,6 +128,30 @@ public function isCache(): bool */ public static function getRecommendedLevelByType(Type $acceptingType, ?Type $acceptedType = null): self { + // A subtracted mixed only makes sense in an error message when the subtraction + // is spelled out. Template bounds are skipped - the subtraction there belongs + // to the bound, not to the type being described. + $hasSubtractedMixed = false; + TypeTraverser::map($acceptingType, static function (Type $type, callable $traverse) use (&$hasSubtractedMixed): Type { + if ($hasSubtractedMixed || $type instanceof TemplateType) { + return $type; + } + + if ( + ($type instanceof MixedType || $type instanceof StrictMixedType) + && $type->getSubtractedType() !== null + ) { + $hasSubtractedMixed = true; + return $type; + } + + return $traverse($type); + }); + + if ($hasSubtractedMixed) { + return self::precise(); + } + $moreVerbose = false; $veryVerbose = false; $moreVerboseCallback = static function (Type $type, callable $traverse) use (&$moreVerbose, &$veryVerbose): Type { diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleInArrayHaystackFiniteTypesRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleInArrayHaystackFiniteTypesRuleTest.php index cb135396815..ab8efd4b0f6 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleInArrayHaystackFiniteTypesRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleInArrayHaystackFiniteTypesRuleTest.php @@ -42,7 +42,7 @@ public function testRule(): void 38, ], [ - 'Value \'installed\' in the haystack passed to in_array() can never be identical to the needle type mixed.', + 'Value \'installed\' in the haystack passed to in_array() can never be identical to the needle type mixed~\'installed\'.', 99, 'Type \'installed\' has already been eliminated from mixed.', ], diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index df3def0c39d..c6d8f49cfce 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -3018,6 +3018,56 @@ public function testBug11894(): void $this->analyse([__DIR__ . '/data/bug-11894.php'], []); } + public static function dataNonEmptyMixedParameter(): iterable + { + yield [false]; + yield [true]; + } + + #[DataProvider('dataNonEmptyMixedParameter')] + public function testNonEmptyMixedParameter(bool $checkExplicitMixed): void + { + $this->checkExplicitMixed = $checkExplicitMixed; + $this->checkImplicitMixed = $checkExplicitMixed; + $this->analyse([__DIR__ . '/data/non-empty-mixed-parameter.php'], [ + [ + 'Parameter #1 $value of function NonEmptyMixedParameter\acceptsNonEmptyMixed expects mixed~(0|0.0|\'\'|\'0\'|array{}|false|null), \'\' given.', + 17, + 'Type 0|0.0|\'\'|\'0\'|array{}|false|null has already been eliminated from mixed.', + ], + [ + 'Parameter #1 $value of function NonEmptyMixedParameter\acceptsNonEmptyMixed expects mixed~(0|0.0|\'\'|\'0\'|array{}|false|null), \'0\' given.', + 18, + 'Type 0|0.0|\'\'|\'0\'|array{}|false|null has already been eliminated from mixed.', + ], + [ + 'Parameter #1 $value of function NonEmptyMixedParameter\acceptsNonEmptyMixed expects mixed~(0|0.0|\'\'|\'0\'|array{}|false|null), 0 given.', + 19, + 'Type 0|0.0|\'\'|\'0\'|array{}|false|null has already been eliminated from mixed.', + ], + [ + 'Parameter #1 $value of function NonEmptyMixedParameter\acceptsNonEmptyMixed expects mixed~(0|0.0|\'\'|\'0\'|array{}|false|null), 0.0 given.', + 20, + 'Type 0|0.0|\'\'|\'0\'|array{}|false|null has already been eliminated from mixed.', + ], + [ + 'Parameter #1 $value of function NonEmptyMixedParameter\acceptsNonEmptyMixed expects mixed~(0|0.0|\'\'|\'0\'|array{}|false|null), array{} given.', + 21, + 'Type 0|0.0|\'\'|\'0\'|array{}|false|null has already been eliminated from mixed.', + ], + [ + 'Parameter #1 $value of function NonEmptyMixedParameter\acceptsNonEmptyMixed expects mixed~(0|0.0|\'\'|\'0\'|array{}|false|null), false given.', + 22, + 'Type 0|0.0|\'\'|\'0\'|array{}|false|null has already been eliminated from mixed.', + ], + [ + 'Parameter #1 $value of function NonEmptyMixedParameter\acceptsNonEmptyMixed expects mixed~(0|0.0|\'\'|\'0\'|array{}|false|null), null given.', + 23, + 'Type 0|0.0|\'\'|\'0\'|array{}|false|null has already been eliminated from mixed.', + ], + ]); + } + public function testBug11494(): void { $this->analyse([__DIR__ . '/data/bug-11494.php'], [ diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index 88a4b5562cf..3bc7c388c78 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -6,6 +6,7 @@ use PHPStan\Rules\Rule; use PHPStan\Rules\RuleLevelHelper; use PHPStan\Testing\RuleTestCase; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhp; /** @@ -438,6 +439,36 @@ public function testBug14428(): void $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14428.php'], []); } + public static function dataNonEmptyMixedReturn(): iterable + { + yield [false]; + yield [true]; + } + + #[DataProvider('dataNonEmptyMixedReturn')] + public function testNonEmptyMixedReturn(bool $checkExplicitMixed): void + { + $this->checkNullables = true; + $this->checkExplicitMixed = $checkExplicitMixed; + $this->analyse([__DIR__ . '/data/non-empty-mixed-return.php'], [ + [ + 'Function NonEmptyMixedReturn\returnsEmptyString() should return mixed~(0|0.0|\'\'|\'0\'|array{}|false|null) but returns \'\'.', + 8, + 'Type 0|0.0|\'\'|\'0\'|array{}|false|null has already been eliminated from mixed.', + ], + [ + 'Function NonEmptyMixedReturn\returnsNull() should return mixed~(0|0.0|\'\'|\'0\'|array{}|false|null) but returns null.', + 14, + 'Type 0|0.0|\'\'|\'0\'|array{}|false|null has already been eliminated from mixed.', + ], + [ + 'Function NonEmptyMixedReturn\returnsEmptyArray() should return mixed~(0|0.0|\'\'|\'0\'|array{}|false|null) but returns array{}.', + 20, + 'Type 0|0.0|\'\'|\'0\'|array{}|false|null has already been eliminated from mixed.', + ], + ]); + } + public function testBug13565(): void { $this->checkNullables = true; diff --git a/tests/PHPStan/Rules/Functions/data/non-empty-mixed-parameter.php b/tests/PHPStan/Rules/Functions/data/non-empty-mixed-parameter.php new file mode 100644 index 00000000000..65f01824a69 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/non-empty-mixed-parameter.php @@ -0,0 +1,45 @@ +accepts($otherType, true)->result; + $this->assertSame( + $expectedResult->describe(), + $actualResult->describe(), + sprintf('%s -> accepts(%s)', $type->describe(VerbosityLevel::precise()), $otherType->describe(VerbosityLevel::precise())), + ); + } + public static function dataSubstractedIsArray(): array { return [