55use TypePHP \Internal \DocblockNormalizer ;
66
77describe ('DocblockNormalizer ' , function () {
8- test ('returns docblock string unchanged when no curly braces are present ' , function () {
8+ test ('returns docblock string unchanged when no special keywords or curly braces are present ' , function () {
99 $ doc = '/** @param string $name */ ' ;
1010 expect (DocblockNormalizer::normalize ($ doc ))->toBe ($ doc );
1111 });
1212
13- test ('auto-completes omitted return types for callable and Closure signatures ' , function () {
14- $ doc1 = '/** @var callable(int[] $items) $callback */ ' ;
15- $ expected1 = '/** @var callable(int[] $items): mixed $callback */ ' ;
16- expect (DocblockNormalizer::normalize ($ doc1 ))->toBe ($ expected1 );
13+ describe ('Fast-Path String Guards ' , function () {
14+ test ('bypasses type alias regex when equals sign is absent ' , function () {
15+ $ doc = '/** @phpstan-type MetricTypeValues "histogram"|"gauge" */ ' ;
16+ expect (DocblockNormalizer::normalize ($ doc ))->toBe ($ doc );
17+ });
1718
18- $ doc2 = ' /** @param Closure(string $name) $closure */ ' ;
19- $ expected2 = '/** @param Closure(string $name ): mixed $closure */ ' ;
20- expect (DocblockNormalizer::normalize ($ doc2 ))->toBe ($ expected2 );
19+ test ( ' bypasses callable regex when colon return type is already defined ' , function () {
20+ $ doc1 = '/** @param callable(int ): string $cb */ ' ;
21+ expect (DocblockNormalizer::normalize ($ doc1 ))->toBe ($ doc1 );
2122
22- $ doc3 = '/** @param callable() $emptyCallable */ ' ;
23- $ expected3 = '/** @param callable(): mixed $emptyCallable */ ' ;
24- expect (DocblockNormalizer::normalize ($ doc3 ))->toBe ($ expected3 );
25- });
23+ $ doc2 = '/** @param Closure(int, string): bool $closure */ ' ;
24+ expect (DocblockNormalizer::normalize ($ doc2 ))->toBe ($ doc2 );
25+ });
2626
27- test ('preserves existing return types on callable signatures untouched ' , function () {
28- $ doc1 = '/** @param callable(int): string $cb */ ' ;
29- expect (DocblockNormalizer::normalize ($ doc1 ))->toBe ($ doc1 );
27+ test ('bypasses class constant regex when double colons or single colons are absent ' , function () {
28+ $ doc1 = '/** @param string $class User::class */ ' ;
29+ expect (DocblockNormalizer::normalize ($ doc1 ))->toBe ($ doc1 );
3030
31- $ doc2 = '/** @param Closure(int, string): bool $closure */ ' ;
32- expect (DocblockNormalizer::normalize ($ doc2 ))->toBe ($ doc2 );
31+ $ doc2 = '/** @param array{id: int} $data */ ' ;
32+ expect (DocblockNormalizer::normalize ($ doc2 ))->toBe ($ doc2 );
33+ });
3334 });
3435
35- test ('strips optional equals sign from @phpstan-type and @psalm-type tags ' , function () {
36- $ doc1 = '/** @phpstan-type MetricTypeValues = "histogram"|"gauge" */ ' ;
37- $ expected1 = '/** @phpstan-type MetricTypeValues "histogram"|"gauge" */ ' ;
38- expect (DocblockNormalizer::normalize ($ doc1 ))->toBe ($ expected1 );
36+ describe ('Callable and Closure Return Type Normalization ' , function () {
37+ test ('auto-completes omitted return types for callable and Closure signatures ' , function () {
38+ $ doc1 = '/** @var callable(int[] $items) $callback */ ' ;
39+ $ expected1 = '/** @var callable(int[] $items): mixed $callback */ ' ;
40+ expect (DocblockNormalizer::normalize ($ doc1 ))->toBe ($ expected1 );
41+
42+ $ doc2 = '/** @param Closure(string $name) $closure */ ' ;
43+ $ expected2 = '/** @param Closure(string $name): mixed $closure */ ' ;
44+ expect (DocblockNormalizer::normalize ($ doc2 ))->toBe ($ expected2 );
3945
40- $ doc2 = '/** @psalm-type UserRole = "admin"|"user" */ ' ;
41- $ expected2 = '/** @psalm-type UserRole "admin"|"user" */ ' ;
42- expect (DocblockNormalizer::normalize ($ doc2 ))->toBe ($ expected2 );
46+ $ doc3 = '/** @param callable() $emptyCallable */ ' ;
47+ $ expected3 = '/** @param callable(): mixed $emptyCallable */ ' ;
48+ expect (DocblockNormalizer::normalize ($ doc3 ))->toBe ($ expected3 );
49+ });
4350 });
4451
45- test ('converts stdClass shapes into intersection shapes ' , function () {
46- $ doc = '/** @param stdClass{id: int, name: string} $data */ ' ;
47- $ expected = '/** @param (stdClass&object{id: int, name: string}) $data */ ' ;
52+ describe ('Type Alias Normalization (@phpstan-type and @psalm-type) ' , function () {
53+ test ('strips optional equals sign from @phpstan-type and @psalm-type tags ' , function () {
54+ $ doc1 = '/** @phpstan-type MetricTypeValues = "histogram"|"gauge" */ ' ;
55+ $ expected1 = '/** @phpstan-type MetricTypeValues "histogram"|"gauge" */ ' ;
56+ expect (DocblockNormalizer::normalize ($ doc1 ))->toBe ($ expected1 );
4857
49- expect (DocblockNormalizer::normalize ($ doc ))->toBe ($ expected );
58+ $ doc2 = '/** @psalm-type UserRole = "admin"|"user" */ ' ;
59+ $ expected2 = '/** @psalm-type UserRole "admin"|"user" */ ' ;
60+ expect (DocblockNormalizer::normalize ($ doc2 ))->toBe ($ expected2 );
61+ });
5062 });
5163
52- test ( ' converts namespaced class shapes into intersection shapes ' , function () {
53- $ doc1 = ' /** @param \stdClass{id: int} $data */ ' ;
54- $ expected1 = '/** @param (\stdClass&object{id: int}) $data */ ' ;
55- expect (DocblockNormalizer:: normalize ( $ doc1 ))-> toBe ( $ expected1 ) ;
64+ describe ( ' Class Constant Keys in Array Shapes ' , function () {
65+ test ( ' wraps class constant array shape keys in quotes for parser compatibility ' , function () {
66+ $ doc = '/** @param array{self::KEY_ID: int, App\Constants::ROLE: string, Config::OPTIONAL?: bool} $payload */ ' ;
67+ $ expected = ' /** @param array{"self::KEY_ID": int, "App\Constants::ROLE": string, "Config::OPTIONAL"?: bool} $payload */ ' ;
5668
57- $ doc2 = '/** @param App\Models\User{id: positive-int} $user */ ' ;
58- $ expected2 = '/** @param (App\Models\User&object{id: positive-int}) $user */ ' ;
59- expect (DocblockNormalizer::normalize ($ doc2 ))->toBe ($ expected2 );
69+ expect (DocblockNormalizer::normalize ($ doc ))->toBe ($ expected );
70+ });
6071 });
6172
62- test ('preserves built-in PHPStan shape keywords (array, list, object, non-empty-array, non-empty-list) ' , function () {
63- $ arrayDoc = '/** @param array{id: int, name: string} $data */ ' ;
64- expect (DocblockNormalizer::normalize ($ arrayDoc ))->toBe ($ arrayDoc );
73+ describe ('Custom Class Shapes to Intersection Shapes ' , function () {
74+ test ('converts stdClass shapes into intersection shapes ' , function () {
75+ $ doc = '/** @param stdClass{id: int, name: string} $data */ ' ;
76+ $ expected = '/** @param (stdClass&object{id: int, name: string}) $data */ ' ;
6577
66- $ listDoc = ' /** @param list{int, string} $data */ ' ;
67- expect (DocblockNormalizer:: normalize ( $ listDoc ))-> toBe ( $ listDoc );
78+ expect (DocblockNormalizer:: normalize ( $ doc ))-> toBe ( $ expected ) ;
79+ } );
6880
69- $ objectDoc = '/** @param object{id: int} $data */ ' ;
70- expect (DocblockNormalizer::normalize ($ objectDoc ))->toBe ($ objectDoc );
81+ test ('converts namespaced and leading backslash class shapes into intersection shapes ' , function () {
82+ $ doc1 = '/** @param \stdClass{id: int} $data */ ' ;
83+ $ expected1 = '/** @param (\stdClass&object{id: int}) $data */ ' ;
84+ expect (DocblockNormalizer::normalize ($ doc1 ))->toBe ($ expected1 );
7185
72- $ nonEmptyArrayDoc = '/** @param non-empty-array{id: int} $data */ ' ;
73- expect (DocblockNormalizer::normalize ($ nonEmptyArrayDoc ))->toBe ($ nonEmptyArrayDoc );
86+ $ doc2 = '/** @param App\Models\User{id: positive-int} $user */ ' ;
87+ $ expected2 = '/** @param (App\Models\User&object{id: positive-int}) $user */ ' ;
88+ expect (DocblockNormalizer::normalize ($ doc2 ))->toBe ($ expected2 );
89+ });
7490
75- $ nonEmptyListDoc = ' /** @param non-empty-list{string} $data */ ' ;
76- expect (DocblockNormalizer:: normalize ( $ nonEmptyListDoc ))-> toBe ( $ nonEmptyListDoc ) ;
77- } );
91+ test ( ' preserves built-in PHPStan shape keywords (array, list, object, non-empty-array, non-empty- list) ' , function () {
92+ $ arrayDoc = ' /** @param array{id: int, name: string} $data */ ' ;
93+ expect (DocblockNormalizer:: normalize ( $ arrayDoc ))-> toBe ( $ arrayDoc );
7894
79- test ('handles case-insensitive built-in keywords ' , function () {
80- $ upperArrayDoc = '/** @param ARRAY{id: int} $data */ ' ;
81- expect (DocblockNormalizer::normalize ($ upperArrayDoc ))->toBe ($ upperArrayDoc );
95+ $ listDoc = '/** @param list{int, string} $data */ ' ;
96+ expect (DocblockNormalizer::normalize ($ listDoc ))->toBe ($ listDoc );
8297
83- $ upperObjectDoc = '/** @param OBJECT{id: int} $data */ ' ;
84- expect (DocblockNormalizer::normalize ($ upperObjectDoc ))->toBe ($ upperObjectDoc );
85- });
98+ $ objectDoc = '/** @param object{id: int} $data */ ' ;
99+ expect (DocblockNormalizer::normalize ($ objectDoc ))->toBe ($ objectDoc );
86100
87- test ('normalizes custom class shapes inside generic containers and unions ' , function () {
88- $ nestedDoc = '/** @param list<stdClass{id: positive-int}> $items */ ' ;
89- $ expected = '/** @param list<(stdClass&object{id: positive-int})> $items */ ' ;
101+ $ nonEmptyArrayDoc = '/** @param non-empty-array{id: int} $data */ ' ;
102+ expect (DocblockNormalizer::normalize ($ nonEmptyArrayDoc ))->toBe ($ nonEmptyArrayDoc );
90103
91- expect (DocblockNormalizer::normalize ($ nestedDoc ))->toBe ($ expected );
92- });
104+ $ nonEmptyListDoc = '/** @param non-empty-list{string} $data */ ' ;
105+ expect (DocblockNormalizer::normalize ($ nonEmptyListDoc ))->toBe ($ nonEmptyListDoc );
106+ });
93107
94- test ( ' normalizes inline @var annotations with class shapes ' , function () {
95- $ varDoc = '/** @var stdClass {id: int, role: string } $user */ ' ;
96- $ expected = ' /** @var (stdClass&object{id: int, role: string}) $user */ ' ;
108+ test ( ' handles case-insensitive built-in keywords ' , function () {
109+ $ upperArrayDoc = '/** @param ARRAY {id: int} $data */ ' ;
110+ expect (DocblockNormalizer:: normalize ( $ upperArrayDoc ))-> toBe ( $ upperArrayDoc ) ;
97111
98- expect (DocblockNormalizer::normalize ($ varDoc ))->toBe ($ expected );
99- });
112+ $ upperObjectDoc = '/** @param OBJECT{id: int} $data */ ' ;
113+ expect (DocblockNormalizer::normalize ($ upperObjectDoc ))->toBe ($ upperObjectDoc );
114+ });
100115
101- test ('normalizes class shapes with weird spacing and newlines between name and brace ' , function () {
102- $ doc = '/** @param stdClass {id: int} $data */ ' ;
103- $ expected = '/** @param (stdClass&object{id: int}) $data */ ' ;
116+ test ('normalizes custom class shapes inside generic containers and unions ' , function () {
117+ $ nestedDoc = '/** @param list< stdClass{id: positive- int}> $items */ ' ;
118+ $ expected = '/** @param list< (stdClass&object{id: positive- int})> $items */ ' ;
104119
105- expect (DocblockNormalizer::normalize ($ doc ))->toBe ($ expected );
106- });
120+ expect (DocblockNormalizer::normalize ($ nestedDoc ))->toBe ($ expected );
121+ });
107122
108- test ('normalizes standard multi-line parameter docblocks with class shapes ' , function () {
109- $ doc = " /** \n * @param stdClass{id: positive- int, name: non-empty- string} \$ payload \n */ " ;
110- $ expected = " /** \n * @param (stdClass&object{id: positive- int, name: non-empty- string}) \$ payload \n */ " ;
123+ test ('normalizes inline @var annotations with class shapes ' , function () {
124+ $ varDoc = ' /** @var stdClass{id: int, role: string} $user */ ' ;
125+ $ expected = ' /** @var (stdClass&object{id: int, role: string}) $user */ ' ;
111126
112- expect (DocblockNormalizer::normalize ($ doc ))->toBe ($ expected );
113- });
127+ expect (DocblockNormalizer::normalize ($ varDoc ))->toBe ($ expected );
128+ });
114129
115- test ('normalizes multi-line class shapes with inner newlines and asterisks ' , function () {
116- $ doc = " /** \n * @param stdClass{ \n * id: positive- int, \n * name: non-empty-string \n * } \ $data \n */ " ;
117- $ expected = " /** \n * @param (stdClass&object{\n * id: positive- int, \n * name: non-empty-string \n * }) \ $data \n */ " ;
130+ test ('normalizes class shapes with spacing and newlines between name and brace ' , function () {
131+ $ doc = ' /** @param stdClass { id: int} $data */ ' ;
132+ $ expected = ' /** @param (stdClass&object{id: int}) $data */ ' ;
118133
119- expect (DocblockNormalizer::normalize ($ doc ))->toBe ($ expected );
120- });
134+ expect (DocblockNormalizer::normalize ($ doc ))->toBe ($ expected );
135+ });
136+
137+ test ('normalizes standard multi-line parameter docblocks with class shapes ' , function () {
138+ $ doc = "/** \n * @param stdClass{id: positive-int, name: non-empty-string} \$payload \n */ " ;
139+ $ expected = "/** \n * @param (stdClass&object{id: positive-int, name: non-empty-string}) \$payload \n */ " ;
121140
122- test ( ' wraps class constant array shape keys in quotes for legacy phpdoc-parser compatibility ' , function () {
123- $ doc = ' /** @param array{self::KEY_ID: int, App\Constants::ROLE: string} $payload */ ' ;
141+ expect (DocblockNormalizer:: normalize ( $ doc ))-> toBe ( $ expected );
142+ }) ;
124143
125- $ expected = '/** @param array{"self::KEY_ID": int, "App\Constants::ROLE": string} $payload */ ' ;
144+ test ('normalizes multi-line class shapes with inner newlines and asterisks ' , function () {
145+ $ doc = "/** \n * @param stdClass{ \n * id: positive-int, \n * name: non-empty-string \n * } \$data \n */ " ;
146+ $ expected = "/** \n * @param (stdClass&object{ \n * id: positive-int, \n * name: non-empty-string \n * }) \$data \n */ " ;
126147
127- expect (DocblockNormalizer::normalize ($ doc ))->toBe ($ expected );
148+ expect (DocblockNormalizer::normalize ($ doc ))->toBe ($ expected );
149+ });
128150 });
129- });
151+ });
0 commit comments