diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ec7779bfbed..733646773b2 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -837,6 +837,12 @@ parameters: count: 1 path: src/Type/Accessory/HasMethodType.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/HasOffsetType.php + - rawMessage: Doing instanceof PHPStan\Type\IntersectionType is error-prone and deprecated. identifier: phpstanApi.instanceofType @@ -852,7 +858,7 @@ parameters: - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.' identifier: phpstanApi.instanceofType - count: 2 + count: 3 path: src/Type/Accessory/HasOffsetValueType.php - @@ -954,7 +960,7 @@ parameters: - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.' identifier: phpstanApi.instanceofType - count: 2 + count: 3 path: src/Type/Constant/ConstantArrayType.php - diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 292aea88cd5..3e9682ad5ab 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -63,7 +63,6 @@ use PHPStan\Type\TypeTraverser; use PHPStan\Type\UnionType; use Throwable; -use function array_fill; use function array_filter; use function array_map; use function array_merge; @@ -750,64 +749,12 @@ private function getArraySortPreserveListFunctionType(Type $type): Type private function getArraySortDoNotPreserveListFunctionType(Type $type): Type { - $isIterableAtLeastOnce = $type->isIterableAtLeastOnce(); - if ($isIterableAtLeastOnce->no()) { - return $type; - } - - return TypeTraverser::map($type, static function (Type $type, callable $traverse) use ($isIterableAtLeastOnce): Type { - if ($type instanceof UnionType) { - return $traverse($type); - } - - $constantArrays = $type->getConstantArrays(); - if (count($constantArrays) > 0) { - $types = []; - foreach ($constantArrays as $constantArray) { - $types[] = new ConstantArrayType( - $constantArray->getKeyTypes(), - $constantArray->getValueTypes(), - $constantArray->getNextAutoIndexes(), - $constantArray->getOptionalKeys(), - $constantArray->isList()->and(TrinaryLogic::createMaybe()), - ); - } - - return TypeCombinator::union(...$types); - } - - $newArrayType = new ArrayType($type->getIterableKeyType(), $type->getIterableValueType()); - if ($isIterableAtLeastOnce->yes()) { - $newArrayType = new IntersectionType([$newArrayType, new NonEmptyArrayType()]); - } - - return $newArrayType; - }); + return $type->makeListMaybe(); } private function getArrayWalkResultType(Type $arrayType, Type $newValueType): Type { - return TypeTraverser::map($arrayType, static function (Type $type, callable $traverse) use ($newValueType): Type { - if ($type instanceof UnionType || $type instanceof IntersectionType) { - return $traverse($type); - } - - if ($type instanceof ConstantArrayType) { - return new ConstantArrayType( - $type->getKeyTypes(), - array_fill(0, count($type->getValueTypes()), $newValueType), - $type->getNextAutoIndexes(), - $type->getOptionalKeys(), - $type->isList(), - ); - } - - if (!$type instanceof ArrayType) { - return $type; - } - - return new ArrayType($type->getKeyType(), $newValueType); - }); + return $arrayType->mapValueType(static fn (Type $type): Type => $newValueType); } public function resolveType(MutatingScope $scope, Expr $expr): Type diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 0bad74200b7..63a7ffc2ff5 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -139,14 +139,12 @@ use PHPStan\Rules\Properties\ReadWritePropertiesExtensionProvider; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; -use PHPStan\Type\ArrayType; use PHPStan\Type\ClosureType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\FileTypeMapper; use PHPStan\Type\Generic\TemplateTypeHelper; use PHPStan\Type\Generic\TemplateTypeMap; -use PHPStan\Type\IntersectionType; use PHPStan\Type\MixedType; use PHPStan\Type\NeverType; use PHPStan\Type\NullType; @@ -1381,35 +1379,22 @@ public function processStmtNode( $keyTypeChanged = !$keyLoopType->equals($exprType->getIterableKeyType()); if ($valueTypeChanged || $keyTypeChanged) { - $newExprType = TypeTraverser::map($exprType, static function (Type $type, callable $traverse) use ($arrayDimFetchLoopType, $keyLoopType, $valueTypeChanged, $keyTypeChanged): Type { - if ($type instanceof UnionType || $type instanceof IntersectionType) { - return $traverse($type); - } - - if (!$type instanceof ArrayType) { - return $type; - } + $newExprType = $exprType; + if ($valueTypeChanged) { + $newExprType = $newExprType->mapValueType(static fn (Type $type): Type => $arrayDimFetchLoopType); + } + if ($keyTypeChanged) { + $newExprType = $newExprType->mapKeyType(static fn (Type $type): Type => $keyLoopType); + } - return new ArrayType( - $keyTypeChanged ? $keyLoopType : $type->getKeyType(), - $valueTypeChanged ? $arrayDimFetchLoopType : $type->getIterableValueType(), - ); - }); $nativeExprType = $scope->getNativeType($stmt->expr); - $newExprNativeType = TypeTraverser::map($nativeExprType, static function (Type $type, callable $traverse) use ($arrayDimFetchLoopNativeType, $keyLoopNativeType, $valueTypeChanged, $keyTypeChanged): Type { - if ($type instanceof UnionType || $type instanceof IntersectionType) { - return $traverse($type); - } - - if (!$type instanceof ArrayType) { - return $type; - } - - return new ArrayType( - $keyTypeChanged ? $keyLoopNativeType : $type->getKeyType(), - $valueTypeChanged ? $arrayDimFetchLoopNativeType : $type->getIterableValueType(), - ); - }); + $newExprNativeType = $nativeExprType; + if ($valueTypeChanged) { + $newExprNativeType = $newExprNativeType->mapValueType(static fn (Type $type): Type => $arrayDimFetchLoopNativeType); + } + if ($keyTypeChanged) { + $newExprNativeType = $newExprNativeType->mapKeyType(static fn (Type $type): Type => $keyLoopNativeType); + } if ($stmt->expr instanceof Variable && is_string($stmt->expr->name)) { $finalScope = $finalScope->assignVariable( diff --git a/src/Type/Accessory/AccessoryArrayListType.php b/src/Type/Accessory/AccessoryArrayListType.php index ff3536f4735..e8af2f9a9a0 100644 --- a/src/Type/Accessory/AccessoryArrayListType.php +++ b/src/Type/Accessory/AccessoryArrayListType.php @@ -260,6 +260,44 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return $this; } + public function makeListMaybe(): Type + { + // This accessory is the list assertion itself; weakening the + // list-ness to "maybe" means the accessory no longer applies. + // Returning `MixedType` lets the enclosing `IntersectionType` drop + // it via `TypeCombinator::intersect` while preserving the rest. + return new MixedType(); + } + + public function mapValueType(callable $cb): Type + { + // Mapping values doesn't disturb list-ness. + return $this; + } + + public function mapKeyType(callable $cb): Type + { + return $this; + } + + public function makeAllArrayKeysOptional(): Type + { + // Marking keys optional in an arbitrary list keeps it a list. + return $this; + } + + public function changeKeyCaseArray(?int $case): Type + { + // List keys are integers; case-folding leaves them alone. + return $this; + } + + public function filterArrayRemovingFalsey(): Type + { + // Filtering creates gaps in the integer-key sequence — list-ness lost. + return new MixedType(); + } + public function isIterable(): TrinaryLogic { return TrinaryLogic::createYes(); diff --git a/src/Type/Accessory/HasOffsetType.php b/src/Type/Accessory/HasOffsetType.php index b6757fefb62..8146403416d 100644 --- a/src/Type/Accessory/HasOffsetType.php +++ b/src/Type/Accessory/HasOffsetType.php @@ -32,6 +32,10 @@ use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; use function sprintf; +use function strtolower; +use function strtoupper; +use const CASE_LOWER; +use const CASE_UPPER; class HasOffsetType implements CompoundType, AccessoryType { @@ -221,6 +225,59 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return new MixedType(); } + public function makeListMaybe(): Type + { + // Having an offset doesn't conflict with list-being-maybe. + return $this; + } + + public function mapValueType(callable $cb): Type + { + // `HasOffsetType` only records that an offset exists, not its + // value; the assertion still holds after a value transformation. + return $this; + } + + public function mapKeyType(callable $cb): Type + { + // Match the prior `TypeTraverser`-based pattern that left + // accessories untouched while rewriting the array key type. + return $this; + } + + public function makeAllArrayKeysOptional(): Type + { + // "Has offset X" is no longer guaranteed when X is now optional. + return new MixedType(); + } + + public function changeKeyCaseArray(?int $case): Type + { + // A string offset is itself case-folded; an int offset is unchanged. + if (!$this->offsetType instanceof ConstantStringType) { + return $this; + } + + $value = $this->offsetType->getValue(); + if ($case === CASE_LOWER) { + return new self(new ConstantStringType(strtolower($value))); + } + if ($case === CASE_UPPER) { + return new self(new ConstantStringType(strtoupper($value))); + } + + // Unknown case → could be either fold; the accessory weakens to + // "no specific offset known". + return new MixedType(); + } + + public function filterArrayRemovingFalsey(): Type + { + // We don't track the value at this offset, so we can't guarantee + // it survives a falsey filter. Drop the assertion. + return new MixedType(); + } + public function isIterableAtLeastOnce(): TrinaryLogic { return TrinaryLogic::createYes(); diff --git a/src/Type/Accessory/HasOffsetValueType.php b/src/Type/Accessory/HasOffsetValueType.php index 66872dc2f3e..f72d0641107 100644 --- a/src/Type/Accessory/HasOffsetValueType.php +++ b/src/Type/Accessory/HasOffsetValueType.php @@ -21,6 +21,7 @@ use PHPStan\Type\IsSuperTypeOfResult; use PHPStan\Type\MixedType; use PHPStan\Type\ObjectWithoutClassType; +use PHPStan\Type\StaticTypeFactory; use PHPStan\Type\StringType; use PHPStan\Type\Traits\MaybeArrayTypeTrait; use PHPStan\Type\Traits\MaybeCallableTypeTrait; @@ -36,6 +37,10 @@ use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; use function sprintf; +use function strtolower; +use function strtoupper; +use const CASE_LOWER; +use const CASE_UPPER; class HasOffsetValueType implements CompoundType, AccessoryType { @@ -309,6 +314,64 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return new MixedType(); } + public function makeListMaybe(): Type + { + // Knowing a specific offset/value is independent of list-ness. + return $this; + } + + public function mapValueType(callable $cb): Type + { + // The assertion is "offset X has value V"; after the transform + // the value at X is `cb(V)`. + return new self($this->offsetType, $cb($this->valueType)); + } + + public function mapKeyType(callable $cb): Type + { + // The offset itself is unaffected; passes through. + return $this; + } + + public function makeAllArrayKeysOptional(): Type + { + return new MixedType(); + } + + public function changeKeyCaseArray(?int $case): Type + { + if (!$this->offsetType instanceof ConstantStringType) { + return $this; + } + + $value = $this->offsetType->getValue(); + if ($case === CASE_LOWER) { + return new self(new ConstantStringType(strtolower($value)), $this->valueType); + } + if ($case === CASE_UPPER) { + return new self(new ConstantStringType(strtoupper($value)), $this->valueType); + } + + // Unknown case → drop the specific-offset assertion. + return new MixedType(); + } + + public function filterArrayRemovingFalsey(): Type + { + $falseyTypes = StaticTypeFactory::falsey(); + $isFalsey = $falseyTypes->isSuperTypeOf($this->valueType); + if ($isFalsey->yes()) { + // Definitely filtered out — the offset assertion no longer holds. + return new MixedType(); + } + if ($isFalsey->no()) { + // Definitely survives. + return $this; + } + // Maybe filtered: drop the specific-value assertion. + return new MixedType(); + } + public function isIterableAtLeastOnce(): TrinaryLogic { return TrinaryLogic::createYes(); diff --git a/src/Type/Accessory/NonEmptyArrayType.php b/src/Type/Accessory/NonEmptyArrayType.php index 688da67695a..6fdeddd96e8 100644 --- a/src/Type/Accessory/NonEmptyArrayType.php +++ b/src/Type/Accessory/NonEmptyArrayType.php @@ -247,6 +247,42 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return new MixedType(); } + public function makeListMaybe(): Type + { + // Non-emptiness is independent of list-ness; weaken-list keeps it. + return $this; + } + + public function mapValueType(callable $cb): Type + { + // Mapping doesn't change the entry count; non-emptiness is preserved. + return $this; + } + + public function mapKeyType(callable $cb): Type + { + return $this; + } + + public function makeAllArrayKeysOptional(): Type + { + // Without `ConstantArrayType` keys to mark optional, this is a no-op. + // Non-emptiness is unrelated to per-key optionality and is preserved. + return $this; + } + + public function changeKeyCaseArray(?int $case): Type + { + // Case-folding keys doesn't change the entry count. + return $this; + } + + public function filterArrayRemovingFalsey(): Type + { + // Filtering may leave the array empty — drop the assertion. + return new MixedType(); + } + public function isIterable(): TrinaryLogic { return TrinaryLogic::createYes(); diff --git a/src/Type/Accessory/OversizedArrayType.php b/src/Type/Accessory/OversizedArrayType.php index 4956e879926..ca6da6e4caa 100644 --- a/src/Type/Accessory/OversizedArrayType.php +++ b/src/Type/Accessory/OversizedArrayType.php @@ -224,6 +224,36 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return $this; } + public function makeListMaybe(): Type + { + return $this; + } + + public function mapValueType(callable $cb): Type + { + return $this; + } + + public function mapKeyType(callable $cb): Type + { + return $this; + } + + public function makeAllArrayKeysOptional(): Type + { + return $this; + } + + public function changeKeyCaseArray(?int $case): Type + { + return $this; + } + + public function filterArrayRemovingFalsey(): Type + { + return $this; + } + public function isIterable(): TrinaryLogic { return TrinaryLogic::createYes(); diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index 777343626d6..fe8cf48f2bd 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -12,6 +12,11 @@ use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; +use PHPStan\Type\Accessory\AccessoryLowercaseStringType; +use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; +use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; +use PHPStan\Type\Accessory\AccessoryNumericStringType; +use PHPStan\Type\Accessory\AccessoryUppercaseStringType; use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\Constant\ConstantArrayType; @@ -30,9 +35,14 @@ use PHPStan\Type\Traits\NonObjectTypeTrait; use PHPStan\Type\Traits\UndecidedBooleanTypeTrait; use PHPStan\Type\Traits\UndecidedComparisonTypeTrait; +use function array_map; use function array_merge; use function count; use function sprintf; +use function strtolower; +use function strtoupper; +use const CASE_LOWER; +use const CASE_UPPER; /** @api */ class ArrayType implements Type @@ -566,6 +576,100 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return $arrayType; } + public function makeListMaybe(): Type + { + // `ArrayType` doesn't carry list-ness on its own — that's an + // `AccessoryArrayListType` in an enclosing `IntersectionType`. + return $this; + } + + public function mapValueType(callable $cb): Type + { + return new ArrayType($this->keyType, $cb($this->getItemType())); + } + + public function mapKeyType(callable $cb): Type + { + return new ArrayType($cb($this->keyType), $this->getItemType()); + } + + public function makeAllArrayKeysOptional(): Type + { + // `ArrayType` already models arbitrary key subsets. + return $this; + } + + public function changeKeyCaseArray(?int $case): Type + { + $newKeyType = TypeTraverser::map($this->keyType, static 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( + static fn (ConstantStringType $type): Type => self::foldConstantStringKeyCase($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(); + } + + if (count($types) === 1) { + return $types[0]; + } + return new IntersectionType($types); + } + + return $type; + }); + + return new ArrayType($newKeyType, $this->getItemType()); + } + + public function filterArrayRemovingFalsey(): Type + { + $falseyTypes = StaticTypeFactory::falsey(); + $valueType = TypeCombinator::remove($this->getItemType(), $falseyTypes); + if ($valueType instanceof NeverType) { + return new ConstantArrayType([], []); + } + + return new ArrayType($this->keyType, $valueType); + } + + private static function foldConstantStringKeyCase(ConstantStringType $type, ?int $case): Type + { + if ($case === CASE_LOWER) { + return new ConstantStringType(strtolower($type->getValue())); + } + if ($case === CASE_UPPER) { + return new ConstantStringType(strtoupper($type->getValue())); + } + + return TypeCombinator::union( + new ConstantStringType(strtolower($type->getValue())), + new ConstantStringType(strtoupper($type->getValue())), + ); + } + public function isCallable(): TrinaryLogic { return TrinaryLogic::createMaybe()->and($this->itemType->isString()); diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 5283e5bae82..41561fd015a 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -42,6 +42,7 @@ use PHPStan\Type\NeverType; use PHPStan\Type\NullType; use PHPStan\Type\RecursionGuard; +use PHPStan\Type\StaticTypeFactory; use PHPStan\Type\Traits\ArrayTypeTrait; use PHPStan\Type\Traits\NonObjectTypeTrait; use PHPStan\Type\Traits\UndecidedComparisonTypeTrait; @@ -69,6 +70,10 @@ use function sort; use function sprintf; use function str_contains; +use function strtolower; +use function strtoupper; +use const CASE_LOWER; +use const CASE_UPPER; /** * @api @@ -1955,6 +1960,115 @@ public function makeList(): Type return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, TrinaryLogic::createYes()); } + public function makeListMaybe(): Type + { + if (!$this->isList->yes()) { + return $this; + } + + return $this->recreate( + $this->keyTypes, + $this->valueTypes, + $this->nextAutoIndexes, + $this->optionalKeys, + TrinaryLogic::createMaybe(), + ); + } + + public function mapValueType(callable $cb): Type + { + $newValueTypes = []; + foreach ($this->valueTypes as $valueType) { + $newValueTypes[] = $cb($valueType); + } + + return $this->recreate( + $this->keyTypes, + $newValueTypes, + $this->nextAutoIndexes, + $this->optionalKeys, + $this->isList, + ); + } + + public function mapKeyType(callable $cb): Type + { + // Constant array shapes already encode precise per-slot keys; a + // blanket key-type rewrite (the prior `TypeTraverser`-based pattern + // in `NodeScopeResolver`) would coerce constants into a broader + // type and lose precision. Pass through unchanged. + return $this; + } + + public function makeAllArrayKeysOptional(): Type + { + $keyCount = count($this->keyTypes); + if ($keyCount === 0) { + return $this; + } + + return $this->recreate( + $this->keyTypes, + $this->valueTypes, + $this->nextAutoIndexes, + range(0, $keyCount - 1), + $this->isList, + ); + } + + public function changeKeyCaseArray(?int $case): Type + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($this->keyTypes as $i => $keyType) { + if ($keyType instanceof ConstantStringType) { + $newKeyType = self::foldConstantStringKeyCase($keyType, $case); + } else { + $newKeyType = $keyType; + } + $builder->setOffsetValueType($newKeyType, $this->valueTypes[$i], $this->isOptionalKey($i)); + } + $result = $builder->getArray(); + if ($this->isList()->yes()) { + $result = TypeCombinator::intersect($result, new AccessoryArrayListType()); + } + return $result; + } + + public function filterArrayRemovingFalsey(): Type + { + $falseyTypes = StaticTypeFactory::falsey(); + $builder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($this->keyTypes as $i => $keyType) { + $value = $this->valueTypes[$i]; + $isFalsey = $falseyTypes->isSuperTypeOf($value); + if ($isFalsey->yes()) { + continue; + } + if ($isFalsey->maybe()) { + $builder->setOffsetValueType($keyType, TypeCombinator::remove($value, $falseyTypes), true); + continue; + } + $builder->setOffsetValueType($keyType, $value, $this->isOptionalKey($i)); + } + + return $builder->getArray(); + } + + private static function foldConstantStringKeyCase(ConstantStringType $type, ?int $case): Type + { + if ($case === CASE_LOWER) { + return new ConstantStringType(strtolower($type->getValue())); + } + if ($case === CASE_UPPER) { + return new ConstantStringType(strtoupper($type->getValue())); + } + + return TypeCombinator::union( + new ConstantStringType(strtolower($type->getValue())), + new ConstantStringType(strtoupper($type->getValue())), + ); + } + public function toPhpDocNode(): TypeNode { $items = []; diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 158b9183303..c4730af4552 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -1173,6 +1173,36 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return $this->intersectTypes(static fn (Type $type): Type => $type->spliceArray($offsetType, $lengthType, $replacementType)); } + public function makeListMaybe(): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->makeListMaybe()); + } + + public function mapValueType(callable $cb): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->mapValueType($cb)); + } + + public function mapKeyType(callable $cb): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->mapKeyType($cb)); + } + + public function makeAllArrayKeysOptional(): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->makeAllArrayKeysOptional()); + } + + public function changeKeyCaseArray(?int $case): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->changeKeyCaseArray($case)); + } + + public function filterArrayRemovingFalsey(): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->filterArrayRemovingFalsey()); + } + public function getEnumCases(): array { $compare = []; diff --git a/src/Type/MixedType.php b/src/Type/MixedType.php index 0c1892e01eb..807a5ab259b 100644 --- a/src/Type/MixedType.php +++ b/src/Type/MixedType.php @@ -305,6 +305,60 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return new ArrayType(new MixedType($this->isExplicitMixed), new MixedType($this->isExplicitMixed)); } + public function makeListMaybe(): Type + { + // `mixed` doesn't track list-ness; nothing to weaken. + return $this; + } + + public function mapValueType(callable $cb): Type + { + if ($this->isArray()->no()) { + return new ErrorType(); + } + + return new ArrayType( + new MixedType($this->isExplicitMixed), + $cb(new MixedType($this->isExplicitMixed)), + ); + } + + public function mapKeyType(callable $cb): Type + { + if ($this->isArray()->no()) { + return new ErrorType(); + } + + return new ArrayType( + $cb(new MixedType($this->isExplicitMixed)), + new MixedType($this->isExplicitMixed), + ); + } + + public function makeAllArrayKeysOptional(): Type + { + // `mixed` is already arbitrary; nothing to weaken. + return $this; + } + + public function changeKeyCaseArray(?int $case): Type + { + if ($this->isArray()->no()) { + return new ErrorType(); + } + + return new ArrayType(new MixedType($this->isExplicitMixed), new MixedType($this->isExplicitMixed)); + } + + public function filterArrayRemovingFalsey(): Type + { + if ($this->isArray()->no()) { + return new ErrorType(); + } + + return new ArrayType(new MixedType($this->isExplicitMixed), new MixedType($this->isExplicitMixed)); + } + public function isCallable(): TrinaryLogic { if ($this->subtractedType !== null) { diff --git a/src/Type/NeverType.php b/src/Type/NeverType.php index cf1d90f7a1d..9b1bcd9e363 100644 --- a/src/Type/NeverType.php +++ b/src/Type/NeverType.php @@ -384,6 +384,36 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return new NeverType(); } + public function makeListMaybe(): Type + { + return new NeverType(); + } + + public function mapValueType(callable $cb): Type + { + return new NeverType(); + } + + public function mapKeyType(callable $cb): Type + { + return new NeverType(); + } + + public function makeAllArrayKeysOptional(): Type + { + return new NeverType(); + } + + public function changeKeyCaseArray(?int $case): Type + { + return new NeverType(); + } + + public function filterArrayRemovingFalsey(): Type + { + return new NeverType(); + } + public function isCallable(): TrinaryLogic { return TrinaryLogic::createNo(); diff --git a/src/Type/Php/ArrayChangeKeyCaseFunctionReturnTypeExtension.php b/src/Type/Php/ArrayChangeKeyCaseFunctionReturnTypeExtension.php index 070e61558ce..9230f64046d 100644 --- a/src/Type/Php/ArrayChangeKeyCaseFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayChangeKeyCaseFunctionReturnTypeExtension.php @@ -6,30 +6,10 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\FunctionReflection; -use PHPStan\Type\Accessory\AccessoryArrayListType; -use PHPStan\Type\Accessory\AccessoryLowercaseStringType; -use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; -use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; -use PHPStan\Type\Accessory\AccessoryNumericStringType; -use PHPStan\Type\Accessory\AccessoryUppercaseStringType; -use PHPStan\Type\Accessory\NonEmptyArrayType; -use PHPStan\Type\ArrayType; -use PHPStan\Type\Constant\ConstantArrayTypeBuilder; -use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\DynamicFunctionReturnTypeExtension; -use PHPStan\Type\IntersectionType; -use PHPStan\Type\StringType; use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; -use PHPStan\Type\TypeTraverser; -use PHPStan\Type\TypeUtils; -use PHPStan\Type\UnionType; -use function array_map; use function count; -use function strtolower; -use function strtoupper; use const CASE_LOWER; -use const CASE_UPPER; #[AutowiredService] final class ArrayChangeKeyCaseFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension @@ -60,107 +40,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } } - $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 = TypeCombinator::intersect($newConstantArrayType, new AccessoryArrayListType()); - } - $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(); - } - - if (count($types) === 1) { - return $types[0]; - } - return new IntersectionType($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())), - ); + return $arrayType->changeKeyCaseArray($case); } } diff --git a/src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php b/src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php index 6c71da8a1cd..16e03fd68aa 100644 --- a/src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php +++ b/src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php @@ -29,7 +29,6 @@ use PHPStan\Type\MixedType; use PHPStan\Type\NeverType; use PHPStan\Type\NullType; -use PHPStan\Type\StaticTypeFactory; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeUtils; @@ -152,42 +151,7 @@ public function getType(Scope $scope, ?Expr $arrayArg, ?Expr $callbackArg, ?Expr private function removeFalsey(Type $type): Type { - $falseyTypes = StaticTypeFactory::falsey(); - - if (count($type->getConstantArrays()) > 0) { - $result = []; - foreach ($type->getConstantArrays() as $constantArray) { - $keys = $constantArray->getKeyTypes(); - $values = $constantArray->getValueTypes(); - - $builder = ConstantArrayTypeBuilder::createEmpty(); - - foreach ($values as $offset => $value) { - $isFalsey = $falseyTypes->isSuperTypeOf($value); - - if ($isFalsey->maybe()) { - $builder->setOffsetValueType($keys[$offset], TypeCombinator::remove($value, $falseyTypes), true); - } elseif ($isFalsey->no()) { - $builder->setOffsetValueType($keys[$offset], $value, $constantArray->isOptionalKey($offset)); - } - } - - $result[] = $builder->getArray(); - } - - return TypeCombinator::union(...$result); - } - - $keyType = $type->getIterableKeyType(); - $valueType = $type->getIterableValueType(); - - $valueType = TypeCombinator::remove($valueType, $falseyTypes); - - if ($valueType instanceof NeverType) { - return new ConstantArrayType([], []); - } - - return new ArrayType($keyType, $valueType); + return $type->filterArrayRemovingFalsey(); } private function filterByTruthyValue(Scope $scope, Error|Variable|null $itemVar, Type $arrayType, Error|Variable|null $keyVar, Expr $expr): Type diff --git a/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php b/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php index 78699643bd4..f217fd0c96f 100644 --- a/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php @@ -126,29 +126,11 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } $constantArrays = $arrayType->getConstantArrays(); if (count($constantArrays) > 0) { - $arrayTypes = []; $totalCount = TypeCombinator::countConstantArrayValueTypes($constantArrays) * TypeCombinator::countConstantArrayValueTypes([$valueType]); if ($totalCount < ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { - foreach ($constantArrays as $constantArray) { - $returnedArrayBuilder = ConstantArrayTypeBuilder::createEmpty(); - $valueTypes = $constantArray->getValueTypes(); - foreach ($constantArray->getKeyTypes() as $i => $keyType) { - $returnedArrayBuilder->setOffsetValueType( - $keyType, - $scope->getType(new FuncCall($callback, [ - new Node\Arg(new TypeExpr($valueTypes[$i])), - ])), - $constantArray->isOptionalKey($i), - ); - } - $returnedArray = $returnedArrayBuilder->getArray(); - if ($constantArray->isList()->yes()) { - $returnedArray = TypeCombinator::intersect($returnedArray, new AccessoryArrayListType()); - } - $arrayTypes[] = $returnedArray; - } - - $mappedArrayType = TypeCombinator::union(...$arrayTypes); + $mappedArrayType = $arrayType->mapValueType(static fn (Type $type): Type => $scope->getType(new FuncCall($callback, [ + new Node\Arg(new TypeExpr($type)), + ]))); } else { $mappedArrayType = TypeCombinator::intersect(new ArrayType( $arrayType->getIterableKeyType(), diff --git a/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php b/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php index ab2b39e77b9..996f8dfe195 100644 --- a/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php +++ b/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php @@ -8,14 +8,11 @@ use PHPStan\Reflection\FunctionReflection; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\ShouldNotHappenException; -use PHPStan\Type\Accessory\AccessoryArrayListType; use PHPStan\Type\Accessory\AccessoryLowercaseStringType; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\Accessory\AccessoryUppercaseStringType; -use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\ArrayType; -use PHPStan\Type\Constant\ConstantArrayTypeBuilder; use PHPStan\Type\DynamicFunctionReturnTypeExtension; use PHPStan\Type\IntersectionType; use PHPStan\Type\MixedType; @@ -134,35 +131,14 @@ private function getPreliminarilyResolvedTypeFromFunctionCall( true, ); - $constantArrays = $arrayArgumentType->getConstantArrays(); - if ($constantArrays !== []) { - foreach ($constantArrays as $constantArray) { - $valueTypes = $constantArray->getValueTypes(); - - $builder = ConstantArrayTypeBuilder::createEmpty(); - foreach ($constantArray->getKeyTypes() as $index => $keyType) { - $builder->setOffsetValueType( - $keyType, - $this->getReplaceType($valueTypes[$index], $replaceArgumentType), - $keyShouldBeOptional || $constantArray->isOptionalKey($index), - ); - } - $result[] = $builder->getArray(); - } - } else { - $newArrayType = new ArrayType( - $arrayArgumentType->getIterableKeyType(), - $this->getReplaceType($arrayArgumentType->getIterableValueType(), $replaceArgumentType), - ); - if ($arrayArgumentType->isList()->yes()) { - $newArrayType = TypeCombinator::intersect($newArrayType, new AccessoryArrayListType()); - } - if ($arrayArgumentType->isIterableAtLeastOnce()->yes()) { - $newArrayType = TypeCombinator::intersect($newArrayType, new NonEmptyArrayType()); - } - - $result[] = $newArrayType; + $mapped = $arrayArgumentType->mapValueType( + fn (Type $value): Type => $this->getReplaceType($value, $replaceArgumentType), + ); + if ($keyShouldBeOptional) { + $mapped = $mapped->makeAllArrayKeysOptional(); } + + $result[] = $mapped; } return TypeCombinator::union(...$result); diff --git a/src/Type/StaticType.php b/src/Type/StaticType.php index 682bc77d300..6e9cfc841bb 100644 --- a/src/Type/StaticType.php +++ b/src/Type/StaticType.php @@ -558,6 +558,36 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return $this->getStaticObjectType()->spliceArray($offsetType, $lengthType, $replacementType); } + public function makeListMaybe(): Type + { + return $this->getStaticObjectType()->makeListMaybe(); + } + + public function mapValueType(callable $cb): Type + { + return $this->getStaticObjectType()->mapValueType($cb); + } + + public function mapKeyType(callable $cb): Type + { + return $this->getStaticObjectType()->mapKeyType($cb); + } + + public function makeAllArrayKeysOptional(): Type + { + return $this->getStaticObjectType()->makeAllArrayKeysOptional(); + } + + public function changeKeyCaseArray(?int $case): Type + { + return $this->getStaticObjectType()->changeKeyCaseArray($case); + } + + public function filterArrayRemovingFalsey(): Type + { + return $this->getStaticObjectType()->filterArrayRemovingFalsey(); + } + public function isCallable(): TrinaryLogic { return $this->getStaticObjectType()->isCallable(); diff --git a/src/Type/Traits/LateResolvableTypeTrait.php b/src/Type/Traits/LateResolvableTypeTrait.php index 4b0dacddd72..ff9e91a6f61 100644 --- a/src/Type/Traits/LateResolvableTypeTrait.php +++ b/src/Type/Traits/LateResolvableTypeTrait.php @@ -354,6 +354,36 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return $this->resolve()->spliceArray($offsetType, $lengthType, $replacementType); } + public function makeListMaybe(): Type + { + return $this->resolve()->makeListMaybe(); + } + + public function mapValueType(callable $cb): Type + { + return $this->resolve()->mapValueType($cb); + } + + public function mapKeyType(callable $cb): Type + { + return $this->resolve()->mapKeyType($cb); + } + + public function makeAllArrayKeysOptional(): Type + { + return $this->resolve()->makeAllArrayKeysOptional(); + } + + public function changeKeyCaseArray(?int $case): Type + { + return $this->resolve()->changeKeyCaseArray($case); + } + + public function filterArrayRemovingFalsey(): Type + { + return $this->resolve()->filterArrayRemovingFalsey(); + } + public function isCallable(): TrinaryLogic { return $this->resolve()->isCallable(); diff --git a/src/Type/Traits/MaybeArrayTypeTrait.php b/src/Type/Traits/MaybeArrayTypeTrait.php index a4080f0aa13..9b87c858cad 100644 --- a/src/Type/Traits/MaybeArrayTypeTrait.php +++ b/src/Type/Traits/MaybeArrayTypeTrait.php @@ -109,4 +109,34 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return new ErrorType(); } + public function makeListMaybe(): Type + { + return $this; + } + + public function mapValueType(callable $cb): Type + { + return $this; + } + + public function mapKeyType(callable $cb): Type + { + return $this; + } + + public function makeAllArrayKeysOptional(): Type + { + return $this; + } + + public function changeKeyCaseArray(?int $case): Type + { + return new ErrorType(); + } + + public function filterArrayRemovingFalsey(): Type + { + return new ErrorType(); + } + } diff --git a/src/Type/Traits/NonArrayTypeTrait.php b/src/Type/Traits/NonArrayTypeTrait.php index 5f586ad1a64..eb06353c49f 100644 --- a/src/Type/Traits/NonArrayTypeTrait.php +++ b/src/Type/Traits/NonArrayTypeTrait.php @@ -109,4 +109,34 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return new ErrorType(); } + public function makeListMaybe(): Type + { + return $this; + } + + public function mapValueType(callable $cb): Type + { + return $this; + } + + public function mapKeyType(callable $cb): Type + { + return $this; + } + + public function makeAllArrayKeysOptional(): Type + { + return $this; + } + + public function changeKeyCaseArray(?int $case): Type + { + return new ErrorType(); + } + + public function filterArrayRemovingFalsey(): Type + { + return new ErrorType(); + } + } diff --git a/src/Type/Type.php b/src/Type/Type.php index 9af6fcf203c..73c46a213ad 100644 --- a/src/Type/Type.php +++ b/src/Type/Type.php @@ -278,6 +278,61 @@ public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $pre /** Models array_splice() effect on the array (the modified array, not the removed portion). */ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacementType): Type; + /** + * Downgrades the list-ness of the array from `Yes` to `Maybe` (e.g. for + * `asort`/`uksort`/etc. which preserve keys but break list ordering). + * Other shape information (keys, values, accessories like NonEmpty) is + * preserved. + */ + public function makeListMaybe(): Type; + + /** + * Models "same keys, every value transformed" (e.g. `array_walk`, + * `array_map($cb, $a)`, `preg_replace*` over an array subject). Keys + * and accessories like list-ness / non-emptiness are preserved. + * + * @param callable(Type): Type $cb + */ + public function mapValueType(callable $cb): Type; + + /** + * Replaces the iterable key type via `$cb($currentKeyType)`. For + * `ArrayType` rewrites the key type wholesale; for `ConstantArrayType` + * the explicit keys (which are already precise constants) are preserved + * — pass-through, matching the prior `TypeTraverser`-based callers. + * Used to widen / narrow the key type after a foreach narrowed `$key` + * via `is_int($key)` / `is_string($key)` checks. + * + * @param callable(Type): Type $cb + */ + public function mapKeyType(callable $cb): Type; + + /** + * Marks every explicit key in a `ConstantArrayType` as optional (the + * shape can have any subset of the original keys). For non-`CAT` arrays + * this is a no-op — they already model arbitrary subsets. Used by + * `preg_replace*` over array subjects, where the callback can drop + * entries. + */ + public function makeAllArrayKeysOptional(): Type; + + /** + * Models `array_change_key_case($a, $case)`. String keys are case-folded + * (constant ones to a specific value, general ones via accessories); + * non-string keys, values, accessories and list-ness are preserved. + * `$case` matches PHP's `CASE_LOWER` / `CASE_UPPER`; `null` means the + * case is non-constant and the result is the union of both folds. + */ + public function changeKeyCaseArray(?int $case): Type; + + /** + * Models `array_filter($a)` (no callback): drops entries whose value is + * definitely falsey, marks possibly-falsey entries optional, keeps + * definitely-truthy entries unchanged. Keys are preserved; list-ness + * is downgraded since gaps may appear. + */ + public function filterArrayRemovingFalsey(): Type; + /** @return list */ public function getEnumCases(): array; diff --git a/src/Type/UnionType.php b/src/Type/UnionType.php index a29b089a0b6..6a9c3c14657 100644 --- a/src/Type/UnionType.php +++ b/src/Type/UnionType.php @@ -904,6 +904,36 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return $this->unionTypes(static fn (Type $type): Type => $type->spliceArray($offsetType, $lengthType, $replacementType)); } + public function makeListMaybe(): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->makeListMaybe()); + } + + public function mapValueType(callable $cb): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->mapValueType($cb)); + } + + public function mapKeyType(callable $cb): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->mapKeyType($cb)); + } + + public function makeAllArrayKeysOptional(): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->makeAllArrayKeysOptional()); + } + + public function changeKeyCaseArray(?int $case): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->changeKeyCaseArray($case)); + } + + public function filterArrayRemovingFalsey(): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->filterArrayRemovingFalsey()); + } + public function getEnumCases(): array { return $this->pickFromTypes(