diff --git a/build/PHPStan/Build/SkipTestsWithRequiresPhpAttributeRule.php b/build/PHPStan/Build/SkipTestsWithRequiresPhpAttributeRule.php index 05de1553e22..12f24f79882 100644 --- a/build/PHPStan/Build/SkipTestsWithRequiresPhpAttributeRule.php +++ b/build/PHPStan/Build/SkipTestsWithRequiresPhpAttributeRule.php @@ -52,6 +52,16 @@ public function processNode(Node $node, Scope $scope): array return []; } + // Before asking for an inverse: anything that is not a PHP_VERSION_ID comparison is + // none of this rule's business, and there is no inverse to name for it. + if (!$firstStmt->cond->left instanceof Node\Expr\ConstFetch || $firstStmt->cond->left->name->toString() !== 'PHP_VERSION_ID') { + return []; + } + + if (!$firstStmt->cond->right instanceof Node\Scalar\Int_) { + return []; + } + switch (get_class($firstStmt->cond)) { case Node\Expr\BinaryOp\SmallerOrEqual::class: $inverseBinaryOpSigil = '>'; @@ -75,14 +85,6 @@ public function processNode(Node $node, Scope $scope): array throw new ShouldNotHappenException('No inverse comparison specified for ' . get_class($firstStmt->cond)); } - if (!$firstStmt->cond->left instanceof Node\Expr\ConstFetch || $firstStmt->cond->left->name->toString() !== 'PHP_VERSION_ID') { - return []; - } - - if (!$firstStmt->cond->right instanceof Node\Scalar\Int_) { - return []; - } - if (count($firstStmt->stmts) !== 1) { return []; } diff --git a/src/Type/FiniteTypeSet.php b/src/Type/FiniteTypeSet.php new file mode 100644 index 00000000000..a77325b2508 --- /dev/null +++ b/src/Type/FiniteTypeSet.php @@ -0,0 +1,298 @@ + $members + * @param array $membersByKind + * @param list $others + */ + private function __construct(private array $members, private array $membersByKind, private array $others) + { + } + + /** + * Returns null when none of the types is a finite value - there is nothing to look up + * then, and the caller would only pay for building an empty map. + * + * Two types standing for the same value are not merged: the second one goes to $others + * so that the set never claims a union has fewer members than it does. + * + * @param list $types + */ + public static function create(array $types): ?self + { + $members = []; + $membersByKind = []; + $others = []; + foreach ($types as $type) { + $key = self::key($type); + if ($key === null || array_key_exists($key, $members)) { + $others[] = $type; + continue; + } + + $members[$key] = $type; + $membersByKind[self::kind($type)] ??= $type; + } + + if ($members === []) { + return null; + } + + return new self($members, $membersByKind, $others); + } + + /** + * Identity key of a single finite value: two types share a key iff they are equals(), + * and types with different keys are disjoint. + * + * Returns null for anything else. Floats are excluded because equals() does not agree + * with value identity for them (-0.0 === 0.0, NAN !== NAN). A type that merely contains + * a finite value - an intersection with an accessory type, a whole single-case enum, a + * conditional type resolving to a constant - is excluded by the equals() check: only a + * type that *is* the value can stand in for it. Template types are excluded outright, + * their comparison semantics are not value identity. + */ + public static function key(Type $type): ?string + { + // A union of finite values is made of these five classes, and every comparison against + // such a union derives at least one key - so the general path below, three virtual + // calls and an array allocation, is worth skipping for them. Matching the class + // exactly is what makes that safe: for these, getConstantScalarTypes() is [$this] and + // equals() is value identity, which is precisely what the general path checks. + // Subclasses - template constant types among them - do not match and fall through. + switch (get_class($type)) { + case ConstantStringType::class: + return self::STRING_KEY_PREFIX . $type->getValue(); + case ConstantIntegerType::class: + return self::INTEGER_KEY_PREFIX . $type->getValue(); + case ConstantBooleanType::class: + return self::BOOLEAN_KEY_PREFIX . ($type->getValue() ? '1' : '0'); + case NullType::class: + return self::NULL_KEY; + case ConstantFloatType::class: + // Excluded on purpose, see above - said here too so that a union of constant + // floats pays one get_class() per member instead of walking the general path. + return null; + } + + if ($type instanceof TemplateType) { + return null; + } + + // Only a bare case is safe to key by class + case name: for anything else - + // $this & Enum::C, a whole single-case enum, an enum subtracted to one case - + // EnumCaseObjectType::equals() is false because it requires an EnumCaseObjectType, + // which makes instanceof exactly the question being asked here. Type::getEnumCases() + // would answer it too, but only by resolving a ClassReflection - and a key has to be + // derivable from the type alone, on every comparison, without reflection. + // Key by class + case name, the identity equals() compares (describe() would also + // fold in a subtracted type, which equals() ignores). + if ($type instanceof EnumCaseObjectType) { // @phpstan-ignore phpstanApi.instanceofType + return self::kind($type) . '::' . $type->getEnumCaseName(); + } + + if (!$type->isConstantScalarValue()->yes()) { + return null; + } + + $scalarTypes = $type->getConstantScalarTypes(); + if (count($scalarTypes) !== 1 || !$scalarTypes[0]->equals($type)) { + return null; + } + + $value = $scalarTypes[0]->getValue(); + if ($value === null) { + return self::NULL_KEY; + } + if (is_int($value)) { + return self::INTEGER_KEY_PREFIX . $value; + } + if (is_bool($value)) { + return self::BOOLEAN_KEY_PREFIX . ($value ? '1' : '0'); + } + if (is_string($value)) { + return self::STRING_KEY_PREFIX . $value; + } + + return null; + } + + /** + * The kind of value a type stands for. + * + * Members of one kind answer accepts() identically for every value none of them holds, + * which is what lets one of them stand in for all its siblings there. Being of the same + * class is enough for that - accepts() on a constant scalar only asks whether the other + * type equals it - except for enum cases, where the enum is part of the answer. + * + * Only meaningful for a type that key() keys; anything else gets a kind of its own, + * which merely costs it a representative. + */ + private static function kind(Type $type): string + { + if ($type instanceof EnumCaseObjectType) { // @phpstan-ignore phpstanApi.instanceofType + return self::ENUM_CASE_KEY_PREFIX . $type->getClassName(); + } + + return get_class($type); + } + + /** + * One member per kind other than $type's own, in the union's order. + * + * For a value the set does not hold, every member of $type's kind answers accepts() no, + * and the remaining members answer per kind - so or()-ing over these few is the same + * answer as or()-ing over all of them. + * + * @return list + */ + public function getRepresentativesOfOtherKinds(Type $type): array + { + $kind = self::kind($type); + $representatives = []; + foreach ($this->membersByKind as $memberKind => $member) { + if ($memberKind === $kind) { + continue; + } + + $representatives[] = $member; + } + + return $representatives; + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->members); + } + + /** Whether every member of the union is keyed, so the map answers for the whole union. */ + public function isComplete(): bool + { + return $this->others === []; + } + + /** + * Members in the union's own order. + * + * @return array + */ + public function getMembers(): array + { + return $this->members; + } + + /** @return list */ + public function getOthers(): array + { + return $this->others; + } + + /** + * Yes when every keyed member is also in $other, no when none of them is. + * + * Only keyed members are compared - call isComplete() first when the answer has to + * hold for the whole union. + */ + public function containedIn(self $other): TrinaryLogic + { + // One array_diff_key() rather than a lookup per member: the keys are what both sets + // are indexed by, so the whole comparison is a single C-level hash join. Asking for + // what is missing rather than for what is shared makes the yes answer the cheap one - + // it is the one that costs nothing to collect, and the one all three callers are + // after (isAcceptedBy() and equals() want nothing else). + $missing = count(array_diff_key($this->members, $other->members)); + + if ($missing === 0) { + return TrinaryLogic::createYes(); + } + + if ($missing === count($this->members)) { + return TrinaryLogic::createNo(); + } + + return TrinaryLogic::createMaybe(); + } + + /** + * Whether a constant string member might also be a class-string. + * + * The class-string flag is part of a constant string's representation but not of its + * value, so operations that pick a member to hand back - as opposed to merely comparing + * values - cannot treat two same-valued constant strings as interchangeable. Answering + * this costs a reflection lookup per string member, so it is computed on demand: only + * combining operations ask. + */ + public function hasClassStringMember(): bool + { + if ($this->hasClassStringMember !== null) { + return $this->hasClassStringMember; + } + + $this->hasClassStringMember = false; + foreach ($this->members as $member) { + if (!$member->isString()->yes()) { + continue; + } + if ($member->isClassString()->no()) { + continue; + } + + $this->hasClassStringMember = true; + break; + } + + return $this->hasClassStringMember; + } + +} diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 16445c1e6d6..1c6b04d0d60 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -43,9 +43,7 @@ use function get_class; use function implode; use function in_array; -use function is_bool; use function is_int; -use function is_string; use function sprintf; use function usort; use const PHP_INT_MAX; @@ -1581,60 +1579,27 @@ private static function intersectFiniteUnions(UnionType $a, UnionType $b): ?Type } /** - * Keys a union's members by identity for the finite-union fast path in intersect(). + * The union's members keyed by identity, or null when the fast path does not apply. * - * Handles constant scalars and enum cases: each stands for one concrete value, so two - * members are interchangeable iff they share a key and are otherwise disjoint. Returns - * null if any member is not such a value. Class-string constant strings are excluded - * (the class-string flag is not captured by the value) and floats are excluded (-0.0 / - * NAN comparison quirks). Enum cases are keyed by class + case name, the identity - * EnumCaseObjectType::equals() compares. + * FiniteTypeSet does the keying and the union caches it; on top of that, intersect() + * hands one of the two members back instead of only comparing them, so a constant string + * that is also a class-string is not interchangeable with a same-valued one that is not - + * the class-string flag would be lost. Such unions go the slow way. * * @return array|null */ private static function finiteUnionMembers(UnionType $union): ?array { - $members = []; - foreach ($union->getTypes() as $member) { - $enumCase = $member->getEnumCaseObject(); - if ($member->isNull()->yes()) { - $key = 'null'; - } elseif ($enumCase !== null) { - // getEnumCaseObject() also returns the case for a refined member - an - // intersection like $this & Enum::C, a whole single-case enum, or an enum - // subtracted to one case - none of which are a bare EnumCaseObjectType. - // Only a bare case is safe to key by class + case name; for the rest, - // EnumCaseObjectType::equals() is false (it requires an EnumCaseObjectType), - // so bail to the slow path rather than collapse the refinement. - if (!$enumCase->equals($member)) { - return null; - } - - // Key by class + case name, the identity EnumCaseObjectType::equals() compares - // (describe() would also fold in a subtracted type, which equals() ignores). - $key = 'enum:' . $enumCase->getClassName() . '::' . $enumCase->getEnumCaseName(); - } else { - $values = $member->getConstantScalarValues(); - if (count($values) !== 1) { - return null; - } - - $value = $values[0]; - if (is_int($value)) { - $key = 'i:' . $value; - } elseif (is_bool($value)) { - $key = $value ? 'b:1' : 'b:0'; - } elseif (is_string($value) && $member->isClassString()->no()) { - $key = 's:' . $value; - } else { - return null; - } - } + $finiteTypeSet = $union->getFiniteTypeSet(); + if ($finiteTypeSet === null || !$finiteTypeSet->isComplete()) { + return null; + } - $members[$key] = $member; + if ($finiteTypeSet->hasClassStringMember()) { + return null; } - return $members; + return $finiteTypeSet->getMembers(); } public static function intersect(Type ...$types): Type diff --git a/src/Type/UnionType.php b/src/Type/UnionType.php index 011cdec9389..f07aab71384 100644 --- a/src/Type/UnionType.php +++ b/src/Type/UnionType.php @@ -74,6 +74,12 @@ class UnionType implements CompoundType /** @var array */ private array $cachedDescriptions = []; + /** + * Identity-keyed view of $types, built on first use. False once it is known that the + * members cannot be keyed at all, so a union of objects pays for the attempt only once. + */ + private FiniteTypeSet|false|null $finiteTypeSet = null; + /** * @api * @param list $types @@ -138,6 +144,17 @@ public function isNormalized(): bool return $this->normalized; } + /** @internal */ + public function getFiniteTypeSet(): ?FiniteTypeSet + { + $finiteTypeSet = $this->finiteTypeSet ??= FiniteTypeSet::create($this->types) ?? false; + if ($finiteTypeSet === false) { + return null; + } + + return $finiteTypeSet; + } + /** * @return list */ @@ -200,6 +217,41 @@ public function getConstantStrings(): array public function accepts(Type $type, bool $strictTypes): AcceptsResult { + $finiteTypeSet = $this->getFiniteTypeSet(); + if ($finiteTypeSet !== null) { + $key = FiniteTypeSet::key($type); + if ($key !== null && $finiteTypeSet->has($key)) { + return AcceptsResult::createYes(); + } + + if ($finiteTypeSet->isComplete()) { + if ($key !== null) { + // A value the union does not hold can still be accepted through scalar + // coercion, so unlike isSuperTypeOf() the answer is not simply no - but it + // is the same for every member of a kind, so one member of each is enough. + // None of the branches below apply to a value: it is not iterable, not + // compound, and an enum case already is the union of its own cases. + $result = AcceptsResult::createNo(); + foreach ($finiteTypeSet->getRepresentativesOfOtherKinds($type) as $representative) { + $result = $result->or($representative->accepts($type, $strictTypes)); + } + + return $result; + } + + if ($type instanceof self && !$type instanceof TemplateType) { + $otherFiniteTypeSet = $type->getFiniteTypeSet(); + if ($otherFiniteTypeSet !== null && $otherFiniteTypeSet->isComplete()) { + // A member standing for a single value never accepts more than one of + // the other union's values, and the other union has at least two of + // them - so the member-by-member or() below cannot come out yes, and + // its result is discarded in favour of the compound answer anyway. + return $type->isAcceptedBy($this, $strictTypes); + } + } + } + } + if ($type instanceof IterableType) { return $this->accepts($type->toArrayOrTraversable(), $strictTypes); } @@ -276,8 +328,27 @@ public function isSuperTypeOf(Type $otherType): IsSuperTypeOfResult return $otherType->isSubTypeOf($this); } + $types = $this->types; + $finiteTypeSet = $this->getFiniteTypeSet(); + if ($finiteTypeSet !== null) { + $key = FiniteTypeSet::key($otherType); + if ($key !== null) { + if ($finiteTypeSet->has($key)) { + return IsSuperTypeOfResult::createYes(); + } + + // Every keyed member stands for a different value than $otherType, so all of + // them answer no - only the members that could not be keyed are left to ask. + if ($finiteTypeSet->isComplete()) { + return IsSuperTypeOfResult::createNo(); + } + + $types = $finiteTypeSet->getOthers(); + } + } + $results = []; - foreach ($this->types as $innerType) { + foreach ($types as $innerType) { $result = $innerType->isSuperTypeOf($otherType); if ($result->yes()) { return $result; @@ -298,14 +369,61 @@ public function isSuperTypeOf(Type $otherType): IsSuperTypeOfResult public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult { + $containment = $this->finiteTypeSetContainedIn($otherType, false); + if ($containment !== null) { + if ($containment->maybe()) { + return IsSuperTypeOfResult::createMaybe(); + } + + return IsSuperTypeOfResult::createFromBoolean($containment->yes()); + } + return IsSuperTypeOfResult::extremeIdentity(...array_map(static fn (Type $innerType) => $otherType->isSuperTypeOf($innerType), $this->types)); } public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult { + // Unlike isSubTypeOf() only the positive answer holds: a member $acceptingType does + // not hold can still be accepted through scalar coercion. + $containment = $this->finiteTypeSetContainedIn($acceptingType, true); + if ($containment !== null && $containment->yes()) { + return AcceptsResult::createYes(); + } + return AcceptsResult::extremeIdentity(...array_map(static fn (Type $innerType) => $acceptingType->accepts($innerType, $strictTypes), $this->types)); } + /** + * Whether the other union holds every member of this one, or null when the question + * cannot be settled from the identity maps alone. + * + * $yesOnly skips the completeness requirement on the other union: a member missing from + * its map only rules out the yes answer, which is all the caller is after. + */ + private function finiteTypeSetContainedIn(Type $otherType, bool $yesOnly): ?TrinaryLogic + { + if (!$otherType instanceof self || $otherType instanceof TemplateType) { + return null; + } + + $finiteTypeSet = $this->getFiniteTypeSet(); + if ($finiteTypeSet === null || !$finiteTypeSet->isComplete()) { + return null; + } + + $otherFiniteTypeSet = $otherType->getFiniteTypeSet(); + if ($otherFiniteTypeSet === null) { + return null; + } + + $containment = $finiteTypeSet->containedIn($otherFiniteTypeSet); + if (!$containment->yes() && ($yesOnly || !$otherFiniteTypeSet->isComplete())) { + return null; + } + + return $containment; + } + public function equals(Type $type): bool { if (!$type instanceof static) { @@ -316,6 +434,14 @@ public function equals(Type $type): bool return false; } + $finiteTypeSet = $this->getFiniteTypeSet(); + if ($finiteTypeSet !== null && $finiteTypeSet->isComplete()) { + $otherFiniteTypeSet = $type->getFiniteTypeSet(); + if ($otherFiniteTypeSet !== null && $otherFiniteTypeSet->isComplete()) { + return $finiteTypeSet->containedIn($otherFiniteTypeSet)->yes(); + } + } + $otherTypes = $type->types; foreach ($this->types as $innerType) { $match = false; @@ -1336,6 +1462,31 @@ public function traverseSimultaneously(Type $right, callable $cb): Type public function tryRemove(Type $typeToRemove): ?Type { + $finiteTypeSet = $this->getFiniteTypeSet(); + if ($finiteTypeSet !== null && $finiteTypeSet->isComplete()) { + $key = FiniteTypeSet::key($typeToRemove); + if ($key !== null) { + if (!$finiteTypeSet->has($key)) { + return null; + } + + $remainingTypes = []; + foreach ($finiteTypeSet->getMembers() as $memberKey => $member) { + if ($memberKey === $key) { + continue; + } + + $remainingTypes[] = $member; + } + + if (count($remainingTypes) === 1) { + return $remainingTypes[0]; + } + + return new UnionType($remainingTypes); + } + } + $innerTypes = []; $changed = false; foreach ($this->types as $innerType) { diff --git a/tests/PHPStan/Type/FiniteTypeSetTest.php b/tests/PHPStan/Type/FiniteTypeSetTest.php new file mode 100644 index 00000000000..8905ed230fc --- /dev/null +++ b/tests/PHPStan/Type/FiniteTypeSetTest.php @@ -0,0 +1,566 @@ + + */ + private static function unions(): array + { + return [ + 'strings' => new UnionType([new ConstantStringType('a'), new ConstantStringType('b'), new ConstantStringType('c')]), + 'strings superset' => new UnionType([new ConstantStringType('a'), new ConstantStringType('b'), new ConstantStringType('c'), new ConstantStringType('d')]), + 'strings reordered' => new UnionType([new ConstantStringType('c'), new ConstantStringType('b'), new ConstantStringType('a')]), + 'strings disjoint' => new UnionType([new ConstantStringType('x'), new ConstantStringType('y')]), + 'strings differing only in case' => new UnionType([new ConstantStringType('a'), new ConstantStringType('A')]), + 'strings and null' => new UnionType([new ConstantStringType('a'), new ConstantStringType('b'), new NullType()]), + 'integers' => new UnionType([new ConstantIntegerType(1), new ConstantIntegerType(2), new ConstantIntegerType(3)]), + 'booleans' => new UnionType([new ConstantBooleanType(true), new ConstantBooleanType(false)]), + 'enum cases' => new UnionType([ + new EnumCaseObjectType(ManyCasesTestEnum::class, 'A'), + new EnumCaseObjectType(ManyCasesTestEnum::class, 'B'), + ]), + 'enum cases superset' => new UnionType([ + new EnumCaseObjectType(ManyCasesTestEnum::class, 'A'), + new EnumCaseObjectType(ManyCasesTestEnum::class, 'B'), + new EnumCaseObjectType(ManyCasesTestEnum::class, 'C'), + ]), + 'mixed kinds' => new UnionType([ + new ConstantStringType('a'), + new ConstantIntegerType(1), + new ConstantBooleanType(true), + new NullType(), + new EnumCaseObjectType(ManyCasesTestEnum::class, 'A'), + ]), + 'strings and object' => new UnionType([new ConstantStringType('a'), new ConstantStringType('b'), new ObjectType('DateTimeImmutable')]), + 'strings and float' => new UnionType([new ConstantStringType('a'), new ConstantFloatType(1.0)]), + 'strings and class-string' => new UnionType([new ConstantStringType('a'), new ConstantStringType('DateTimeImmutable', true)]), + 'strings and general string' => new UnionType([new ConstantStringType('a'), new StringType()]), + 'string and integer' => new UnionType([new ConstantStringType('a'), new IntegerType()]), + 'benevolent strings' => new BenevolentUnionType([new ConstantStringType('a'), new ConstantStringType('b')]), + 'objects' => new UnionType([new ObjectType('DateTimeImmutable'), new ObjectType('DateTime')]), + ]; + } + + /** + * @return array + */ + private static function otherTypes(): array + { + return [ + 'string a' => new ConstantStringType('a'), + 'string A' => new ConstantStringType('A'), + 'string d' => new ConstantStringType('d'), + 'string z' => new ConstantStringType('z'), + 'class-string' => new ConstantStringType('DateTimeImmutable', true), + 'numeric string' => new ConstantStringType('1'), + 'integer 1' => new ConstantIntegerType(1), + 'integer 9' => new ConstantIntegerType(9), + 'true' => new ConstantBooleanType(true), + 'float' => new ConstantFloatType(1.0), + 'null' => new NullType(), + 'enum case A' => new EnumCaseObjectType(ManyCasesTestEnum::class, 'A'), + 'enum case F' => new EnumCaseObjectType(ManyCasesTestEnum::class, 'F'), + 'whole enum' => new ObjectType(ManyCasesTestEnum::class), + 'string' => new StringType(), + 'integer' => new IntegerType(), + 'object' => new ObjectType('DateTimeImmutable'), + 'mixed' => new MixedType(), + 'template' => TemplateTypeFactory::create( + TemplateTypeScope::createWithFunction('foo'), + 'T', + new StringType(), + TemplateTypeVariance::createInvariant(), + ), + ]; + } + + /** + * @return Iterator + */ + public static function dataUnionAndOtherType(): Iterator + { + foreach (self::unions() as $unionName => $union) { + foreach (self::otherTypes() as $otherName => $otherType) { + yield sprintf('%s <-> %s', $unionName, $otherName) => [$union, $otherType]; + } + } + } + + /** + * @return Iterator + */ + public static function dataUnionPairs(): Iterator + { + foreach (self::unions() as $unionName => $union) { + foreach (self::unions() as $otherName => $otherUnion) { + yield sprintf('%s <-> %s', $unionName, $otherName) => [$union, $otherUnion]; + } + } + } + + #[DataProvider('dataUnionAndOtherType')] + public function testIsSuperTypeOf(UnionType $type, Type $otherType): void + { + $this->assertSame( + self::referenceIsSuperTypeOf($type, $otherType)->describe(), + $type->isSuperTypeOf($otherType)->result->describe(), + sprintf('%s -> isSuperTypeOf(%s)', $type->describe(VerbosityLevel::precise()), $otherType->describe(VerbosityLevel::precise())), + ); + } + + #[DataProvider('dataUnionAndOtherType')] + public function testAccepts(UnionType $type, Type $otherType): void + { + $this->assertAccepts($type, $otherType); + } + + #[DataProvider('dataUnionPairs')] + public function testAcceptsUnion(UnionType $type, UnionType $otherType): void + { + $this->assertAccepts($type, $otherType); + } + + private function assertAccepts(UnionType $type, Type $otherType): void + { + $finiteTypeSet = $type->getFiniteTypeSet(); + $answersPerKind = FiniteTypeSet::key($otherType) !== null + && $finiteTypeSet !== null + && $finiteTypeSet->isComplete(); + + foreach ([true, false] as $strictTypes) { + $this->assertSame( + self::referenceAccepts($type, $otherType, $strictTypes)->describe(), + $type->accepts($otherType, $strictTypes)->result->describe(), + sprintf('%s -> accepts(%s, strictTypes: %s)', $type->describe(VerbosityLevel::precise()), $otherType->describe(VerbosityLevel::precise()), $strictTypes ? 'true' : 'false'), + ); + + if (!$answersPerKind) { + continue; + } + + // Letting one member of a kind answer for all of them is only invisible as long + // as none of them attaches a reason to its answer - reasons are per member. + foreach ($type->getTypes() as $innerType) { + $this->assertSame( + [], + $innerType->accepts($otherType, $strictTypes)->reasons, + sprintf('%s -> accepts(%s)', $innerType->describe(VerbosityLevel::precise()), $otherType->describe(VerbosityLevel::precise())), + ); + } + } + } + + #[DataProvider('dataUnionAndOtherType')] + public function testTryRemove(UnionType $type, Type $otherType): void + { + $expected = self::referenceTryRemove($type, $otherType); + $actual = $type->tryRemove($otherType); + + $this->assertSame( + $expected === null ? null : $expected->describe(VerbosityLevel::precise()), + $actual === null ? null : $actual->describe(VerbosityLevel::precise()), + sprintf('%s -> tryRemove(%s)', $type->describe(VerbosityLevel::precise()), $otherType->describe(VerbosityLevel::precise())), + ); + } + + #[DataProvider('dataUnionPairs')] + public function testIsSubTypeOf(UnionType $type, UnionType $otherType): void + { + $this->assertSame( + self::referenceIsSubTypeOf($type, $otherType)->describe(), + $type->isSubTypeOf($otherType)->result->describe(), + sprintf('%s -> isSubTypeOf(%s)', $type->describe(VerbosityLevel::precise()), $otherType->describe(VerbosityLevel::precise())), + ); + } + + #[DataProvider('dataUnionPairs')] + public function testIsAcceptedBy(UnionType $type, UnionType $otherType): void + { + foreach ([true, false] as $strictTypes) { + $this->assertSame( + self::referenceIsAcceptedBy($type, $otherType, $strictTypes)->describe(), + $type->isAcceptedBy($otherType, $strictTypes)->result->describe(), + sprintf('%s -> isAcceptedBy(%s, strictTypes: %s)', $type->describe(VerbosityLevel::precise()), $otherType->describe(VerbosityLevel::precise()), $strictTypes ? 'true' : 'false'), + ); + } + } + + #[DataProvider('dataUnionPairs')] + public function testEquals(UnionType $type, UnionType $otherType): void + { + $this->assertSame( + self::referenceEquals($type, $otherType), + $type->equals($otherType), + sprintf('%s -> equals(%s)', $type->describe(VerbosityLevel::precise()), $otherType->describe(VerbosityLevel::precise())), + ); + } + + /** + * @return Iterator + */ + public static function dataKey(): Iterator + { + yield [new ConstantStringType('a'), 's:a']; + yield [new ConstantStringType('a', true), 's:a']; + // the value goes into the key verbatim - two strings that differ in any way at all + // stand for two different values + yield [new ConstantStringType('A'), 's:A']; + yield [new ConstantStringType(''), 's:']; + yield [new ConstantStringType('0'), 's:0']; + yield [new ConstantIntegerType(1), 'i:1']; + yield [new ConstantIntegerType(-1), 'i:-1']; + yield [new ConstantIntegerType(0), 'i:0']; + yield [new ConstantBooleanType(true), 'b:1']; + yield [new ConstantBooleanType(false), 'b:0']; + yield [new NullType(), 'null']; + yield [new EnumCaseObjectType(ManyCasesTestEnum::class, 'A'), 'enum:PHPStan\Fixture\ManyCasesTestEnum::A']; + + // floats do not have value identity: -0.0 === 0.0 and NAN !== NAN + yield [new ConstantFloatType(1.0), null]; + yield [new StringType(), null]; + yield [new IntegerType(), null]; + yield [new MixedType(), null]; + yield [new ObjectType(ManyCasesTestEnum::class), null]; + // a type that merely contains a value is not the value + yield [new IntersectionType([new ConstantStringType('a'), new AccessoryNonEmptyStringType()]), null]; + yield [new UnionType([new ConstantStringType('a'), new ConstantStringType('b')]), null]; + yield [ + TemplateTypeFactory::create( + TemplateTypeScope::createWithFunction('foo'), + 'T', + new StringType(), + TemplateTypeVariance::createInvariant(), + ), + null, + ]; + } + + #[DataProvider('dataKey')] + public function testKey(Type $type, ?string $expectedKey): void + { + $this->assertSame($expectedKey, FiniteTypeSet::key($type)); + } + + /** + * @return Iterator + */ + public static function dataShortcutClass(): Iterator + { + yield [new ConstantStringType('a')]; + yield [new ConstantStringType('A')]; + yield [new ConstantStringType('DateTimeImmutable', true)]; + yield [new ConstantStringType('')]; + yield [new ConstantIntegerType(1)]; + yield [new ConstantIntegerType(-1)]; + yield [new ConstantIntegerType(0)]; + yield [new ConstantBooleanType(true)]; + yield [new ConstantBooleanType(false)]; + yield [new NullType()]; + } + + /** + * key() answers for these classes straight from get_class() instead of asking the type, + * which is only sound while the checks it skips would have passed. Assert them here so + * that a change to any of these classes fails loudly rather than silently keying a type + * that no longer stands for exactly one value. + */ + #[DataProvider('dataShortcutClass')] + public function testShortcutClassesSatisfyTheGeneralPath(Type $type): void + { + $this->assertTrue($type->isConstantScalarValue()->yes()); + $this->assertSame([$type], $type->getConstantScalarTypes()); + $this->assertTrue($type->equals($type)); + $this->assertNotNull(FiniteTypeSet::key($type)); + } + + /** + * The shortcut matches the class exactly, so a subclass has to reach the general path - + * and land on the very same key, or a union holding both would count them as two values. + */ + public function testSubclassOfAShortcutClassIsKeyedTheSameWay(): void + { + $subclass = new class ('a') extends ConstantStringType { + + }; + + $this->assertSame(FiniteTypeSet::key(new ConstantStringType('a')), FiniteTypeSet::key($subclass)); + + $set = (new UnionType([new ConstantStringType('a'), $subclass]))->getFiniteTypeSet(); + $this->assertNotNull($set); + $this->assertCount(1, $set->getMembers()); + $this->assertCount(1, $set->getOthers()); + } + + /** + * Types sharing a key must be interchangeable, so they have to be equal - and equal + * types must never end up under different keys. + */ + #[DataProvider('dataUnionAndOtherType')] + public function testKeyAgreesWithEquals(UnionType $type, Type $otherType): void + { + $otherKey = FiniteTypeSet::key($otherType); + $equal = []; + $sameKey = []; + foreach ($type->getTypes() as $i => $innerType) { + $innerKey = FiniteTypeSet::key($innerType); + if ($innerKey === null || $otherKey === null) { + continue; + } + + $equal[$i] = $innerType->equals($otherType); + $sameKey[$i] = $innerKey === $otherKey; + } + + $this->assertSame( + $equal, + $sameKey, + sprintf('%s <-> %s', $type->describe(VerbosityLevel::precise()), $otherType->describe(VerbosityLevel::precise())), + ); + } + + /** + * Members of one kind stand in for each other when the set is asked about a value it does + * not hold, so a kind must never span two classes - nor two enums, which answer for their + * own cases only. + */ + public function testEachKindIsRepresentedOnce(): void + { + $set = (new UnionType([ + new ConstantStringType('a'), + new ConstantStringType('b'), + new ConstantIntegerType(1), + new ConstantIntegerType(2), + new ConstantBooleanType(true), + new NullType(), + new EnumCaseObjectType(ManyCasesTestEnum::class, 'A'), + new EnumCaseObjectType(ManyCasesTestEnum::class, 'B'), + new EnumCaseObjectType(AnotherTestEnum::class, 'ONE'), + ]))->getFiniteTypeSet(); + + $this->assertNotNull($set); + + $describe = static fn (Type $type): string => $type->describe(VerbosityLevel::precise()); + + // the first member of every kind but the queried one, in the union's order + $this->assertSame( + ['1', 'true', 'null', 'PHPStan\Fixture\ManyCasesTestEnum::A', 'PHPStan\Fixture\AnotherTestEnum::ONE'], + array_map($describe, $set->getRepresentativesOfOtherKinds(new ConstantStringType('zzz'))), + ); + + // one enum does not answer for another's cases, so both are represented + $this->assertSame( + ["'a'", '1', 'true', 'null', 'PHPStan\Fixture\AnotherTestEnum::ONE'], + array_map($describe, $set->getRepresentativesOfOtherKinds(new EnumCaseObjectType(ManyCasesTestEnum::class, 'C'))), + ); + + // a type of no keyed kind is answered by every one of the six kinds + $this->assertCount(6, $set->getRepresentativesOfOtherKinds(new ObjectType('DateTimeImmutable'))); + } + + public function testUnkeyableMembersDoNotDefeatTheSet(): void + { + $set = (new UnionType([ + new ConstantStringType('a'), + new ObjectType('DateTimeImmutable'), + new ConstantStringType('b'), + ]))->getFiniteTypeSet(); + + $this->assertNotNull($set); + $this->assertFalse($set->isComplete()); + $this->assertTrue($set->has('s:a')); + $this->assertTrue($set->has('s:b')); + $this->assertCount(1, $set->getOthers()); + } + + public function testUnionWithoutFiniteMembersHasNoSet(): void + { + $this->assertNull((new UnionType([new StringType(), new IntegerType()]))->getFiniteTypeSet()); + } + + /** + * Mirrors UnionType::isSuperTypeOf() as it looked before the identity map, for types + * that reach its member loop - which none of the delegating or late-resolvable ones in + * dataUnionAndOtherType() are. + */ + private static function referenceIsSuperTypeOf(UnionType $type, Type $otherType): TrinaryLogic + { + $results = []; + foreach ($type->getTypes() as $innerType) { + $result = $innerType->isSuperTypeOf($otherType); + if ($result->yes()) { + return $result->result; + } + $results[] = $result->result; + } + + return TrinaryLogic::createNo()->or(...$results); + } + + /** Mirrors UnionType::isSubTypeOf() as it looked before the identity map. */ + private static function referenceIsSubTypeOf(UnionType $type, Type $otherType): TrinaryLogic + { + return TrinaryLogic::extremeIdentity(...array_map( + static fn (Type $innerType): TrinaryLogic => $otherType->isSuperTypeOf($innerType)->result, + $type->getTypes(), + )); + } + + /** + * Mirrors UnionType::accepts() as it looked before the identity map, minus the branches + * for iterables, callables and intersections - none of the types in + * dataUnionAndOtherType() take them. + */ + private static function referenceAccepts(UnionType $type, Type $otherType, bool $strictTypes): TrinaryLogic + { + foreach (UnionType::EQUAL_UNION_CLASSES as $baseClass => $classes) { + if (!$otherType->equals(new ObjectType($baseClass))) { + continue; + } + + $union = TypeCombinator::union( + ...array_map(static fn (string $objectClass): Type => new ObjectType($objectClass), $classes), + ); + if (self::referenceAccepts($type, $union, $strictTypes)->yes()) { + return TrinaryLogic::createYes(); + } + break; + } + + $result = TrinaryLogic::createNo(); + foreach ($type->getTypes() as $innerType) { + $result = $result->or($innerType->accepts($otherType, $strictTypes)->result); + } + if ($result->yes()) { + return $result; + } + + if ($otherType instanceof CompoundType && !$otherType instanceof TemplateType) { + return $otherType->isAcceptedBy($type, $strictTypes)->result; + } + + if ($otherType->isEnum()->yes() && !$type->isEnum()->no()) { + $enumCasesUnion = TypeCombinator::union(...$otherType->getEnumCases()); + if (!$otherType->equals($enumCasesUnion)) { + return self::referenceAccepts($type, $enumCasesUnion, $strictTypes); + } + } + + return $result; + } + + /** Mirrors UnionType::isAcceptedBy() as it looked before the identity map. */ + private static function referenceIsAcceptedBy(UnionType $type, Type $acceptingType, bool $strictTypes): TrinaryLogic + { + if ($type instanceof BenevolentUnionType) { + $result = TrinaryLogic::createNo(); + foreach ($type->getTypes() as $innerType) { + $result = $result->or($acceptingType->accepts($innerType, $strictTypes)->result); + } + + return $result; + } + + return TrinaryLogic::extremeIdentity(...array_map( + static fn (Type $innerType): TrinaryLogic => $acceptingType->accepts($innerType, $strictTypes)->result, + $type->getTypes(), + )); + } + + /** Mirrors UnionType::equals() as it looked before the identity map. */ + private static function referenceEquals(UnionType $type, UnionType $otherType): bool + { + $otherTypes = $otherType->getTypes(); + if (count($type->getTypes()) !== count($otherTypes)) { + return false; + } + + foreach ($type->getTypes() as $innerType) { + $match = false; + foreach ($otherTypes as $i => $otherInnerType) { + if (!$innerType->equals($otherInnerType)) { + continue; + } + + $match = true; + unset($otherTypes[$i]); + break; + } + + if (!$match) { + return false; + } + } + + return count($otherTypes) === 0; + } + + /** Mirrors UnionType::tryRemove() as it looked before the identity map. */ + private static function referenceTryRemove(UnionType $type, Type $typeToRemove): ?Type + { + $innerTypes = []; + $changed = false; + foreach ($type->getTypes() as $innerType) { + $removed = TypeCombinator::remove($innerType, $typeToRemove); + if (!$removed->equals($innerType)) { + $changed = true; + } + if ($removed instanceof NeverType) { + continue; + } + if ($removed instanceof UnionType) { + foreach ($removed->getTypes() as $removedInnerType) { + $innerTypes[] = $removedInnerType; + } + } else { + $innerTypes[] = $removed; + } + } + + if (!$changed) { + return null; + } + + if (count($innerTypes) === 0) { + return new NeverType(); + } + + if (count($innerTypes) === 1) { + return $innerTypes[0]; + } + + return new UnionType($innerTypes); + } + +} diff --git a/tests/bench/data/big-constant-string-union.php b/tests/bench/data/big-constant-string-union.php new file mode 100644 index 00000000000..cc50a9e0f3f --- /dev/null +++ b/tests/bench/data/big-constant-string-union.php @@ -0,0 +1,233 @@ +