diff --git a/qa/PHPStan/Extension/ApiAndInternalAnnotationCheck.php b/qa/PHPStan/Extension/ApiAndInternalAnnotationCheck.php index a42455aa..c05891c3 100644 --- a/qa/PHPStan/Extension/ApiAndInternalAnnotationCheck.php +++ b/qa/PHPStan/Extension/ApiAndInternalAnnotationCheck.php @@ -34,7 +34,9 @@ public function processNode(Node $node, Scope $scope): array return []; } - if (str_starts_with($reflection->getName(), 'CuyZ\Valinor\Tests')) { + if (str_starts_with($reflection->getName(), 'CuyZ\Valinor\Tests') + || str_starts_with($reflection->getName(), 'SimpleNamespace') + ) { return []; } diff --git a/src/Mapper/Tree/Builder/UnionNodeBuilder.php b/src/Mapper/Tree/Builder/UnionNodeBuilder.php index dd35ef87..eebd747a 100644 --- a/src/Mapper/Tree/Builder/UnionNodeBuilder.php +++ b/src/Mapper/Tree/Builder/UnionNodeBuilder.php @@ -79,18 +79,13 @@ private function narrow(UnionType $type, mixed $source): Type private function tryToBuildClassNode(UnionType $type, Shell $shell, RootNodeBuilder $rootBuilder): ?TreeNode { - $classTypes = []; + $classTypes = array_filter( + $type->types(), + fn (Type $type) => $type instanceof ClassType, + ); - foreach ($type->types() as $subType) { - if ($subType instanceof NullType) { - continue; - } - - if (! $subType instanceof ClassType) { - return null; - } - - $classTypes[] = $subType; + if (count($classTypes) === 0) { + return null; } $objectBuilder = $this->objectBuilder($shell->value(), ...$classTypes); diff --git a/src/Type/Parser/Lexer/AliasLexer.php b/src/Type/Parser/Lexer/AliasLexer.php index d5320a30..192bf2df 100644 --- a/src/Type/Parser/Lexer/AliasLexer.php +++ b/src/Type/Parser/Lexer/AliasLexer.php @@ -80,17 +80,7 @@ private function resolveAlias(string $symbol): string private function resolveNamespaced(string $symbol): string { - $reflection = $this->reflection; - - if ($reflection instanceof ReflectionFunction) { - $reflection = $reflection->getClosureScopeClass(); - } - - if (! $reflection) { - return $symbol; - } - - $namespace = $reflection->getNamespaceName(); + $namespace = $this->reflection->getNamespaceName(); if (! $namespace) { return $symbol; diff --git a/src/Type/Types/IntegerRangeType.php b/src/Type/Types/IntegerRangeType.php index 2c282903..7df97ea1 100644 --- a/src/Type/Types/IntegerRangeType.php +++ b/src/Type/Types/IntegerRangeType.php @@ -11,6 +11,9 @@ use CuyZ\Valinor\Type\Parser\Exception\Scalar\SameValueForIntegerRange; use CuyZ\Valinor\Type\Type; +use function is_string; +use function ltrim; +use function preg_match; use function sprintf; /** @internal */ @@ -79,6 +82,12 @@ public function matches(Type $other): bool public function canCast(mixed $value): bool { + if (is_string($value)) { + $value = preg_match('/^0+$/', $value) + ? '0' + : ltrim($value, '0'); + } + return ! is_bool($value) && filter_var($value, FILTER_VALIDATE_INT) !== false && $value >= $this->min diff --git a/src/Type/Types/IntegerValueType.php b/src/Type/Types/IntegerValueType.php index f6cd7033..23a22703 100644 --- a/src/Type/Types/IntegerValueType.php +++ b/src/Type/Types/IntegerValueType.php @@ -13,6 +13,9 @@ use function assert; use function filter_var; use function is_bool; +use function is_string; +use function ltrim; +use function preg_match; /** @internal */ final class IntegerValueType implements IntegerType, FixedType @@ -55,6 +58,12 @@ public function matches(Type $other): bool public function canCast(mixed $value): bool { + if (is_string($value)) { + $value = preg_match('/^0+$/', $value) + ? '0' + : ltrim($value, '0'); + } + return ! is_bool($value) && filter_var($value, FILTER_VALIDATE_INT) !== false && (int)$value === $this->value; // @phpstan-ignore-line; diff --git a/src/Type/Types/NativeIntegerType.php b/src/Type/Types/NativeIntegerType.php index f03f9e57..70249b08 100644 --- a/src/Type/Types/NativeIntegerType.php +++ b/src/Type/Types/NativeIntegerType.php @@ -14,6 +14,8 @@ use function filter_var; use function is_bool; use function is_int; +use function is_string; +use function ltrim; /** @internal */ final class NativeIntegerType implements IntegerType @@ -37,6 +39,10 @@ public function matches(Type $other): bool public function canCast(mixed $value): bool { + if (is_string($value)) { + $value = ltrim($value, '0') . '0'; + } + return ! is_bool($value) && filter_var($value, FILTER_VALIDATE_INT) !== false; } diff --git a/src/Type/Types/NonNegativeIntegerType.php b/src/Type/Types/NonNegativeIntegerType.php index ff6c5f04..8a3b87b8 100644 --- a/src/Type/Types/NonNegativeIntegerType.php +++ b/src/Type/Types/NonNegativeIntegerType.php @@ -10,6 +10,9 @@ use CuyZ\Valinor\Type\Type; use CuyZ\Valinor\Utility\IsSingleton; +use function is_string; +use function ltrim; + /** @internal */ final class NonNegativeIntegerType implements IntegerType { @@ -33,6 +36,10 @@ public function matches(Type $other): bool public function canCast(mixed $value): bool { + if (is_string($value)) { + $value = ltrim($value, '0') . '0'; + } + return ! is_bool($value) && filter_var($value, FILTER_VALIDATE_INT) !== false && $value >= 0; diff --git a/src/Type/Types/PositiveIntegerType.php b/src/Type/Types/PositiveIntegerType.php index 1b2e135a..f15882db 100644 --- a/src/Type/Types/PositiveIntegerType.php +++ b/src/Type/Types/PositiveIntegerType.php @@ -14,6 +14,8 @@ use function filter_var; use function is_bool; use function is_int; +use function is_string; +use function ltrim; /** @internal */ final class PositiveIntegerType implements IntegerType @@ -38,6 +40,10 @@ public function matches(Type $other): bool public function canCast(mixed $value): bool { + if (is_string($value)) { + $value = ltrim($value, '0'); + } + return ! is_bool($value) && filter_var($value, FILTER_VALIDATE_INT) !== false && $value > 0; diff --git a/src/Utility/Reflection/TokenParser.php b/src/Utility/Reflection/TokenParser.php index 2feda9a1..2e58f740 100644 --- a/src/Utility/Reflection/TokenParser.php +++ b/src/Utility/Reflection/TokenParser.php @@ -119,7 +119,7 @@ private function next(): ?PhpToken private function parseNamespace(): string { while ($token = $this->next()) { - if ($token->is([T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED])) { + if ($token->is([T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED, T_STRING])) { return (string)$token; } } diff --git a/tests/Integration/Mapping/Fixture/SimpleNamespacedObject.php b/tests/Integration/Mapping/Fixture/SimpleNamespacedObject.php new file mode 100644 index 00000000..93c5736f --- /dev/null +++ b/tests/Integration/Mapping/Fixture/SimpleNamespacedObject.php @@ -0,0 +1,13 @@ + */ + public array $value, + ) {} +} diff --git a/tests/Integration/Mapping/Object/SimpleNamespacedObjectMappingTest.php b/tests/Integration/Mapping/Object/SimpleNamespacedObjectMappingTest.php new file mode 100644 index 00000000..dcc526e1 --- /dev/null +++ b/tests/Integration/Mapping/Object/SimpleNamespacedObjectMappingTest.php @@ -0,0 +1,26 @@ +mapper()->map(SimpleNamespacedObject::class, ['foo']); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame(['foo'], $object->value); + } +} diff --git a/tests/Integration/Mapping/Other/FlexibleCastingMappingTest.php b/tests/Integration/Mapping/Other/FlexibleCastingMappingTest.php index ea630bd3..fb09d16a 100644 --- a/tests/Integration/Mapping/Other/FlexibleCastingMappingTest.php +++ b/tests/Integration/Mapping/Other/FlexibleCastingMappingTest.php @@ -24,6 +24,71 @@ protected function setUp(): void $this->mapper = (new MapperBuilder())->enableFlexibleCasting()->mapper(); } + public function test_leading_zero_in_numeric_is_mapped_properly(): void + { + $source = ['000', '040', '00040', '0001337.404']; + + try { + $result = $this->mapper->map('array', $source); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame([0, 40, 40, 1337.404], $result); + } + + public function test_leading_zero_in_integer_range_is_mapped_properly(): void + { + $source = ['060', '042', '000404']; + + try { + $result = $this->mapper->map('array>', $source); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame([60, 42, 404], $result); + } + + public function test_leading_zero_in_integer_value_is_mapped_properly(): void + { + $source = ['000', '040', '000404']; + + try { + $result = $this->mapper->map('array<0|40|404>', $source); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame([0, 40, 404], $result); + } + + public function test_leading_zero_in_positive_integer_is_mapped_properly(): void + { + $source = ['040', '000404']; + + try { + $result = $this->mapper->map('array', $source); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame([40, 404], $result); + } + + public function test_leading_zero_in_non_negative_integer_is_mapped_properly(): void + { + $source = ['000', '040', '000404']; + + try { + $result = $this->mapper->map('array', $source); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame([0, 40, 404], $result); + } + public function test_array_of_scalars_is_mapped_properly(): void { $source = ['foo', 42, 1337.404]; diff --git a/tests/Integration/Mapping/UnionMappingTest.php b/tests/Integration/Mapping/UnionMappingTest.php new file mode 100644 index 00000000..b850dea9 --- /dev/null +++ b/tests/Integration/Mapping/UnionMappingTest.php @@ -0,0 +1,72 @@ +mapper()->map("list", [123, "foo"]); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame(123, $array[0]); + self::assertInstanceOf(SimpleObject::class, $array[1]); + } + + public function test_union_with_string_or_object_prioritizes_string(): void + { + try { + $array = (new MapperBuilder()) + ->mapper() + ->map("list", ["foo", "bar", "baz"]); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame(["foo", "bar", "baz"], $array); + } + + public function test_union_with_string_literal_or_object_prioritizes_string_literal(): void + { + try { + $array = (new MapperBuilder()) + ->mapper() + ->map("list<'foo'|" . SimpleObject::class . "|'bar'>", ["foo", "bar", "baz"]); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame("foo", $array[0]); + self::assertSame("bar", $array[1]); + self::assertInstanceOf(SimpleObject::class, $array[2]); + } + + public function test_union_of_objects(): void + { + try { + $array = (new MapperBuilder()) + ->mapper() + ->map( + "list<" . SimpleObject::class . "|" . City::class . ">", + ["foo", ["name" => "foobar", "timeZone" => "UTC"], "baz"], + ); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertInstanceOf(SimpleObject::class, $array[0]); + self::assertInstanceOf(City::class, $array[1]); + self::assertInstanceOf(SimpleObject::class, $array[2]); + } +}