@@ -56,6 +56,35 @@ final class MixinGenerator
5656 'allNullOrNotNull ' , // meaningless
5757 ];
5858
59+ /**
60+ * @var array<string, bool>
61+ */
62+ private array $ haveAssert = [];
63+
64+ public function generateHasAssert (): string {
65+ $ res = <<<'PHP'
66+ <?php
67+
68+ declare(strict_types=1);
69+
70+ namespace Webmozart\Assert;
71+
72+ /** @internal Used by the Psalm plugin */
73+ final class HasAssert
74+ {
75+ public const HAS_ASSERT = [
76+
77+ PHP;
78+ foreach ($ this ->haveAssert as $ name => $ has ) {
79+ if ($ has ) {
80+ $ name = strtolower ($ name );
81+ $ res .= " ' $ name' => true, \n" ;
82+ }
83+ }
84+ $ res .= " ]; \n} \n" ;
85+ return $ res ;
86+ }
87+
5988 public function generate (): string
6089 {
6190 return \sprintf (
@@ -77,6 +106,7 @@ private function namespace(): string
77106
78107 $ namespace = sprintf ("namespace %s; \n\n" , $ assert ->getNamespaceName ());
79108 $ namespace .= sprintf ("use %s; \n" , ArrayAccess::class);
109+ $ namespace .= sprintf ("use %s; \n" , \Closure::class);
80110 $ namespace .= sprintf ("use %s; \n" , Countable::class);
81111 $ namespace .= sprintf ("use %s; \n" , Throwable::class);
82112 $ namespace .= "\n" ;
@@ -93,18 +123,23 @@ private function trait(ReflectionClass $assert): string
93123 $ declaredMethods = [];
94124
95125 foreach ($ staticMethods as $ method ) {
126+ $ this ->haveAssert [$ method ->getName ()] = str_contains ($ method ->getDocComment (), '@psalm-assert ' );
127+
96128 $ nullOr = $ this ->nullOr ($ method , 4 );
97129 if (null !== $ nullOr ) {
130+ $ this ->haveAssert ["nullOr " .$ method ->getName ()] = str_contains ($ nullOr , '@psalm-assert ' );
98131 $ declaredMethods [] = $ nullOr ;
99132 }
100133
101134 $ all = $ this ->all ($ method , 4 );
102135 if (null !== $ all ) {
136+ $ this ->haveAssert ["all " .$ method ->getName ()] = str_contains ($ all , '@psalm-assert ' );
103137 $ declaredMethods [] = $ all ;
104138 }
105139
106140 $ allNullOr = $ this ->allNullOr ($ method , 4 );
107141 if (null !== $ allNullOr ) {
142+ $ this ->haveAssert ["allNullOr " .$ method ->getName ()] = str_contains ($ allNullOr , '@psalm-assert ' );
108143 $ declaredMethods [] = $ allNullOr ;
109144 }
110145 }
@@ -231,11 +266,11 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
231266 }
232267
233268 if ($ parameterReflection ->hasType ()) {
234- if ($ parameterReflection -> name === ' value ' ) {
269+ if (count ( $ parameters ) === 1 ) {
235270 $ parameterTypes [$ parameterReflection ->name ] = 'mixed ' ;
236271
237272 $ nativeReturnType = match ($ typeTemplate ) {
238- '%s|null ' => $ this ->reduceParameterType ( $ parameterReflection -> getType ()),
273+ '%s|null ' => $ this ->nullableReturnType ( $ method -> getReturnType ()),
239274 'iterable<%s> ' => 'iterable ' ,
240275 'iterable<%s|null> ' => 'iterable ' ,
241276 };
@@ -245,6 +280,12 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
245280 }
246281 }
247282
283+ // Ensure @template comes before @param, and @param values match function signature order
284+ $ parsedComment = $ this ->reorderAnnotations ($ parsedComment );
285+ if (isset ($ parsedComment ['param ' ])) {
286+ $ parsedComment ['param ' ] = $ this ->reorderParamsBySignature ($ parsedComment ['param ' ], $ parameters );
287+ }
288+
248289 if (in_array ($ newMethodName , $ this ->skipMethods , true )) {
249290 return null ;
250291 }
@@ -253,6 +294,13 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
253294
254295 $ phpdocReturnType = 'mixed ' ;
255296
297+ $ templateTypeNames = [];
298+ if (isset ($ parsedComment ['template ' ])) {
299+ foreach ($ parsedComment ['template ' ] as $ template ) {
300+ $ templateTypeNames [] = explode (' ' , $ template )[0 ];
301+ }
302+ }
303+
256304 $ phpdocLines = [];
257305 foreach ($ parsedComment as $ key => $ values ) {
258306 if ($ this ->shouldSkipAnnotation ($ newMethodName , $ key )) {
@@ -275,14 +323,24 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
275323
276324 foreach ($ values as $ i => $ value ) {
277325 $ 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 );
326+ if ('param ' === $ key && isset ($ parts [1 ]) && isset ($ parameters [0 ]) && $ parts [1 ] === '$ ' .$ parameters [0 ] && 'mixed ' !== $ parts [0 ]) {
327+ $ parts [0 ] = $ this ->applyTypeTemplate ($ parts [0 ], $ typeTemplate, $ templateTypeNames );
280328
281329 $ values [$ i ] = \implode (' ' , $ parts );
330+
331+ if ('mixed ' === $ phpdocReturnType ) {
332+ $ phpdocReturnType = $ parts [0 ];
333+ }
282334 }
283335 }
284336
285- if ('psalm-return ' === $ key || 'return ' === $ key ) {
337+ if ('return ' === $ key ) {
338+ foreach ($ values as $ value ) {
339+ $ parts = $ this ->splitDocLine ($ value );
340+ if ('mixed ' !== $ parts [0 ]) {
341+ $ phpdocReturnType = $ this ->applyTypeTemplate ($ parts [0 ], $ typeTemplate , $ templateTypeNames );
342+ }
343+ }
286344 continue ;
287345 }
288346
@@ -294,8 +352,12 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
294352 $ parts = $ this ->splitDocLine ($ value );
295353 $ type = $ parts [0 ];
296354
355+ if ('template ' === $ key && 'iterable<%s|null> ' === $ typeTemplate ) {
356+ $ type = preg_replace ('/^(\S+\s+(?:of|as)\s+)(.+)$/ ' , '$1$2|null ' , $ type ) ?? $ type ;
357+ }
358+
297359 if ('psalm-assert ' === $ key ) {
298- $ type = $ this ->applyTypeTemplate ($ type , $ typeTemplate );
360+ $ type = $ this ->applyTypeTemplate ($ type , $ typeTemplate, $ templateTypeNames );
299361
300362 $ phpdocReturnType = $ type ;
301363 }
@@ -322,6 +384,20 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
322384 }
323385 }
324386
387+ if ('mixed ' === $ phpdocReturnType ) {
388+ $ returnType = $ method ->getReturnType ();
389+ if ($ returnType !== null ) {
390+ $ returnTypeStr = $ this ->reduceParameterType ($ returnType );
391+ if ('mixed ' !== $ returnTypeStr ) {
392+ $ phpdocReturnType = $ this ->applyTypeTemplate ($ returnTypeStr , $ typeTemplate , $ templateTypeNames );
393+ }
394+ }
395+ }
396+
397+ if ('mixed ' === $ phpdocReturnType && 'mixed ' !== $ nativeReturnType ) {
398+ $ phpdocReturnType = $ nativeReturnType ;
399+ }
400+
325401 $ phpdocLines [] = '@return ' .$ phpdocReturnType ;
326402 $ phpdocLines [] = '' ;
327403
@@ -360,8 +436,28 @@ private function reduceParameterType(ReflectionType $type): string
360436 return ($ type ->allowsNull () ? '? ' : '' ) . $ type ->getName ();
361437 }
362438
363- private function applyTypeTemplate (string $ type , string $ typeTemplate ): string
439+ private function nullableReturnType (?ReflectionType $ type ): string
440+ {
441+ if ($ type === null ) {
442+ return 'mixed ' ;
443+ }
444+ $ typeStr = $ this ->reduceParameterType ($ type );
445+ if ($ typeStr === 'mixed ' ) {
446+ return 'mixed ' ;
447+ }
448+ if ($ type instanceof ReflectionUnionType || $ type instanceof ReflectionIntersectionType) {
449+ return $ typeStr .'|null ' ;
450+ }
451+
452+ return '? ' .$ typeStr ;
453+ }
454+
455+ private function applyTypeTemplate (string $ type , string $ typeTemplate , array $ templateTypeNames = []): string
364456 {
457+ if (in_array ($ type , $ templateTypeNames , true ) && str_contains ($ typeTemplate , 'iterable ' ) && str_contains ($ typeTemplate , '|null ' )) {
458+ $ typeTemplate = str_replace ('|null ' , '' , $ typeTemplate );
459+ }
460+
365461 $ combinedType = sprintf ($ typeTemplate , $ type );
366462
367463 if ('empty|null ' === $ combinedType ) {
@@ -377,7 +473,7 @@ private function shouldSkipAnnotation(string $newMethodName, string $key): bool
377473 return false ;
378474 }
379475
380- return 'psalm-assert ' === $ key || ' psalm-return ' === $ key ;
476+ return 'psalm-assert ' === $ key ;
381477 }
382478
383479 /**
@@ -557,6 +653,70 @@ private function splitDocLine(string $line): array
557653 return [trim ($ matches [1 ]), $ matches [2 ], $ matches [3 ] ?? null ];
558654 }
559655
656+ /**
657+ * Ensures @template annotations appear before @param annotations.
658+ *
659+ * @param array<string, list<string>> $annotations
660+ *
661+ * @return array<string, list<string>>
662+ */
663+ private function reorderAnnotations (array $ annotations ): array
664+ {
665+ $ keys = array_keys ($ annotations );
666+ $ templatePos = array_search ('template ' , $ keys , true );
667+ $ paramPos = array_search ('param ' , $ keys , true );
668+
669+ if ($ templatePos === false || $ paramPos === false || $ templatePos < $ paramPos ) {
670+ return $ annotations ;
671+ }
672+
673+ $ result = [];
674+ foreach ($ annotations as $ key => $ values ) {
675+ if ($ key === 'param ' ) {
676+ $ result ['template ' ] = $ annotations ['template ' ];
677+ }
678+ if ($ key !== 'template ' ) {
679+ $ result [$ key ] = $ values ;
680+ }
681+ }
682+
683+ return $ result ;
684+ }
685+
686+ /**
687+ * Reorders @param doc entries to match the function signature parameter order.
688+ *
689+ * @param list<string> $paramDocs
690+ * @param list<string> $parameterNames
691+ *
692+ * @return list<string>
693+ */
694+ private function reorderParamsBySignature (array $ paramDocs , array $ parameterNames ): array
695+ {
696+ $ byVarName = [];
697+ $ withoutVarName = [];
698+
699+ foreach ($ paramDocs as $ doc ) {
700+ $ parts = $ this ->splitDocLine ($ doc );
701+ if (isset ($ parts [1 ])) {
702+ $ byVarName [$ parts [1 ]] = $ doc ;
703+ } else {
704+ $ withoutVarName [] = $ doc ;
705+ }
706+ }
707+
708+ $ ordered = [];
709+ foreach ($ parameterNames as $ name ) {
710+ $ key = '$ ' .$ name ;
711+ if (isset ($ byVarName [$ key ])) {
712+ $ ordered [] = $ byVarName [$ key ];
713+ unset($ byVarName [$ key ]);
714+ }
715+ }
716+
717+ return array_merge ($ ordered , array_values ($ byVarName ), $ withoutVarName );
718+ }
719+
560720 /**
561721 * @psalm-return list<ReflectionMethod>
562722 *
0 commit comments