From 54aa321c8119a9c4c9d0d9dcd88ca9ac4184a0ed Mon Sep 17 00:00:00 2001 From: Caleb White Date: Thu, 16 Jul 2026 22:23:15 -0500 Subject: [PATCH] feat: improve laravel macro discovery Discover Laravel macros from provider-rooted helper classes while preserving real vendor package registrations such as Livewire, Inertia, Nightwatch, and Laraflake. Cache cheap macro-token checks to avoid repeated scans, and support statically typed variable macro registrations so patterns like Builder followed by ->macro(...) resolve correctly. --- docs/CHANGELOG.md | 2 +- src/lib.rs | 5 + src/parser/ast_update.rs | 2 + src/server.rs | 160 +++++--- src/util.rs | 8 + src/virtual_members/laravel/macros.rs | 308 +++++++++++++++- src/virtual_members/laravel/macros_tests.rs | 46 +++ src/virtual_members/laravel/mod.rs | 2 +- tests/integration/laravel_macros.rs | 386 ++++++++++++++++++++ 9 files changed, 874 insertions(+), 45 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cbce6a7d..13ba1ba0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -29,7 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Laravel custom Eloquent builder support.** Models using the `#[UseEloquentBuilder]` attribute now have their custom builder's methods forwarded as static methods on the model. `query()`, `newQuery()`, and `newModelQuery()` return the custom builder type with correct generic model substitution. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/118. - **Eloquent relation and column string completion.** Typing inside string arguments to `with()`, `load()`, `whereHas()`, and other Eloquent methods that accept relation names now offers relationship method names as completions, with dot-notation traversal for nested relations. Similarly, `where()`, `orderBy()`, `select()`, `pluck()`, and other column-accepting methods offer model column names (from `$casts`, `$fillable`, `@property` tags, timestamps, etc.). - **Authenticated user resolves to the configured model.** `$request->user()`, `auth()->user()`, and `Auth::user()` now resolve to the Eloquent model declared in `config/auth.php` instead of only the bare `Authenticatable` contract, so completion, hover, and member access work on the concrete model (`$request->user()->email`). Naming a guard selects that guard's model, so `auth('admin')->user()`, `Auth::guard('admin')->user()`, and `$request->user('admin')` resolve to the model configured for the `admin` guard rather than the default one. The config is read statically: only the literal default of `env('AUTH_MODEL', User::class)` is used, never the runtime environment. When a guard, provider, or model could vary at runtime, the result widens to a union of every candidate, and the floor is raised from the abstract contract to the project's own classes that implement it, so a single-model app resolves to just that model while a multi-model app offers each. Members that exist on some candidate resolve; genuinely unknown members still report. -- **Laravel macros are recognized as real methods.** A method registered with `SomeClass::macro('name', fn (...) => ...)`, whether in your own service providers or in an installed package's, now appears in completion on that class, shows the closure's parameters and return type on hover and in signature help, and resolves for member access and chaining. Go-to-definition on a macro call jumps to its `::macro(...)` registration site. Both instance (`$collection->name()`) and static (`SomeClass::name()`) calls work. A macro registered through a facade (`View::macro('extends', ...)`) also attaches to the concrete class the facade resolves to, so an instance call on that class (`$factory->extends()`) resolves as well as the static facade call. +- **Laravel macros are recognized as real methods.** A method registered with `SomeClass::macro('name', fn (...) => ...)`, whether in your own service providers or in an installed package's, now appears in completion on that class, shows the closure's parameters and return type on hover and in signature help, and resolves for member access and chaining. Go-to-definition on a macro call jumps to its `::macro(...)` registration site. Both instance (`$collection->name()`) and static (`SomeClass::name()`) calls work. A macro registered through a facade (`View::macro('extends', ...)`) also attaches to the concrete class the facade resolves to, so an instance call on that class (`$factory->extends()`) resolves as well as the static facade call. Discovery now follows provider-rooted helper classes in both app code and installed packages, including helper classes referenced through `Foo::class`, and also recognizes statically typed variable registrations like `Builder $query` followed by `$query->macro(...)`. - **Container string aliases and global facades resolve.** `resolve('blade.compiler')` and `app('cache')` resolve to the concrete class Laravel binds the string to, so member access on the result completes, navigates, and type-checks. Bare global facade aliases such as `\App` and `\DB` resolve to their facade class without an explicit import. Both alias tables are read by parsing the framework the project actually has installed (never a version-specific list baked into PHPantom), so a name that only a service provider registers stays unresolved rather than being guessed. A project class whose short name collides with a facade alias (e.g. an app's own `Request` in the current namespace) still wins, since the alias table is only consulted after namespace-aware resolution misses. - **`model-property` pseudo-type recognition.** The Larastan `model-property` type no longer triggers "unknown class" diagnostics. It is treated as a string subtype. - **`compact()` strings are linked to local variables.** A string argument to `compact('user')` is now treated as a reference to the matching local variable. Renaming the variable updates the string (and renaming from the string updates the variable and its other uses), find-references includes the string, and go-to-definition on the string jumps to the variable's assignment. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/159. diff --git a/src/lib.rs b/src/lib.rs index 96a3a3c7..f0e582ae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -462,6 +462,8 @@ pub struct Backend { /// when the index holds at least one macro, so the hot class-load path /// skips the lock entirely for the common (no-macro) case. pub(crate) laravel_has_macros: Arc, + /// Memoized `macro(` token presence by URI for Laravel macro discovery. + pub(crate) laravel_macro_token_cache: Arc>>, /// Per-target member completion cache. /// /// Typing `$model->wh...` triggers a completion request for each @@ -882,6 +884,7 @@ impl Backend { virtual_members::laravel::LaravelMacroIndex::default(), )), laravel_has_macros: Arc::new(std::sync::atomic::AtomicBool::new(false)), + laravel_macro_token_cache: Arc::new(RwLock::new(HashMap::new())), member_completion_cache: Arc::new(Mutex::new(HashMap::new())), method_store: Arc::new(RwLock::new(HashMap::new())), gti_index: Arc::new(RwLock::new(HashMap::new())), @@ -976,6 +979,7 @@ impl Backend { virtual_members::laravel::LaravelMacroIndex::default(), )), laravel_has_macros: Arc::new(std::sync::atomic::AtomicBool::new(false)), + laravel_macro_token_cache: Arc::new(RwLock::new(HashMap::new())), member_completion_cache: Arc::new(Mutex::new(HashMap::new())), method_store: Arc::new(RwLock::new(HashMap::new())), gti_index: Arc::new(RwLock::new(HashMap::new())), @@ -1569,6 +1573,7 @@ impl Backend { laravel_aliases: Arc::clone(&self.laravel_aliases), laravel_macros: Arc::clone(&self.laravel_macros), laravel_has_macros: Arc::clone(&self.laravel_has_macros), + laravel_macro_token_cache: Arc::clone(&self.laravel_macro_token_cache), member_completion_cache: Arc::clone(&self.member_completion_cache), method_store: Arc::clone(&self.method_store), gti_index: Arc::clone(&self.gti_index), diff --git a/src/parser/ast_update.rs b/src/parser/ast_update.rs index 42e1879a..75cc4aee 100644 --- a/src/parser/ast_update.rs +++ b/src/parser/ast_update.rs @@ -96,6 +96,8 @@ impl Backend { self.update_ast_inner(&uri_owned, &content_owned) }); + self.laravel_macro_token_cached(uri, content); + // Keep the Laravel macro index coherent with edits to files that // register macros. Cheap no-op for files without a `macro(` call. self.refresh_laravel_macros(uri, content); diff --git a/src/server.rs b/src/server.rs index 7b6c6669..0c188c70 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1890,36 +1890,31 @@ impl Backend { } /// Build the Laravel macro index by scanning the project's own source - /// directories, plus the service-provider files of installed vendor - /// packages, for `Target::macro('name', closure)` registrations. + /// service providers, plus one level of classes they import, for + /// `Target::macro('name', closure)` registrations. /// - /// Project code is walked via the PSR-4 source directories. Vendor macros - /// are recovered from the service providers packages register (via - /// `extra.laravel.providers` in `installed.json`) plus any providers the - /// app registers in `bootstrap/providers.php` / `config/app.php`, rather - /// than re-reading the whole vendor tree. Called once after indexing for - /// Laravel projects. Files are byte-prefiltered for `macro(` so only - /// candidates are parsed. + /// Vendor macros are recovered from the service providers packages register + /// (via `extra.laravel.providers` in `installed.json`) plus any providers + /// the app registers in `bootstrap/providers.php` / `config/app.php`, + /// rather than re-reading the whole vendor tree. Project macros follow the + /// same provider-rooted shape: each provider file is scanned directly and + /// each imported class is scanned as a one-level helper candidate. Called + /// once after indexing for Laravel projects. Files are byte-prefiltered for + /// `macro(` so only candidates are parsed. fn build_laravel_macro_index(&self) { let php_version = Some(*self.php_version.lock()); - let vendor = self.vendor_dir_paths.lock().clone(); - - // Distinct PSR-4 source base directories (project code only). - let mut dirs: Vec = Vec::new(); - for mapping in self.psr4_mappings.read().iter() { - let dir = std::path::PathBuf::from(&mapping.base_path); - if !dirs.contains(&dir) { - dirs.push(dir); - } - } let mut index = crate::virtual_members::laravel::LaravelMacroIndex::default(); + let mut candidate_uris: std::collections::HashSet = + std::collections::HashSet::new(); + let mut provider_uris: Vec = Vec::new(); + let mut imported_uris: Vec = Vec::new(); // Scan a single file's content into the index, keyed by its URI. let scan_content = |index: &mut crate::virtual_members::laravel::LaravelMacroIndex, uri: String, content: &str| { - if memchr::memmem::find(content.as_bytes(), b"macro(").is_none() { + if !self.laravel_macro_token_cached(&uri, content) { return; } let mut regs = @@ -1933,46 +1928,57 @@ impl Backend { index.set_file(uri, regs); }; - for dir in dirs { - if !dir.is_dir() { + // Vendor- and app-registered service providers seed macro discovery. + for fqn in self.laravel_provider_fqns() { + let Some(uri) = self.resolve_class_uri(&fqn) else { continue; + }; + if candidate_uris.insert(uri.clone()) { + provider_uris.push(uri); } - for file in crate::util::collect_php_files(&dir, &vendor) { - let Ok(bytes) = std::fs::read(&file) else { + } + + for uri in &provider_uris { + let Some(content) = self.get_file_content(uri) else { + continue; + }; + scan_content(&mut index, uri.clone(), &content); + + for imported_fqn in + crate::virtual_members::laravel::parse_provider_referenced_classes(&content) + { + let Some(imported_uri) = self.resolve_class_uri(&imported_fqn) else { continue; }; - // Byte pre-filter before the UTF-8 conversion so non-candidate - // files are skipped as cheaply as possible. - if memchr::memmem::find(&bytes, b"macro(").is_none() { + if !self.is_laravel_macro_helper_uri_allowed(uri, &imported_uri) { continue; } - let Ok(content) = String::from_utf8(bytes) else { - continue; - }; - scan_content(&mut index, crate::util::path_to_uri(&file), &content); + if candidate_uris.insert(imported_uri.clone()) { + imported_uris.push(imported_uri); + } } } - // Vendor- and app-registered service providers: scan each provider's - // source file for macro registrations. - for fqn in self.laravel_provider_fqns() { - let Some(uri) = self.resolve_class_uri(&fqn) else { + for uri in &imported_uris { + let Some(content) = self.get_file_content(uri) else { continue; }; - if index.has_uri(&uri) { - continue; // already scanned as project source - } - let Some(content) = self.get_file_content(&uri) else { - continue; - }; - scan_content(&mut index, uri, &content); + scan_content(&mut index, uri.clone(), &content); } index.rebuild(); let has_macros = !index.is_empty(); + let target_count = index.target_fqns().len(); *self.laravel_macros.write() = index; self.laravel_has_macros .store(has_macros, std::sync::atomic::Ordering::Relaxed); + tracing::info!( + "PHPantom: scanned {} Laravel macro candidates ({} providers, {} imported classes), indexed {} macro targets", + candidate_uris.len(), + provider_uris.len(), + imported_uris.len(), + target_count, + ); } /// Collect the FQNs of every Laravel service provider that could register a @@ -2037,6 +2043,10 @@ impl Backend { if !self.resolved_class_cache.read().is_laravel() { return; } + if self.is_laravel_macro_seed_uri(uri) { + self.build_laravel_macro_index(); + return; + } let had = self.laravel_macros.read().has_uri(uri); let has_token = memchr::memmem::find(content.as_bytes(), b"macro(").is_some(); if !had && !has_token { @@ -2067,6 +2077,72 @@ impl Backend { } } + fn is_laravel_macro_seed_uri(&self, uri: &str) -> bool { + if let Some(root) = self.workspace_root.read().clone() { + for rel in ["bootstrap/providers.php", "config/app.php"] { + if crate::util::path_to_uri(&root.join(rel)) == uri { + return true; + } + } + } + + for fqn in self.laravel_provider_fqns() { + if self.resolve_class_uri(&fqn).as_deref() == Some(uri) { + return true; + } + } + + false + } + + fn is_laravel_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; + }; + let Ok(helper_url) = tower_lsp::lsp_types::Url::parse(helper_uri) else { + return false; + }; + let Ok(provider_path) = provider_url.to_file_path() else { + return false; + }; + let Ok(helper_path) = helper_url.to_file_path() else { + return false; + }; + + // Vendor providers may live under the workspace root, so classify + // package-local vendor helpers before the broader app-root check. + if let Some(root) = self.vendor_package_root(&provider_path) { + return helper_path.starts_with(&root); + } + + if let Some(root) = self.workspace_root.read().clone() + && provider_path.starts_with(&root) + { + return helper_path.starts_with(&root) && !self.is_in_vendor_dir(&helper_path); + } + + false + } + + fn vendor_package_root(&self, path: &std::path::Path) -> Option { + for vendor_dir in self.vendor_dir_paths.lock().iter() { + if let Ok(rel) = path.strip_prefix(vendor_dir) + && let mut comps = rel.components() + && let (Some(vendor), Some(package)) = (comps.next(), comps.next()) + { + return Some(vendor_dir.join(vendor).join(package)); + } + } + None + } + + fn is_in_vendor_dir(&self, path: &std::path::Path) -> bool { + self.vendor_dir_paths + .lock() + .iter() + .any(|vendor_dir| path.starts_with(vendor_dir)) + } + /// Initialize a single-project workspace (root `composer.json` exists). /// /// This is the standard fast path: read PSR-4 mappings, build the diff --git a/src/util.rs b/src/util.rs index 38c572dd..662ea942 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1631,6 +1631,14 @@ pub(crate) fn find_brace_match_line( } impl Backend { + pub(crate) fn laravel_macro_token_cached(&self, uri: &str, content: &str) -> bool { + let has_token = memchr::memmem::find(content.as_bytes(), b"macro(").is_some(); + self.laravel_macro_token_cache + .write() + .insert(uri.to_string(), has_token); + has_token + } + /// Look up a class by its (possibly namespace-qualified) name in the /// in-memory `uri_classes_index`, without triggering any disk I/O. /// diff --git a/src/virtual_members/laravel/macros.rs b/src/virtual_members/laravel/macros.rs index cdb34932..5d413d28 100644 --- a/src/virtual_members/laravel/macros.rs +++ b/src/virtual_members/laravel/macros.rs @@ -20,7 +20,7 @@ //! to the concrete class's `__call`, which is the current gracefully //! degraded behaviour. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use bumpalo::Bump; @@ -82,6 +82,13 @@ pub(crate) fn extract_macro_registrations( out.push(reg); } } + collect_instance_macro_registrations( + Node::Program(program), + &owned, + content, + php_version, + &mut out, + ); out } @@ -235,6 +242,184 @@ fn collect_macro_calls<'ast, 'arena>( node.visit_children(|child| collect_macro_calls(child, out)); } +fn collect_instance_macro_registrations( + node: Node<'_, '_>, + resolved: &OwnedResolvedNames, + content: &str, + php_version: Option, + out: &mut Vec, +) { + use mago_syntax::ast::class_like::member::ClassLikeMember; + use mago_syntax::ast::class_like::method::MethodBody; + + match node { + Node::Program(program) => { + for statement in program.statements.iter() { + collect_instance_macro_registrations( + Node::Statement(statement), + resolved, + content, + php_version, + out, + ); + } + } + Node::Statement(Statement::Namespace(namespace)) => { + for statement in namespace.statements().iter() { + collect_instance_macro_registrations( + Node::Statement(statement), + resolved, + content, + php_version, + out, + ); + } + } + Node::Statement(Statement::Function(function)) => collect_instance_macros_in_body( + Node::Block(&function.body), + &typed_parameter_targets(&function.parameter_list, resolved), + content, + php_version, + out, + ), + Node::Class(class) => { + for member in class.members.iter() { + if let ClassLikeMember::Method(method) = member + && let MethodBody::Concrete(body) = &method.body + { + collect_instance_macros_in_body( + Node::Block(body), + &typed_parameter_targets(&method.parameter_list, resolved), + content, + php_version, + out, + ); + } + } + } + Node::Trait(trait_) => { + for member in trait_.members.iter() { + if let ClassLikeMember::Method(method) = member + && let MethodBody::Concrete(body) = &method.body + { + collect_instance_macros_in_body( + Node::Block(body), + &typed_parameter_targets(&method.parameter_list, resolved), + content, + php_version, + out, + ); + } + } + } + Node::Enum(enum_) => { + for member in enum_.members.iter() { + if let ClassLikeMember::Method(method) = member + && let MethodBody::Concrete(body) = &method.body + { + collect_instance_macros_in_body( + Node::Block(body), + &typed_parameter_targets(&method.parameter_list, resolved), + content, + php_version, + out, + ); + } + } + } + _ => node.visit_children(|child| { + collect_instance_macro_registrations(child, resolved, content, php_version, out) + }), + } +} + +fn collect_instance_macros_in_body( + node: Node<'_, '_>, + typed_targets: &HashMap, + content: &str, + php_version: Option, + out: &mut Vec, +) { + if let Node::MethodCall(call) = node + && let ClassLikeMemberSelector::Identifier(ident) = &call.method + && bytes_to_str(ident.value).eq_ignore_ascii_case("macro") + && let Expression::Variable(Variable::Direct(dv)) = call.object + && let Some(target) = typed_targets.get(bytes_to_str(dv.name)) + && let Some(reg) = build_instance_registration(call, target, content, php_version) + { + out.push(reg); + } + node.visit_children(|child| { + collect_instance_macros_in_body(child, typed_targets, content, php_version, out) + }); +} + +fn typed_parameter_targets( + params: &FunctionLikeParameterList<'_>, + resolved: &OwnedResolvedNames, +) -> HashMap { + let mut out = HashMap::new(); + for param in params.parameters.iter() { + let Some(hint) = param.hint.as_ref() else { + continue; + }; + let Some(target) = resolve_hint_target_fqn(hint, resolved) else { + continue; + }; + out.insert(bytes_to_str(param.variable.name).to_string(), target); + } + out +} + +fn resolve_hint_target_fqn(hint: &Hint<'_>, resolved: &OwnedResolvedNames) -> Option { + match hint { + Hint::Identifier(ident) => { + let raw = bytes_to_str(ident.value()); + if matches!( + raw.to_ascii_lowercase().as_str(), + "self" | "static" | "parent" + ) { + return None; + } + let offset = ident.span().start.offset; + resolved + .get(offset) + .map(|fqn| fqn.trim_start_matches('\\').to_string()) + .or_else(|| (!raw.is_empty()).then(|| raw.trim_start_matches('\\').to_string())) + } + Hint::Nullable(nullable) => resolve_hint_target_fqn(nullable.hint, resolved), + Hint::Parenthesized(paren) => resolve_hint_target_fqn(paren.hint, resolved), + _ => None, + } +} + +fn build_instance_registration( + mc: &MethodCall<'_>, + target: &str, + content: &str, + php_version: Option, +) -> Option { + let mut args = mc.argument_list.arguments.iter(); + 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 parameters = + crate::parser::extract_parameters(parameter_list, Some(content), php_version, None); + let return_type = return_type_hint.map(|rth| crate::parser::extract_hint_type(&rth.hint)); + + let mut method = MethodInfo::virtual_method_typed(&name, return_type.as_ref()); + method.parameters = parameters; + method.native_return_type = return_type; + + Some(MacroRegistration { + target: target.to_string(), + method, + name_offset, + }) +} + /// Build a [`MacroRegistration`] from a `Target::macro('name', closure)` call, /// or `None` when the call does not match the supported literal shape. fn build_registration( @@ -369,6 +554,23 @@ pub(crate) fn parse_provider_class_list(content: &str) -> Vec { out } +pub(crate) fn parse_provider_referenced_classes(content: &str) -> Vec { + if !content.contains("::") && !content.contains("new ") { + return Vec::new(); + } + + let arena = Bump::new(); + let file_id = FileId::new(b"input.php"); + let program = parse_file_content(&arena, file_id, content.as_bytes()); + let resolved = NameResolver::new(&arena).resolve(program); + let owned = OwnedResolvedNames::from_resolved(&resolved); + + let mut out = Vec::new(); + let mut seen = HashSet::new(); + collect_provider_method_refs(Node::Program(program), &owned, &mut seen, &mut out); + out +} + /// The value expression of a top-level `return [ 'key' => … ]` array entry. fn find_return_array_entry<'ast, 'arena>( program: &'ast Program<'arena>, @@ -395,6 +597,110 @@ fn find_return_array_entry<'ast, 'arena>( None } +fn collect_provider_method_refs( + node: Node<'_, '_>, + resolved: &OwnedResolvedNames, + seen: &mut HashSet, + out: &mut Vec, +) { + use mago_syntax::ast::class_like::member::ClassLikeMember; + use mago_syntax::ast::class_like::method::MethodBody; + + match node { + Node::Class(class) => { + for member in class.members.iter() { + if let ClassLikeMember::Method(method) = member + && let MethodBody::Concrete(body) = &method.body + { + collect_class_refs(Node::Block(body), resolved, seen, out); + } + } + } + Node::AnonymousClass(class) => { + for member in class.members.iter() { + if let ClassLikeMember::Method(method) = member + && let MethodBody::Concrete(body) = &method.body + { + collect_class_refs(Node::Block(body), resolved, seen, out); + } + } + } + Node::Trait(trait_) => { + for member in trait_.members.iter() { + if let ClassLikeMember::Method(method) = member + && let MethodBody::Concrete(body) = &method.body + { + collect_class_refs(Node::Block(body), resolved, seen, out); + } + } + } + Node::Enum(enum_) => { + for member in enum_.members.iter() { + if let ClassLikeMember::Method(method) = member + && let MethodBody::Concrete(body) = &method.body + { + collect_class_refs(Node::Block(body), resolved, seen, out); + } + } + } + Node::Interface(_) => {} + _ => node.visit_children(|child| collect_provider_method_refs(child, resolved, seen, out)), + } +} + +fn collect_class_refs( + node: Node<'_, '_>, + resolved: &OwnedResolvedNames, + seen: &mut HashSet, + out: &mut Vec, +) { + match node { + Node::StaticMethodCall(call) => push_resolved_expr_fqn(call.class, resolved, seen, out), + Node::ClassConstantAccess(access) + if matches!( + &access.constant, + ClassLikeConstantSelector::Identifier(id) + if bytes_to_str(id.value).eq_ignore_ascii_case("class") + ) => + { + push_resolved_expr_fqn(access.class, resolved, seen, out) + } + _ => node.visit_children(|child| collect_class_refs(child, resolved, seen, out)), + } +} + +fn push_resolved_expr_fqn( + expr: &Expression<'_>, + resolved: &OwnedResolvedNames, + seen: &mut HashSet, + out: &mut Vec, +) { + let Expression::Identifier(ident) = expr else { + return; + }; + let raw = bytes_to_str(ident.value()); + if matches!( + raw.to_ascii_lowercase().as_str(), + "self" | "static" | "parent" + ) { + return; + } + let Some(fqn) = resolved.get(ident.span().start.offset) else { + if raw.is_empty() { + return; + } + let raw = raw.trim_start_matches('\\').to_string(); + if seen.insert(raw.clone()) { + out.push(raw); + } + return; + }; + let fqn = fqn.trim_start_matches('\\').to_string(); + if seen.insert(fqn.clone()) { + out.push(fqn); + } +} + /// Recursively collect the FQN of every `Something::class` constant reachable /// from `node`, resolving short names via the file's `use` statements. /// `self`/`static`/`parent` are skipped (no concrete FQN). diff --git a/src/virtual_members/laravel/macros_tests.rs b/src/virtual_members/laravel/macros_tests.rs index 90e8ae4a..ea55391d 100644 --- a/src/virtual_members/laravel/macros_tests.rs +++ b/src/virtual_members/laravel/macros_tests.rs @@ -230,6 +230,52 @@ fn parse_provider_class_list_empty_without_class_const() { assert!(parse_provider_class_list("macro('withConfidential', function (bool $flag = true): Builder { + return $this; + }); + } +} +"#; + let regs = extract_macro_registrations(content, None); + assert_eq!(regs.len(), 1); + assert_eq!(regs[0].target, "Illuminate\\Database\\Eloquent\\Builder"); + assert_eq!(regs[0].method.name.as_str(), "withConfidential"); +} + #[test] fn index_removes_file_contributions_when_emptied() { let content = r#" + } +} +"; + let (backend, _dir) = create_psr4_workspace( + composer_json, + &[ + ("vendor/illuminate/Support/Collection.php", COLLECTION_PHP), + ( + "bootstrap/providers.php", + " items, + CompletionResponse::List(list) => list.items, + }; + let names: Vec<&str> = items + .iter() + .filter_map(|i| i.filter_text.as_deref()) + .collect(); + + assert!( + names.contains(&"sameNamespaceSum"), + "same-namespace helper reference should be scanned, got: {names:?}" + ); +} + +#[tokio::test] +async fn vendor_provider_same_package_helper_reference_is_scanned() { + let composer_json = r#"{ + "require": { "laravel/framework": "^11.0" }, + "autoload": { + "psr-4": { + "App\\": "src/", + "Illuminate\\Support\\": "vendor/illuminate/Support/" + } + } + }"#; + let installed_json = r#"{"packages": [{ + "name": "acme/pkg", + "version": "1.0.0", + "install-path": "../acme/pkg", + "autoload": {"psr-4": {"Acme\\Pkg\\": ""}}, + "extra": {"laravel": {"providers": ["Acme\\Pkg\\PkgServiceProvider"]}} + }]}"#; + let vendor_provider = "\ +registerMacros(); + } + + protected function registerMacros(): void { + CollectionMacros::boot(); + } +} +"; + let vendor_helper = "\ + + } +} +"; + let (backend, _dir) = create_psr4_workspace( + composer_json, + &[ + ("vendor/illuminate/Support/Collection.php", COLLECTION_PHP), + ("vendor/acme/pkg/PkgServiceProvider.php", vendor_provider), + ("vendor/acme/pkg/Macros/CollectionMacros.php", vendor_helper), + ("vendor/composer/installed.json", installed_json), + ("src/Consumer.php", consumer), + ], + ); + + backend.initialized(InitializedParams {}).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: 12, + }, + }, + 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(&"vendorDelegatedSum"), + "same-package vendor helper reference should be scanned, got: {names:?}" + ); +} + +#[tokio::test] +async fn typed_variable_macro_registration_is_surfaced() { + let composer_json = r#"{ + "require": { "laravel/framework": "^11.0" }, + "autoload": { + "psr-4": { + "App\\": "src/", + "Illuminate\\Database\\Eloquent\\": "vendor/illuminate/Database/Eloquent/" + } + } + }"#; + let builder = "\ +macro('withConfidential', function (bool $withConfidential = true): Builder { + return $this; + }); + } +} +"; + let consumer = "\ + + } +} +"; + let (backend, _dir) = create_psr4_workspace( + composer_json, + &[ + ("vendor/illuminate/Database/Eloquent/Builder.php", builder), + ("src/ConfidentialScope.php", scope), + ("src/Consumer.php", consumer), + ], + ); + + open(&backend, "file:///src/ConfidentialScope.php", scope).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: 16, + }, + }, + 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(&"withConfidential"), + "typed-variable macro registration should be surfaced, got: {names:?}" + ); +} + +#[tokio::test] +async fn provider_imported_macro_helper_is_scanned_without_opening_it() { + let composer_json = r#"{ + "require": { "laravel/framework": "^11.0" }, + "autoload": { + "psr-4": { + "App\\": "src/", + "Illuminate\\Support\\": "vendor/illuminate/Support/" + } + } + }"#; + let provider = "\ + + } +} +"; + let (backend, _dir) = create_psr4_workspace( + composer_json, + &[ + ("vendor/illuminate/Support/Collection.php", COLLECTION_PHP), + ( + "bootstrap/providers.php", + " items, + CompletionResponse::List(list) => list.items, + }; + let names: Vec<&str> = items + .iter() + .filter_map(|i| i.filter_text.as_deref()) + .collect(); + + assert!( + names.contains(&"delegatedSum"), + "provider-imported macro helper should be scanned, got: {names:?}" + ); + assert!( + !names.contains(&"ignoredMacro"), + "unrelated project files should not seed macro discovery, got: {names:?}" + ); +}