From d3f45eda38e056a3730785a27cda6cfab2187136 Mon Sep 17 00:00:00 2001 From: Caleb White Date: Fri, 17 Jul 2026 11:15:33 -0500 Subject: [PATCH] feat: macro hover with origin indicator and inferred return types Macro methods now show a "macro" origin indicator in hover instead of the generic "virtual" label, helping users distinguish ::macro() registrations from @method/@mixin synthesized members. When a macro closure has no explicit return type hint, the return type is inferred from the closure body and displayed with an "(inferred)" annotation. This also applies to regular methods whose return types are inferred at hover time. A new preserve_static flag on ResolutionCtx controls whether $this/self/static resolve to their keyword form or the concrete class name. For method chains like $this->transform(...), the last method's declared return type is used directly via resolve_chain_declared_return(), preserving $this, static, and generic parameters that the general expression resolver would flatten to a bare class name. Raw class methods are checked before the fully-resolved class so template parameter names (e.g. TValue) are preserved instead of being replaced with their bounds. Key changes: - is_macro and is_inferred_return fields on MethodInfo - MemberOrigin::Macro variant for hover rendering - preserve_static on ResolutionCtx with chain-aware resolution - resolve_chain_declared_return() for declared return type lookup - 4 new integration tests for hover behavior --- docs/CHANGELOG.md | 1 + src/code_actions/extract_interface.rs | 2 + src/completion/call_resolution.rs | 86 +++++++- src/completion/handler.rs | 2 + src/completion/resolver.rs | 9 + .../source/throws_analysis_tests.rs | 2 + src/definition/implementation.rs | 1 + src/definition/member/mod.rs | 1 + src/definition/type_definition.rs | 1 + src/diagnostics/deprecated.rs | 1 + src/diagnostics/unknown_members/mod.rs | 2 + src/docblock/virtual_members.rs | 2 + src/document_symbols.rs | 4 + src/hover/formatting.rs | 9 +- src/hover/mod.rs | 14 +- src/inheritance_tests.rs | 4 + src/parser/classes.rs | 2 + src/resolution.rs | 1 + src/server.rs | 44 ++++- src/types.rs | 18 ++ src/virtual_members/laravel/macros.rs | 25 ++- src/virtual_members/laravel/mod.rs | 4 +- tests/integration/laravel_macros.rs | 183 ++++++++++++++++++ 23 files changed, 405 insertions(+), 13 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 656ef3d1..2075005d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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. diff --git a/src/code_actions/extract_interface.rs b/src/code_actions/extract_interface.rs index 660de231..ceffb5a2 100644 --- a/src/code_actions/extract_interface.rs +++ b/src/code_actions/extract_interface.rs @@ -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, diff --git a/src/completion/call_resolution.rs b/src/completion/call_resolution.rs index b0ead11f..3c6d49c5 100644 --- a/src/completion/call_resolution.rs +++ b/src/completion/call_resolution.rs @@ -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); @@ -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 @@ -3112,6 +3128,7 @@ impl Backend { function_loader: ctx.function_loader, scope_var_resolver: Some(¶m_aware_resolver), is_in_static_method: ctx.is_in_static_method, + preserve_static: false, }; Self::resolve_arg_text_to_type(body, ¶m_ctx) } @@ -3137,6 +3154,67 @@ fn resolve_expression_to_type(text: &str, ctx: &ResolutionCtx<'_>) -> Optiontransform(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 { + 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 diff --git a/src/completion/handler.rs b/src/completion/handler.rs index 8bab77b4..e0b791bd 100644 --- a/src/completion/handler.rs +++ b/src/completion/handler.rs @@ -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![] @@ -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, diff --git a/src/completion/resolver.rs b/src/completion/resolver.rs index 0c40e257..2529a346 100644 --- a/src/completion/resolver.rs +++ b/src/completion/resolver.rs @@ -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. @@ -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, } } diff --git a/src/completion/source/throws_analysis_tests.rs b/src/completion/source/throws_analysis_tests.rs index 120d6f50..efca4494 100644 --- a/src/completion/source/throws_analysis_tests.rs +++ b/src/completion/source/throws_analysis_tests.rs @@ -830,6 +830,8 @@ fn make_class_with_throws(name: &str, methods: Vec<(&str, Vec<&str>)>) -> Arc Vec { 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, diff --git a/src/document_symbols.rs b/src/document_symbols.rs index ce1dc90c..b2b8f6d0 100644 --- a/src/document_symbols.rs +++ b/src/document_symbols.rs @@ -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, @@ -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, diff --git a/src/hover/formatting.rs b/src/hover/formatting.rs index 8180b13a..9981cb00 100644 --- a/src/hover/formatting.rs +++ b/src/hover/formatting.rs @@ -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 { let mut entries = Vec::new(); @@ -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()); @@ -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); } diff --git a/src/hover/mod.rs b/src/hover/mod.rs index 7cf24c88..fcdb08ab 100644 --- a/src/hover/mod.rs +++ b/src/hover/mod.rs @@ -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 @@ -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>, ) -> String { let mut origins: Vec = Vec::new(); - if is_virtual { + if is_macro { + origins.push(MemberOrigin::Macro); + } else if is_virtual { origins.push(MemberOrigin::Virtual); } @@ -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(); @@ -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 { @@ -1214,6 +1221,7 @@ impl Backend { &method.name, owner, method.is_virtual, + method.is_macro, MemberKindForOrigin::Method, class_loader, ); @@ -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); } @@ -1309,6 +1319,7 @@ impl Backend { &property.name, owner, property.is_virtual, + false, MemberKindForOrigin::Property, class_loader, ); @@ -1379,6 +1390,7 @@ impl Backend { &constant.name, owner, constant.is_virtual, + false, MemberKindForOrigin::Constant, class_loader, ); diff --git a/src/inheritance_tests.rs b/src/inheritance_tests.rs index dd860d68..8f48a65a 100644 --- a/src/inheritance_tests.rs +++ b/src/inheritance_tests.rs @@ -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, @@ -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, diff --git a/src/parser/classes.rs b/src/parser/classes.rs index 226216a8..119608ac 100644 --- a/src/parser/classes.rs +++ b/src/parser/classes.rs @@ -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 diff --git a/src/resolution.rs b/src/resolution.rs index cfe031a2..b95bee67 100644 --- a/src/resolution.rs +++ b/src/resolution.rs @@ -1181,6 +1181,7 @@ impl Backend { function_loader: Some(&function_loader), scope_var_resolver: None, is_in_static_method: false, + preserve_static: false, }; crate::completion::variable::closure_resolution::find_closure_this_override(&rctx) } diff --git a/src/server.rs b/src/server.rs index 88523c8b..ca6e6500 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1954,6 +1954,7 @@ impl Backend { if regs.is_empty() { return; } + self.infer_laravel_macro_return_types(&mut regs, &uri, content); // A macro registered through a facade also attaches to the // facade's concrete container-bound class. self.expand_facade_macros(&mut regs); @@ -1983,7 +1984,7 @@ impl Backend { let Some(imported_uri) = self.resolve_class_uri(imported_fqn) else { continue; }; - if !self.is_laravel_macro_helper_uri_allowed(uri, &imported_uri) { + if !self.is_macro_helper_uri_allowed(uri, &imported_uri) { continue; } if candidate_uris.insert(imported_uri.clone()) { @@ -2117,6 +2118,7 @@ impl Backend { let php_version = Some(*self.php_version.lock()); let mut regs = crate::virtual_members::laravel::extract_macro_registrations(content, php_version); + self.infer_laravel_macro_return_types(&mut regs, uri, content); // A macro registered through a facade also attaches to the facade's // concrete container-bound class. self.expand_facade_macros(&mut regs); @@ -2155,7 +2157,45 @@ impl Backend { .any(|rel| crate::util::path_to_uri(&root.join(rel)) == uri) } - fn is_laravel_macro_helper_uri_allowed(&self, provider_uri: &str, helper_uri: &str) -> bool { + fn infer_laravel_macro_return_types( + &self, + regs: &mut [crate::virtual_members::laravel::MacroRegistration], + uri: &str, + content: &str, + ) { + let file_ctx = self.file_context(uri); + let class_loader = self.class_loader(&file_ctx); + let function_loader = self.function_loader(&file_ctx); + for reg in regs.iter_mut() { + if reg.method.return_type.is_some() || reg.method.native_return_type.is_some() { + continue; + } + let Some(closure_text) = reg.closure_text.as_deref() else { + continue; + }; + let Some(target_class) = self.find_or_load_class(®.target) else { + continue; + }; + let rctx = crate::completion::resolver::ResolutionCtx { + current_class: Some(target_class.as_ref()), + all_classes: &file_ctx.classes, + content, + cursor_offset: reg.name_offset, + class_loader: &class_loader, + resolved_class_cache: Some(&self.resolved_class_cache), + function_loader: Some(&function_loader), + scope_var_resolver: None, + is_in_static_method: false, + preserve_static: true, + }; + if let Some(ty) = Self::infer_closure_return_type(closure_text, &rctx) { + reg.method.return_type = Some(ty); + reg.method.is_inferred_return = true; + } + } + } + + fn is_macro_helper_uri_allowed(&self, provider_uri: &str, helper_uri: &str) -> bool { let Ok(provider_url) = tower_lsp::lsp_types::Url::parse(provider_uri) else { return false; }; diff --git a/src/types.rs b/src/types.rs index f9dec6f2..e2ca574d 100644 --- a/src/types.rs +++ b/src/types.rs @@ -601,6 +601,18 @@ pub struct MethodInfo { /// Set to `true` by [`MethodInfo::virtual_method`] and by providers; /// set to `false` by the parser for real declared methods. pub is_virtual: bool, + /// Whether this method originates from a `::macro()` registration. + /// + /// Used by hover to show a "macro" indicator instead of the generic + /// "virtual" label, so the user can distinguish macros from `@method` + /// or `@mixin` synthesized members. + pub is_macro: bool, + /// Whether the return type was inferred from closure body analysis + /// rather than explicitly declared via a type hint or docblock. + /// + /// When `true`, hover appends "(inferred)" to the return type line + /// so the user knows the type is best-effort, not authoritative. + pub is_inferred_return: bool, /// Type assertions declared via `@phpstan-assert` / `@psalm-assert` tags /// in the method's docblock. /// @@ -652,6 +664,8 @@ impl MethodInfo { && self.has_scope_attribute == other.has_scope_attribute && self.is_abstract == other.is_abstract && self.is_virtual == other.is_virtual + && self.is_macro == other.is_macro + && self.is_inferred_return == other.is_inferred_return && self.throws == other.throws && self.parameters.len() == other.parameters.len() && self @@ -706,6 +720,8 @@ impl 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, @@ -737,6 +753,8 @@ impl 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, diff --git a/src/virtual_members/laravel/macros.rs b/src/virtual_members/laravel/macros.rs index 69ad11eb..d8fdb1ee 100644 --- a/src/virtual_members/laravel/macros.rs +++ b/src/virtual_members/laravel/macros.rs @@ -52,6 +52,11 @@ pub(crate) struct MacroRegistration { /// call jumps here, since the synthesized method has no declaration in the /// target class's own file. pub name_offset: u32, + /// Raw source text of the closure / arrow-function argument. + /// + /// Kept so the backend can infer a return type from the body when the + /// registration has no explicit `: ReturnType` annotation. + pub closure_text: Option, } /// Extract every literal macro registration from a file's source. @@ -551,7 +556,9 @@ fn build_instance_registration( let name_arg = args.next()?.value(); let name = macro_name(name_arg)?; let name_offset = name_arg.span().start.offset; - let (parameter_list, return_type_hint) = closure_signature(args.next()?.value())?; + let closure_expr = args.next()?.value(); + let (parameter_list, return_type_hint) = closure_signature(closure_expr)?; + let closure_text = expr_source_text(Some(closure_expr), content); let parameters = crate::parser::extract_parameters(parameter_list, Some(content), php_version, None); @@ -560,11 +567,13 @@ fn build_instance_registration( let mut method = MethodInfo::virtual_method_typed(&name, return_type.as_ref()); method.parameters = parameters; method.native_return_type = return_type; + method.is_macro = true; Some(MacroRegistration { target: target.to_string(), method, name_offset, + closure_text, }) } @@ -582,7 +591,9 @@ fn build_registration( let name_arg = args.next()?.value(); let name = macro_name(name_arg)?; let name_offset = name_arg.span().start.offset; - let (parameter_list, return_type_hint) = closure_signature(args.next()?.value())?; + let closure_expr = args.next()?.value(); + let (parameter_list, return_type_hint) = closure_signature(closure_expr)?; + let closure_text = expr_source_text(Some(closure_expr), content); let parameters = crate::parser::extract_parameters(parameter_list, Some(content), php_version, None); @@ -591,14 +602,24 @@ fn build_registration( let mut method = MethodInfo::virtual_method_typed(&name, return_type.as_ref()); method.parameters = parameters; method.native_return_type = return_type; + method.is_macro = true; Some(MacroRegistration { target, method, name_offset, + closure_text, }) } +fn expr_source_text(expr: Option<&Expression<'_>>, content: &str) -> Option { + let expr = expr?; + let span = expr.span(); + let start = span.start.offset as usize; + let end = span.end.offset as usize; + (start < end && end <= content.len()).then(|| content[start..end].to_string()) +} + /// Resolve the class written before `::macro` to a fully-qualified name via /// the file's resolved `use` statements. `self`/`static`/`parent` are /// skipped (a relative target carries no concrete FQN here). diff --git a/src/virtual_members/laravel/mod.rs b/src/virtual_members/laravel/mod.rs index fc9a0c80..36457420 100644 --- a/src/virtual_members/laravel/mod.rs +++ b/src/virtual_members/laravel/mod.rs @@ -103,8 +103,8 @@ pub(crate) use config_keys::{ }; pub(crate) use env_vars::resolve_env_definition; pub(crate) use macros::{ - LaravelMacroIndex, extract_macro_registrations, inject_macros, parse_installed_providers, - parse_provider_class_list, parse_provider_referenced_classes, + LaravelMacroIndex, MacroRegistration, extract_macro_registrations, inject_macros, + parse_installed_providers, parse_provider_class_list, parse_provider_referenced_classes, }; /// Unified go-to-definition entry point for all Laravel string-key spans. diff --git a/tests/integration/laravel_macros.rs b/tests/integration/laravel_macros.rs index 9e462727..f1392d9a 100644 --- a/tests/integration/laravel_macros.rs +++ b/tests/integration/laravel_macros.rs @@ -39,6 +39,19 @@ class AppServiceProvider { } "; +const PROVIDER_RETURNS_THIS_PHP: &str = "\ + (phpantom_lsp::Backend, tempfile::TempDir) { create_psr4_workspace( COMPOSER_JSON, @@ -122,6 +135,60 @@ class Consumer { ); } +#[tokio::test] +async fn macro_without_return_hint_can_chain_from_this_return() { + let consumer = "\ +asValueLabel()-> + } +} +"; + let (backend, _dir) = workspace_files(consumer); + open( + &backend, + "file:///src/Providers/AppServiceProvider.php", + PROVIDER_RETURNS_THIS_PHP, + ) + .await; + open(&backend, "file:///src/Consumer.php", consumer).await; + + let result = backend + .completion(CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: Url::parse("file:///src/Consumer.php").unwrap(), + }, + position: Position { + line: 5, + character: 28, + }, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: None, + }) + .await + .unwrap(); + + let items = match result.expect("completion should return results") { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + let names: Vec<&str> = items + .iter() + .filter_map(|i| i.filter_text.as_deref()) + .collect(); + + assert!( + names.contains(&"count"), + "macro returning $this should preserve chain completions, got: {names:?}" + ); +} + #[tokio::test] async fn macro_call_is_not_flagged_and_resolves() { let consumer = "\ @@ -841,3 +908,119 @@ class Consumer { "helper referenced by the edited provider should be scanned, got: {names:?}" ); } + +#[tokio::test] +async fn hover_on_macro_call_shows_macro_origin() { + let consumer = "\ +sumField('price'); + } +} +"; + let (backend, _dir) = workspace_files(consumer); + open( + &backend, + "file:///src/Providers/AppServiceProvider.php", + PROVIDER_PHP, + ) + .await; + open(&backend, "file:///src/Consumer.php", consumer).await; + + let result = backend + .hover(HoverParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: Url::parse("file:///src/Consumer.php").unwrap(), + }, + position: Position { + line: 5, + character: 14, + }, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .await + .unwrap(); + + let hover = result.expect("hover should return a result"); + if let HoverContents::Markup(markup) = hover.contents { + let value = &markup.value; + assert!( + value.contains("macro"), + "hover should show macro origin indicator, got:\n{value}" + ); + assert!( + !value.contains("(inferred)"), + "explicit return type should not show (inferred), got:\n{value}" + ); + } else { + panic!("expected HoverContents::Markup"); + } +} + +#[tokio::test] +async fn hover_on_macro_with_inferred_return_shows_annotation() { + let consumer = "\ +asValueLabel(); + } +} +"; + let (backend, _dir) = create_psr4_workspace( + COMPOSER_JSON, + &[ + ("vendor/illuminate/Support/Collection.php", COLLECTION_PHP), + ( + "src/Providers/AppServiceProvider.php", + PROVIDER_RETURNS_THIS_PHP, + ), + ("src/Consumer.php", consumer), + ], + ); + open( + &backend, + "file:///src/Providers/AppServiceProvider.php", + PROVIDER_RETURNS_THIS_PHP, + ) + .await; + open(&backend, "file:///src/Consumer.php", consumer).await; + + let result = backend + .hover(HoverParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: Url::parse("file:///src/Consumer.php").unwrap(), + }, + position: Position { + line: 5, + character: 14, + }, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .await + .unwrap(); + + let hover = result.expect("hover should return a result"); + if let HoverContents::Markup(markup) = hover.contents { + let value = &markup.value; + assert!( + value.contains("macro"), + "hover should show macro origin indicator, got:\n{value}" + ); + assert!( + value.contains("(inferred)"), + "inferred return type should show (inferred) annotation, got:\n{value}" + ); + } else { + panic!("expected HoverContents::Markup"); + } +}