diff --git a/src/Type/FiniteTypeSet.php b/src/Type/FiniteTypeSet.php new file mode 100644 index 00000000000..3ba2dd7af2a --- /dev/null +++ b/src/Type/FiniteTypeSet.php @@ -0,0 +1,321 @@ + $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(); + } + + /** + * containedIn() against the one-member set holding just $key. + * + * Yes only when this set holds nothing besides that value, no when it does not hold it + * at all, maybe in between - the same three answers as containedIn(), which is what a + * single value is being compared as here. + * + * Only keyed members are compared - call isComplete() first when the answer has to + * hold for the whole union. + */ + public function containedInKey(string $key): TrinaryLogic + { + if (!$this->has($key)) { + return TrinaryLogic::createNo(); + } + + if (count($this->members) === 1) { + return TrinaryLogic::createYes(); + } + + 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..97d60a32ec7 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,86 @@ 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 $otherType holds every member of this one, or null when the question cannot + * be settled from the identity maps alone. + * + * $otherType is compared as a set of values: a union through its own map, a single + * finite value as the one-member set holding it. + * + * $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 + { + $finiteTypeSet = $this->getFiniteTypeSet(); + if ($finiteTypeSet === null || !$finiteTypeSet->isComplete()) { + return null; + } + + if (!$otherType instanceof self || $otherType instanceof TemplateType) { + // A single value covers a member iff they are the same value, and no member + // stands for a value it is not keyed by - so one lookup answers for every member + // at once. This is the shape TypeCombinator::remove() takes to ask whether + // removing a value empties a union of values, and the only way a non-union other + // type reaches this helper at all. + $key = FiniteTypeSet::key($otherType); + if ($key === null) { + return null; + } + + $containment = $finiteTypeSet->containedInKey($key); + + // No constant value accepts another one - not even a numeric string an int, in + // either direction - so today accepts() agrees with isSuperTypeOf() here and this + // guard never changes an answer. It is kept because that is a fact about the + // current value types rather than something the identity map guarantees: a value + // the map does not hold being accepted anyway is exactly what accepts() is + // allowed to do, and all isAcceptedBy() wants from the map is the yes. + if (!$containment->yes() && $yesOnly) { + return null; + } + + return $containment; + } + + $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 +459,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 +1487,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..149b2439d18 --- /dev/null +++ b/tests/PHPStan/Type/FiniteTypeSetTest.php @@ -0,0 +1,822 @@ + + */ + 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('PHPStan\Fixture\ManyCasesTestEnum', 'A'), + new EnumCaseObjectType('PHPStan\Fixture\ManyCasesTestEnum', 'B'), + ]), + 'enum cases superset' => new UnionType([ + new EnumCaseObjectType('PHPStan\Fixture\ManyCasesTestEnum', 'A'), + new EnumCaseObjectType('PHPStan\Fixture\ManyCasesTestEnum', 'B'), + new EnumCaseObjectType('PHPStan\Fixture\ManyCasesTestEnum', 'C'), + ]), + 'mixed kinds' => new UnionType([ + new ConstantStringType('a'), + new ConstantIntegerType(1), + new ConstantBooleanType(true), + new NullType(), + new EnumCaseObjectType('PHPStan\Fixture\ManyCasesTestEnum', '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('PHPStan\Fixture\ManyCasesTestEnum', 'A'), + 'enum case F' => new EnumCaseObjectType('PHPStan\Fixture\ManyCasesTestEnum', 'F'), + 'whole enum' => new ObjectType('PHPStan\Fixture\ManyCasesTestEnum'), + '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); + } + + /** + * Whether the map alone settles a comparison of $type against $otherType - the whole + * union is keyed, and $otherType is one value to look up in it. + */ + private static function answersFromTheMap(UnionType $type, Type $otherType): bool + { + $finiteTypeSet = $type->getFiniteTypeSet(); + + return FiniteTypeSet::key($otherType) !== null + && $finiteTypeSet !== null + && $finiteTypeSet->isComplete(); + } + + private function assertAccepts(UnionType $type, Type $otherType): void + { + $answersPerKind = self::answersFromTheMap($type, $otherType); + + 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')] + #[DataProvider('dataUnionAndOtherType')] + public function testIsSubTypeOf(UnionType $type, Type $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())), + ); + + if (!self::answersFromTheMap($type, $otherType)) { + return; + } + + // One key lookup stands in for asking every member, which is only invisible as long + // as none of them attaches a reason to its answer - reasons are per member. + foreach ($type->getTypes() as $innerType) { + $result = $otherType->isSuperTypeOf($innerType); + $this->assertSame( + [], + $result->reasons, + sprintf('%s -> isSuperTypeOf(%s)', $otherType->describe(VerbosityLevel::precise()), $innerType->describe(VerbosityLevel::precise())), + ); + $this->assertSame([], $result->lazyReasons); + } + } + + #[DataProvider('dataUnionPairs')] + #[DataProvider('dataUnionAndOtherType')] + public function testIsAcceptedBy(UnionType $type, Type $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('PHPStan\Fixture\ManyCasesTestEnum', '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('PHPStan\Fixture\ManyCasesTestEnum'), 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('PHPStan\Fixture\ManyCasesTestEnum', 'A'), + new EnumCaseObjectType('PHPStan\Fixture\ManyCasesTestEnum', 'B'), + new EnumCaseObjectType('PHPStan\Fixture\AnotherTestEnum', '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('PHPStan\Fixture\ManyCasesTestEnum', 'C'))), + ); + + // a type of no keyed kind is answered by every one of the six kinds + $this->assertCount(6, $set->getRepresentativesOfOtherKinds(new ObjectType('DateTimeImmutable'))); + } + + /** + * A union of one kind has nobody to speak for a value it does not hold, so accepts() + * has nothing left to consult and the plain no stands. + */ + public function testTheOnlyKindRepresentsNoOtherKind(): void + { + $set = FiniteTypeSet::create([new ConstantStringType('a'), new ConstantStringType('b')]); + + $this->assertNotNull($set); + $this->assertSame([], $set->getRepresentativesOfOtherKinds(new ConstantStringType('zzz'))); + } + + /** + * @return Iterator, list, TrinaryLogic}> + */ + public static function dataContainedIn(): Iterator + { + yield 'same members' => [ + [new ConstantStringType('a'), new ConstantStringType('b')], + [new ConstantStringType('a'), new ConstantStringType('b')], + TrinaryLogic::createYes(), + ]; + yield 'same members, reordered' => [ + [new ConstantStringType('a'), new ConstantStringType('b')], + [new ConstantStringType('b'), new ConstantStringType('a')], + TrinaryLogic::createYes(), + ]; + yield 'proper subset' => [ + [new ConstantStringType('a'), new ConstantStringType('b')], + [new ConstantStringType('a'), new ConstantStringType('b'), new ConstantStringType('c')], + TrinaryLogic::createYes(), + ]; + yield 'single member, held' => [ + [new ConstantStringType('a')], + [new ConstantStringType('a'), new ConstantStringType('b')], + TrinaryLogic::createYes(), + ]; + // the other way round: containment is not symmetric, one member is missing + yield 'proper superset' => [ + [new ConstantStringType('a'), new ConstantStringType('b'), new ConstantStringType('c')], + [new ConstantStringType('a'), new ConstantStringType('b')], + TrinaryLogic::createMaybe(), + ]; + yield 'one of two held' => [ + [new ConstantStringType('a'), new ConstantStringType('b')], + [new ConstantStringType('b'), new ConstantStringType('c')], + TrinaryLogic::createMaybe(), + ]; + yield 'none held' => [ + [new ConstantStringType('a'), new ConstantStringType('b')], + [new ConstantStringType('x'), new ConstantStringType('y')], + TrinaryLogic::createNo(), + ]; + // none held, and the other set is the smaller one - what counts is how many of *this* + // set's members are missing, not how many the other set has + yield 'none held, smaller other set' => [ + [new ConstantStringType('a'), new ConstantStringType('b')], + [new ConstantStringType('x')], + TrinaryLogic::createNo(), + ]; + yield 'single member, not held' => [ + [new ConstantStringType('a')], + [new ConstantStringType('b')], + TrinaryLogic::createNo(), + ]; + // the keys carry the kind, so a value is never held by a member of another kind + yield 'same values of another kind' => [ + [new ConstantIntegerType(1), new ConstantIntegerType(0)], + [new ConstantStringType('1'), new ConstantBooleanType(false)], + TrinaryLogic::createNo(), + ]; + yield 'across kinds' => [ + [new ConstantStringType('a'), new ConstantIntegerType(1), new NullType()], + [new NullType(), new ConstantStringType('a'), new ConstantIntegerType(1)], + TrinaryLogic::createYes(), + ]; + } + + /** + * @param list $types + * @param list $otherTypes + */ + #[DataProvider('dataContainedIn')] + public function testContainedIn(array $types, array $otherTypes, TrinaryLogic $expected): void + { + $set = FiniteTypeSet::create($types); + $otherSet = FiniteTypeSet::create($otherTypes); + $this->assertNotNull($set); + $this->assertNotNull($otherSet); + + $this->assertSame($expected->describe(), $set->containedIn($otherSet)->describe()); + } + + /** + * @return Iterator, Type, TrinaryLogic}> + */ + public static function dataContainedInKey(): Iterator + { + // the only way a set is wholly under one value is by being that one value + yield 'the only member' => [[new ConstantStringType('a')], new ConstantStringType('a'), TrinaryLogic::createYes()]; + yield 'one of two members' => [ + [new ConstantStringType('a'), new ConstantStringType('b')], + new ConstantStringType('a'), + TrinaryLogic::createMaybe(), + ]; + yield 'the last of many members' => [ + [new ConstantStringType('a'), new ConstantStringType('b'), new ConstantStringType('c')], + new ConstantStringType('c'), + TrinaryLogic::createMaybe(), + ]; + yield 'not held' => [ + [new ConstantStringType('a'), new ConstantStringType('b')], + new ConstantStringType('z'), + TrinaryLogic::createNo(), + ]; + yield 'not held by a single-member set' => [ + [new ConstantStringType('a')], + new ConstantStringType('z'), + TrinaryLogic::createNo(), + ]; + // the key carries the kind, so the same value of another kind is not held either + yield 'the same value of another kind' => [ + [new ConstantIntegerType(1)], + new ConstantStringType('1'), + TrinaryLogic::createNo(), + ]; + yield 'one of two kinds' => [ + [new ConstantStringType('a'), new NullType()], + new NullType(), + TrinaryLogic::createMaybe(), + ]; + } + + /** + * @param list $types + */ + #[DataProvider('dataContainedInKey')] + public function testContainedInKey(array $types, Type $value, TrinaryLogic $expected): void + { + $set = FiniteTypeSet::create($types); + $this->assertNotNull($set); + + $key = FiniteTypeSet::key($value); + $this->assertNotNull($key); + + $this->assertSame($expected->describe(), $set->containedInKey($key)->describe()); + + // the same answer containedIn() gives against the set of just that one value, which + // is what a single value is being compared as + $singleMemberSet = FiniteTypeSet::create([$value]); + $this->assertNotNull($singleMemberSet); + $this->assertSame($expected->describe(), $set->containedIn($singleMemberSet)->describe()); + } + + 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()); + } + + /** + * An incomplete set speaks for its keyed members only, so a miss is not the union's + * answer - the members it could not key still have to be asked, and here each of them + * answers differently than the map would have. + */ + public function testMissInAnIncompleteSetStillConsultsTheOtherMembers(): void + { + $union = new UnionType([new ConstantStringType('a'), new ConstantStringType('b'), new StringType()]); + $set = $union->getFiniteTypeSet(); + $this->assertNotNull($set); + $this->assertFalse($set->isComplete()); + $this->assertFalse($set->has('s:z')); + + // 'z' is not in the map, but the general string member holds it + $this->assertSame('Yes', $union->isSuperTypeOf(new ConstantStringType('z'))->result->describe()); + $this->assertSame('Yes', $union->accepts(new ConstantStringType('z'), true)->result->describe()); + + // removing 'a' must not drop the member the map does not know about + $removed = $union->tryRemove(new ConstantStringType('a')); + $this->assertNotNull($removed); + $this->assertSame("'b'|string", $removed->describe(VerbosityLevel::precise())); + } + + /** + * Union-against-union answers need both sets to speak for their whole union: here the + * maps are identical while the unions are not, so reading the answer off them alone + * would call two different types the same. + */ + public function testUnionComparisonsRequireBothSetsToBeComplete(): void + { + $union = new UnionType([new ConstantStringType('a'), new ObjectType('DateTime')]); + $otherUnion = new UnionType([new ConstantStringType('a'), new ObjectType('DateTimeImmutable')]); + + $set = $union->getFiniteTypeSet(); + $otherSet = $otherUnion->getFiniteTypeSet(); + $this->assertNotNull($set); + $this->assertNotNull($otherSet); + $this->assertFalse($set->isComplete()); + $this->assertFalse($otherSet->isComplete()); + $this->assertSame('Yes', $set->containedIn($otherSet)->describe()); + + $this->assertFalse($union->equals($otherUnion)); + $this->assertSame('Maybe', $union->isSubTypeOf($otherUnion)->result->describe()); + $this->assertSame('Maybe', $union->isAcceptedBy($otherUnion, true)->result->describe()); + } + + /** + * @return Iterator, bool}> + */ + public static function dataHasClassStringMember(): Iterator + { + yield 'plain strings' => [[new ConstantStringType('a'), new ConstantStringType('b')], false]; + yield 'a value that names a class' => [[new ConstantStringType('a'), new ConstantStringType('DateTimeImmutable')], true]; + yield 'the class-string flag' => [[new ConstantStringType('a'), new ConstantStringType('Zzz', true)], true]; + // only string members can carry the flag, so no other kind is worth asking + yield 'no strings at all' => [[new ConstantIntegerType(1), new NullType()], false]; + } + + /** + * @param list $types + */ + #[DataProvider('dataHasClassStringMember')] + public function testHasClassStringMember(array $types, bool $expected): void + { + $set = FiniteTypeSet::create($types); + $this->assertNotNull($set); + + $this->assertSame($expected, $set->hasClassStringMember()); + // answered from the cache the second time round, with the same answer + $this->assertSame($expected, $set->hasClassStringMember()); + } + + 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 @@ +