Skip to content

Commit 6267cf5

Browse files
committed
add string resolver
1 parent a4b177d commit 6267cf5

File tree

11 files changed

+423
-65
lines changed

11 files changed

+423
-65
lines changed

src/Symfony/Component/TypeInfo/GenericType.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public function __construct(
3434
$glue = '';
3535
foreach ($genericTypes as $t) {
3636
$parameterTypesStringRepresentation .= $glue.((string) $t);
37-
$glue = ', ';
37+
$glue = ',';
3838
}
3939

4040
$this->stringRepresentation = ((string) $mainType).'<'.$parameterTypesStringRepresentation.'>';

src/Symfony/Component/TypeInfo/IntersectionType.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@
2828

2929
public function __construct(Type ...$types)
3030
{
31-
usort($types, fn (Type $t): string => (string) $t);
32-
$this->types = array_unique($types);
31+
usort($types, fn (Type $a, Type $b): int => (string) $a <=> (string) $b);
32+
$this->types = array_values(array_unique($types));
3333

3434
$stringRepresentation = '';
3535
$glue = '';

src/Symfony/Component/TypeInfo/Resolver/ReflectionTypeResolver.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public function resolve(mixed $subject, \ReflectionClass $declaringClass = null)
6060
}
6161

6262
if ($subject->isBuiltin()) {
63-
return new Type($builtinTypeOrClass, nullable: $nullable);
63+
return Type::builtin($builtinTypeOrClass, nullable: $nullable);
6464
}
6565

6666
/** @var class-string $className */

src/Symfony/Component/TypeInfo/Resolver/StringTypeResolver.php

Lines changed: 173 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,190 @@
1111

1212
namespace Symfony\Component\TypeInfo\Resolver;
1313

14+
use Psr\Cache\CacheItemPoolInterface;
1415
use Symfony\Component\TypeInfo\Exception\UnsupportedException;
1516
use Symfony\Component\TypeInfo\Type;
1617

1718
/**
1819
* @author Mathias Arlaud <[email protected]>
1920
* @author Baptiste Leduc <[email protected]>
2021
*/
21-
final class StringTypeResolver implements TypeResolver
22+
final readonly class StringTypeResolver implements TypeResolverInterface
2223
{
24+
public function __construct(
25+
private ReflectionTypeResolver $reflectionTypeResolver,
26+
private ?CacheItemPoolInterface $enumBackingTypeCache = null,
27+
) {
28+
}
29+
2330
public function resolve(mixed $subject): Type
2431
{
25-
if (!is_string($subject)) {
26-
throw new UnsupportedException(sprintf('"%s" expects the subject to be a "string", "%s" given.', __CLASS__, get_debug_type($subject)));
32+
if (!\is_string($subject)) {
33+
throw new UnsupportedException(sprintf('Expected subject to be a "string", "%s" given.', get_debug_type($subject)));
34+
}
35+
36+
$subject = str_replace(' ', '', $subject);
37+
38+
if ($nullable = str_starts_with($subject, '?')) {
39+
$subject = substr($subject, 1);
40+
}
41+
42+
if (\in_array($subject, [Type::BUILTIN_TYPE_NULL, Type::BUILTIN_TYPE_MIXED], true)) {
43+
if ($nullable) {
44+
throw new UnsupportedException(sprintf('Invalid "%s" type.', $subject));
45+
}
46+
47+
return new Type($subject);
48+
}
49+
50+
if (Type::BUILTIN_TYPE_ARRAY === $subject) {
51+
return Type::array(nullable: $nullable);
52+
}
53+
54+
if ('list' === $subject) {
55+
return Type::list(nullable: $nullable);
56+
}
57+
58+
if (Type::BUILTIN_TYPE_ITERABLE === $subject) {
59+
return Type::iterable(nullable: $nullable);
60+
}
61+
62+
if (\in_array($subject, Type::BUILTIN_TYPES, true)) {
63+
return Type::builtin($subject, nullable: $nullable);
64+
}
65+
66+
if (preg_match('/^callable(\(.*\)(:.+)?)?$/', $subject)) {
67+
return Type::callable(nullable: $nullable);
68+
}
69+
70+
if (class_exists($subject) || interface_exists($subject)) {
71+
if (is_subclass_of($subject, \UnitEnum::class)) {
72+
if ($this->enumBackingTypeCache) {
73+
// TODO test cache
74+
$item = $this->enumBackingTypeCache->getItem($subject);
75+
if ($item->isHit()) {
76+
return Type::enum($subject, $item->get(), $nullable);
77+
}
78+
}
79+
80+
$backingType = $this->reflectionTypeResolver->resolve((new \ReflectionEnum($subject))->getBackingType());
81+
82+
if (isset($item)) {
83+
$this->enumBackingTypeCache->save($item->set($backingType));
84+
}
85+
86+
return Type::enum($subject, $backingType, $nullable);
87+
}
88+
89+
return Type::object($subject, $nullable);
90+
}
91+
92+
$current = '';
93+
$composingTypes = [];
94+
$glue = null;
95+
$level = 0;
96+
97+
foreach (str_split($subject) as $char) {
98+
if (0 === $level && \in_array($char, ['|', '&'], true)) {
99+
if (null !== $glue && $char !== $glue) {
100+
throw new UnsupportedException(sprintf('"%s" DNF type is not supported.', $subject));
101+
}
102+
103+
$glue = $char;
104+
$composingTypes[] = $current;
105+
$current = '';
106+
107+
continue;
108+
}
109+
110+
if ('<' === $char) {
111+
++$level;
112+
}
113+
114+
if ('>' === $char) {
115+
--$level;
116+
}
117+
118+
$current .= $char;
119+
}
120+
121+
$composingTypes[] = $current;
122+
123+
if (0 !== $level) {
124+
throw new UnsupportedException(sprintf('Invalid "%s" type.', $subject));
125+
}
126+
127+
if (\count($composingTypes) > 1) {
128+
$types = [];
129+
130+
foreach ($composingTypes as &$type) {
131+
if (str_starts_with($type, '?')) {
132+
$nullable = true;
133+
$type = substr($type, 1);
134+
}
135+
136+
if (Type::BUILTIN_TYPE_NULL === $type) {
137+
$nullable = true;
138+
}
139+
140+
$type = $this->resolve($type);
141+
}
142+
143+
if ($nullable) {
144+
$composingTypes[] = Type::null();
145+
}
146+
147+
return '|' === $glue ? Type::union(...$composingTypes) : Type::intersection(...$composingTypes);
148+
}
149+
150+
$results = [];
151+
if (preg_match('/^(?P<main>[^<]+)<(?P<generics>.+)>$/', $subject, $results)) {
152+
$mainType = $results['main'];
153+
154+
$genericTypes = [];
155+
$current = '';
156+
$level = 0;
157+
158+
foreach (str_split($results['generics']) as $char) {
159+
if (',' === $char && 0 === $level) {
160+
$genericTypes[] = $current;
161+
$current = '';
162+
163+
continue;
164+
}
165+
166+
if ('<' === $char) {
167+
++$level;
168+
}
169+
170+
if ('>' === $char) {
171+
--$level;
172+
}
173+
174+
$current .= $char;
175+
}
176+
177+
$genericTypes[] = $current;
178+
179+
if (0 !== $level) {
180+
throw new UnsupportedException(sprintf('Invalid "%s" type.', $subject));
181+
}
182+
183+
if ('list' === $mainType && 1 === \count($genericTypes)) {
184+
$mainType = Type::builtin(Type::BUILTIN_TYPE_ARRAY, nullable: $nullable);
185+
array_unshift($genericTypes, 'int');
186+
} elseif (\in_array($mainType, [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_ITERABLE], true)) {
187+
$mainType = Type::builtin($mainType, nullable: $nullable);
188+
if (1 === \count($genericTypes)) {
189+
array_unshift($genericTypes, 'int');
190+
}
191+
} else {
192+
$mainType = $this->resolve(($nullable ? '?' : '').$mainType);
193+
}
194+
195+
return Type::generic($mainType, ...array_map($this->resolve(...), $genericTypes));
27196
}
28197

29-
throw new \RuntimeException('TODO');
198+
throw new UnsupportedException(sprintf('Invalid "%s" type.', $subject));
30199
}
31200
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
namespace Symfony\Component\TypeInfo\Tests\Fixtures;
4+
5+
final class Dummy
6+
{
7+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
namespace Symfony\Component\TypeInfo\Tests\Fixtures;
4+
5+
enum DummyBackedEnum: string
6+
{
7+
case ONE = 'one';
8+
case TWO = 'two';
9+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
namespace Symfony\Component\TypeInfo\Tests\Fixtures;
4+
5+
interface DummyInterface
6+
{
7+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\TypeInfo\Tests\Resolver;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\TypeInfo\Exception\UnsupportedException;
16+
use Symfony\Component\TypeInfo\Resolver\ReflectionTypeResolver;
17+
use Symfony\Component\TypeInfo\Resolver\StringTypeResolver;
18+
use Symfony\Component\TypeInfo\Tests\Fixtures\Dummy;
19+
use Symfony\Component\TypeInfo\Tests\Fixtures\DummyBackedEnum;
20+
use Symfony\Component\TypeInfo\Tests\Fixtures\DummyInterface;
21+
use Symfony\Component\TypeInfo\Type;
22+
23+
class StringTypeResolverTest extends TestCase
24+
{
25+
private StringTypeResolver $resolver;
26+
27+
protected function setUp(): void
28+
{
29+
parent::setUp();
30+
$this->resolver = new StringTypeResolver(new ReflectionTypeResolver());
31+
}
32+
33+
public function testCanOnlyResolveString()
34+
{
35+
$this->resolver->resolve('string');
36+
$this->addToAssertionCount(1);
37+
38+
$this->expectException(UnsupportedException::class);
39+
$this->resolver->resolve(true);
40+
}
41+
42+
/**
43+
* @dataProvider stringTypeDataProvider
44+
*/
45+
public function testResolve(string $string, Type $resolved)
46+
{
47+
$this->assertEquals($resolved, $this->resolver->resolve($string));
48+
}
49+
50+
/**
51+
* @return iterable<array{0: Type, 1: string}>
52+
*/
53+
public function stringTypeDataProvider(): iterable
54+
{
55+
yield ['mixed', Type::mixed()];
56+
yield ['null', Type::null()];
57+
58+
yield ['array', Type::array()];
59+
yield ['?array', Type::array(nullable: true)];
60+
yield ['list', Type::list()];
61+
yield ['?list', Type::list(nullable: true)];
62+
yield ['iterable', Type::iterable()];
63+
yield ['?iterable', Type::iterable(nullable: true)];
64+
65+
yield ['int', Type::int()];
66+
yield ['float', Type::float()];
67+
yield ['string', Type::string()];
68+
yield ['bool', Type::bool()];
69+
yield ['resource', Type::resource()];
70+
yield ['true', Type::true()];
71+
yield ['false', Type::false()];
72+
yield ['object', Type::object()];
73+
yield ['?int', Type::int(nullable: true)];
74+
75+
yield ['callable', Type::callable()];
76+
yield ['?callable(int, string): mixed', Type::callable(nullable: true)];
77+
78+
yield [Dummy::class, Type::object(Dummy::class)];
79+
yield [DummyInterface::class, Type::object(DummyInterface::class)];
80+
yield ['?'.Dummy::class, Type::object(Dummy::class, nullable: true)];
81+
yield [DummyBackedEnum::class, Type::enum(DummyBackedEnum::class, backingType: Type::string())];
82+
yield ['?'.DummyBackedEnum::class, Type::enum(DummyBackedEnum::class, backingType: Type::string(), nullable: true)];
83+
84+
yield ['int|string', Type::union(Type::int(), Type::string())];
85+
yield ['?int|string', Type::union(Type::int(), Type::string(), Type::null())];
86+
yield ['int|string|null', Type::union(Type::int(), Type::string(), Type::null())];
87+
yield ['int|null|?string|null', Type::union(Type::int(), Type::string(), Type::null())];
88+
yield ['int&string', Type::intersection(Type::int(), Type::string())];
89+
yield ['?int&string', Type::intersection(Type::int(), Type::string(), Type::null())];
90+
yield ['int&string&null', Type::intersection(Type::int(), Type::string(), Type::null())];
91+
yield ['int&null&?string&null', Type::intersection(Type::int(), Type::string(), Type::null())];
92+
93+
yield ['array<object>', Type::list(Type::object())];
94+
yield ['array<string, int>', Type::dict(Type::int())];
95+
yield ['list<int>', Type::list(Type::int())];
96+
yield ['array<string, int>', Type::dict(Type::int())];
97+
yield [Dummy::class.'<int, ?string, null>', Type::generic(Type::object(Dummy::class), Type::int(), Type::string(nullable: true), Type::null())];
98+
yield ['?array<object>', Type::list(Type::object(), nullable: true)];
99+
100+
yield ['int|?string<float&bool<true|false>>', Type::union(
101+
Type::int(),
102+
Type::generic(Type::string(), Type::intersection(Type::float(), Type::generic(Type::bool(), Type::union(Type::true(), Type::false())))),
103+
Type::null(),
104+
)];
105+
}
106+
107+
/**
108+
* @dataProvider invalidStringTypeDataProvider
109+
*/
110+
public function testCannotResolveInvalidString(string $string)
111+
{
112+
$this->expectException(UnsupportedException::class);
113+
$this->resolver->resolve($string);
114+
}
115+
116+
/**
117+
* @return iterable<array{0: string}>
118+
*/
119+
public function invalidStringTypeDataProvider(): iterable
120+
{
121+
yield ['?mixed'];
122+
yield ['?null'];
123+
yield ['int|string&float'];
124+
yield ['int<'];
125+
yield ['i<>nt'];
126+
yield ['int<|>'];
127+
yield ['InvalidClass'];
128+
}
129+
}

0 commit comments

Comments
 (0)