diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 256cd87c44..9640c1af44 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -777,6 +777,12 @@ parameters: count: 1 path: src/Type/Accessory/AccessoryArrayListType.php + - + rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.' + identifier: phpstanApi.instanceofType + count: 1 + path: src/Type/Accessory/AccessoryDecimalIntegerStringType.php + - rawMessage: Doing instanceof PHPStan\Type\IntersectionType is error-prone and deprecated. identifier: phpstanApi.instanceofType @@ -1662,7 +1668,7 @@ parameters: - rawMessage: Doing instanceof PHPStan\Type\IntersectionType is error-prone and deprecated. identifier: phpstanApi.instanceofType - count: 6 + count: 7 path: src/Type/TypeCombinator.php - diff --git a/src/Type/Accessory/AccessoryDecimalIntegerStringType.php b/src/Type/Accessory/AccessoryDecimalIntegerStringType.php index d965eb0ebc..df5059700c 100644 --- a/src/Type/Accessory/AccessoryDecimalIntegerStringType.php +++ b/src/Type/Accessory/AccessoryDecimalIntegerStringType.php @@ -16,6 +16,7 @@ use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantIntegerType; +use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\ErrorType; use PHPStan\Type\FloatType; use PHPStan\Type\GeneralizePrecision; @@ -27,7 +28,6 @@ use PHPStan\Type\Traits\NonGenericTypeTrait; use PHPStan\Type\Traits\NonIterableTypeTrait; use PHPStan\Type\Traits\NonObjectTypeTrait; -use PHPStan\Type\Traits\NonRemoveableTypeTrait; use PHPStan\Type\Traits\UndecidedComparisonCompoundTypeTrait; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -55,7 +55,6 @@ class AccessoryDecimalIntegerStringType implements CompoundType, AccessoryType use NonIterableTypeTrait; use UndecidedComparisonCompoundTypeTrait; use NonGenericTypeTrait; - use NonRemoveableTypeTrait; /** @api */ public function __construct(private bool $inverse = false) @@ -203,6 +202,19 @@ public function unsetOffset(Type $offsetType): Type return new ErrorType(); } + public function tryRemove(Type $typeToRemove): ?Type + { + if ($this->inverse) { + return null; + } + + if ($typeToRemove instanceof ConstantStringType && $typeToRemove->getValue() === '0') { + return new IntersectionType([new StringType(), $this, new AccessoryNonFalsyStringType()]); + } + + return null; + } + public function toNumber(): Type { if ($this->inverse) { diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index b3a0e1b757..77d9e9caad 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -7,6 +7,7 @@ use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; use PHPStan\Type\Accessory\AccessoryLowercaseStringType; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; +use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\Accessory\AccessoryType; use PHPStan\Type\Accessory\AccessoryUppercaseStringType; use PHPStan\Type\Accessory\HasOffsetType; @@ -600,13 +601,9 @@ private static function compareTypesInUnion(Type $a, Type $b): ?array } if ($a->getValue() === '0') { - $description = $b->describe(VerbosityLevel::value()); - if ($description === 'non-falsy-string') { - return [null, new IntersectionType([ - new StringType(), - new AccessoryNonEmptyStringType(), - ...self::getAccessoryCaseStringTypes($b), - ])]; + $nonEmpty = self::downgradeNonFalsyStringToNonEmpty($b); + if ($nonEmpty !== null) { + return [null, $nonEmpty]; } } } @@ -625,13 +622,9 @@ private static function compareTypesInUnion(Type $a, Type $b): ?array } if ($b->getValue() === '0') { - $description = $a->describe(VerbosityLevel::value()); - if ($description === 'non-falsy-string') { - return [new IntersectionType([ - new StringType(), - new AccessoryNonEmptyStringType(), - ...self::getAccessoryCaseStringTypes($a), - ]), null]; + $nonEmpty = self::downgradeNonFalsyStringToNonEmpty($a); + if ($nonEmpty !== null) { + return [$nonEmpty, null]; } } } @@ -673,6 +666,43 @@ private static function getAccessoryCaseStringTypes(Type $type): array return $accessory; } + /** + * Turns a non-falsy-string type into its non-empty-string counterpart by + * downgrading the non-falsy accessory while preserving every other accessory + * (numeric-string, decimal-int-string, lowercase-string, …). Used to simplify + * `'0' | non-falsy-string-X` back to `non-empty-string-X`, since `"0"` is the + * only value that separates the two. Returns null when $type is not a + * non-constant non-falsy-string built from an intersection. + */ + private static function downgradeNonFalsyStringToNonEmpty(Type $type): ?Type + { + if (!$type instanceof IntersectionType || $type->isNonFalsyString()->no()) { + return null; + } + + $newTypes = []; + $found = false; + foreach ($type->getTypes() as $innerType) { + if ($innerType instanceof AccessoryNonFalsyStringType) { + $found = true; + continue; + } + + $newTypes[] = $innerType; + } + + if (!$found) { + return null; + } + + $withoutNonFalsy = self::intersect(...$newTypes); + if ($withoutNonFalsy->isNonEmptyString()->yes()) { + return $withoutNonFalsy; + } + + return self::intersect($withoutNonFalsy, new AccessoryNonEmptyStringType()); + } + private static function removeDecimalIntStringAccessory(Type $type): Type { if (!$type instanceof IntersectionType) { diff --git a/tests/PHPStan/Analyser/nsrt/decimal-int-string.php b/tests/PHPStan/Analyser/nsrt/decimal-int-string.php index fe5c5fdd52..171e8972e9 100644 --- a/tests/PHPStan/Analyser/nsrt/decimal-int-string.php +++ b/tests/PHPStan/Analyser/nsrt/decimal-int-string.php @@ -55,4 +55,118 @@ public function emptyStringIsNonDecimal(string $s): void } } + /** + * @param decimal-int-string $s + */ + public function removingZeroMakesNonFalsy(string $s): void + { + if ($s !== '0') { + assertType('decimal-int-string&non-falsy-string', $s); + } else { + assertType("'0'", $s); + } + + if ($s != '0') { + assertType('decimal-int-string&non-falsy-string', $s); + } + + if ($s) { + assertType('decimal-int-string&non-falsy-string', $s); + } else { + assertType("'0'", $s); + } + } + + /** + * @param numeric-string $s + */ + public function removingZeroMakesNumericNonFalsy(string $s): void + { + if ($s !== '0') { + assertType('non-falsy-string&numeric-string', $s); + } else { + assertType("'0'", $s); + } + } + + /** + * @param non-decimal-int-string $s + */ + public function removingZeroFromNonDecimal(string $s): void + { + // '0' is a decimal-int-string, so it is not part of non-decimal-int-string + if ($s !== '0') { + assertType('non-decimal-int-string', $s); + } + } + + /** + * @param decimal-int-string $s + */ + public function unionWithZeroRoundTrips(string $s): void + { + $t = $s !== '0' ? $s : '0'; + assertType('decimal-int-string', $t); + } + + /** + * @param numeric-string $s + */ + public function numericUnionWithZeroRoundTrips(string $s): void + { + $t = $s !== '0' ? $s : '0'; + assertType('numeric-string', $t); + } + + /** + * @param non-empty-string $s + */ + public function nonEmptyUnionWithZeroRoundTrips(string $s): void + { + $t = $s !== '0' ? $s : '0'; + assertType('non-empty-string', $t); + } + + /** + * @param uppercase-string $s + */ + public function removingZeroFromUppercase(string $s): void + { + // '' is a falsy uppercase-string too, so removing '0' alone does not + // make an uppercase-string non-falsy. + if ($s !== '0') { + assertType('uppercase-string', $s); + } + } + + /** + * @param uppercase-string $s + */ + public function uppercaseUnionWithZeroRoundTrips(string $s): void + { + $t = $s !== '0' ? $s : '0'; + assertType('uppercase-string', $t); + } + + /** + * @param lowercase-string $s + */ + public function removingZeroFromLowercase(string $s): void + { + // '' is a falsy lowercase-string too, so removing '0' alone does not + // make a lowercase-string non-falsy. + if ($s !== '0') { + assertType('lowercase-string', $s); + } + } + + /** + * @param lowercase-string $s + */ + public function lowercaseUnionWithZeroRoundTrips(string $s): void + { + $t = $s !== '0' ? $s : '0'; + assertType('lowercase-string', $t); + } + }