Skip to content

Commit a2c2ae3

Browse files
committed
Actualize type resolver documentation
1 parent cf3c02e commit a2c2ae3

6 files changed

Lines changed: 529 additions & 42 deletions

File tree

src/TypeResolver.php

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,120 @@
55
namespace TypeLang\Parser;
66

77
use TypeLang\Parser\Traverser\TypeMapVisitor;
8+
use TypeLang\Parser\TypeResolver\PhpUseStatementsTransformer;
89
use TypeLang\Type\TypeNode;
910

10-
final class TypeResolver implements TypeResolverInterface
11+
final readonly class TypeResolver
1112
{
12-
public function resolve(TypeNode $type, callable $transform): TypeNode
13+
public function __construct(
14+
/**
15+
* @var array<array-key, non-empty-string>
16+
*/
17+
private array $imports = [],
18+
) {}
19+
20+
/**
21+
* Registers a non-aliased `use Some\Any;` import, so that every relative
22+
* name starting with the imported one is expanded to its full form.
23+
*
24+
* For example, for such code:
25+
* ```
26+
* use TypeLang\Parser\Node;
27+
* ```
28+
*
29+
* You need to add an import like this:
30+
* ```
31+
* $resolver = new TypeResolver()
32+
* ->withTypeImport('TypeLang\Parser\Node');
33+
*
34+
* $ast = new TypeParser()
35+
* ->parse('Node\SemanticException');
36+
*
37+
* $resolver->resolve($ast);
38+
*
39+
* // Expected Output:
40+
* // > TypeLang\Parser\Node\SemanticException
41+
* echo $ast->name->toString();
42+
* ```
43+
*
44+
* @api
45+
* @param non-empty-string $name
46+
*/
47+
public function withTypeImport(string $name): self
48+
{
49+
return new self([...$this->imports, $name]);
50+
}
51+
52+
/**
53+
* Registers an aliased `use Some\Any as AliasName;` import, so that every
54+
* relative name starting with the alias is expanded to the imported type.
55+
*
56+
* For example, for such code:
57+
* ```
58+
* use TypeLang\Parser\Exception as Error;
59+
* ```
60+
*
61+
* You need to add an import like this:
62+
* ```
63+
* $resolver = new TypeResolver()
64+
* ->withTypeImportAs('TypeLang\Parser\Exception', 'Error');
65+
*
66+
* $ast = new TypeParser()
67+
* ->parse('Error\SemanticException');
68+
*
69+
* $resolver->resolve($ast);
70+
*
71+
* // Expected Output:
72+
* // > TypeLang\Parser\Exception\SemanticException
73+
* echo $ast->name->toString();
74+
* ```
75+
*
76+
* @api
77+
* @param non-empty-string $name
78+
* @param non-empty-string $alias
79+
*/
80+
public function withTypeImportAs(string $name, string $alias): self
81+
{
82+
return new self([...$this->imports, $alias => $name]);
83+
}
84+
85+
private function createTraverser(): TraverserInterface
86+
{
87+
$transformer = new PhpUseStatementsTransformer($this->imports);
88+
89+
return new Traverser([
90+
new TypeMapVisitor($transformer(...)),
91+
]);
92+
}
93+
94+
/**
95+
* Rewrites every type name in the given AST according to the registered
96+
* imports, mutating and returning the same node instance.
97+
*
98+
* ```
99+
* $ast = new TypeParser()
100+
* ->parse(<<<'PHP'
101+
* array { Node, Error\SemanticException }
102+
* PHP);
103+
*
104+
* new TypeResolver()
105+
* ->withTypeImport('TypeLang\Parser\Node')
106+
* ->withTypeImportAs('TypeLang\Parser\Exception', 'Error')
107+
* ->resolve($ast);
108+
*
109+
* // Expected Output:
110+
* // > array{
111+
* // > TypeLang\Parser\Node,
112+
* // > TypeLang\Parser\Exception\SemanticException
113+
* // > }
114+
* ```
115+
*
116+
* @api
117+
*/
118+
public function resolve(TypeNode $type): TypeNode
13119
{
14-
Traverser::through(new TypeMapVisitor($transform(...)), [$type]);
120+
$traverser = $this->createTraverser();
121+
$traverser->traverse([$type]);
15122

16123
return $type;
17124
}

src/TypeResolver/PhpUseStatementsTransformer.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
* // > }
4242
* ```
4343
*/
44-
final readonly class PhpUseStatementsTransformer implements TransformerInterface
44+
final readonly class PhpUseStatementsTransformer
4545
{
4646
/**
4747
* @var array<non-empty-lowercase-string, Name>
@@ -51,8 +51,9 @@
5151
/**
5252
* @param iterable<non-empty-string|array-key, non-empty-string|Name> $replacements
5353
*/
54-
public function __construct(iterable $replacements)
55-
{
54+
public function __construct(
55+
iterable $replacements,
56+
) {
5657
$this->replacements = $this->format($replacements);
5758
}
5859

@@ -83,6 +84,10 @@ private function format(iterable $replacements): array
8384

8485
public function __invoke(Name $name): ?Name
8586
{
87+
if ($name->isBuiltin || $name->isSpecial || $name->isFullyQualified) {
88+
return null;
89+
}
90+
8691
$first = \strtolower($name->first->toString());
8792
$prefix = $this->replacements[$first] ?? null;
8893

src/TypeResolver/TransformerInterface.php

Lines changed: 0 additions & 12 deletions
This file was deleted.

src/TypeResolverInterface.php

Lines changed: 0 additions & 24 deletions
This file was deleted.

tests/TypeResolverEdgeCaseTest.php

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypeLang\Parser\Tests;
6+
7+
use PHPUnit\Framework\Attributes\DataProvider;
8+
use PHPUnit\Framework\Attributes\DataProviderClosure;
9+
use PHPUnit\Framework\Attributes\Group;
10+
use TypeLang\Parser\TypeResolver;
11+
use TypeLang\Type\NamedTypeNode;
12+
13+
/**
14+
* Edge-case specification for {@see TypeResolver}.
15+
*
16+
* These tests describe how the resolver is *expected* to behave if it mirrored
17+
* the semantics of PHP `use` statements — not how the current implementation
18+
* happens to behave. A failing test here therefore points at a place where the
19+
* resolver diverges from the language it emulates.
20+
*/
21+
#[Group('unit'), Group('type-lang/parser')]
22+
final class TypeResolverEdgeCaseTest extends TestCase
23+
{
24+
/**
25+
* @throws \Throwable
26+
*/
27+
private function resolveName(TypeResolver $resolver, string $code): string
28+
{
29+
$node = $resolver->resolve($this->parse($code));
30+
31+
self::assertInstanceOf(NamedTypeNode::class, $node);
32+
33+
return $node->name->toString();
34+
}
35+
36+
/**
37+
* A leading `\` denotes a fully-qualified (absolute) name. In PHP such a
38+
* name is resolved from the global namespace and is never affected by any
39+
* `use` statement, even when its first segment textually equals an alias.
40+
*
41+
* @return iterable<non-empty-string, array{non-empty-string, non-empty-string}>
42+
*/
43+
public static function fullyQualifiedNameDataProvider(): iterable
44+
{
45+
// Collides with `use TypeLang\Parser\Node` — but must stay absolute.
46+
yield 'fq alias root' => ['\Node', '\Node'];
47+
yield 'fq alias with tail' => ['\Node\Foo', '\Node\Foo'];
48+
// Collides with `use TypeLang\Parser\Exception as Error`.
49+
yield 'fq aliased root' => ['\Error\SemanticException', '\Error\SemanticException'];
50+
// No collision — trivially unchanged (sanity contrast).
51+
yield 'fq unrelated' => ['\Some\Other\Node', '\Some\Other\Node'];
52+
}
53+
54+
#[DataProvider('fullyQualifiedNameDataProvider')]
55+
public function testFullyQualifiedNamesIgnoreImports(string $code, string $expected): void
56+
{
57+
$resolver = new TypeResolver()
58+
->withTypeImport('TypeLang\Parser\Node')
59+
->withTypeImportAs('TypeLang\Parser\Exception', 'Error');
60+
61+
self::assertSame($expected, $this->resolveName($resolver, $code));
62+
}
63+
64+
/**
65+
* Built-in scalar/compound types (`int`, `string`, ...) are reserved words.
66+
* PHP rejects `use Something as int;` outright, so within a type expression
67+
* such a name always denotes the built-in and can never be rewritten into a
68+
* class name by an import.
69+
*
70+
* @return iterable<non-empty-string, array{non-empty-string}>
71+
*/
72+
public static function reservedBuiltinTypeDataProvider(): iterable
73+
{
74+
yield 'int' => ['int'];
75+
yield 'string' => ['string'];
76+
yield 'bool' => ['bool'];
77+
yield 'float' => ['float'];
78+
yield 'array' => ['array'];
79+
yield 'object' => ['object'];
80+
yield 'iterable' => ['iterable'];
81+
yield 'callable' => ['callable'];
82+
yield 'mixed' => ['mixed'];
83+
yield 'void' => ['void'];
84+
yield 'never' => ['never'];
85+
}
86+
87+
#[DataProvider('reservedBuiltinTypeDataProvider')]
88+
public function testReservedBuiltinTypeIsNeverRewrittenByImport(string $reserved): void
89+
{
90+
$resolver = new TypeResolver()
91+
->withTypeImport("Vendor\\{$reserved}");
92+
93+
self::assertSame($reserved, $this->resolveName($resolver, $reserved));
94+
}
95+
96+
/**
97+
* `self`, `static` and `parent` are reserved special class references and,
98+
* like the built-in scalars, cannot be aliased away by a `use` statement.
99+
*
100+
* @return iterable<non-empty-string, array{non-empty-string}>
101+
*/
102+
public static function reservedSpecialTypeDataProvider(): iterable
103+
{
104+
yield 'self' => ['self'];
105+
yield 'static' => ['static'];
106+
yield 'parent' => ['parent'];
107+
}
108+
109+
#[DataProvider('reservedSpecialTypeDataProvider')]
110+
public function testReservedSpecialTypeIsNeverRewrittenByImport(string $reserved): void
111+
{
112+
$resolver = new TypeResolver()
113+
->withTypeImport("Vendor\\{$reserved}");
114+
115+
self::assertSame($reserved, $this->resolveName($resolver, $reserved));
116+
}
117+
118+
/**
119+
* An import aliases a *whole* leading segment, never a textual prefix of it.
120+
* `use A\Node;` must not touch `NodeList` or `Node_`, which are distinct
121+
* identifiers that merely start with the same characters.
122+
*
123+
* @return iterable<non-empty-string, array{non-empty-string}>
124+
*/
125+
public static function nonMatchingSegmentDataProvider(): iterable
126+
{
127+
yield 'longer identifier' => ['NodeList'];
128+
yield 'trailing underscore' => ['Node_'];
129+
yield 'first segment differs' => ['X\Node'];
130+
yield 'alias only in tail' => ['Vendor\Node\Leaf'];
131+
}
132+
133+
#[DataProvider('nonMatchingSegmentDataProvider')]
134+
public function testImportMatchesWholeSegmentOnly(string $code): void
135+
{
136+
$resolver = new TypeResolver()
137+
->withTypeImport('A\Node');
138+
139+
self::assertSame($code, $this->resolveName($resolver, $code));
140+
}
141+
142+
/**
143+
* Resolving is idempotent: once a short name has been expanded to its
144+
* fully-qualified form, re-running the resolver leaves it untouched (its
145+
* first segment no longer matches the alias).
146+
*/
147+
public function testResolutionIsIdempotent(): void
148+
{
149+
$resolver = new TypeResolver()
150+
->withTypeImport('TypeLang\Parser\Node');
151+
152+
$node = $this->parse('Node\Foo');
153+
154+
$resolver->resolve($node);
155+
$resolver->resolve($node);
156+
157+
self::assertInstanceOf(NamedTypeNode::class, $node);
158+
self::assertSame('TypeLang\Parser\Node\Foo', $node->name->toString());
159+
}
160+
161+
/**
162+
* All trailing segments of an aliased reference are preserved when the
163+
* alias prefix is substituted.
164+
*/
165+
public function testAliasSubstitutionKeepsAllTrailingSegments(): void
166+
{
167+
$resolver = new TypeResolver()
168+
->withTypeImportAs('A\B\Exception', 'Error');
169+
170+
self::assertSame(
171+
'A\B\Exception\Sub\Deep',
172+
$this->resolveName($resolver, 'Error\Sub\Deep'),
173+
);
174+
}
175+
}

0 commit comments

Comments
 (0)