diff --git a/documentation/components/libs/types/architecture.md b/documentation/components/libs/types/architecture.md index 1d9b1ff57f..5709209303 100644 --- a/documentation/components/libs/types/architecture.md +++ b/documentation/components/libs/types/architecture.md @@ -71,7 +71,7 @@ The DSL definition can thus be considered the library's API, located in the file [functions.php](/src/lib/types/src/Flow/Types/DSL/functions.php). One of the library's key principles is tight integration with static code analysis tools. This is achieved through -proper use of template mechanisms and type narrowing, `@template` and `@phpstan-assert`. +proper use of template mechanisms and type narrowing, `@template` and `@assert`. More details can be found at: @@ -81,6 +81,26 @@ More details can be found at: Therefore, it is critical that all classes and functions in this library are properly documented in PHPDoc. +Two rules follow from that, and both are load-bearing rather than stylistic. + +**Every implementation restates `@return` when its own type is narrower than its native signature.** Mago resolves a +return type from the implementation's docblock, never from the interface's — an implementation that declares +`assert(mixed $value): string` and omits `@return non-empty-string` silently hands back `string`, discarding exactly +the narrowing the library exists to provide. This produces no diagnostic on its own: the resulting program is valid, +and nothing observes an inferred type. +[`TypeNarrowing`](/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Fixtures/TypeNarrowing.php) funnels every `type_*()` +through `assert()`, `cast()` and `isValid()` into sinks that accept only the narrow type, so `just analyze` fails on +the line where a narrowing is lost. + +**Every implementation declares `@template T of X` and `@implements Type`, including those where nothing can bind +`T` to anything but its own bound.** These templates look like ceremony and are not. Mago's `flow-php` analyzer plugin +builds a `type_structure()` shape by reading type parameter 0 off each element's concrete class; an element class with +no type parameters makes the plugin discard the *entire* shape, degrading it to `array` — again with +no diagnostic. +[`StructureShapeInference`](/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Fixtures/StructureShapeInference.php) +pins those shapes so `just analyze` fails if this is broken. Removing the templates requires an upstream mago change +that resolves the element through the `Flow\Types\Type` template instead. + ## Testing This library provides a set of unit tests that verify the correct operation of all types and DSL functions. diff --git a/src/lib/types/src/Flow/Types/Type.php b/src/lib/types/src/Flow/Types/Type.php index cf9c12a92e..0aa3c0be68 100644 --- a/src/lib/types/src/Flow/Types/Type.php +++ b/src/lib/types/src/Flow/Types/Type.php @@ -21,7 +21,7 @@ interface Type * * @return T * - * @phpstan-assert T $value + * @assert T $value */ public function assert(mixed $value): mixed; @@ -39,10 +39,10 @@ public function cast(mixed $value): mixed; /** * Checks if the value is of the type of this object, returning a boolean instead of throwing. * When this method returns true, static analysis tools narrow the value's type to T at the call site - * (via @phpstan-assert-if-true). Use this when you want to branch on the result; use assert() when you + * (via @assert-if-true). Use this when you want to branch on the result; use assert() when you * want the call to fail loudly on a mismatch. * - * @phpstan-assert-if-true T $value + * @assert-if-true T $value */ public function isValid(mixed $value): bool; diff --git a/src/lib/types/src/Flow/Types/Type/ArrayContentDetector.php b/src/lib/types/src/Flow/Types/Type/ArrayContentDetector.php index f4106729b5..b0c22a48f8 100644 --- a/src/lib/types/src/Flow/Types/Type/ArrayContentDetector.php +++ b/src/lib/types/src/Flow/Types/Type/ArrayContentDetector.php @@ -75,7 +75,7 @@ public function firstValueType(): ?Type } /** - * @phpstan-assert-if-true Type $this->firstKeyType() + * @assert-if-true Type $this->firstKeyType() */ public function isList(): bool { @@ -83,7 +83,7 @@ public function isList(): bool } /** - * @phpstan-assert-if-true Type $this->firstKeyType() + * @assert-if-true Type $this->firstKeyType() */ public function isMap(): bool { diff --git a/src/lib/types/src/Flow/Types/Type/Logical/NonEmptyStringType.php b/src/lib/types/src/Flow/Types/Type/Logical/NonEmptyStringType.php index 45acd6c891..efa560667e 100644 --- a/src/lib/types/src/Flow/Types/Type/Logical/NonEmptyStringType.php +++ b/src/lib/types/src/Flow/Types/Type/Logical/NonEmptyStringType.php @@ -29,6 +29,9 @@ */ final class NonEmptyStringType implements Type { + /** + * @return non-empty-string + */ public function assert(mixed $value): string { if ($this->isValid($value)) { @@ -38,6 +41,9 @@ public function assert(mixed $value): string throw InvalidTypeException::value($value, $this); } + /** + * @return non-empty-string + */ public function cast(mixed $value): string { if ($this->isValid($value)) { diff --git a/src/lib/types/src/Flow/Types/Type/Logical/PositiveIntegerType.php b/src/lib/types/src/Flow/Types/Type/Logical/PositiveIntegerType.php index 2431f8ac47..24a5f31556 100644 --- a/src/lib/types/src/Flow/Types/Type/Logical/PositiveIntegerType.php +++ b/src/lib/types/src/Flow/Types/Type/Logical/PositiveIntegerType.php @@ -23,6 +23,9 @@ */ final readonly class PositiveIntegerType implements Type { + /** + * @return int<0, max> + */ public function assert(mixed $value): int { if ($this->isValid($value)) { @@ -32,6 +35,9 @@ public function assert(mixed $value): int throw InvalidTypeException::value($value, $this); } + /** + * @return int<0, max> + */ public function cast(mixed $value): int { if ($this->isValid($value)) { diff --git a/src/lib/types/src/Flow/Types/Type/Logical/UuidType.php b/src/lib/types/src/Flow/Types/Type/Logical/UuidType.php index a4ee6d9d28..ca67a094f9 100644 --- a/src/lib/types/src/Flow/Types/Type/Logical/UuidType.php +++ b/src/lib/types/src/Flow/Types/Type/Logical/UuidType.php @@ -30,6 +30,9 @@ public function assert(mixed $value): Uuid throw InvalidTypeException::value($value, $this); } + /** + * @return Uuid + */ public function cast(mixed $value): mixed { if ($this->isValid($value)) { diff --git a/src/lib/types/src/Flow/Types/Type/Native/EnumType.php b/src/lib/types/src/Flow/Types/Type/Native/EnumType.php index 03499ec859..3c690ba8c1 100644 --- a/src/lib/types/src/Flow/Types/Type/Native/EnumType.php +++ b/src/lib/types/src/Flow/Types/Type/Native/EnumType.php @@ -81,6 +81,9 @@ public function assert(mixed $value): UnitEnum throw InvalidTypeException::value($value, $this); } + /** + * @return T + */ public function cast(mixed $value): UnitEnum { if ($this->isValid($value)) { @@ -95,8 +98,9 @@ public function cast(mixed $value): UnitEnum throw new CastingException($value, $this); } + // is_a() above narrows $enumClass to class-string, so ::from() loses T; assert() rebinds it. // @mago-ignore analysis:possibly-static-access-on-interface - return $enumClass::from($value); + return $this->assert($enumClass::from($value)); } throw new CastingException($value, $this); diff --git a/src/lib/types/src/Flow/Types/Type/Native/IntersectionType.php b/src/lib/types/src/Flow/Types/Type/Native/IntersectionType.php index a058b03b58..ee9b12b312 100644 --- a/src/lib/types/src/Flow/Types/Type/Native/IntersectionType.php +++ b/src/lib/types/src/Flow/Types/Type/Native/IntersectionType.php @@ -92,6 +92,9 @@ public function assert(mixed $value): mixed return $value; } + /** + * @return TLeft&TRight + */ public function cast(mixed $value): mixed { if ($this->isValid($value)) { diff --git a/src/lib/types/src/Flow/Types/Type/Native/UnionType.php b/src/lib/types/src/Flow/Types/Type/Native/UnionType.php index c4c6bc49f0..e5c8efa9de 100644 --- a/src/lib/types/src/Flow/Types/Type/Native/UnionType.php +++ b/src/lib/types/src/Flow/Types/Type/Native/UnionType.php @@ -95,6 +95,9 @@ public function assert(mixed $value): mixed throw InvalidTypeException::value($value, $this); } + /** + * @return TLeft|TRight + */ public function cast(mixed $value): mixed { if ($this->isValid($value)) { diff --git a/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Fixtures/TypeNarrowing.php b/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Fixtures/TypeNarrowing.php new file mode 100644 index 0000000000..00549fad0e --- /dev/null +++ b/src/lib/types/tests/Flow/Types/Tests/Unit/Type/Fixtures/TypeNarrowing.php @@ -0,0 +1,677 @@ + $v + * + * @return list + */ +function sink_list_of_int(array $v): array +{ + return $v; +} + +/** + * @param 'fixed' $v + * + * @return 'fixed' + */ +function sink_literal(string $v): string +{ + return $v; +} + +/** + * @param array $v + * + * @return array + */ +function sink_map(array $v): array +{ + return $v; +} + +function sink_mixed(mixed $v): mixed +{ + return $v; +} + +function sink_null(null $v): null +{ + return $v; +} + +/** + * @param non-empty-string $v + * + * @return non-empty-string + */ +function sink_non_empty_string(string $v): string +{ + return $v; +} + +/** + * @param numeric-string $v + * + * @return numeric-string + */ +function sink_numeric_string(string $v): string +{ + return $v; +} + +function sink_object(object $v): object +{ + return $v; +} + +function sink_optional_int(?int $v): ?int +{ + return $v; +} + +/** + * @param int<0, max> $v + * + * @return int<0, max> + */ +function sink_positive_int(int $v): int +{ + return $v; +} + +/** + * @param resource $v + * + * @return resource + */ +function sink_resource($v): mixed +{ + return $v; +} + +function sink_scalar(bool|float|int|string $v): bool|float|int|string +{ + return $v; +} + +/** + * @param array{id: int} $v + * + * @return array{id: int} + */ +function sink_structure(array $v): array +{ + return $v; +} + +function sink_string(string $v): string +{ + return $v; +} + +function sink_time(DateInterval $v): DateInterval +{ + return $v; +} + +function sink_time_zone(DateTimeZone $v): DateTimeZone +{ + return $v; +} + +function sink_union(int|string $v): int|string +{ + return $v; +} + +function sink_uuid(Uuid $v): Uuid +{ + return $v; +} + +/** + * @param DOMDocument|XMLDocument $v + * + * @return DOMDocument|XMLDocument + */ +function sink_xml(object $v): object +{ + return $v; +} + +/** + * @param DOMElement|Element $v + * + * @return DOMElement|Element + */ +function sink_xml_element(object $v): object +{ + return $v; +} + +/** + * Pins the static type every `Type` implementation returns from `assert()` and `cast()`, and narrows to in `isValid()`. + * + * Erasure produces no diagnostic on its own — an `assert()` declaring `non-empty-string` but resolving to `string` is + * a valid program. Each call below feeds a sink accepting only the narrow type, so `just analyze` fails on the line + * where a narrowing is lost. Mago reads a return from the implementation's own `@return`, never the interface's. + * + * One fresh parameter per call: a reused variable carries the first call's narrowing into the second and hides it. + */ +final class TypeNarrowing +{ + /** + * @return array + */ + public function assertNarrowsLogical( + mixed $classString, + mixed $dateTime, + mixed $date, + mixed $instanceOf, + mixed $json, + mixed $list, + mixed $literal, + mixed $map, + mixed $nonEmptyString, + mixed $numericString, + mixed $optional, + mixed $positiveInteger, + mixed $scalar, + mixed $structure, + mixed $time, + mixed $timeZone, + mixed $uuid, + mixed $xmlElement, + mixed $xml, + ): array { + return [ + 'class_string' => sink_class_string(type_class_string()->assert($classString)), + 'date_time' => sink_datetime(type_datetime()->assert($dateTime)), + 'date' => sink_datetime(type_date()->assert($date)), + 'instance_of' => sink_date_immutable(type_instance_of(DateTimeImmutable::class)->assert($instanceOf)), + 'json' => sink_json(type_json()->assert($json)), + 'list' => sink_list_of_int(type_list(type_integer())->assert($list)), + 'literal' => sink_literal(type_literal('fixed')->assert($literal)), + 'map' => sink_map(type_map(type_string(), type_integer())->assert($map)), + 'non_empty_string' => sink_non_empty_string(type_non_empty_string()->assert($nonEmptyString)), + 'numeric_string' => sink_numeric_string(type_numeric_string()->assert($numericString)), + 'optional' => sink_optional_int(type_optional(type_integer())->assert($optional)), + 'positive_integer' => sink_positive_int(type_positive_integer()->assert($positiveInteger)), + 'scalar' => sink_scalar(type_scalar()->assert($scalar)), + 'structure' => sink_structure(type_structure(['id' => type_integer()])->assert($structure)), + 'time' => sink_time(type_time()->assert($time)), + 'time_zone' => sink_time_zone(type_time_zone()->assert($timeZone)), + 'uuid' => sink_uuid(type_uuid()->assert($uuid)), + 'xml_element' => sink_xml_element(type_xml_element()->assert($xmlElement)), + 'xml' => sink_xml(type_xml()->assert($xml)), + ]; + } + + /** + * @return array + */ + public function assertNarrowsNative( + mixed $array, + mixed $boolean, + mixed $callable, + mixed $enum, + mixed $float, + mixed $integer, + mixed $intersection, + mixed $mixed, + mixed $null, + mixed $object, + mixed $resource, + mixed $string, + mixed $union, + ): array { + return [ + 'array' => sink_array(type_array()->assert($array)), + 'boolean' => sink_bool(type_boolean()->assert($boolean)), + 'callable' => sink_callable(type_callable()->assert($callable)), + 'enum' => sink_enum(type_enum(SomeEnum::class)->assert($enum)), + 'float' => sink_float(type_float()->assert($float)), + 'integer' => sink_int(type_integer()->assert($integer)), + // IntersectionType's TRight never binds, so this resolves to `(Date&TRight)|(Time&TRight)`. + // @mago-expect analysis:less-specific-nested-argument-type + 'intersection' => sink_intersection(type_intersection( + type_instance_of(Date::class), + type_instance_of(Time::class), + )->assert($intersection)), + 'mixed' => sink_mixed(type_mixed()->assert($mixed)), + 'null' => sink_null(type_null()->assert($null)), + 'object' => sink_object(type_object()->assert($object)), + 'resource' => sink_resource(type_resource()->assert($resource)), + 'string' => sink_string(type_string()->assert($string)), + 'union' => sink_union(type_union(type_integer(), type_string())->assert($union)), + ]; + } + + /** + * @return array + */ + public function castNarrowsLogical( + mixed $classString, + mixed $dateTime, + mixed $date, + mixed $instanceOf, + mixed $json, + mixed $list, + mixed $literal, + mixed $map, + mixed $nonEmptyString, + mixed $numericString, + mixed $optional, + mixed $positiveInteger, + mixed $scalar, + mixed $structure, + mixed $time, + mixed $timeZone, + mixed $uuid, + mixed $xmlElement, + mixed $xml, + ): array { + return [ + 'class_string' => sink_class_string(type_class_string()->cast($classString)), + 'date_time' => sink_datetime(type_datetime()->cast($dateTime)), + 'date' => sink_datetime(type_date()->cast($date)), + 'instance_of' => sink_date_immutable(type_instance_of(DateTimeImmutable::class)->cast($instanceOf)), + 'json' => sink_json(type_json()->cast($json)), + 'list' => sink_list_of_int(type_list(type_integer())->cast($list)), + 'literal' => sink_literal(type_literal('fixed')->cast($literal)), + 'map' => sink_map(type_map(type_string(), type_integer())->cast($map)), + 'non_empty_string' => sink_non_empty_string(type_non_empty_string()->cast($nonEmptyString)), + 'numeric_string' => sink_numeric_string(type_numeric_string()->cast($numericString)), + 'optional' => sink_optional_int(type_optional(type_integer())->cast($optional)), + 'positive_integer' => sink_positive_int(type_positive_integer()->cast($positiveInteger)), + 'scalar' => sink_scalar(type_scalar()->cast($scalar)), + 'structure' => sink_structure(type_structure(['id' => type_integer()])->cast($structure)), + 'time' => sink_time(type_time()->cast($time)), + 'time_zone' => sink_time_zone(type_time_zone()->cast($timeZone)), + 'uuid' => sink_uuid(type_uuid()->cast($uuid)), + 'xml_element' => sink_xml_element(type_xml_element()->cast($xmlElement)), + 'xml' => sink_xml(type_xml()->cast($xml)), + ]; + } + + /** + * @return array + */ + public function castNarrowsNative( + mixed $array, + mixed $boolean, + mixed $callable, + mixed $enum, + mixed $float, + mixed $integer, + mixed $intersection, + mixed $mixed, + mixed $null, + mixed $object, + mixed $resource, + mixed $string, + mixed $union, + ): array { + return [ + 'array' => sink_array(type_array()->cast($array)), + 'boolean' => sink_bool(type_boolean()->cast($boolean)), + 'callable' => sink_callable(type_callable()->cast($callable)), + 'enum' => sink_enum(type_enum(SomeEnum::class)->cast($enum)), + 'float' => sink_float(type_float()->cast($float)), + 'integer' => sink_int(type_integer()->cast($integer)), + // IntersectionType's TRight never binds, so this resolves to `(Date&TRight)|(Time&TRight)`. + // @mago-expect analysis:less-specific-nested-argument-type + 'intersection' => sink_intersection(type_intersection( + type_instance_of(Date::class), + type_instance_of(Time::class), + )->cast($intersection)), + 'mixed' => sink_mixed(type_mixed()->cast($mixed)), + 'null' => sink_null(type_null()->cast($null)), + 'object' => sink_object(type_object()->cast($object)), + 'resource' => sink_resource(type_resource()->cast($resource)), + 'string' => sink_string(type_string()->cast($string)), + 'union' => sink_union(type_union(type_integer(), type_string())->cast($union)), + ]; + } + + /** + * `type_mixed()` is absent deliberately: narrowing to `mixed` asserts nothing, so a sink cannot observe it. + * + * @return array + */ + public function isValidNarrowsLogical( + mixed $classString, + mixed $dateTime, + mixed $date, + mixed $instanceOf, + mixed $json, + mixed $list, + mixed $literal, + mixed $map, + mixed $nonEmptyString, + mixed $numericString, + mixed $optional, + mixed $positiveInteger, + mixed $scalar, + mixed $structure, + mixed $time, + mixed $timeZone, + mixed $uuid, + mixed $xmlElement, + mixed $xml, + ): array { + $narrowed = []; + + if (type_class_string()->isValid($classString)) { + $narrowed['class_string'] = sink_class_string($classString); + } + + if (type_datetime()->isValid($dateTime)) { + $narrowed['date_time'] = sink_datetime($dateTime); + } + + if (type_date()->isValid($date)) { + $narrowed['date'] = sink_datetime($date); + } + + if (type_instance_of(DateTimeImmutable::class)->isValid($instanceOf)) { + $narrowed['instance_of'] = sink_date_immutable($instanceOf); + } + + if (type_json()->isValid($json)) { + $narrowed['json'] = sink_json($json); + } + + if (type_list(type_integer())->isValid($list)) { + $narrowed['list'] = sink_list_of_int($list); + } + + if (type_literal('fixed')->isValid($literal)) { + $narrowed['literal'] = sink_literal($literal); + } + + if (type_map(type_string(), type_integer())->isValid($map)) { + $narrowed['map'] = sink_map($map); + } + + if (type_non_empty_string()->isValid($nonEmptyString)) { + $narrowed['non_empty_string'] = sink_non_empty_string($nonEmptyString); + } + + if (type_numeric_string()->isValid($numericString)) { + $narrowed['numeric_string'] = sink_numeric_string($numericString); + } + + if (type_optional(type_integer())->isValid($optional)) { + $narrowed['optional'] = sink_optional_int($optional); + } + + if (type_positive_integer()->isValid($positiveInteger)) { + $narrowed['positive_integer'] = sink_positive_int($positiveInteger); + } + + if (type_scalar()->isValid($scalar)) { + $narrowed['scalar'] = sink_scalar($scalar); + } + + if (type_structure(['id' => type_integer()])->isValid($structure)) { + $narrowed['structure'] = sink_structure($structure); + } + + if (type_time()->isValid($time)) { + $narrowed['time'] = sink_time($time); + } + + if (type_time_zone()->isValid($timeZone)) { + $narrowed['time_zone'] = sink_time_zone($timeZone); + } + + if (type_uuid()->isValid($uuid)) { + $narrowed['uuid'] = sink_uuid($uuid); + } + + if (type_xml_element()->isValid($xmlElement)) { + $narrowed['xml_element'] = sink_xml_element($xmlElement); + } + + if (type_xml()->isValid($xml)) { + $narrowed['xml'] = sink_xml($xml); + } + + return $narrowed; + } + + /** + * @return array + */ + public function isValidNarrowsNative( + mixed $array, + mixed $boolean, + mixed $callable, + mixed $enum, + mixed $float, + mixed $integer, + mixed $intersection, + mixed $null, + mixed $object, + mixed $resource, + mixed $string, + mixed $union, + ): array { + $narrowed = []; + + if (type_array()->isValid($array)) { + $narrowed['array'] = sink_array($array); + } + + if (type_boolean()->isValid($boolean)) { + $narrowed['boolean'] = sink_bool($boolean); + } + + if (type_callable()->isValid($callable)) { + $narrowed['callable'] = sink_callable($callable); + } + + if (type_enum(SomeEnum::class)->isValid($enum)) { + $narrowed['enum'] = sink_enum($enum); + } + + if (type_float()->isValid($float)) { + $narrowed['float'] = sink_float($float); + } + + if (type_integer()->isValid($integer)) { + $narrowed['integer'] = sink_int($integer); + } + + if (type_intersection(type_instance_of(Date::class), type_instance_of(Time::class))->isValid($intersection)) { + // IntersectionType's TRight never binds, so this resolves to `(Date&TRight)|(Time&TRight)`. + // @mago-expect analysis:less-specific-nested-argument-type + $narrowed['intersection'] = sink_intersection($intersection); + } + + if (type_null()->isValid($null)) { + $narrowed['null'] = sink_null($null); + } + + if (type_object()->isValid($object)) { + $narrowed['object'] = sink_object($object); + } + + if (type_resource()->isValid($resource)) { + $narrowed['resource'] = sink_resource($resource); + } + + if (type_string()->isValid($string)) { + $narrowed['string'] = sink_string($string); + } + + if (type_union(type_integer(), type_string())->isValid($union)) { + $narrowed['union'] = sink_union($union); + } + + return $narrowed; + } + + /** + * Split out of the methods above because `Dom\HTMLDocument` and `Dom\HTMLElement` only exist on PHP >= 8.4; + * keeping them here lets the rest stay executable on 8.3. + * + * @return array + */ + public function narrowsHtml( + mixed $assertedHtml, + mixed $assertedHtmlElement, + mixed $castHtml, + mixed $castHtmlElement, + mixed $validHtml, + mixed $validHtmlElement, + ): array { + $narrowed = [ + 'asserted_html' => sink_html(type_html()->assert($assertedHtml)), + 'asserted_html_element' => sink_html_element(type_html_element()->assert($assertedHtmlElement)), + 'cast_html' => sink_html(type_html()->cast($castHtml)), + 'cast_html_element' => sink_html_element(type_html_element()->cast($castHtmlElement)), + ]; + + if (type_html()->isValid($validHtml)) { + $narrowed['valid_html'] = sink_html($validHtml); + } + + if (type_html_element()->isValid($validHtmlElement)) { + $narrowed['valid_html_element'] = sink_html_element($validHtmlElement); + } + + return $narrowed; + } +} diff --git a/src/lib/types/tests/Flow/Types/Tests/Unit/Type/TypeNarrowingTest.php b/src/lib/types/tests/Flow/Types/Tests/Unit/Type/TypeNarrowingTest.php new file mode 100644 index 0000000000..7531f2f995 --- /dev/null +++ b/src/lib/types/tests/Flow/Types/Tests/Unit/Type/TypeNarrowingTest.php @@ -0,0 +1,337 @@ +assertNarrowsLogical( + stdClass::class, + $dateTime, + $date, + $dateTime, + Json::fromString('{"a":1}'), + [1, 2], + 'fixed', + ['one' => 1], + 'x', + '12', + 5, + 7, + 'scalar', + ['id' => 1], + new DateInterval('PT1S'), + $timeZone, + $uuid, + $document->createElement('a'), + $document, + ); + + static::assertSame(stdClass::class, $narrowed['class_string']); + static::assertSame($dateTime, $narrowed['date_time']); + static::assertSame($date, $narrowed['date']); + static::assertSame([1, 2], $narrowed['list']); + static::assertSame('fixed', $narrowed['literal']); + static::assertSame(['one' => 1], $narrowed['map']); + static::assertSame('x', $narrowed['non_empty_string']); + static::assertSame('12', $narrowed['numeric_string']); + static::assertSame(5, $narrowed['optional']); + static::assertSame(7, $narrowed['positive_integer']); + static::assertSame(['id' => 1], $narrowed['structure']); + static::assertSame($timeZone, $narrowed['time_zone']); + static::assertSame($uuid, $narrowed['uuid']); + static::assertSame($document, $narrowed['xml']); + } + + public function test_assert_narrows_native_types(): void + { + $handle = fopen('php://memory', 'rb'); + $object = new stdClass(); + + $narrowed = (new TypeNarrowing())->assertNarrowsNative( + [], + true, + 'strlen', + SomeEnum::A, + 1.5, + 1, + new DateOrTime(), + 'anything', + null, + $object, + $handle, + 'a', + 42, + ); + + static::assertSame([], $narrowed['array']); + static::assertTrue($narrowed['boolean']); + static::assertSame('strlen', $narrowed['callable']); + static::assertSame(SomeEnum::A, $narrowed['enum']); + static::assertSame(1.5, $narrowed['float']); + static::assertSame(1, $narrowed['integer']); + static::assertSame('anything', $narrowed['mixed']); + static::assertNull($narrowed['null']); + static::assertSame($object, $narrowed['object']); + static::assertSame($handle, $narrowed['resource']); + static::assertSame('a', $narrowed['string']); + static::assertSame(42, $narrowed['union']); + + fclose($handle); + } + + public function test_cast_narrows_logical_types(): void + { + $dateTime = new DateTimeImmutable('2026-08-07 10:00:00'); + $document = new DOMDocument(); + + $narrowed = (new TypeNarrowing())->castNarrowsLogical( + stdClass::class, + $dateTime, + $dateTime, + $dateTime, + Json::fromString('{"a":1}'), + [1, 2], + 'fixed', + ['one' => 1], + 'x', + '12', + 5, + 7, + 'scalar', + ['id' => 1], + new DateInterval('PT1S'), + new DateTimeZone('UTC'), + Uuid::fromString('00000000-0000-0000-0000-000000000000'), + $document->createElement('a'), + $document, + ); + + static::assertSame(stdClass::class, $narrowed['class_string']); + static::assertSame([1, 2], $narrowed['list']); + static::assertSame('fixed', $narrowed['literal']); + static::assertSame('x', $narrowed['non_empty_string']); + static::assertSame('12', $narrowed['numeric_string']); + static::assertSame(7, $narrowed['positive_integer']); + static::assertSame(['id' => 1], $narrowed['structure']); + } + + public function test_cast_narrows_native_types(): void + { + $handle = fopen('php://memory', 'rb'); + + $narrowed = (new TypeNarrowing())->castNarrowsNative( + [], + true, + 'strlen', + SomeEnum::A, + 1.5, + 1, + new DateOrTime(), + 'anything', + null, + new stdClass(), + $handle, + 'a', + 42, + ); + + static::assertSame([], $narrowed['array']); + static::assertTrue($narrowed['boolean']); + static::assertSame(SomeEnum::A, $narrowed['enum']); + static::assertSame(1.5, $narrowed['float']); + static::assertSame(1, $narrowed['integer']); + static::assertNull($narrowed['null']); + static::assertSame('a', $narrowed['string']); + static::assertSame(42, $narrowed['union']); + + fclose($handle); + } + + public function test_cast_narrows_enum_from_backed_value(): void + { + $handle = fopen('php://memory', 'rb'); + + $narrowed = (new TypeNarrowing())->castNarrowsNative( + [], + true, + 'strlen', + SomeEnum::A->value, + 1.5, + 1, + new DateOrTime(), + 'anything', + null, + new stdClass(), + $handle, + 'a', + 42, + ); + + static::assertSame(SomeEnum::A, $narrowed['enum']); + + fclose($handle); + } + + #[RequiresPhp('>= 8.4.0')] + public function test_html_types_narrow(): void + { + // @mago-expect analysis:unavailable-method + $document = HTMLDocument::createFromString(''); + $element = $document->createElement('p'); + + $narrowed = (new TypeNarrowing())->narrowsHtml($document, $element, $document, $element, $document, $element); + + static::assertSame($document, $narrowed['asserted_html']); + static::assertSame($element, $narrowed['asserted_html_element']); + static::assertSame($document, $narrowed['cast_html']); + static::assertSame($element, $narrowed['cast_html_element']); + static::assertSame($document, $narrowed['valid_html']); + static::assertSame($element, $narrowed['valid_html_element']); + } + + public function test_is_valid_narrows_logical_types(): void + { + $dateTime = new DateTimeImmutable('2026-08-07 10:00:00'); + $document = new DOMDocument(); + + $narrowed = (new TypeNarrowing())->isValidNarrowsLogical( + stdClass::class, + $dateTime, + new DateTimeImmutable('2026-08-07 00:00:00'), + $dateTime, + Json::fromString('{"a":1}'), + [1, 2], + 'fixed', + ['one' => 1], + 'x', + '12', + 5, + 7, + 'scalar', + ['id' => 1], + new DateInterval('PT1S'), + new DateTimeZone('UTC'), + Uuid::fromString('00000000-0000-0000-0000-000000000000'), + $document->createElement('a'), + $document, + ); + + static::assertSame( + [ + 'class_string', + 'date_time', + 'date', + 'instance_of', + 'json', + 'list', + 'literal', + 'map', + 'non_empty_string', + 'numeric_string', + 'optional', + 'positive_integer', + 'scalar', + 'structure', + 'time', + 'time_zone', + 'uuid', + 'xml_element', + 'xml', + ], + array_keys($narrowed), + ); + } + + public function test_is_valid_narrows_native_types(): void + { + $handle = fopen('php://memory', 'rb'); + + $narrowed = (new TypeNarrowing())->isValidNarrowsNative( + [], + true, + 'strlen', + SomeEnum::A, + 1.5, + 1, + new DateOrTime(), + null, + new stdClass(), + $handle, + 'a', + 42, + ); + + static::assertSame( + [ + 'array', + 'boolean', + 'callable', + 'enum', + 'float', + 'integer', + 'intersection', + 'null', + 'object', + 'resource', + 'string', + 'union', + ], + array_keys($narrowed), + ); + + fclose($handle); + } + + public function test_is_valid_rejects_mismatched_values(): void + { + $narrowed = (new TypeNarrowing())->isValidNarrowsNative( + 'not-an-array', + 'not-a-bool', + 'not/a/callable', + 'not-an-enum', + 'not-a-float', + 'not-an-int', + new stdClass(), + 'not-null', + 'not-an-object', + 'not-a-resource', + 1, + 1.5, + ); + + static::assertSame([], $narrowed); + } +}