@@ -14,6 +14,8 @@ use mago_syntax::ast::*;
1414
1515use crate :: types:: { AssertionKind , MethodInfo , ParameterInfo , TypeAssertion , Visibility } ;
1616
17+ use crate :: types:: { ConditionalReturnType , ParamCondition } ;
18+
1719use super :: types:: { clean_type, is_scalar, split_type_token, strip_nullable} ;
1820
1921// ─── Public API ─────────────────────────────────────────────────────────────
@@ -875,6 +877,137 @@ pub fn extract_generics_tag(docblock: &str, tag: &str) -> Vec<(String, Vec<Strin
875877 results
876878}
877879
880+ /// Attempt to synthesize a `ConditionalReturnType` from method-level
881+ /// `@template` annotations.
882+ ///
883+ /// When a method (or standalone function) declares `@template T`,
884+ /// `@param class-string<T> $class`, and `@return T`, this creates a
885+ /// conditional return type that resolves `T` from the call-site argument.
886+ ///
887+ /// For example, given:
888+ /// ```text
889+ /// @template T
890+ /// @param class-string<T> $class
891+ /// @return T
892+ /// ```
893+ /// Calling `find(User::class)` will resolve the return type to `User`.
894+ ///
895+ /// Returns `None` when:
896+ /// - No template params are provided
897+ /// - The return type does not reference a template param
898+ /// - No `@param class-string<T>` annotation is found for the template param
899+ /// - An existing conditional return type is already present (pass `true`
900+ /// for `has_existing_conditional` to skip synthesis)
901+ pub fn synthesize_template_conditional (
902+ docblock : & str ,
903+ template_params : & [ String ] ,
904+ return_type : Option < & str > ,
905+ has_existing_conditional : bool ,
906+ ) -> Option < ConditionalReturnType > {
907+ // Don't override an existing conditional return type.
908+ if has_existing_conditional {
909+ return None ;
910+ }
911+
912+ if template_params. is_empty ( ) {
913+ return None ;
914+ }
915+
916+ let ret = return_type?;
917+
918+ // Strip nullable prefix so that `?T` matches template param `T`.
919+ let stripped = ret. strip_prefix ( '?' ) . unwrap_or ( ret) ;
920+
921+ // Check if the (stripped) return type is one of the template params.
922+ if !template_params. iter ( ) . any ( |t| t == stripped) {
923+ return None ;
924+ }
925+
926+ // Find a `@param class-string<T> $paramName` annotation for this
927+ // template param, and extract the parameter name (without `$`).
928+ let param_name = find_class_string_param_name ( docblock, stripped) ?;
929+
930+ Some ( ConditionalReturnType :: Conditional {
931+ param_name,
932+ condition : ParamCondition :: ClassString ,
933+ // `then_type` is unused for ClassString — the resolver extracts
934+ // the class name directly from the argument (e.g. `User::class`
935+ // → `"User"`).
936+ then_type : Box :: new ( ConditionalReturnType :: Concrete ( "mixed" . into ( ) ) ) ,
937+ // `else_type` is used when the argument is not a `::class`
938+ // literal — `mixed` will produce `None` from resolution, which
939+ // lets the caller fall back to the plain return type.
940+ else_type : Box :: new ( ConditionalReturnType :: Concrete ( "mixed" . into ( ) ) ) ,
941+ } )
942+ }
943+
944+ /// Search a docblock for a `@param class-string<T> $paramName` annotation
945+ /// where `T` matches the given `template_name`.
946+ ///
947+ /// Returns the parameter name **without** the `$` prefix, or `None` if no
948+ /// matching annotation is found.
949+ ///
950+ /// Handles common type variants:
951+ /// - `class-string<T>`
952+ /// - `?class-string<T>` (nullable)
953+ /// - `class-string<T>|null` (union with null)
954+ fn find_class_string_param_name ( docblock : & str , template_name : & str ) -> Option < String > {
955+ let inner = docblock
956+ . trim ( )
957+ . strip_prefix ( "/**" )
958+ . unwrap_or ( docblock)
959+ . strip_suffix ( "*/" )
960+ . unwrap_or ( docblock) ;
961+
962+ let pattern = format ! ( "class-string<{}>" , template_name) ;
963+
964+ for line in inner. lines ( ) {
965+ let trimmed = line. trim ( ) . trim_start_matches ( '*' ) . trim ( ) ;
966+
967+ if let Some ( rest) = trimmed. strip_prefix ( "@param" ) {
968+ let rest = rest. trim_start ( ) ;
969+ if rest. is_empty ( ) {
970+ continue ;
971+ }
972+
973+ // Extract the full type token (respects `<…>` nesting).
974+ let ( type_token, remainder) = split_type_token ( rest) ;
975+
976+ // Check if the type token contains `class-string<T>`.
977+ // We strip `?` prefix and check for the pattern.
978+ let check = type_token. strip_prefix ( '?' ) . unwrap_or ( type_token) ;
979+ // Also handle `class-string<T>|null` — split on `|` and
980+ // check each part.
981+ let matches = check. split ( '|' ) . any ( |part| part. trim ( ) == pattern) ;
982+
983+ if !matches {
984+ continue ;
985+ }
986+
987+ // The next token after the type should be `$paramName`.
988+ // However, `split_type_token` splits at the closing `>`,
989+ // so if the type is `class-string<T>|null`, the remainder
990+ // will be `|null $class`. Skip any union continuation
991+ // (`|part`) before looking for the `$` variable name.
992+ let mut search = remainder;
993+ while let Some ( rest) = search. strip_prefix ( '|' ) {
994+ // Skip `|unionPart` — the next whitespace-delimited
995+ // token is the union type, not the variable name.
996+ let rest = rest. trim_start ( ) ;
997+ let ( _, after) = split_type_token ( rest) ;
998+ search = after;
999+ }
1000+ if let Some ( var_name) = search. split_whitespace ( ) . next ( )
1001+ && let Some ( name) = var_name. strip_prefix ( '$' )
1002+ {
1003+ return Some ( name. to_string ( ) ) ;
1004+ }
1005+ }
1006+ }
1007+
1008+ None
1009+ }
1010+
8781011/// Split a comma-separated generic argument list, respecting `<…>` and `(…)`
8791012/// nesting. Returns cleaned argument strings.
8801013///
0 commit comments