@@ -31,9 +31,12 @@ use std::sync::Arc;
3131
3232use crate :: Backend ;
3333use crate :: docblock;
34+ use crate :: inheritance:: resolve_property_type_hint;
3435use crate :: php_type:: PhpType ;
36+ use crate :: subject_expr:: SubjectExpr ;
3537use crate :: types:: * ;
3638use crate :: util:: find_class_by_name;
39+ use crate :: virtual_members:: resolve_class_fully_maybe_cached;
3740
3841/// Type alias for the optional function-loader closure passed through
3942/// the resolution chain. Reduces clippy `type_complexity` warnings.
@@ -957,6 +960,303 @@ fn resolve_call_raw_return_type(
957960 }
958961}
959962
963+ // ─── Enriched subject resolution for diagnostics ────────────────────────────
964+
965+ /// The outcome of resolving a subject for diagnostic purposes.
966+ ///
967+ /// [`resolve_target_classes`] only returns classes and silently drops
968+ /// scalar types. Diagnostics need to know *why* resolution returned
969+ /// empty — was the subject a scalar type (runtime crash), an
970+ /// unresolvable class name (likely typo / missing import), or truly
971+ /// untyped? This enum carries that information so the diagnostic
972+ /// collector can emit the right message without re-running resolution.
973+ #[ derive( Clone , Debug ) ]
974+ pub ( crate ) enum SubjectOutcome {
975+ /// Subject resolved to one or more classes.
976+ Resolved ( Vec < Arc < ClassInfo > > ) ,
977+ /// Subject resolved to a scalar type — member access is always a
978+ /// runtime crash. The string is the display name of the scalar
979+ /// type (e.g. `"int"`, `"string"`, `"bool|int"`).
980+ Scalar ( String ) ,
981+ /// Subject resolved to a class name that couldn't be loaded.
982+ UnresolvableClass ( String ) ,
983+ /// Subject type could not be resolved — no class information
984+ /// available.
985+ Untyped ,
986+ }
987+
988+ /// Resolve a subject to a [`SubjectOutcome`] in a single pass.
989+ ///
990+ /// This is the unified entry point for diagnostic subject resolution.
991+ /// It first tries [`resolve_target_classes`] (the same pipeline used
992+ /// by completion and hover). When that returns empty, it inspects the
993+ /// raw resolved types to determine whether the subject is scalar,
994+ /// an unresolvable class name, or truly untyped — without re-running
995+ /// variable resolution or calling separate secondary helpers.
996+ pub ( crate ) fn resolve_subject_outcome (
997+ subject : & str ,
998+ access_kind : AccessKind ,
999+ ctx : & ResolutionCtx < ' _ > ,
1000+ ) -> SubjectOutcome {
1001+ let classes = resolve_target_classes ( subject, access_kind, ctx) ;
1002+ if !classes. is_empty ( ) {
1003+ return SubjectOutcome :: Resolved ( classes) ;
1004+ }
1005+
1006+ // ── Subject did not resolve to any class — determine why ────
1007+ let expr = SubjectExpr :: parse ( subject) ;
1008+ resolve_subject_outcome_from_expr ( & expr, access_kind, ctx)
1009+ }
1010+
1011+ /// Inner dispatch for [`resolve_subject_outcome`], operating on a
1012+ /// pre-parsed [`SubjectExpr`].
1013+ fn resolve_subject_outcome_from_expr (
1014+ expr : & SubjectExpr ,
1015+ access_kind : AccessKind ,
1016+ ctx : & ResolutionCtx < ' _ > ,
1017+ ) -> SubjectOutcome {
1018+ match expr {
1019+ // ── Bare variable ───────────────────────────────────────
1020+ SubjectExpr :: Variable ( var_name) => resolve_subject_outcome_variable ( var_name, ctx) ,
1021+
1022+ // ── Property chain: $user->age->value ───────────────────
1023+ SubjectExpr :: PropertyChain { base, property } => {
1024+ resolve_subject_outcome_property_chain ( base, property, access_kind, ctx)
1025+ }
1026+
1027+ // ── Call expression ─────────────────────────────────────
1028+ SubjectExpr :: CallExpr { callee, args_text } => {
1029+ resolve_subject_outcome_call_expr ( callee, args_text, access_kind, ctx)
1030+ }
1031+
1032+ _ => SubjectOutcome :: Untyped ,
1033+ }
1034+ }
1035+
1036+ /// Resolve a bare variable subject to a [`SubjectOutcome`].
1037+ ///
1038+ /// Re-uses `resolve_variable_types` (the same function that
1039+ /// `resolve_variable_fallback` calls) to get the raw resolved types.
1040+ /// If they are all scalar, returns `Scalar`. If the raw type string
1041+ /// looks like a class name that can't be loaded, returns
1042+ /// `UnresolvableClass`. Otherwise returns `Untyped`.
1043+ fn resolve_subject_outcome_variable ( var_name : & str , ctx : & ResolutionCtx < ' _ > ) -> SubjectOutcome {
1044+ let default_class = ClassInfo :: default ( ) ;
1045+ let effective_class = ctx. current_class . unwrap_or ( & default_class) ;
1046+
1047+ let resolved = super :: variable:: resolution:: resolve_variable_types (
1048+ var_name,
1049+ effective_class,
1050+ ctx. all_classes ,
1051+ ctx. content ,
1052+ ctx. cursor_offset ,
1053+ ctx. class_loader ,
1054+ Loaders :: with_function ( ctx. function_loader ) ,
1055+ ) ;
1056+
1057+ if !resolved. is_empty ( ) {
1058+ let joined = ResolvedType :: types_joined ( & resolved) ;
1059+ if joined. all_members_primitive_scalar ( ) {
1060+ let display = joined
1061+ . non_null_type ( )
1062+ . map_or_else ( || joined. to_string ( ) , |t| t. to_string ( ) ) ;
1063+ return SubjectOutcome :: Scalar ( display) ;
1064+ }
1065+ // The resolved types contain non-scalar, non-class entries
1066+ // (e.g. type aliases we can't resolve). Check for
1067+ // unresolvable class names.
1068+ let raw_type = ResolvedType :: type_strings_joined ( & resolved) ;
1069+ if let Some ( unresolved) = check_unresolvable_class_name ( & raw_type, ctx. class_loader ) {
1070+ return SubjectOutcome :: UnresolvableClass ( unresolved) ;
1071+ }
1072+ return SubjectOutcome :: Untyped ;
1073+ }
1074+
1075+ // Variable resolution returned nothing. Fall back to the hover
1076+ // variable type resolver which also checks class-based foreach
1077+ // resolution through @implements / @extends generics.
1078+ if let Some ( raw_type) = crate :: hover:: variable_type:: resolve_variable_type_string (
1079+ var_name,
1080+ ctx. content ,
1081+ ctx. cursor_offset ,
1082+ ctx. current_class ,
1083+ ctx. all_classes ,
1084+ ctx. class_loader ,
1085+ Loaders :: with_function ( ctx. function_loader ) ,
1086+ ) && let Some ( unresolved) = check_unresolvable_class_name ( & raw_type, ctx. class_loader )
1087+ {
1088+ return SubjectOutcome :: UnresolvableClass ( unresolved) ;
1089+ }
1090+
1091+ SubjectOutcome :: Untyped
1092+ }
1093+
1094+ /// Resolve a property chain subject to a [`SubjectOutcome`].
1095+ ///
1096+ /// Resolves the base to classes, then looks up the property's type
1097+ /// hint. If the type is purely scalar, returns `Scalar`.
1098+ fn resolve_subject_outcome_property_chain (
1099+ base : & SubjectExpr ,
1100+ property : & str ,
1101+ access_kind : AccessKind ,
1102+ ctx : & ResolutionCtx < ' _ > ,
1103+ ) -> SubjectOutcome {
1104+ let base_classes = resolve_target_classes_expr ( base, access_kind, ctx) ;
1105+ for cls in & base_classes {
1106+ let resolved =
1107+ resolve_class_fully_maybe_cached ( cls, ctx. class_loader , ctx. resolved_class_cache ) ;
1108+ if let Some ( parsed) = resolve_property_type_hint ( & resolved, property, ctx. class_loader ) {
1109+ if parsed. all_members_primitive_scalar ( ) {
1110+ let display = parsed
1111+ . non_null_type ( )
1112+ . map_or_else ( || parsed. to_string ( ) , |t| t. to_string ( ) ) ;
1113+ return SubjectOutcome :: Scalar ( display) ;
1114+ }
1115+ // Non-scalar, non-class type — treat as unresolvable.
1116+ return SubjectOutcome :: Untyped ;
1117+ }
1118+ }
1119+ SubjectOutcome :: Untyped
1120+ }
1121+
1122+ /// Resolve a call expression subject to a [`SubjectOutcome`].
1123+ ///
1124+ /// First tries `resolve_call_return_types_expr` (the normal path).
1125+ /// When that returns empty, inspects the raw return type hint of the
1126+ /// callable — if it's scalar, returns `Scalar`.
1127+ fn resolve_subject_outcome_call_expr (
1128+ callee : & SubjectExpr ,
1129+ args_text : & str ,
1130+ access_kind : AccessKind ,
1131+ ctx : & ResolutionCtx < ' _ > ,
1132+ ) -> SubjectOutcome {
1133+ let return_classes = Backend :: resolve_call_return_types_expr ( callee, args_text, ctx) ;
1134+ if !return_classes. is_empty ( ) {
1135+ // Shouldn't happen (resolve_target_classes would have returned
1136+ // these), but handle gracefully.
1137+ return SubjectOutcome :: Resolved ( return_classes) ;
1138+ }
1139+
1140+ // Try to get the raw return type hint from the callable.
1141+ if let Some ( scalar) = resolve_call_scalar_return ( callee, access_kind, ctx) {
1142+ return SubjectOutcome :: Scalar ( scalar) ;
1143+ }
1144+
1145+ // Try unresolvable class detection for function calls.
1146+ if let SubjectExpr :: FunctionCall ( fn_name) = callee
1147+ && let Some ( fl) = ctx. function_loader
1148+ && let Some ( func_info) = fl ( fn_name. as_str ( ) )
1149+ && let Some ( raw_type) = func_info. return_type_str ( )
1150+ && let Some ( unresolved) = check_unresolvable_class_name ( & raw_type, ctx. class_loader )
1151+ {
1152+ return SubjectOutcome :: UnresolvableClass ( unresolved) ;
1153+ }
1154+
1155+ SubjectOutcome :: Untyped
1156+ }
1157+
1158+ /// Check whether a call expression's return type is a scalar.
1159+ ///
1160+ /// Inspects the raw return type hint on the method or function without
1161+ /// going through the full class resolution pipeline.
1162+ fn resolve_call_scalar_return (
1163+ callee : & SubjectExpr ,
1164+ access_kind : AccessKind ,
1165+ ctx : & ResolutionCtx < ' _ > ,
1166+ ) -> Option < String > {
1167+ match callee {
1168+ // Instance method call: $obj->getAge()
1169+ SubjectExpr :: MethodCall { base, method } => {
1170+ let base_classes = resolve_target_classes_expr ( base, access_kind, ctx) ;
1171+ for cls in & base_classes {
1172+ let resolved = resolve_class_fully_maybe_cached (
1173+ cls,
1174+ ctx. class_loader ,
1175+ ctx. resolved_class_cache ,
1176+ ) ;
1177+ if let Some ( m) = resolved
1178+ . methods
1179+ . iter ( )
1180+ . find ( |m| m. name . eq_ignore_ascii_case ( method) )
1181+ && let Some ( ref hint) = m. return_type
1182+ && hint. all_members_primitive_scalar ( )
1183+ {
1184+ let display = hint
1185+ . non_null_type ( )
1186+ . map_or_else ( || hint. to_string ( ) , |t| t. to_string ( ) ) ;
1187+ return Some ( display) ;
1188+ }
1189+ }
1190+ None
1191+ }
1192+ // Standalone function call: getInt()
1193+ SubjectExpr :: FunctionCall ( fn_name) => {
1194+ if let Some ( fl) = ctx. function_loader
1195+ && let Some ( func_info) = fl ( fn_name)
1196+ && let Some ( ref hint) = func_info. return_type
1197+ && hint. all_members_primitive_scalar ( )
1198+ {
1199+ let display = hint
1200+ . non_null_type ( )
1201+ . map_or_else ( || hint. to_string ( ) , |t| t. to_string ( ) ) ;
1202+ return Some ( display) ;
1203+ }
1204+ None
1205+ }
1206+ // Static method call: Foo::getInt()
1207+ SubjectExpr :: StaticMethodCall { class, method } => {
1208+ let cls = ( ctx. class_loader ) ( class) ;
1209+ if let Some ( cls) = cls {
1210+ let resolved = resolve_class_fully_maybe_cached (
1211+ & cls,
1212+ ctx. class_loader ,
1213+ ctx. resolved_class_cache ,
1214+ ) ;
1215+ if let Some ( m) = resolved
1216+ . methods
1217+ . iter ( )
1218+ . find ( |m| m. name . eq_ignore_ascii_case ( method) )
1219+ && let Some ( ref hint) = m. return_type
1220+ && hint. all_members_primitive_scalar ( )
1221+ {
1222+ let display = hint
1223+ . non_null_type ( )
1224+ . map_or_else ( || hint. to_string ( ) , |t| t. to_string ( ) ) ;
1225+ return Some ( display) ;
1226+ }
1227+ }
1228+ None
1229+ }
1230+ _ => None ,
1231+ }
1232+ }
1233+
1234+ /// Check whether a raw type string refers to a class that cannot be
1235+ /// loaded.
1236+ ///
1237+ /// Returns `Some(class_name)` when the type looks like a class name
1238+ /// (not scalar, not a PHPDoc pseudo-type) but the class loader cannot
1239+ /// find it. Returns `None` for scalars, unions, shapes, and types
1240+ /// that resolve successfully.
1241+ fn check_unresolvable_class_name (
1242+ raw_type : & str ,
1243+ class_loader : & dyn Fn ( & str ) -> Option < Arc < ClassInfo > > ,
1244+ ) -> Option < String > {
1245+ let parsed = PhpType :: parse ( raw_type) ;
1246+ if parsed. all_members_scalar ( ) {
1247+ return None ;
1248+ }
1249+
1250+ let effective = parsed. non_null_type ( ) . unwrap_or_else ( || parsed. clone ( ) ) ;
1251+ let base = effective. base_name ( ) ?;
1252+
1253+ if class_loader ( base) . is_none ( ) {
1254+ Some ( base. to_string ( ) )
1255+ } else {
1256+ None
1257+ }
1258+ }
1259+
9601260/// Shared variable-resolution logic extracted from the former
9611261/// bare-`$var` branch of `resolve_target_classes`.
9621262fn resolve_variable_fallback (
0 commit comments