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"); + } +}