Skip to content

Commit 92d2873

Browse files
committed
Add SubjectOutcome to resolver and unify diagnostic subject resolution
1 parent 80f7b78 commit 92d2873

4 files changed

Lines changed: 306 additions & 324 deletions

File tree

docs/todo.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ within the same impact tier.
2626
| T18 | [Method-level template parameter resolution at call sites](todo/type-inference.md#t18-method-level-template-parameter-resolution-at-call-sites) | Medium | Medium |
2727
| H17 | [`missingType.iterableValue` — add `@return` with inferred element type](todo/phpstan-actions.md#h17-missingtype-iterablevalue-return-type--add-return-with-iterable-type) | Medium | High |
2828
| H10 | [`return.unusedType` — remove unused type from return union](todo/phpstan-actions.md#h10-returnunusedtype--remove-unused-type-from-return-union) | Medium | Medium |
29-
| L12 | [`App::make` / `App::makeWith` class-string return type dispatch](todo/laravel.md#l12-appmake--appmakewith-class-string-return-type-dispatch) | Medium | Low |
3029
| H6 | `return.type` — update return type to match actual returns | Medium | Medium |
3130
| | **Release 0.7.0** | | |
3231

@@ -37,7 +36,6 @@ within the same impact tier.
3736
| | Clear [refactoring gate](todo/refactor.md) |||
3837
| D8 | [Undefined variable diagnostic](todo/diagnostics.md#d8-undefined-variable-diagnostic) | High | Medium |
3938
| D11 | [Invalid class-like kind in context](todo/diagnostics.md#d11-invalid-class-like-kind-in-context) | Medium | Low |
40-
| D4 | Unused variable warning | Medium | Medium |
4139
| | **Release 0.8.0** | | |
4240

4341
> **Note:** F1 (Workspace symbol search), F2 (Document symbols), A8

docs/todo/diagnostics.md

Lines changed: 2 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -50,31 +50,6 @@ The following have been verified and are covered by tests:
5050

5151
---
5252

53-
## D4. Unresolved type in PHPDoc
54-
55-
**Impact: Medium · Effort: Medium**
56-
57-
A `@return`, `@param`, `@var`, `@throws`, `@mixin`, or `@extends` tag
58-
references a class that cannot be resolved. This is advisory (the code
59-
may still work if the type is only used for static analysis), so it
60-
should be **Information** severity.
61-
62-
| Scenario | Expected |
63-
| --------------------------------------------------------------------------- | --------------------------------------------------------------- |
64-
| `@return SomeAlias` where SomeAlias is not a class, type alias, or template | **Info**: Type 'SomeAlias' in @return could not be resolved |
65-
| `@param NonExistent $x` | **Info**: Type 'NonExistent' in @param could not be resolved |
66-
| `@throws FakeException` | **Info**: Type 'FakeException' in @throws could not be resolved |
67-
68-
### Implementation notes
69-
70-
This partially overlaps with `unknown_classes.rs` which already flags
71-
`ClassReference` spans in docblock type positions. The remaining gap is
72-
PHPDoc tags that reference types which are not emitted as
73-
`ClassReference` spans by the symbol map. Audit which docblock type
74-
positions produce `ClassReference` spans and which don't.
75-
76-
---
77-
7853
## D5. Diagnostic suppression intelligence
7954

8055
**Impact: Medium · Effort: Medium**
@@ -92,12 +67,10 @@ ignores reported by PHPStan. What remains:
9267

9368
### Remaining tools
9469

95-
- Psalm: `/** @psalm-suppress [IssueType] */` on the line or above
96-
the function/class.
9770
- PHPCS: `// phpcs:ignore [Sniff.Name]` or `// phpcs:disable` /
9871
`// phpcs:enable` blocks.
99-
- PHPMD: `// @SuppressWarnings(PHPMD.[RuleName])` in a docblock.
100-
- For PHPantom's own diagnostics: support `@suppress PHPxxxx`
72+
- PHPMD (3.0): `#[SuppressWarnings(RuleName::class)]` as a PHP attribute.
73+
- For PHPantom's own diagnostics: support `@suppress phpantom.*`
10174
in docblocks (matching PHP Tools' convention) and a config flag
10275
`phpantom.diagnostics.enabled: bool` (default `true`).
10376

src/completion/resolver.rs

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,12 @@ use std::sync::Arc;
3131

3232
use crate::Backend;
3333
use crate::docblock;
34+
use crate::inheritance::resolve_property_type_hint;
3435
use crate::php_type::PhpType;
36+
use crate::subject_expr::SubjectExpr;
3537
use crate::types::*;
3638
use 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`.
9621262
fn resolve_variable_fallback(

0 commit comments

Comments
 (0)