@@ -76,6 +76,7 @@ private function namespace(): string
7676 $ assert = new ReflectionClass (Assert::class);
7777
7878 $ namespace = sprintf ("namespace %s; \n\n" , $ assert ->getNamespaceName ());
79+ $ namespace .= sprintf ("use %s; \n" , \Closure::class);
7980 $ namespace .= sprintf ("use %s; \n" , ArrayAccess::class);
8081 $ namespace .= sprintf ("use %s; \n" , Countable::class);
8182 $ namespace .= sprintf ("use %s; \n" , Throwable::class);
@@ -231,11 +232,11 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
231232 }
232233
233234 if ($ parameterReflection ->hasType ()) {
234- if ($ parameterReflection -> name === ' value ' ) {
235+ if (count ( $ parameters ) === 1 ) {
235236 $ parameterTypes [$ parameterReflection ->name ] = 'mixed ' ;
236237
237238 $ nativeReturnType = match ($ typeTemplate ) {
238- '%s|null ' => $ this ->reduceParameterType ( $ parameterReflection -> getType ()),
239+ '%s|null ' => $ this ->nullableReturnType ( $ method -> getReturnType ()),
239240 'iterable<%s> ' => 'iterable ' ,
240241 'iterable<%s|null> ' => 'iterable ' ,
241242 };
@@ -245,6 +246,12 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
245246 }
246247 }
247248
249+ // Ensure @template comes before @param, and @param values match function signature order
250+ $ parsedComment = $ this ->reorderAnnotations ($ parsedComment );
251+ if (isset ($ parsedComment ['param ' ])) {
252+ $ parsedComment ['param ' ] = $ this ->reorderParamsBySignature ($ parsedComment ['param ' ], $ parameters );
253+ }
254+
248255 if (in_array ($ newMethodName , $ this ->skipMethods , true )) {
249256 return null ;
250257 }
@@ -253,6 +260,13 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
253260
254261 $ phpdocReturnType = 'mixed ' ;
255262
263+ $ templateTypeNames = [];
264+ if (isset ($ parsedComment ['template ' ])) {
265+ foreach ($ parsedComment ['template ' ] as $ template ) {
266+ $ templateTypeNames [] = explode (' ' , $ template )[0 ];
267+ }
268+ }
269+
256270 $ phpdocLines = [];
257271 foreach ($ parsedComment as $ key => $ values ) {
258272 if ($ this ->shouldSkipAnnotation ($ newMethodName , $ key )) {
@@ -275,14 +289,24 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
275289
276290 foreach ($ values as $ i => $ value ) {
277291 $ parts = $ this ->splitDocLine ($ value );
278- if (( 'param ' === $ key || ' psalm-param ' === $ key ) && isset ($ parts [1 ]) && isset ($ parameters [0 ]) && $ parts [1 ] === '$ ' .$ parameters [0 ] && 'mixed ' !== $ parts [0 ]) {
279- $ parts [0 ] = $ this ->applyTypeTemplate ($ parts [0 ], $ typeTemplate );
292+ if ('param ' === $ key && isset ($ parts [1 ]) && isset ($ parameters [0 ]) && $ parts [1 ] === '$ ' .$ parameters [0 ] && 'mixed ' !== $ parts [0 ]) {
293+ $ parts [0 ] = $ this ->applyTypeTemplate ($ parts [0 ], $ typeTemplate, $ templateTypeNames );
280294
281295 $ values [$ i ] = \implode (' ' , $ parts );
296+
297+ if ('mixed ' === $ phpdocReturnType ) {
298+ $ phpdocReturnType = $ parts [0 ];
299+ }
282300 }
283301 }
284302
285- if ('psalm-return ' === $ key || 'return ' === $ key ) {
303+ if ('return ' === $ key ) {
304+ foreach ($ values as $ value ) {
305+ $ parts = $ this ->splitDocLine ($ value );
306+ if ('mixed ' !== $ parts [0 ]) {
307+ $ phpdocReturnType = $ this ->applyTypeTemplate ($ parts [0 ], $ typeTemplate , $ templateTypeNames );
308+ }
309+ }
286310 continue ;
287311 }
288312
@@ -294,8 +318,12 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
294318 $ parts = $ this ->splitDocLine ($ value );
295319 $ type = $ parts [0 ];
296320
321+ if ('template ' === $ key && 'iterable<%s|null> ' === $ typeTemplate ) {
322+ $ type = preg_replace ('/^(\S+\s+(?:of|as)\s+)(.+)$/ ' , '$1$2|null ' , $ type ) ?? $ type ;
323+ }
324+
297325 if ('psalm-assert ' === $ key ) {
298- $ type = $ this ->applyTypeTemplate ($ type , $ typeTemplate );
326+ $ type = $ this ->applyTypeTemplate ($ type , $ typeTemplate, $ templateTypeNames );
299327
300328 $ phpdocReturnType = $ type ;
301329 }
@@ -322,6 +350,20 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
322350 }
323351 }
324352
353+ if ('mixed ' === $ phpdocReturnType ) {
354+ $ returnType = $ method ->getReturnType ();
355+ if ($ returnType !== null ) {
356+ $ returnTypeStr = $ this ->reduceParameterType ($ returnType );
357+ if ('mixed ' !== $ returnTypeStr ) {
358+ $ phpdocReturnType = $ this ->applyTypeTemplate ($ returnTypeStr , $ typeTemplate , $ templateTypeNames );
359+ }
360+ }
361+ }
362+
363+ if ('mixed ' === $ phpdocReturnType && 'mixed ' !== $ nativeReturnType ) {
364+ $ phpdocReturnType = $ nativeReturnType ;
365+ }
366+
325367 $ phpdocLines [] = '@return ' .$ phpdocReturnType ;
326368 $ phpdocLines [] = '' ;
327369
@@ -351,7 +393,13 @@ private function reduceParameterType(ReflectionType $type): string
351393 return \implode ('| ' , \array_map ([$ this , 'reduceParameterType ' ], $ type ->getTypes ()));
352394 }
353395
354- $ type = Assert::isInstanceOf ($ type , ReflectionNamedType::class);
396+ if (!$ type instanceof ReflectionNamedType) {
397+ throw new RuntimeException (sprintf (
398+ 'Expected a "%s" instance, got "%s". ' ,
399+ ReflectionNamedType::class,
400+ get_debug_type ($ type )
401+ ));
402+ }
355403
356404 if ($ type ->getName () === 'mixed ' ) {
357405 return $ type ->getName ();
@@ -360,8 +408,25 @@ private function reduceParameterType(ReflectionType $type): string
360408 return ($ type ->allowsNull () ? '? ' : '' ) . $ type ->getName ();
361409 }
362410
363- private function applyTypeTemplate (string $ type , string $ typeTemplate ): string
411+ private function nullableReturnType (?ReflectionType $ type ): string
412+ {
413+ if ($ type === null ) {
414+ return 'mixed ' ;
415+ }
416+ $ typeStr = $ this ->reduceParameterType ($ type );
417+ if ($ typeStr === 'mixed ' ) {
418+ return 'mixed ' ;
419+ }
420+
421+ return $ typeStr .'|null ' ;
422+ }
423+
424+ private function applyTypeTemplate (string $ type , string $ typeTemplate , array $ templateTypeNames = []): string
364425 {
426+ if (in_array ($ type , $ templateTypeNames , true ) && str_contains ($ typeTemplate , 'iterable ' ) && str_contains ($ typeTemplate , '|null ' )) {
427+ $ typeTemplate = str_replace ('|null ' , '' , $ typeTemplate );
428+ }
429+
365430 $ combinedType = sprintf ($ typeTemplate , $ type );
366431
367432 if ('empty|null ' === $ combinedType ) {
@@ -377,7 +442,7 @@ private function shouldSkipAnnotation(string $newMethodName, string $key): bool
377442 return false ;
378443 }
379444
380- return 'psalm-assert ' === $ key || ' psalm-return ' === $ key ;
445+ return 'psalm-assert ' === $ key ;
381446 }
382447
383448 /**
@@ -557,6 +622,70 @@ private function splitDocLine(string $line): array
557622 return [trim ($ matches [1 ]), $ matches [2 ], $ matches [3 ] ?? null ];
558623 }
559624
625+ /**
626+ * Ensures @template annotations appear before @param annotations.
627+ *
628+ * @param array<string, list<string>> $annotations
629+ *
630+ * @return array<string, list<string>>
631+ */
632+ private function reorderAnnotations (array $ annotations ): array
633+ {
634+ $ keys = array_keys ($ annotations );
635+ $ templatePos = array_search ('template ' , $ keys , true );
636+ $ paramPos = array_search ('param ' , $ keys , true );
637+
638+ if ($ templatePos === false || $ paramPos === false || $ templatePos < $ paramPos ) {
639+ return $ annotations ;
640+ }
641+
642+ $ result = [];
643+ foreach ($ annotations as $ key => $ values ) {
644+ if ($ key === 'param ' ) {
645+ $ result ['template ' ] = $ annotations ['template ' ];
646+ }
647+ if ($ key !== 'template ' ) {
648+ $ result [$ key ] = $ values ;
649+ }
650+ }
651+
652+ return $ result ;
653+ }
654+
655+ /**
656+ * Reorders @param doc entries to match the function signature parameter order.
657+ *
658+ * @param list<string> $paramDocs
659+ * @param list<string> $parameterNames
660+ *
661+ * @return list<string>
662+ */
663+ private function reorderParamsBySignature (array $ paramDocs , array $ parameterNames ): array
664+ {
665+ $ byVarName = [];
666+ $ withoutVarName = [];
667+
668+ foreach ($ paramDocs as $ doc ) {
669+ $ parts = $ this ->splitDocLine ($ doc );
670+ if (isset ($ parts [1 ])) {
671+ $ byVarName [$ parts [1 ]] = $ doc ;
672+ } else {
673+ $ withoutVarName [] = $ doc ;
674+ }
675+ }
676+
677+ $ ordered = [];
678+ foreach ($ parameterNames as $ name ) {
679+ $ key = '$ ' .$ name ;
680+ if (isset ($ byVarName [$ key ])) {
681+ $ ordered [] = $ byVarName [$ key ];
682+ unset($ byVarName [$ key ]);
683+ }
684+ }
685+
686+ return array_merge ($ ordered , array_values ($ byVarName ), $ withoutVarName );
687+ }
688+
560689 /**
561690 * @psalm-return list<ReflectionMethod>
562691 *
0 commit comments