Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Macro hover shows origin and inferred return types.** Hovering on a macro method call now displays a "macro" indicator instead of the generic "virtual" label, distinguishing `::macro()` registrations from `@method`/`@mixin` synthesized members. When the closure has no explicit return type hint, the return type is inferred from the closure body and shown with an "(inferred)" annotation. Bare `$this` / `self` / `static` returns preserve their keyword form, and method chains like `$this->transform(...)` use the last method's declared return type directly — preserving `$this`, `static`, and generic parameters that the general resolver would flatten to a bare class name. Regular (non-macro) methods with inferred return types also show the "(inferred)" annotation on hover. A new `preserve_static` flag on the resolution context controls whether self-references resolve to their keyword form or the concrete class name. Contributed by @calebdw.
- **PSR-4 mismatch diagnostics and rename-based moves.** Files now warn when the declared namespace or primary class name does not match the PSR-4 path or filename, with quick fixes to correct them. Renaming a class from its declaration now opens the full FQCN so you can move it between namespaces in one step, and renaming a namespace can rewrite multiple segments at once while moving PSR-4 directories and updating references across the project. Contributed by @calebdw.
- **Case-sensitive autoloading diagnostic.** A class reference whose casing differs from the class's actual declaration is now flagged, with a quick fix to correct it. This catches the bug where code loads on a case-insensitive filesystem (macOS, Windows) but fails with a class-not-found error on Linux, because PSR-4 maps the name to a file path and path lookups are case-sensitive there. It covers `use` imports and inline references to autoloaded classes; built-in classes and same-file references, which never reach the autoloader, are left alone.
- **Completion candidates ranked by dependency provenance.** Class, function, and constant completions are now sorted by origin tier: project code first, then core/stub symbols, then explicit Composer dependencies (`require` / `require-dev`), then transitive vendor dependencies last. The provenance is inferred from `composer.json` and `installed.json` during indexing. Contributed by @calebdw.
Expand Down
2 changes: 2 additions & 0 deletions src/code_actions/extract_interface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,8 @@ mod tests {
has_scope_attribute: false,
is_abstract: false,
is_virtual: false,
is_macro: false,
is_inferred_return: false,
type_assertions: vec![],
throws: vec![],
if_this_is: None,
Expand Down
86 changes: 82 additions & 4 deletions src/completion/call_resolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,7 @@ impl Backend {
function_loader: Some(&function_loader_cl),
scope_var_resolver: None,
is_in_static_method: false,
preserve_static: false,
};

let parsed = SubjectExpr::parse(expr);
Expand Down Expand Up @@ -2980,11 +2981,26 @@ impl Backend {
return Some(PhpType::Named(resolved_name));
}

// $this / self / static → current class
// $this / self / static → current class (or preserve the keyword when asked)
if is_self_or_static(trimmed) {
return ctx
.current_class
.map(|c| PhpType::Named(c.name.to_string()));
return ctx.current_class.map(|c| {
if ctx.preserve_static {
PhpType::Named(trimmed.to_string())
} else {
PhpType::Named(c.name.to_string())
}
});
}

// When preserve_static is set, try resolving method chains by
// looking up the last method's declared return type directly.
// This preserves $this/static and generics that the general
// expression resolver would flatten to a bare class name.
if ctx.preserve_static
&& trimmed.contains("->")
&& let Some(ty) = resolve_chain_declared_return(trimmed, ctx)
{
return Some(ty);
}

// General expression fallback: parse the argument text as a
Expand Down Expand Up @@ -3112,6 +3128,7 @@ impl Backend {
function_loader: ctx.function_loader,
scope_var_resolver: Some(&param_aware_resolver),
is_in_static_method: ctx.is_in_static_method,
preserve_static: false,
};
Self::resolve_arg_text_to_type(body, &param_ctx)
}
Expand All @@ -3137,6 +3154,67 @@ fn resolve_expression_to_type(text: &str, ctx: &ResolutionCtx<'_>) -> Option<Php
Some(crate::types::ResolvedType::types_joined(&results))
}

/// Resolve a method chain by looking up the *declared* return type of the
/// last method call, rather than flattening the whole chain to a bare class
/// name.
///
/// For `$this->transform(str(...))`, this:
/// 1. Parses into `CallExpr { callee: MethodCall { base: This, method: "transform" } }`
/// 2. Resolves `This` → `Collection` class
/// 3. Looks up `transform` on `Collection` → gets declared return type (`$this`)
/// 4. Returns `$this` directly, preserving generics and self-references
///
/// Falls back to `None` when the expression is not a method call or the
/// method's return type is unknown.
fn resolve_chain_declared_return(text: &str, ctx: &ResolutionCtx<'_>) -> Option<PhpType> {
let expr = crate::subject_expr::SubjectExpr::parse(text);
let (base, method_name) = match &expr {
crate::subject_expr::SubjectExpr::CallExpr { callee, .. } => match callee.as_ref() {
crate::subject_expr::SubjectExpr::MethodCall { base, method } => {
(base.as_ref(), method.as_str())
}
_ => return None,
},
_ => return None,
};

let base_results =
super::resolver::resolve_target_classes_expr(base, crate::types::AccessKind::Arrow, ctx);

for rt in &base_results {
let ci = rt.class_info.as_ref()?;

// Try the raw class first — its return types preserve template
// parameter names (e.g. `TValue`) that full resolution replaces
// with their bounds (`mixed`).
if let Some(method) = ci
.methods
.iter()
.find(|m| m.name.eq_ignore_ascii_case(method_name))
&& let Some(ref ret) = method.return_type
{
return Some(ret.clone());
}

// Fall back to the fully resolved class for inherited methods.
let resolved = crate::virtual_members::resolve_class_fully_maybe_cached(
ci,
ctx.class_loader,
ctx.resolved_class_cache,
);
if let Some(method) = resolved
.methods
.iter()
.find(|m| m.name.eq_ignore_ascii_case(method_name))
&& let Some(ref ret) = method.return_type
{
return Some(ret.clone());
}
}

None
}

/// Resolve a `ClassName::Member` expression to a type.
///
/// Handles enum cases (`MyEnum::Case` → `MyEnum`) and class constants
Expand Down
2 changes: 2 additions & 0 deletions src/completion/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -989,6 +989,7 @@ impl Backend {
function_loader: Some(&function_loader),
scope_var_resolver: None,
is_in_static_method: false,
preserve_static: false,
};
let mut resolved = if suppress {
vec![]
Expand All @@ -1014,6 +1015,7 @@ impl Backend {
function_loader: Some(&function_loader),
scope_var_resolver: None,
is_in_static_method: false,
preserve_static: false,
};
resolved = super::resolver::resolve_target_classes(
&target.subject,
Expand Down
9 changes: 9 additions & 0 deletions src/completion/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,14 @@ pub(crate) struct ResolutionCtx<'a> {
/// resolves to nothing. Precomputed from the `SymbolMap` at the
/// call site to avoid re-parsing the AST.
pub is_in_static_method: bool,
/// When `true`, `$this` / `self` / `static` resolve to their
/// keyword form rather than the concrete class name, and method
/// chains use the last method's declared return type directly.
///
/// Used by macro return-type inference so that `return $this;` in
/// a macro closure produces `$this` — preserving polymorphism and
/// generics that the general resolver would flatten.
pub preserve_static: bool,
}

/// Bundles the common parameters threaded through variable-type resolution.
Expand Down Expand Up @@ -249,6 +257,7 @@ impl<'a> VarResolutionCtx<'a> {
resolved_class_cache: self.resolved_class_cache,
scope_var_resolver: self.scope_var_resolver,
is_in_static_method: false,
preserve_static: false,
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/completion/source/throws_analysis_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,8 @@ fn make_class_with_throws(name: &str, methods: Vec<(&str, Vec<&str>)>) -> Arc<Cl
has_scope_attribute: false,
is_abstract: false,
is_virtual: false,
is_macro: false,
is_inferred_return: false,
type_assertions: Vec::new(),
throws: throws.into_iter().map(PhpType::parse).collect(),
if_this_is: None,
Expand Down
1 change: 1 addition & 0 deletions src/definition/implementation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,7 @@ impl Backend {
function_loader: Some(&function_loader),
scope_var_resolver: None,
is_in_static_method: false,
preserve_static: false,
};

let candidates = ResolvedType::into_arced_classes(
Expand Down
1 change: 1 addition & 0 deletions src/definition/member/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ impl Backend {
function_loader: Some(&function_loader),
scope_var_resolver: None,
is_in_static_method: false,
preserve_static: false,
};
let candidates = ResolvedType::into_arced_classes(
crate::completion::resolver::resolve_target_classes(subject, access_kind, &rctx),
Expand Down
1 change: 1 addition & 0 deletions src/definition/type_definition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ impl Backend {
),
scope_var_resolver: None,
is_in_static_method: false,
preserve_static: false,
};

let candidates = ResolvedType::into_arced_classes(
Expand Down
1 change: 1 addition & 0 deletions src/diagnostics/deprecated.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ impl Backend {
function_loader: Some(&function_loader),
scope_var_resolver: None,
is_in_static_method: false,
preserve_static: false,
};

resolve_variable_subject(subject_text, *is_static, &rctx)
Expand Down
2 changes: 2 additions & 0 deletions src/diagnostics/unknown_members/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,7 @@ impl Backend {
function_loader: Some(&function_loader),
scope_var_resolver: None,
is_in_static_method: symbol_map.is_in_static_method(span.start),
preserve_static: false,
};
resolve_subject_outcome(subject_text, access_kind, &rctx)
})
Expand Down Expand Up @@ -624,6 +625,7 @@ impl Backend {
function_loader: Some(&function_loader),
scope_var_resolver: None,
is_in_static_method: symbol_map.is_in_static_method(span.start),
preserve_static: false,
};
let fresh = resolve_subject_outcome(subject_text, access_kind, &rctx);
if let SubjectOutcome::Resolved(ref fresh_classes) = fresh {
Expand Down
2 changes: 2 additions & 0 deletions src/docblock/virtual_members.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,8 @@ pub fn extract_method_tags(docblock: &str) -> Vec<MethodInfo> {
has_scope_attribute: false,
is_abstract: false,
is_virtual: true,
is_macro: false,
is_inferred_return: false,
type_assertions: Vec::new(),
throws: Vec::new(),
if_this_is: None,
Expand Down
4 changes: 4 additions & 0 deletions src/document_symbols.rs
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,8 @@ mod tests {
has_scope_attribute: false,
is_abstract: false,
is_virtual: false,
is_macro: false,
is_inferred_return: false,
type_assertions: vec![],
throws: vec![],
if_this_is: None,
Expand Down Expand Up @@ -637,6 +639,8 @@ mod tests {
has_scope_attribute: false,
is_abstract: false,
is_virtual: false,
is_macro: false,
is_inferred_return: false,
type_assertions: vec![],
throws: vec![],
if_this_is: None,
Expand Down
9 changes: 7 additions & 2 deletions src/hover/formatting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ pub(super) fn build_param_return_section(
effective_return: Option<&PhpType>,
native_return: Option<&PhpType>,
return_description: Option<&str>,
is_inferred_return: bool,
) -> Option<String> {
let mut entries = Vec::new();

Expand Down Expand Up @@ -235,12 +236,15 @@ pub(super) fn build_param_return_section(
};
let has_ret_desc = return_description.is_some_and(|d| !d.is_empty());

if ret_type_differs || has_ret_desc {
if ret_type_differs || has_ret_desc || (is_inferred_return && effective_return.is_some()) {
let mut entry = String::from("**return**");
if ret_type_differs {
if ret_type_differs || (is_inferred_return && effective_return.is_some()) {
if let Some(eff) = effective_return {
entry.push_str(&format!(" `{}`", shorten_php_type(eff)));
}
if is_inferred_return {
entry.push_str(" *(inferred)*");
}
if has_ret_desc {
entry.push_str(" \n\u{00a0}\u{00a0}\u{00a0}\u{00a0}");
entry.push_str(return_description.unwrap());
Expand Down Expand Up @@ -398,6 +402,7 @@ pub(crate) fn hover_for_function(
func.return_type.as_ref(),
func.native_return_type.as_ref(),
func.return_description.as_deref(),
false,
) {
lines.push(section);
}
Expand Down
14 changes: 13 additions & 1 deletion src/hover/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ enum MemberOrigin {
/// The member is virtual (synthesized from `@method`, `@property`,
/// `@mixin`, or a framework provider).
Virtual,
/// The member is a macro registration.
Macro,
}

/// Check whether the **raw** (unmerged) class declares a member with the
Expand Down Expand Up @@ -89,12 +91,15 @@ fn build_origin_lines(
member_name: &str,
owner: &ClassInfo,
is_virtual: bool,
is_macro: bool,
member_kind: MemberKindForOrigin,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
) -> String {
let mut origins: Vec<MemberOrigin> = Vec::new();

if is_virtual {
if is_macro {
origins.push(MemberOrigin::Macro);
} else if is_virtual {
origins.push(MemberOrigin::Virtual);
}

Expand Down Expand Up @@ -158,6 +163,7 @@ fn build_origin_lines(
MemberOrigin::Override(name) => format!("↑ overrides **{}**", name),
MemberOrigin::Implements(name) => format!("◆ implements **{}**", name),
MemberOrigin::Virtual => "👻 virtual".to_string(),
MemberOrigin::Macro => "🔌 macro".to_string(),
})
.collect();

Expand Down Expand Up @@ -566,6 +572,7 @@ impl Backend {
function_loader: Some(&function_loader),
scope_var_resolver: None,
is_in_static_method: false,
preserve_static: false,
};

let access_kind = if *is_static {
Expand Down Expand Up @@ -1214,6 +1221,7 @@ impl Backend {
&method.name,
owner,
method.is_virtual,
method.is_macro,
MemberKindForOrigin::Method,
class_loader,
);
Expand All @@ -1238,11 +1246,13 @@ impl Backend {
format_see_refs(&resolved_see, &method.links, &mut lines);

// Build the readable param/return section as markdown.
let show_inferred = method.is_inferred_return || inferred_return_type.is_some();
if let Some(section) = build_param_return_section(
&method.parameters,
effective_return,
method.native_return_type.as_ref(),
method.return_description.as_deref(),
show_inferred,
) {
lines.push(section);
}
Expand Down Expand Up @@ -1309,6 +1319,7 @@ impl Backend {
&property.name,
owner,
property.is_virtual,
false,
MemberKindForOrigin::Property,
class_loader,
);
Expand Down Expand Up @@ -1379,6 +1390,7 @@ impl Backend {
&constant.name,
owner,
constant.is_virtual,
false,
MemberKindForOrigin::Constant,
class_loader,
);
Expand Down
4 changes: 4 additions & 0 deletions src/inheritance_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,8 @@ fn test_apply_substitution_to_method_modifies_return_and_params() {
has_scope_attribute: false,
is_abstract: false,
is_virtual: false,
is_macro: false,
is_inferred_return: false,
type_assertions: Vec::new(),
throws: Vec::new(),
if_this_is: None,
Expand Down Expand Up @@ -388,6 +390,8 @@ fn test_extends_generics_propagate_through_parent_use_generics() {
has_scope_attribute: false,
is_abstract: false,
is_virtual: false,
is_macro: false,
is_inferred_return: false,
type_assertions: Vec::new(),
throws: Vec::new(),
if_this_is: None,
Expand Down
2 changes: 2 additions & 0 deletions src/parser/classes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2484,6 +2484,8 @@ impl Backend {
has_scope_attribute: has_scope_attr,
is_abstract: method.is_abstract(),
is_virtual: false,
is_macro: false,
is_inferred_return: false,
type_assertions,
throws,
if_this_is: method_docblock_text
Expand Down
Loading
Loading