diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 13ba1ba0..e64c41df 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. 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(...)`. +- **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, landing on the first character of the macro name string. 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(...)`. Find-references and rename now link the registration string with macro call sites in both directions, including chained collection-style calls such as `->pluck(...)->macroName()`, and workspace symbol maps are warmed in the background so repeated workspace-wide rename/reference requests avoid reparsing unopened files. - **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/definition/member/mod.rs b/src/definition/member/mod.rs index 1885bb90..b330e0fa 100644 --- a/src/definition/member/mod.rs +++ b/src/definition/member/mod.rs @@ -654,7 +654,7 @@ impl Backend { (uri.to_string(), offset) }; let content = self.get_file_content(&uri)?; - let position = crate::util::offset_to_position(&content, offset as usize); + let position = crate::util::offset_to_position(&content, offset as usize + 1); let parsed_uri = Url::parse(&uri).ok()?; Some(point_location(parsed_uri, position)) } diff --git a/src/definition/resolve.rs b/src/definition/resolve.rs index a5ff42dd..b0877698 100644 --- a/src/definition/resolve.rs +++ b/src/definition/resolve.rs @@ -331,6 +331,11 @@ impl Backend { if locs.is_empty() { None } else { Some(locs) } } + SymbolKind::LaravelMacroString { .. } => Some(vec![point_location( + Url::parse(uri).ok()?, + crate::util::offset_to_position(content, cursor_offset as usize), + )]), + SymbolKind::Keyword | SymbolKind::CastType | SymbolKind::Comment => None, } } diff --git a/src/definition/type_definition.rs b/src/definition/type_definition.rs index aa90f105..fc959078 100644 --- a/src/definition/type_definition.rs +++ b/src/definition/type_definition.rs @@ -148,6 +148,7 @@ impl Backend { | SymbolKind::ConstantReference { .. } | SymbolKind::NamespaceDeclaration { .. } | SymbolKind::LaravelStringKey { .. } + | SymbolKind::LaravelMacroString { .. } | SymbolKind::Keyword | SymbolKind::CastType | SymbolKind::Comment => { diff --git a/src/highlight/mod.rs b/src/highlight/mod.rs index 57149e8b..0745bc06 100644 --- a/src/highlight/mod.rs +++ b/src/highlight/mod.rs @@ -85,6 +85,7 @@ impl Backend { } SymbolKind::NamespaceDeclaration { .. } | SymbolKind::LaravelStringKey { .. } + | SymbolKind::LaravelMacroString { .. } | SymbolKind::Keyword | SymbolKind::CastType | SymbolKind::Comment => Vec::new(), diff --git a/src/hover/mod.rs b/src/hover/mod.rs index 57962a91..7cf24c88 100644 --- a/src/hover/mod.rs +++ b/src/hover/mod.rs @@ -848,6 +848,7 @@ impl Backend { } SymbolKind::LaravelStringKey { .. } + | SymbolKind::LaravelMacroString { .. } | SymbolKind::Keyword | SymbolKind::CastType | SymbolKind::Comment => None, diff --git a/src/references/mod.rs b/src/references/mod.rs index 62bf9c62..39776908 100644 --- a/src/references/mod.rs +++ b/src/references/mod.rs @@ -204,6 +204,7 @@ impl Backend { subject_text, member_name, is_static, + is_method_call, .. } => { // Resolve the subject to determine the class hierarchy @@ -237,14 +238,47 @@ impl Backend { return self.find_constructor_references(&seeds, include_declaration); } - self.find_member_references( + let mut locations = self.find_member_references( member_name, *is_static, include_declaration, hierarchy.as_ref(), declaration_scope.as_ref(), allow_unresolved_member_subjects, - ) + ); + + if *is_method_call && include_declaration { + let before_len = locations.len(); + let call_position = offset_to_position(content, span_start as usize); + for def in self.resolve_definition(uri, content, call_position) { + let mut start = def.range.start; + let mut end = def.range.end; + if start == end { + let def_uri = def.uri.to_string(); + if let Some(def_content) = self.get_file_content(&def_uri) + && let Some(def_span) = + self.lookup_symbol_at_position(&def_uri, &def_content, start) + { + start = offset_to_position(&def_content, def_span.start as usize); + end = offset_to_position(&def_content, def_span.end as usize); + } + } + push_unique_location(&mut locations, &def.uri, start, end); + } + self.append_laravel_macro_registration_locations( + &mut locations, + member_name, + declaration_scope.as_ref().or(hierarchy.as_ref()), + ); + if locations.len() == before_len { + self.append_unique_laravel_macro_registration_location( + &mut locations, + member_name, + ); + } + } + + locations } SymbolKind::FunctionCall { name, .. } => { let ctx = self.file_context(uri); @@ -322,10 +356,153 @@ impl Backend { ) } + SymbolKind::LaravelMacroString { name } => self.find_laravel_macro_references( + uri, + span_start, + name, + include_declaration, + allow_unresolved_member_subjects, + ), + SymbolKind::Keyword | SymbolKind::CastType | SymbolKind::Comment => Vec::new(), } } + fn find_laravel_macro_references( + &self, + uri: &str, + span_start: u32, + name: &str, + include_declaration: bool, + _allow_unresolved_subjects: bool, + ) -> Vec { + let targets = { + let index = self.laravel_macros.read(); + index.targets_at(uri, span_start, name) + }; + if targets.is_empty() { + return Vec::new(); + } + + let hierarchy = self.collect_hierarchy_for_fqns(&targets); + + let snapshot = self.user_file_symbol_maps(); + let mut locations = Vec::new(); + for (file_uri, symbol_map) in &snapshot { + if symbol_map.member_access_indices(name).is_empty() { + continue; + } + + let Ok(parsed_uri) = Url::parse(file_uri) else { + continue; + }; + let Some(content) = self.get_file_content_arc(file_uri) else { + continue; + }; + let file_ctx = self.file_context(file_uri); + + for &span_idx in symbol_map.member_access_indices(name) { + let span = &symbol_map.spans[span_idx]; + let SymbolKind::MemberAccess { + member_name, + subject_text, + is_static, + .. + } = &span.kind + else { + continue; + }; + if member_name != name { + continue; + } + + let matches_macro = if subject_text.contains('(') { + // Chained call receivers like `$query->pluck(...)->macroName()` + // are expensive to resolve precisely here and are the main + // real-world macro-registration rename case. + true + } else { + let subject_fqns = self.resolve_subject_to_fqns( + subject_text, + *is_static, + &file_ctx, + span.start, + &content, + ); + !subject_fqns.is_empty() + && subject_fqns.iter().any(|fqn| hierarchy.contains(fqn)) + }; + if !matches_macro { + continue; + } + + let start = offset_to_position(&content, span.start as usize); + let end = offset_to_position(&content, span.end as usize); + push_unique_location(&mut locations, &parsed_uri, start, end); + } + } + + if include_declaration { + let macro_scope: HashSet = targets.iter().cloned().collect(); + self.append_laravel_macro_registration_locations( + &mut locations, + name, + Some(¯o_scope), + ); + } + + locations + } + + fn append_laravel_macro_registration_locations( + &self, + locations: &mut Vec, + name: &str, + targets: Option<&HashSet>, + ) { + let Some(targets) = targets else { + return; + }; + let index = self.laravel_macros.read(); + for target in targets { + if !index.has_macro(target, name) { + continue; + } + let Some((uri, offset)) = index.definition(target, name) else { + continue; + }; + let Some(content) = self.get_file_content_arc(uri) else { + continue; + }; + let Ok(parsed_uri) = Url::parse(uri) else { + continue; + }; + let start = offset_to_position(&content, offset as usize + 1); + let end = offset_to_position(&content, offset as usize + 1 + name.len()); + push_unique_location(locations, &parsed_uri, start, end); + } + } + + fn append_unique_laravel_macro_registration_location( + &self, + locations: &mut Vec, + name: &str, + ) { + let index = self.laravel_macros.read(); + let Some((uri, offset)) = index.unique_definition_for_name(name) else { + return; + }; + let Some(content) = self.get_file_content_arc(uri) else { + return; + }; + let Ok(parsed_uri) = Url::parse(uri) else { + return; + }; + let start = offset_to_position(&content, offset as usize + 1); + let end = offset_to_position(&content, offset as usize + 1 + name.len()); + push_unique_location(locations, &parsed_uri, start, end); + } + /// Find all references to a variable within its enclosing scope. /// /// Variables are file-local and scope-local — a `$user` in method A @@ -1107,21 +1284,23 @@ impl Backend { for (file_uri, symbol_map) in &snapshot { // First pass: name-only check to avoid unnecessary work. // When a hierarchy is present (e.g. Laravel), we allow static mismatch. - let has_potential_match = symbol_map.spans.iter().any(|span| match &span.kind { - SymbolKind::MemberAccess { - member_name, - is_static, - .. - } if member_name == target_member => { - hierarchy.is_some() || *is_static == target_is_static - } - SymbolKind::MemberDeclaration { name, is_static } - if include_declaration && name == target_member => - { - hierarchy.is_some() || *is_static == target_is_static - } - _ => false, - }); + let has_member_access_match = symbol_map + .member_access_indices(target_member) + .iter() + .any(|&idx| match &symbol_map.spans[idx].kind { + SymbolKind::MemberAccess { is_static, .. } => { + hierarchy.is_some() || *is_static == target_is_static + } + _ => false, + }); + let has_declaration_match = include_declaration + && symbol_map.spans.iter().any(|span| match &span.kind { + SymbolKind::MemberDeclaration { name, is_static } if name == target_member => { + hierarchy.is_some() || *is_static == target_is_static + } + _ => false, + }); + let has_potential_match = has_member_access_match || has_declaration_match; // Special check for property declarations in ClassInfo (represented as Variable spans) let mut check_ast_map = false; @@ -1160,7 +1339,8 @@ impl Backend { let file_ctx_cell: std::cell::OnceCell = std::cell::OnceCell::new(); - for span in &symbol_map.spans { + for &span_idx in symbol_map.member_access_indices(target_member) { + let span = &symbol_map.spans[span_idx]; match &span.kind { SymbolKind::MemberAccess { subject_text, @@ -1220,56 +1400,61 @@ impl Backend { range: Range { start, end }, }); } - SymbolKind::MemberDeclaration { name, is_static } - if include_declaration && name == target_member => - { - if *is_static != target_is_static && hierarchy.is_none() { - continue; - } + _ => {} + } + } - // Check if the enclosing class is in the hierarchy. - let declaration_filter = if *is_static == target_is_static { - declaration_scope.or(hierarchy) - } else { - hierarchy - }; - if let Some(hier) = declaration_filter { - let ctx = file_ctx_cell.get_or_init(|| self.file_context(file_uri)); - let enclosing = - find_class_at_offset(&ctx.classes, span.start).or_else(|| { - // Docblock MemberDeclaration spans are before the - // opening brace; fall back to the nearest class. - ctx.classes - .iter() - .map(|c| c.as_ref()) - .filter(|c| { - c.keyword_offset > 0 && span.start < c.start_offset - }) - .min_by_key(|c| c.start_offset) - }); - if let Some(enclosing) = enclosing { - let fqn = enclosing.fqn().to_string(); - if !hier.contains(&fqn) { - continue; + if include_declaration { + for span in &symbol_map.spans { + match &span.kind { + SymbolKind::MemberDeclaration { name, is_static } + if name == target_member => + { + if *is_static != target_is_static && hierarchy.is_none() { + continue; + } + + let declaration_filter = if *is_static == target_is_static { + declaration_scope.or(hierarchy) + } else { + hierarchy + }; + if let Some(hier) = declaration_filter { + let ctx = file_ctx_cell.get_or_init(|| self.file_context(file_uri)); + let enclosing = find_class_at_offset(&ctx.classes, span.start) + .or_else(|| { + ctx.classes + .iter() + .map(|c| c.as_ref()) + .filter(|c| { + c.keyword_offset > 0 && span.start < c.start_offset + }) + .min_by_key(|c| c.start_offset) + }); + if let Some(enclosing) = enclosing { + let fqn = enclosing.fqn().to_string(); + if !hier.contains(&fqn) { + continue; + } } } - } - if file_content.is_none() { - file_content = self.get_file_content_arc(file_uri); - } - let Some(ref content) = file_content else { - break; - }; + if file_content.is_none() { + file_content = self.get_file_content_arc(file_uri); + } + let Some(ref content) = file_content else { + break; + }; - let start = offset_to_position(content, span.start as usize); - let end = offset_to_position(content, span.end as usize); - locations.push(Location { - uri: parsed_uri.clone(), - range: Range { start, end }, - }); + let start = offset_to_position(content, span.start as usize); + let end = offset_to_position(content, span.end as usize); + locations.push(Location { + uri: parsed_uri.clone(), + range: Range { start, end }, + }); + } + _ => {} } - _ => {} } } @@ -1562,6 +1747,12 @@ impl Backend { if fqns.is_empty() { return (None, None); } + if let Some(macro_targets) = self.collect_macro_declaring_targets(&fqns, member_name) { + return ( + Some(self.collect_hierarchy_for_fqns(¯o_targets)), + Some(self.collect_macro_declaring_scope(¯o_targets)), + ); + } ( Some(self.collect_hierarchy_for_fqns(&fqns)), self.collect_declaring_seed_scope(&fqns, member_name, is_static), @@ -1824,6 +2015,49 @@ impl Backend { Some(scope) } + fn collect_macro_declaring_targets( + &self, + seed_fqns: &[String], + member_name: &str, + ) -> Option> { + let index = self.laravel_macros.read(); + let mut targets = Vec::new(); + for seed in seed_fqns { + let mut ancestors = HashSet::new(); + let normalized = normalize_fqn(seed).to_string(); + ancestors.insert(normalized.clone()); + let class_loader = + |name: &str| -> Option> { self.find_or_load_class(name) }; + self.collect_ancestors(&normalized, &class_loader, &mut ancestors); + for candidate in ancestors { + if index.has_macro(&candidate, member_name) && !targets.contains(&candidate) { + targets.push(candidate); + } + } + } + (!targets.is_empty()).then_some(targets) + } + + fn collect_macro_declaring_scope(&self, macro_targets: &[String]) -> HashSet { + let mut scope: HashSet = macro_targets + .iter() + .map(|fqn| normalize_fqn(fqn).to_string()) + .collect(); + let mut queue: std::collections::VecDeque = scope.iter().cloned().collect(); + let gti = self.gti_index.read(); + while let Some(fqn) = queue.pop_front() { + if let Some(descendants) = gti.get(&fqn) { + for desc in descendants { + let normalized = normalize_fqn(desc).to_string(); + if scope.insert(normalized.clone()) { + queue.push_back(normalized); + } + } + } + } + scope + } + fn defines_member( &self, fqn: &str, @@ -1964,10 +2198,6 @@ impl Backend { // files that are not open in the editor can still be discovered. // The existing-URI filter below keeps this cheap by parsing only files // that are not already in `symbol_maps`. - let has_scanned_workspace = self - .workspace_indexed - .load(std::sync::atomic::Ordering::Relaxed); - let workspace_root = self.workspace_root.read().clone(); if let Some(root) = workspace_root { @@ -1979,12 +2209,7 @@ impl Backend { let walk_start = std::time::Instant::now(); let php_files = collect_php_files_gitignore(&root, &vendor_dir_paths); tracing::info!( - "ensure_workspace_indexed: Phase 2 {} found {} PHP files in {:?}", - if has_scanned_workspace { - "refresh disk walk" - } else { - "disk walk" - }, + "ensure_workspace_indexed: Phase 2 disk walk found {} PHP files in {:?}", php_files.len(), walk_start.elapsed() ); diff --git a/src/references/tests.rs b/src/references/tests.rs index ba1d62b5..f2a3f159 100644 --- a/src/references/tests.rs +++ b/src/references/tests.rs @@ -1,4 +1,6 @@ use crate::Backend; +use crate::virtual_members::laravel::extract_macro_registrations; +use std::sync::atomic::Ordering; use tower_lsp::LanguageServer; use tower_lsp::lsp_types::*; @@ -42,6 +44,27 @@ async fn find_references( .unwrap_or_default() } +fn seed_macro_index(backend: &Backend, uri: &Url, text: &str) { + let mut index = backend.laravel_macros.write(); + index.set_file( + uri.to_string(), + extract_macro_registrations(text, Some(*backend.php_version.lock())), + ); + index.rebuild(); + backend + .laravel_has_macros + .store(!index.is_empty(), Ordering::Relaxed); +} + +fn line_char_of(haystack: &str, needle: &str) -> (u32, u32) { + for (line_idx, line) in haystack.lines().enumerate() { + if let Some(char_idx) = line.find(needle) { + return (line_idx as u32, char_idx as u32); + } + } + panic!("needle not found: {needle}"); +} + // ─── Variable References ──────────────────────────────────────────────────── #[tokio::test] @@ -1858,3 +1881,170 @@ async fn test_constructor_references_finds_attribute_usage() { locs ); } + +#[tokio::test] +async fn test_macro_registration_string_references_include_call_sites() { + let backend = Backend::new_test(); + let class_uri = Url::parse("file:///Widget.php").unwrap(); + let provider_uri = Url::parse("file:///Provider.php").unwrap(); + let caller_uri = Url::parse("file:///Caller.php").unwrap(); + + let class_text = concat!("shine();\n", + "}\n", + ); + + open_file(&backend, &class_uri, class_text).await; + open_file(&backend, &provider_uri, provider_text).await; + open_file(&backend, &caller_uri, caller_text).await; + seed_macro_index(&backend, &provider_uri, provider_text); + + let (line, character) = line_char_of(provider_text, "shine"); + let locs = find_references(&backend, &provider_uri, line, character, true).await; + assert_eq!( + locs.len(), + 3, + "expected declaration + static + instance call: {locs:?}" + ); + assert!(locs.iter().any(|loc| loc.uri == provider_uri)); + assert!(locs.iter().any(|loc| { + loc.uri == caller_uri && loc.range.start.line == 4 && loc.range.start.character == 12 + })); + assert!(locs.iter().any(|loc| { + loc.uri == caller_uri && loc.range.start.line == 5 && loc.range.start.character == 13 + })); +} + +#[tokio::test] +async fn test_macro_references_from_descendant_call_include_ancestor_and_sibling_calls() { + let backend = Backend::new_test(); + let base_uri = Url::parse("file:///BaseCollection.php").unwrap(); + let child_uri = Url::parse("file:///EloquentCollection.php").unwrap(); + let provider_uri = Url::parse("file:///Provider.php").unwrap(); + let caller_uri = Url::parse("file:///Caller.php").unwrap(); + + let base_text = concat!( + "shine();\n", + " $eloquent->shine();\n", + "}\n", + ); + + open_file(&backend, &base_uri, base_text).await; + open_file(&backend, &child_uri, child_text).await; + open_file(&backend, &provider_uri, provider_text).await; + open_file(&backend, &caller_uri, caller_text).await; + seed_macro_index(&backend, &provider_uri, provider_text); + + let locs = find_references(&backend, &caller_uri, 6, 16, true).await; + assert_eq!( + locs.len(), + 3, + "expected registration + base + descendant call: {locs:?}" + ); + assert!(locs.iter().any(|loc| loc.uri == provider_uri)); + assert!(locs.iter().any(|loc| { + loc.uri == caller_uri && loc.range.start.line == 5 && loc.range.start.character == 11 + })); + assert!(locs.iter().any(|loc| { + loc.uri == caller_uri && loc.range.start.line == 6 && loc.range.start.character == 15 + })); +} + +#[tokio::test] +async fn test_macro_registration_references_include_unresolved_chain_call() { + let backend = Backend::new_test(); + let base_uri = Url::parse("file:///BaseCollection.php").unwrap(); + let provider_uri = Url::parse("file:///Provider.php").unwrap(); + let caller_uri = Url::parse("file:///Caller.php").unwrap(); + + let base_text = concat!( + "pluck('name', 'id')->shine();\n", + "}\n", + ); + + open_file(&backend, &base_uri, base_text).await; + open_file(&backend, &provider_uri, provider_text).await; + open_file(&backend, &caller_uri, caller_text).await; + seed_macro_index(&backend, &provider_uri, provider_text); + + let shine_subject = backend + .symbol_maps + .read() + .get(caller_uri.as_str()) + .expect("caller symbol map should exist") + .spans + .iter() + .find_map(|span| match &span.kind { + crate::symbol_map::SymbolKind::MemberAccess { + member_name, + subject_text, + .. + } if member_name == "shine" => Some(subject_text.clone()), + _ => None, + }) + .expect("expected member-access span for unresolved chain call"); + assert!( + shine_subject.contains("pluck"), + "expected chain subject text, got {shine_subject:?}" + ); + assert!( + shine_subject.contains('('), + "expected call-chain subject text, got {shine_subject:?}" + ); + + let (line, character) = line_char_of(provider_text, "shine"); + let locs = find_references(&backend, &provider_uri, line, character, true).await; + assert!( + locs.iter().any(|loc| { + loc.uri == caller_uri && loc.range.start.line == 3 && loc.range.start.character == 33 + }), + "expected unresolved chain macro call to be included: {locs:?}" + ); +} diff --git a/src/rename/mod.rs b/src/rename/mod.rs index da0badb9..51ff3957 100644 --- a/src/rename/mod.rs +++ b/src/rename/mod.rs @@ -805,6 +805,7 @@ impl Backend { SymbolKind::FunctionCall { name, .. } => Some((name.clone(), range)), SymbolKind::ConstantReference { name } => Some((name.clone(), range)), SymbolKind::NamespaceDeclaration { name } => Some((name.clone(), range)), + SymbolKind::LaravelMacroString { name } => Some((name.clone(), range)), SymbolKind::SelfStaticParent { .. } => None, SymbolKind::LaravelStringKey { .. } | SymbolKind::Keyword diff --git a/src/rename/tests.rs b/src/rename/tests.rs index 9419d637..e43a4f21 100644 --- a/src/rename/tests.rs +++ b/src/rename/tests.rs @@ -1,6 +1,8 @@ #![cfg(test)] use crate::Backend; +use crate::virtual_members::laravel::extract_macro_registrations; +use std::sync::atomic::Ordering; use tower_lsp::LanguageServer; use tower_lsp::lsp_types::*; @@ -52,6 +54,27 @@ async fn rename( backend.rename(params).await.unwrap() } +fn seed_macro_index(backend: &Backend, uri: &Url, text: &str) { + let mut index = backend.laravel_macros.write(); + index.set_file( + uri.to_string(), + extract_macro_registrations(text, Some(*backend.php_version.lock())), + ); + index.rebuild(); + backend + .laravel_has_macros + .store(!index.is_empty(), Ordering::Relaxed); +} + +fn line_char_of(haystack: &str, needle: &str) -> (u32, u32) { + for (line_idx, line) in haystack.lines().enumerate() { + if let Some(char_idx) = line.find(needle) { + return (line_idx as u32, char_idx as u32); + } + } + panic!("needle not found: {needle}"); +} + /// Collect all text edits for a given URI from a WorkspaceEdit. fn edits_for_uri(edit: &WorkspaceEdit, uri: &Url) -> Vec { edit.changes @@ -4053,3 +4076,245 @@ async fn rename_class_move_updates_cross_file_usage() { result_usage ); } + +#[tokio::test] +async fn rename_macro_registration_string_updates_call_sites() { + let backend = Backend::new_test(); + let class_uri = Url::parse("file:///Widget.php").unwrap(); + let provider_uri = Url::parse("file:///Provider.php").unwrap(); + let caller_uri = Url::parse("file:///Caller.php").unwrap(); + + let class_text = concat!("shine();\n", + "}\n", + ); + + open_file(&backend, &class_uri, class_text).await; + open_file(&backend, &provider_uri, provider_text).await; + open_file(&backend, &caller_uri, caller_text).await; + seed_macro_index(&backend, &provider_uri, provider_text); + + let (line, character) = line_char_of(provider_text, "shine"); + let edit = rename(&backend, &provider_uri, line, character, "glow").await; + assert!(edit.is_some(), "expected macro rename edit"); + let edit = edit.unwrap(); + + let provider_result = apply_edits(provider_text, &edits_for_uri(&edit, &provider_uri)); + assert!(provider_result.contains("macro('glow'")); + + let caller_result = apply_edits(caller_text, &edits_for_uri(&edit, &caller_uri)); + assert!(caller_result.contains("Widget::glow();"), "{caller_result}"); + assert!( + caller_result.contains("$widget->glow();"), + "{caller_result}" + ); +} + +#[tokio::test] +async fn rename_macro_call_site_updates_registration_string() { + let backend = Backend::new_test(); + let class_uri = Url::parse("file:///Widget.php").unwrap(); + let provider_uri = Url::parse("file:///Provider.php").unwrap(); + let caller_uri = Url::parse("file:///Caller.php").unwrap(); + + let class_text = concat!("shine();\n", + "}\n", + ); + + open_file(&backend, &class_uri, class_text).await; + open_file(&backend, &provider_uri, provider_text).await; + open_file(&backend, &caller_uri, caller_text).await; + seed_macro_index(&backend, &provider_uri, provider_text); + + let edit = rename(&backend, &caller_uri, 4, 14, "glow").await; + assert!(edit.is_some(), "expected macro rename edit"); + let edit = edit.unwrap(); + + let provider_result = apply_edits(provider_text, &edits_for_uri(&edit, &provider_uri)); + assert!( + provider_result.contains("macro('glow'"), + "{provider_result}" + ); + + let caller_result = apply_edits(caller_text, &edits_for_uri(&edit, &caller_uri)); + assert!(caller_result.contains("Widget::glow();"), "{caller_result}"); + assert!( + caller_result.contains("$widget->glow();"), + "{caller_result}" + ); +} + +#[tokio::test] +async fn rename_macro_from_descendant_call_updates_ancestor_and_sibling_calls() { + let backend = Backend::new_test(); + let base_uri = Url::parse("file:///BaseCollection.php").unwrap(); + let child_uri = Url::parse("file:///EloquentCollection.php").unwrap(); + let provider_uri = Url::parse("file:///Provider.php").unwrap(); + let caller_uri = Url::parse("file:///Caller.php").unwrap(); + + let base_text = concat!( + "shine();\n", + " $eloquent->shine();\n", + "}\n", + ); + + open_file(&backend, &base_uri, base_text).await; + open_file(&backend, &child_uri, child_text).await; + open_file(&backend, &provider_uri, provider_text).await; + open_file(&backend, &caller_uri, caller_text).await; + seed_macro_index(&backend, &provider_uri, provider_text); + + let edit = rename(&backend, &caller_uri, 6, 16, "glow").await; + assert!(edit.is_some(), "expected descendant macro rename edit"); + let edit = edit.unwrap(); + + let provider_result = apply_edits(provider_text, &edits_for_uri(&edit, &provider_uri)); + assert!( + provider_result.contains("macro('glow'"), + "{provider_result}" + ); + + let caller_result = apply_edits(caller_text, &edits_for_uri(&edit, &caller_uri)); + assert!(caller_result.contains("$base->glow();"), "{caller_result}"); + assert!( + caller_result.contains("$eloquent->glow();"), + "{caller_result}" + ); +} + +#[tokio::test] +async fn rename_macro_registration_string_updates_unresolved_chain_call() { + let backend = Backend::new_test(); + let base_uri = Url::parse("file:///BaseCollection.php").unwrap(); + let provider_uri = Url::parse("file:///Provider.php").unwrap(); + let caller_uri = Url::parse("file:///Caller.php").unwrap(); + + let base_text = concat!( + "pluck('name', 'id')->shine();\n", + "}\n", + ); + + open_file(&backend, &base_uri, base_text).await; + open_file(&backend, &provider_uri, provider_text).await; + open_file(&backend, &caller_uri, caller_text).await; + seed_macro_index(&backend, &provider_uri, provider_text); + + let (line, character) = line_char_of(provider_text, "shine"); + let edit = rename(&backend, &provider_uri, line, character, "glow").await; + assert!(edit.is_some(), "expected macro rename edit"); + let edit = edit.unwrap(); + + let caller_result = apply_edits(caller_text, &edits_for_uri(&edit, &caller_uri)); + assert!( + caller_result.contains("$query->pluck('name', 'id')->glow();"), + "{caller_result}" + ); +} + +#[tokio::test] +async fn rename_macro_chain_call_updates_registration_string() { + let backend = Backend::new_test(); + let base_uri = Url::parse("file:///BaseCollection.php").unwrap(); + let provider_uri = Url::parse("file:///Provider.php").unwrap(); + let caller_uri = Url::parse("file:///Caller.php").unwrap(); + + let base_text = concat!( + "pluck('name', 'id')->shine();\n", + "}\n", + ); + + open_file(&backend, &base_uri, base_text).await; + open_file(&backend, &provider_uri, provider_text).await; + open_file(&backend, &caller_uri, caller_text).await; + seed_macro_index(&backend, &provider_uri, provider_text); + + let edit = rename(&backend, &caller_uri, 3, 33, "glow").await; + assert!(edit.is_some(), "expected macro chain rename edit"); + let edit = edit.unwrap(); + + let provider_result = apply_edits(provider_text, &edits_for_uri(&edit, &provider_uri)); + assert!( + provider_result.contains("macro('glow'"), + "{provider_result}" + ); + + let caller_result = apply_edits(caller_text, &edits_for_uri(&edit, &caller_uri)); + assert!( + caller_result.contains("$query->pluck('name', 'id')->glow();"), + "{caller_result}" + ); +} diff --git a/src/semantic_tokens.rs b/src/semantic_tokens.rs index 3473b747..a661ed90 100644 --- a/src/semantic_tokens.rs +++ b/src/semantic_tokens.rs @@ -391,7 +391,9 @@ impl Backend { SymbolKind::Comment => (TT_COMMENT, 0), - SymbolKind::LaravelStringKey { .. } => continue, + SymbolKind::LaravelStringKey { .. } | SymbolKind::LaravelMacroString { .. } => { + continue; + } }; if let Some(abs) = offset_to_absolute( diff --git a/src/server.rs b/src/server.rs index 0c188c70..21aa32d2 100644 --- a/src/server.rs +++ b/src/server.rs @@ -375,6 +375,18 @@ impl LanguageServer for Backend { .await; } + // Build workspace symbol maps in the background so the first + // workspace-wide references/rename request does not have to pay for + // parsing every unopened file interactively. Skip this in headless + // test backends (no client) to keep integration tests deterministic. + if self.client.is_some() { + let backend = self.clone_for_blocking(); + tokio::spawn(async move { + let _ = + tokio::task::spawn_blocking(move || backend.ensure_workspace_indexed()).await; + }); + } + // Spawn the background diagnostic worker. We build a shallow // clone of `self` that shares every `Arc`-wrapped field (maps, // caches, the diagnostic notify/pending slot) so the worker diff --git a/src/symbol_map/extraction.rs b/src/symbol_map/extraction.rs index 2f390732..78da57b1 100644 --- a/src/symbol_map/extraction.rs +++ b/src/symbol_map/extraction.rs @@ -244,8 +244,20 @@ pub(crate) fn extract_symbol_map(program: &Program<'_>, content: &str) -> Symbol ctx.switch_scopes.sort_by_key(|s| s.0); ctx.static_method_scopes.sort_by_key(|s| s.0); + let mut member_access_indices: std::collections::HashMap> = + std::collections::HashMap::new(); + for (idx, span) in ctx.spans.iter().enumerate() { + if let SymbolKind::MemberAccess { member_name, .. } = &span.kind { + member_access_indices + .entry(member_name.clone()) + .or_default() + .push(idx); + } + } + SymbolMap { spans: ctx.spans, + member_access_indices, var_defs: ctx.var_defs, scopes: ctx.scopes, arrow_fn_scopes: ctx.arrow_fn_scopes, @@ -2004,6 +2016,13 @@ fn extract_from_expression<'a>( if let ClassLikeMemberSelector::Identifier(ident) = &method_call.method { let member_name = bytes_to_str(ident.value).to_string(); + if member_name.eq_ignore_ascii_case("macro") { + try_emit_laravel_macro_string_span( + &method_call.argument_list, + ctx.content, + &mut ctx.spans, + ); + } if is_laravel_config_repository_call(method_call.object, &member_name) { try_emit_laravel_string_span( crate::symbol_map::LaravelStringKind::Config, @@ -2094,6 +2113,13 @@ fn extract_from_expression<'a>( if let ClassLikeMemberSelector::Identifier(ident) = &static_call.method { let member_name = bytes_to_str(ident.value).to_string(); + if member_name.eq_ignore_ascii_case("macro") { + try_emit_laravel_macro_string_span( + &static_call.argument_list, + ctx.content, + &mut ctx.spans, + ); + } // Emit call site for static method call: `Class::method(...)` emit_call_site( format!("{}::{}", subject_text, member_name), @@ -3467,6 +3493,37 @@ fn try_emit_laravel_string_span( }); } +/// If `argument_list` starts with a plain, non-empty string literal, push a +/// [`SymbolKind::LaravelMacroString`] span covering the string content. +fn try_emit_laravel_macro_string_span( + argument_list: &ArgumentList<'_>, + content: &str, + spans: &mut Vec, +) { + let Some(first_arg) = argument_list.arguments.iter().next() else { + return; + }; + let Expression::Literal(literal::Literal::String(s)) = first_arg.value() else { + return; + }; + let inner_start = s.span.start.offset + 1; + let inner_end = s.span.end.offset - 1; + if inner_start >= inner_end || inner_end as usize > content.len() { + return; + } + let name = &content[inner_start as usize..inner_end as usize]; + if name.is_empty() { + return; + } + spans.push(SymbolSpan { + start: inner_start, + end: inner_end, + kind: SymbolKind::LaravelMacroString { + name: name.to_string(), + }, + }); +} + /// Detect an array-callable literal — `[Class::class, 'method']` or /// `[$object, 'method']` — and emit a [`SymbolKind::MemberAccess`] span over /// the method-name string so that go-to-definition, references, and rename diff --git a/src/symbol_map/mod.rs b/src/symbol_map/mod.rs index c4f57de8..1c1e25d0 100644 --- a/src/symbol_map/mod.rs +++ b/src/symbol_map/mod.rs @@ -28,6 +28,8 @@ pub(crate) mod docblock; mod extraction; +use std::collections::HashMap; + use crate::php_type::PhpType; // Re-export the public entry point from extraction. @@ -241,6 +243,16 @@ pub(crate) enum SymbolKind { /// The key value, e.g. `"app.name"` or `"users.index"`. key: String, }, + + /// The string-literal name argument inside a Laravel `::macro('name', ...)` + /// registration. + /// + /// The span covers the string content inside the quotes so find-references + /// and rename can link the registration site with macro call sites. + LaravelMacroString { + /// Macro method name, e.g. `"sumPrices"`. + name: String, + }, } /// Identifies the category of a [`SymbolKind::LaravelStringKey`] span. @@ -460,6 +472,11 @@ pub(crate) enum VarDefKind { #[derive(Debug, Clone, Default)] pub(crate) struct SymbolMap { pub spans: Vec, + /// Member-access span indices keyed by member name. + /// + /// This lets references/rename jump straight to relevant `->name` / + /// `::name` spans without rescanning the full `spans` vec for each file. + pub member_access_indices: HashMap>, /// Variable definition sites, sorted by `(scope_start, offset)`. pub var_defs: Vec, /// Scope boundaries `(start_offset, end_offset)` for functions, @@ -539,6 +556,14 @@ pub(crate) struct SymbolMap { } impl SymbolMap { + /// Indices into [`Self::spans`] for member accesses named `name`. + pub fn member_access_indices(&self, name: &str) -> &[usize] { + self.member_access_indices + .get(name) + .map(Vec::as_slice) + .unwrap_or(&[]) + } + /// Find the symbol span (if any) that contains `offset`. /// /// Uses binary search on the sorted `spans` vec. Returns `None` diff --git a/src/virtual_members/laravel/macros.rs b/src/virtual_members/laravel/macros.rs index 5d413d28..0d7c334e 100644 --- a/src/virtual_members/laravel/macros.rs +++ b/src/virtual_members/laravel/macros.rs @@ -110,6 +110,9 @@ pub(crate) struct LaravelMacroIndex { /// jumps to the registration call site rather than the target class's own /// file (where the macro has no declaration). locations: HashMap<(String, String), (String, u32)>, + /// Reverse lookup from a registration string location back to the target + /// FQN(s) and macro name it contributes. + reverse_locations: HashMap<(String, u32), Vec<(String, String)>>, } impl LaravelMacroIndex { @@ -153,6 +156,47 @@ impl LaravelMacroIndex { .map(|(uri, offset)| (uri.as_str(), *offset)) } + /// All macro targets contributed by the registration string at `uri` + + /// `offset`, filtered to the given `name`. + pub(crate) fn targets_at(&self, uri: &str, offset: u32, name: &str) -> Vec { + [offset, offset.saturating_sub(1)] + .into_iter() + .find_map(|candidate| { + self.reverse_locations + .get(&(uri.to_string(), candidate)) + .map(|entries| { + entries + .iter() + .filter(|(_, entry_name)| entry_name == name) + .map(|(target, _)| target.clone()) + .collect::>() + }) + }) + .unwrap_or_default() + } + + /// Whether `fqn` has a recognized macro registration named `name`. + pub(crate) fn has_macro(&self, fqn: &str, name: &str) -> bool { + self.locations + .contains_key(&(fqn.to_string(), name.to_string())) + } + + /// The sole registration location for `name`, if exactly one target in the + /// index contributes that macro name. + pub(crate) fn unique_definition_for_name(&self, name: &str) -> Option<(&str, u32)> { + let mut matches = self + .locations + .iter() + .filter_map(|((_, macro_name), (uri, offset))| { + (macro_name == name).then_some((uri.as_str(), *offset)) + }); + let first = matches.next()?; + if matches.next().is_some() { + return None; + } + Some(first) + } + /// Every class FQN that has at least one macro (used to evict stale /// resolved-class cache entries when the index changes). pub(crate) fn target_fqns(&self) -> Vec { @@ -165,6 +209,7 @@ impl LaravelMacroIndex { fn rebuild_merged(&mut self) { let mut merged: HashMap>> = HashMap::new(); let mut locations: HashMap<(String, String), (String, u32)> = HashMap::new(); + let mut reverse_locations: HashMap<(String, u32), Vec<(String, String)>> = HashMap::new(); for (uri, regs) in self.by_uri.iter() { for reg in regs { let bucket = merged.entry(reg.target.clone()).or_default(); @@ -188,10 +233,15 @@ impl LaravelMacroIndex { .entry((reg.target.clone(), reg.method.name.to_string())) .or_insert_with(|| (uri.clone(), reg.name_offset)); } + reverse_locations + .entry((uri.clone(), reg.name_offset)) + .or_default() + .push((reg.target.clone(), reg.method.name.to_string())); } } self.merged = merged; self.locations = locations; + self.reverse_locations = reverse_locations; } } diff --git a/tests/integration/laravel_macros.rs b/tests/integration/laravel_macros.rs index 74e9a7ad..b046e5fd 100644 --- a/tests/integration/laravel_macros.rs +++ b/tests/integration/laravel_macros.rs @@ -205,7 +205,7 @@ class Consumer { assert_eq!(location.uri, Url::parse(provider_uri).unwrap()); // The `Collection::macro('sumField', ...)` line in PROVIDER_PHP. assert_eq!(location.range.start.line, 5); - assert_eq!(location.range.start.character, 26); + assert_eq!(location.range.start.character, 27); } other => panic!("expected a scalar location, got: {other:?}"), }