diff --git a/Cargo.toml b/Cargo.toml index 855d8bc9..14a15031 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,3 +59,11 @@ harness = false [[bench]] name = "references" harness = false + +[[bench]] +name = "laravel_completion" +harness = false + +[[bench]] +name = "custom_builder" +harness = false diff --git a/benches/custom_builder.rs b/benches/custom_builder.rs new file mode 100644 index 00000000..6a319a49 --- /dev/null +++ b/benches/custom_builder.rs @@ -0,0 +1,121 @@ +use criterion::{Criterion, black_box, criterion_group, criterion_main}; +use phpantom_lsp::Backend; +use std::collections::HashMap; +use tower_lsp::LanguageServer; +use tower_lsp::lsp_types::*; + +fn rt() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() +} + +fn leak_str(s: String) -> &'static str { + Box::leak(s.into_boxed_str()) +} + +async fn setup_laravel_backend() -> Backend { + let mut stubs = HashMap::new(); + stubs.insert("Illuminate\\Database\\Eloquent\\Model", " Url { + let uri = Url::parse(uri_str).unwrap(); + let params = DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "php".to_string(), + version: 1, + text: content.to_string(), + }, + }; + backend.did_open(params).await; + uri +} + +fn generate_source() -> String { + r#" $query */ +$query->wher +"# + .to_string() +} + +fn bench_custom_builder_completion(c: &mut Criterion) { + let runtime = rt(); + let backend = runtime.block_on(setup_laravel_backend()); + let source = generate_source(); + let uri = runtime.block_on(open_file(&backend, "file:///app/Models/User.php", &source)); + let lines: Vec<&str> = source.lines().collect(); + let line = lines.len() as u32 - 1; + let last_line = lines.last().unwrap(); + let col = last_line.len() as u32; + + let mut group = c.benchmark_group("custom_builder_completion"); + + group.bench_function("custom_builder_deep_inheritance", |b| { + b.iter(|| { + runtime.block_on(async { + backend.clear_completion_cache(); + let params = CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position: Position { + line, + character: col, + }, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: Some(CompletionContext { + trigger_kind: CompletionTriggerKind::INVOKED, + trigger_character: None, + }), + }; + let _ = black_box(backend.completion(params).await); + }) + }) + }); + + group.finish(); +} + +criterion_group!(benches, bench_custom_builder_completion); +criterion_main!(benches); diff --git a/benches/laravel_completion.rs b/benches/laravel_completion.rs new file mode 100644 index 00000000..adc469ef --- /dev/null +++ b/benches/laravel_completion.rs @@ -0,0 +1,119 @@ +use criterion::{Criterion, black_box, criterion_group, criterion_main}; +use phpantom_lsp::Backend; +use std::collections::HashMap; +use tower_lsp::LanguageServer; +use tower_lsp::lsp_types::*; + +fn rt() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() +} + +async fn setup_laravel_backend() -> Backend { + let mut stubs = HashMap::new(); + stubs.insert("Illuminate\\Database\\Eloquent\\Model", " Url { + let uri = Url::parse(uri_str).unwrap(); + let params = DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "php".to_string(), + version: 1, + text: content.to_string(), + }, + }; + backend.did_open(params).await; + uri +} + +fn generate_laravel_model_source() -> String { + r#"wher +"# + .to_string() +} + +fn bench_laravel_model_completion(c: &mut Criterion) { + let runtime = rt(); + let backend = runtime.block_on(setup_laravel_backend()); + let source = generate_laravel_model_source(); + let uri = runtime.block_on(open_file(&backend, "file:///app/Models/User.php", &source)); + let lines: Vec<&str> = source.lines().collect(); + let line = lines.len() as u32 - 1; + let last_line = lines.last().unwrap(); + let col = last_line.len() as u32; // After 'wher' + + let mut group = c.benchmark_group("laravel_completion"); + + group.bench_function("model_where_prefix", |b| { + b.iter(|| { + runtime.block_on(async { + let params = CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position: Position { + line, + character: col, + }, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: Some(CompletionContext { + trigger_kind: CompletionTriggerKind::INVOKED, + trigger_character: None, + }), + }; + let _ = black_box(backend.completion(params).await); + }) + }) + }); + + // Simulate typing: Model::w -> Model::wh -> Model::whe -> Model::wher + group.bench_function("model_typing_sequence", |b| { + b.iter(|| { + runtime.block_on(async { + for i in 1..=4 { + let current_col = col - 4 + i; + let params = CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position: Position { + line, + character: current_col, + }, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: Some(CompletionContext { + trigger_kind: CompletionTriggerKind::INVOKED, + trigger_character: None, + }), + }; + let _ = black_box(backend.completion(params).await); + } + }) + }) + }); + + group.finish(); +} + +criterion_group!(benches, bench_laravel_model_completion); +criterion_main!(benches); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3b0f0e7e..16c6983d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,12 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **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. + ### Fixed - **`throw new` completion no longer offers non-instantiable types.** Interfaces, abstract classes, traits, and enums are now filtered out, matching the behavior of `new` completion. The `throw new` path also now filters to Throwable descendants only. - **Unified class name completion architecture.** `throw new` and `catch()` completion now use the same `build_class_name_completions` pipeline as `new`, `extends`, `implements`, etc. `throw new` uses a `ThrowNew` context (instantiable + Throwable) and `catch()`/`@throws` uses a `Catch` context (class or interface + Throwable). This gives both contexts the same affinity scoring, FQN shortening via use-map, namespace segment drill-down, deprecation flags, and consistent filtering. The separate `build_catch_class_name_completions` function has been removed. - **Consolidated class completion passes.** The previous 5-pass architecture (use-map, same-namespace, fqn_uri_index, fqn_uri_index duplicate, stub_index) has been simplified to 2 passes (fqn_uri_index + stub_index) with an inline `classify` closure that determines tier (`'0'` use-imported, `'1'` same/sub-namespace, `'2'` everything else) per candidate. The redundant pass 4 (identical to pass 3) is eliminated, and tier assignment is now based on proximity checks rather than which data source produced the item. +### Changed + +- **Improved LSP responsiveness.** File parsing (`update_ast`) and diagnostics now run in background tasks, preventing interactive requests (completion, hover) from being blocked by full-file parses during typing. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/118. +- **Member completion caching.** Unfiltered member lists are cached per-target to speed up subsequent completions during keyword entry. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/118. +- **Laravel startup performance.** Common Laravel builder classes are warmed in the background at startup to eliminate the first-access penalty on Eloquent completions. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/118. + ## [0.8.0] - 2026-05-14 ### Added diff --git a/docs/todo.md b/docs/todo.md index b04a108a..4a02dee4 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -29,8 +29,8 @@ within the same impact tier. > **Note:** F1 (Workspace symbol search), F2 (Document symbols), A8 > (Implement interface methods), A9 (Auto import), D1 (Unknown class -> diagnostic), and D3 (Unknown member diagnostic) were originally -> planned here but have already shipped. +> diagnostic), D3 (Unknown member diagnostic), and L4 (Custom Eloquent +> builders) were originally planned here but have already shipped. ## Sprint 6 — 1.0 release, editor plugins & type intelligence @@ -151,8 +151,7 @@ unlikely to move the needle for most users. | S4 | Named argument awareness in active parameter | Low-Medium | Medium | | S5 | Language construct signature help and hover | Low | Low | | | **[Laravel](todo/laravel.md)** | | | -| L4 | Custom Eloquent builders (`HasBuilder` / `#[UseEloquentBuilder]`) | Medium | Medium | -| L3 | `$dates` array (deprecated) | Low-Medium | Low | +| L3 | `$dates` array (deprecated) | Low-Medium | Low | | L6 | Factory `has*`/`for*` relationship methods | Low-Medium | Medium | | L7 | `$pivot` property on BelongsToMany | Medium | Medium-High | | L8 | `withSum`/`withAvg`/`withMin`/`withMax` aggregate properties | Low-Medium | Medium-High | diff --git a/docs/todo/laravel.md b/docs/todo/laravel.md index 50a60d3c..82f32f9d 100644 --- a/docs/todo/laravel.md +++ b/docs/todo/laravel.md @@ -238,45 +238,6 @@ today and what is still missing. --- -#### L4. Custom Eloquent builders (`HasBuilder` / `#[UseEloquentBuilder]`) - -**Impact: High · Effort: Medium** - -Custom builders are the recommended pattern for complex query scoping -in modern Laravel. Without this, users get zero completions for -builder-specific methods via static model calls. - -Laravel 11+ introduced the `HasBuilder` trait and -`#[UseEloquentBuilder(UserBuilder::class)]` attribute to let models -declare a custom builder class. When present, `User::query()` and -all static builder-forwarded calls should resolve to the custom -builder instead of the base `Illuminate\Database\Eloquent\Builder`. - -```php -/** @extends Builder */ -class UserBuilder extends Builder { - /** @return $this */ - public function active(): static { ... } -} - -class User extends Model { - /** @use HasBuilder */ - use HasBuilder; -} - -User::query()->active()->get(); // active() should resolve on UserBuilder -``` - -Larastan handles this via `BuilderHelper::determineBuilderName()`, -which inspects `newEloquentBuilder()`'s return type or the -`#[UseEloquentBuilder]` attribute to find the custom builder class. - -**Where to change:** In `build_builder_forwarded_methods`, before -loading the standard `Eloquent\Builder`, check whether the model -declares a custom builder via `@use HasBuilder` in `use_generics` -or a `newEloquentBuilder()` method with a non-default return type. -If found, load and resolve that builder class instead. - #### L5. `abort_if`/`abort_unless` type narrowing **Impact: High · Effort: Medium** diff --git a/examples/laravel/app/Models/Baker.php b/examples/laravel/app/Models/Baker.php index d12a08ab..8408f193 100644 --- a/examples/laravel/app/Models/Baker.php +++ b/examples/laravel/app/Models/Baker.php @@ -3,7 +3,9 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Attributes\UseEloquentBuilder; +#[UseEloquentBuilder(BakerBuilder::class)] class Baker extends Model { public function getName(): string { return ''; } diff --git a/examples/laravel/app/Models/BakerBuilder.php b/examples/laravel/app/Models/BakerBuilder.php new file mode 100644 index 00000000..dfc967a0 --- /dev/null +++ b/examples/laravel/app/Models/BakerBuilder.php @@ -0,0 +1,20 @@ + + */ +class BakerBuilder extends Builder +{ + /** + * @return $this + */ + public function active() + { + return $this->where('active', true); + } +} diff --git a/src/completion/builder.rs b/src/completion/builder.rs index e633351e..fc797955 100644 --- a/src/completion/builder.rs +++ b/src/completion/builder.rs @@ -22,6 +22,7 @@ use std::sync::Arc; use tower_lsp::lsp_types::*; use super::resolve::CompletionItemData; +use crate::hover::{MemberKindForOrigin, find_declaring_class}; use crate::types::Visibility; use crate::types::*; @@ -658,7 +659,8 @@ pub(crate) fn build_union_completion_items( uri, ); - for item in items { + for mut item in items { + apply_declaring_class_label(&mut item, &merged, class_loader); if let Some(existing) = all_items .iter_mut() .find(|existing| existing.label == item.label) @@ -679,6 +681,47 @@ pub(crate) fn build_union_completion_items( merge_union_completion_items(all_items, occurrence_count, num_candidates) } +fn apply_declaring_class_label( + item: &mut CompletionItem, + owner: &ClassInfo, + class_loader: &dyn Fn(&str) -> Option>, +) { + let Some(data_value) = item.data.as_ref() else { + return; + }; + let Ok(mut data) = serde_json::from_value::(data_value.clone()) else { + return; + }; + let member_kind = match data.kind.as_str() { + "method" => MemberKindForOrigin::Method, + "property" => MemberKindForOrigin::Property, + "constant" => MemberKindForOrigin::Constant, + _ => return, + }; + + let declaring = find_declaring_class(owner, &data.member_name, &member_kind, class_loader); + let declaring_name = declaring.name.to_string(); + if declaring_name == data.class_name { + return; + } + + data.class_name = declaring_name.clone(); + data.extra_class_names.clear(); + if let Ok(value) = serde_json::to_value(&data) { + item.data = Some(value); + } + + let description = Some(display_class_name(&declaring_name).to_string()); + if let Some(ref mut label_details) = item.label_details { + label_details.description = description; + } else { + item.label_details = Some(CompletionItemLabelDetails { + detail: None, + description, + }); + } +} + /// Merge the class name from a new item's `data` into the existing item's /// `data.extra_class_names` so that `completionItem/resolve` can iterate /// all union branches when building hover documentation. diff --git a/src/completion/call_resolution.rs b/src/completion/call_resolution.rs index 4f434506..b884e25b 100644 --- a/src/completion/call_resolution.rs +++ b/src/completion/call_resolution.rs @@ -559,12 +559,12 @@ impl Backend { None }; let args = type_args.unwrap_or_else(|| crate::inheritance::default_type_args(&owner)); - let base = crate::virtual_members::resolve_class_fully_maybe_cached( + crate::virtual_members::resolve_class_fully_with_type_args( &owner, rctx.class_loader, rctx.resolved_class_cache, - ); - Arc::new(crate::inheritance::apply_generic_args(&base, &args)) + &args, + ) } else { crate::virtual_members::resolve_class_fully_maybe_cached( &owner, @@ -1523,34 +1523,34 @@ impl Backend { }) }) .collect(); - let resolved = crate::virtual_members::resolve_class_fully_maybe_cached( - &cls_arc, - ctx.class_loader, - ctx.resolved_class_cache, - ); let substituted = - crate::inheritance::apply_generic_args(&resolved, &type_args); + crate::virtual_members::resolve_class_fully_with_type_args( + &cls_arc, + ctx.class_loader, + ctx.resolved_class_cache, + &type_args, + ); if let Some(ref mut hint_out) = return_type_hint_out { **hint_out = Some(PhpType::Generic(substituted.name.to_string(), type_args)); } - return vec![Arc::new(substituted)]; + return vec![substituted]; } } } // Fallback: resolve unbound template params to bounds. let type_args = crate::inheritance::default_type_args(&cls_arc); - let resolved = crate::virtual_members::resolve_class_fully_maybe_cached( + let substituted = crate::virtual_members::resolve_class_fully_with_type_args( &cls_arc, ctx.class_loader, ctx.resolved_class_cache, + &type_args, ); - let substituted = crate::inheritance::apply_generic_args(&resolved, &type_args); if let Some(ref mut hint_out) = return_type_hint_out { **hint_out = Some(PhpType::Generic(substituted.name.to_string(), type_args)); } - vec![Arc::new(substituted)] + vec![substituted] } // ── Any other callee form (e.g. a nested CallExpr used as diff --git a/src/completion/handler.rs b/src/completion/handler.rs index 6a1a63e0..94e86056 100644 --- a/src/completion/handler.rs +++ b/src/completion/handler.rs @@ -263,6 +263,7 @@ impl Backend { ) -> Result> { let uri = params.text_document_position.text_document.uri.to_string(); let mut position = params.text_document_position.position; + let completion_context = params.context.clone(); // Get file content for offset calculation. For Blade files, // use the virtual PHP content and translate the cursor position @@ -285,6 +286,9 @@ impl Backend { // request. The guard is re-entrant safe. let _chain_guard = super::resolver::with_chain_resolution_cache(); let _body_infer_guard = self.activate_body_return_inferrer(); + let _cache_guard = crate::virtual_members::with_active_resolved_class_cache( + &self.resolved_class_cache, + ); // Gather per-file context (classes, use-map, namespace) in one // call instead of three separate lock-and-unwrap blocks. @@ -373,9 +377,13 @@ impl Backend { } // ── Member access completion (-> or ::) ───────────────── - if let Some(response) = - self.try_member_access_completion(&uri, &content, position, &ctx) - { + if let Some(response) = self.try_member_access_completion( + &uri, + &content, + position, + &ctx, + completion_context.as_ref(), + ) { // In simple interpolation (`"$var->"`), PHP only allows // property access — method calls and constants are // syntax errors. Filter to properties only. @@ -900,6 +908,7 @@ impl Backend { content: &str, position: Position, ctx: &FileContext, + completion_context: Option<&CompletionContext>, ) -> Option { // ── Primary path: AST-based detection via symbol map ──────── // The symbol map's `MemberAccess` correctly handles `(new Foo)->`, @@ -911,6 +920,11 @@ impl Backend { let cursor_offset = position_to_offset(content, position); let current_class = find_class_at_offset(&ctx.classes, cursor_offset); + let prefix = if completion_context.is_some() { + Self::member_completion_prefix(content, position) + } else { + String::new() + }; let class_loader = self.class_loader(ctx); let function_loader = self.function_loader(ctx); @@ -920,120 +934,236 @@ impl Backend { // Suppress suggestions to nudge the developer toward `self::`. let suppress = target.subject == "static" && current_class.is_some_and(|cc| cc.is_final); + // ── Resolve subject to concrete types ─────────────────────── + // We resolve the subject BEFORE checking the cache so that the + // cache key can include the actual resolved types. This prevents + // stale results if a variable (e.g. `$model`) changes type + // within the same file. + let rctx = ResolutionCtx { + current_class, + all_classes: &ctx.classes, + content, + cursor_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, + }; + let mut resolved = if suppress { + vec![] + } else { + super::resolver::resolve_target_classes(&target.subject, target.access_kind, &rctx) + }; + + // ── Incomplete-expression retry ───────────────────────────── + if resolved.is_empty() && !suppress && target.subject.starts_with('$') { + let patched = Self::patch_incomplete_member_access(content, position); + if patched != content { + let patched_classes: Vec> = + self.parse_php(&patched).into_iter().map(Arc::new).collect(); + let patched_offset = position_to_offset(&patched, position); + let patched_current = find_class_at_offset(&patched_classes, patched_offset); + let patched_rctx = ResolutionCtx { + current_class: patched_current, + all_classes: &patched_classes, + content: &patched, + cursor_offset: patched_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, + }; + resolved = super::resolver::resolve_target_classes( + &target.subject, + target.access_kind, + &patched_rctx, + ); + } + } + + if resolved.is_empty() { + return None; + } + + let resolved_types: Vec = resolved + .iter() + .map(|rt| rt.type_string.to_string()) + .collect(); + let cache_key = + Self::member_completion_cache_key(uri, &target, current_class, &resolved_types); + // Wrap resolution + inheritance merging in catch_unwind so // that a stack overflow (e.g. from deep trait/inheritance // resolution when the subject is a call expression like // `collect($x)->`) doesn't crash the LSP server process. - // The variable-resolution path already has its own - // catch_unwind, but the direct call-expression path - // (resolve_call_return_types_expr → type_hint_to_classes_typed → - // class_loader → find_or_load_class → parse_php → - // resolve_class_with_inheritance) does not. - let member_items = crate::util::catch_panic_unwind_safe( - "member-access completion", - uri, - Some(position), - || { - let candidates = if suppress { - vec![] - } else { - let rctx = ResolutionCtx { - current_class, - all_classes: &ctx.classes, - content, - cursor_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, - }; - let mut resolved = super::resolver::resolve_target_classes( - &target.subject, - target.access_kind, - &rctx, - ); - - // ── Incomplete-expression retry ───────────────── - // When the cursor sits right after `->` (or `?->`) - // at the end of an expression with no trailing - // semicolon (e.g. inside an arrow function body the - // user is still typing), the PHP parser may fail to - // produce the enclosing statement. Patch the - // content by appending a dummy identifier + - // semicolon so the parser can recover. - if resolved.is_empty() && target.subject.starts_with('$') { - let patched = Self::patch_incomplete_member_access(content, position); - if patched != content { - let patched_classes: Vec> = - self.parse_php(&patched).into_iter().map(Arc::new).collect(); - let patched_offset = position_to_offset(&patched, position); - let patched_current = - find_class_at_offset(&patched_classes, patched_offset); - let patched_rctx = ResolutionCtx { - current_class: patched_current, - all_classes: &patched_classes, - content: &patched, - cursor_offset: patched_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, - }; - resolved = super::resolver::resolve_target_classes( - &target.subject, - target.access_kind, - &patched_rctx, - ); - } + let started = std::time::Instant::now(); + let cached_items = self.member_completion_cache.lock().get(&cache_key).cloned(); + let cache_hit = cached_items.is_some(); + let member_items = cached_items.or_else(|| { + crate::util::catch_panic_unwind_safe( + "member-access completion", + uri, + Some(position), + || { + let candidates = ResolvedType::into_arced_classes(resolved); + if candidates.is_empty() { + return vec![]; } - ResolvedType::into_arced_classes(resolved) - }; - if candidates.is_empty() { - return vec![]; + // `parent::`, `self::`, and `static::` are syntactically + // `::` but semantically different from external static + // access. + let effective_access = + if matches!(target.subject.as_str(), "parent" | "self" | "static") { + crate::AccessKind::ParentDoubleColon + } else { + target.access_kind + }; + + super::builder::build_union_completion_items( + &candidates, + effective_access, + current_class, + &class_loader, + &self.resolved_class_cache, + uri, + ) + }, + ) + .inspect(|items| { + if !items.is_empty() { + let mut cache = self.member_completion_cache.lock(); + // Simple size limit: evict everything if the cache gets too big. + // This is crude but effective at preventing memory leaks + // in long-running sessions. + if cache.len() > 200 { + cache.clear(); + } + cache.insert(cache_key.clone(), items.clone()); } - - // `parent::`, `self::`, and `static::` are syntactically - // `::` but semantically different from external static - // access: they show both static and instance members - // (PHP allows `self::nonStaticMethod()` etc. from an - // instance context). `parent::` additionally excludes - // private members, which is handled by visibility - // filtering in `build_completion_items`. - let effective_access = - if matches!(target.subject.as_str(), "parent" | "self" | "static") { - crate::AccessKind::ParentDoubleColon - } else { - target.access_kind - }; - - super::builder::build_union_completion_items( - &candidates, - effective_access, - current_class, - &class_loader, - &self.resolved_class_cache, - uri, - ) - }, - ); + }) + }); match member_items { Some(all_items) if !all_items.is_empty() => { + let is_filtered = !prefix.is_empty(); + let unfiltered_count = all_items.len(); + let items = if is_filtered { + Self::filter_member_completion_items(all_items, &prefix) + } else { + all_items + }; + // ── Suppress snippet parentheses when `(` already follows ── let items = if paren_follows_cursor(content, position) { - strip_snippet_parens(all_items) + strip_snippet_parens(items) } else { - all_items + items }; - Some(CompletionResponse::Array(items)) + let returned_count = items.len(); + + let elapsed = started.elapsed(); + if elapsed >= std::time::Duration::from_millis(20) || !cache_hit { + tracing::debug!( + target: "performance", + "PHPantom: member completion subject={} access={:?} prefix={} cache={} resolved=[{}] took {:?}, returned {}/{} items", + target.subject, + target.access_kind, + prefix, + if cache_hit { "hit" } else { "miss" }, + resolved_types.join(","), + elapsed, + returned_count, + unfiltered_count + ); + } + + if is_filtered { + Some(CompletionResponse::List(CompletionList { + is_incomplete: false, + items, + })) + } else { + Some(CompletionResponse::Array(items)) + } } _ => None, } } + fn member_completion_cache_key( + uri: &str, + target: &CompletionTarget, + current_class: Option<&ClassInfo>, + resolved_types: &[String], + ) -> String { + format!( + "{}\n{:?}\n{}\n{}\n{}", + uri, + target.access_kind, + target.subject, + current_class + .map(|c| c.fqn().to_string()) + .unwrap_or_default(), + resolved_types.join(",") + ) + } + + fn member_completion_prefix(content: &str, position: Position) -> String { + let cursor_offset = position_to_offset(content, position) as usize; + let bytes = content.as_bytes(); + let mut start = cursor_offset.min(bytes.len()); + while start > 0 { + let b = bytes[start - 1]; + if b.is_ascii_alphanumeric() || b == b'_' { + start -= 1; + } else { + break; + } + } + + let has_member_operator = (start >= 2 + && ((bytes[start - 2] == b'-' && bytes[start - 1] == b'>') + || (bytes[start - 2] == b':' && bytes[start - 1] == b':'))) + || (start >= 3 + && bytes[start - 3] == b'?' + && bytes[start - 2] == b'-' + && bytes[start - 1] == b'>'); + if !has_member_operator { + return String::new(); + } + + content[start..cursor_offset.min(content.len())].to_string() + } + + fn filter_member_completion_items( + items: Vec, + prefix: &str, + ) -> Vec { + if prefix.is_empty() { + return items; + } + + let prefix_lower = prefix.to_ascii_lowercase(); + let started = std::time::Instant::now(); + let filtered: Vec = items + .into_iter() + .filter(|item| item.label.to_ascii_lowercase().starts_with(&prefix_lower)) + .collect(); + let elapsed = started.elapsed(); + if elapsed >= std::time::Duration::from_micros(500) { + tracing::debug!( + target: "performance", + "PHPantom: filter_member_completion_items (prefix: {}) took {:?}", + prefix, + elapsed + ); + } + filtered + } + // ─── Strategy: variable name completion ────────────────────────────── /// Try to offer `$variable` name completions. diff --git a/src/completion/variable/rhs_resolution.rs b/src/completion/variable/rhs_resolution.rs index 88168cc8..dd2f8845 100644 --- a/src/completion/variable/rhs_resolution.rs +++ b/src/completion/variable/rhs_resolution.rs @@ -837,13 +837,14 @@ fn resolve_rhs_instantiation( }) }) .collect(); - let resolved = crate::virtual_members::resolve_class_fully_maybe_cached( - cls, - ctx.class_loader, - ctx.resolved_class_cache, - ); - let mut substituted = - crate::inheritance::apply_generic_args(&resolved, &type_args); + let substituted_arc = + crate::virtual_members::resolve_class_fully_with_type_args( + cls, + ctx.class_loader, + ctx.resolved_class_cache, + &type_args, + ); + let mut substituted = Arc::unwrap_or_clone(substituted_arc); // ── Template-param mixin resolution ──────────────── // When a class declares `@mixin TParam` where `TParam` @@ -890,14 +891,14 @@ fn resolve_rhs_instantiation( // semantics and prevents raw template names from leaking // into method parameter/return types. let type_args = crate::inheritance::default_type_args(cls); - let resolved = crate::virtual_members::resolve_class_fully_maybe_cached( + let substituted = crate::virtual_members::resolve_class_fully_with_type_args( cls, ctx.class_loader, ctx.resolved_class_cache, + &type_args, ); - let substituted = crate::inheritance::apply_generic_args(&resolved, &type_args); let generic_type = PhpType::Generic(substituted.name.to_string(), type_args.clone()); - return vec![ResolvedType::from_both(generic_type, substituted)]; + return vec![ResolvedType::from_both_arc(generic_type, substituted)]; } return ResolvedType::from_classes_with_hint(classes, parsed_name); @@ -3235,17 +3236,15 @@ fn expand_union_generic_owners( let mut expanded_owners: Vec> = Vec::new(); let mut expanded_resolved: Vec = Vec::new(); - let resolved_base = crate::virtual_members::resolve_class_fully_maybe_cached( - base_cls, - ctx.class_loader, - ctx.resolved_class_cache, - ); - for member in union_members { match member { PhpType::Generic(name, args) if is_same_base(name) => { - let substituted = crate::inheritance::apply_generic_args(&resolved_base, args); - let arc = Arc::new(substituted); + let arc = crate::virtual_members::resolve_class_fully_with_type_args( + base_cls, + ctx.class_loader, + ctx.resolved_class_cache, + args, + ); expanded_resolved.push(ResolvedType::from_both_arc( member.clone(), Arc::clone(&arc), diff --git a/src/definition/member/mod.rs b/src/definition/member/mod.rs index 7dc1f119..d1bd0571 100644 --- a/src/definition/member/mod.rs +++ b/src/definition/member/mod.rs @@ -804,16 +804,37 @@ impl Backend { if !extends_eloquent_model(class, class_loader) { return None; } - let builder = class_loader(ELOQUENT_BUILDER_FQN)?; + + // Walk the parent chain to find a custom builder definition. + // Laravel's #[UseEloquentBuilder] and HasBuilder are effectively inherited. + let mut builder_fqn = ELOQUENT_BUILDER_FQN.to_string(); + let mut current = Some(class.clone()); + for _ in 0..MAX_INHERITANCE_DEPTH { + let Some(curr) = current else { break }; + if let Some(name) = curr + .laravel() + .and_then(|l| l.custom_builder.as_ref()) + .and_then(|b| b.base_name()) + { + builder_fqn = name.to_string(); + break; + } + current = curr + .parent_class + .as_ref() + .and_then(|p| class_loader(p)) + .map(Arc::unwrap_or_clone); + } + + let builder = class_loader(&builder_fqn)?; let (declaring_class, fqn) = Self::find_declaring_class(&builder, member_name, class_loader)?; - // When the declaring class is the Eloquent Builder itself, - // find_declaring_class returns the short name ("Builder"). - // Replace it with the fully-qualified name so that - // find_class_file_content can disambiguate classes that share - // the same short name (e.g. Eloquent\Builder vs Demo\Builder). + + // When the declaring class is the builder itself, find_declaring_class + // returns the short name ("Builder"). Replace it with the FQN so + // that find_class_file_content can disambiguate. if !fqn.contains('\\') && fqn == builder.name { - Some((declaring_class, ELOQUENT_BUILDER_FQN.to_string())) + Some((declaring_class, builder_fqn)) } else { Some((declaring_class, fqn)) } diff --git a/src/lib.rs b/src/lib.rs index 5fb4487b..e693b10a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -86,6 +86,7 @@ use std::sync::atomic::AtomicU64; use parking_lot::{Mutex, RwLock}; use tower_lsp::Client; +use tower_lsp::lsp_types::CompletionItem; /// A single parse error entry: `(message, start_byte_offset, end_byte_offset)`. /// @@ -211,7 +212,17 @@ pub struct Backend { /// (caught by `catch_unwind`), a single "Parse failed" entry is /// stored instead. pub(crate) parse_errors: Arc>>>, + /// Per-URI locks for background `didChange` parses. + /// + /// `didChange` handlers offload parsing to blocking tasks. Without a + /// per-file lock, an older parse can finish after a newer edit and publish + /// stale symbol state. These locks serialize parse commits per URI; the + /// handler also verifies that the captured text is still current before + /// updating shared maps. + pub(crate) did_change_parse_locks: Arc>>>>, pub(crate) client: Option, + /// Whether to update ASTs synchronously. Used for testing. + pub(crate) sync_ast_updates: bool, /// The root directory of the workspace (set during `initialize`). pub(crate) workspace_root: Arc>>, /// PSR-4 autoload mappings parsed from `composer.json`. @@ -368,6 +379,13 @@ pub struct Backend { /// `parse_and_cache_content`) so that stale results never survive /// an edit. pub(crate) resolved_class_cache: virtual_members::ResolvedClassCache, + /// Per-target member completion cache. + /// + /// Typing `$model->wh...` triggers a completion request for each + /// keyword edit. The receiver and candidate member set are unchanged + /// across those requests, so cache the unfiltered member list and let + /// each request apply only its current prefix filter. + pub(crate) member_completion_cache: Arc>>>, /// Global method store: `(class_fqn, method_name)` → `Arc`. /// /// Populated alongside `fqn_index` whenever classes are parsed or @@ -643,6 +661,7 @@ impl Backend { uri_classes_index: Arc::new(RwLock::new(HashMap::new())), symbol_maps: Arc::new(RwLock::new(HashMap::new())), parse_errors: Arc::new(RwLock::new(HashMap::new())), + did_change_parse_locks: Arc::new(Mutex::new(HashMap::new())), client: None, workspace_root: Arc::new(RwLock::new(None)), vendor_uri_prefixes: Mutex::new(Vec::new()), @@ -666,6 +685,7 @@ impl Backend { stub_function_index: RwLock::new(stubs::build_stub_function_index()), stub_constant_index: RwLock::new(stubs::build_stub_constant_index()), resolved_class_cache: virtual_members::new_resolved_class_cache(), + 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())), php_version: Mutex::new(types::PhpVersion::default()), @@ -703,6 +723,7 @@ impl Backend { blade_source_maps: Arc::new(RwLock::new(HashMap::new())), blade_uris: Arc::new(RwLock::new(std::collections::HashSet::new())), workspace_indexed: Arc::new(std::sync::atomic::AtomicBool::new(false)), + sync_ast_updates: false, } } @@ -721,6 +742,7 @@ impl Backend { uri_classes_index: Arc::new(RwLock::new(HashMap::new())), symbol_maps: Arc::new(RwLock::new(HashMap::new())), parse_errors: Arc::new(RwLock::new(HashMap::new())), + did_change_parse_locks: Arc::new(Mutex::new(HashMap::new())), client: None, workspace_root: Arc::new(RwLock::new(None)), vendor_uri_prefixes: Mutex::new(Vec::new()), @@ -744,6 +766,7 @@ impl Backend { stub_function_index: RwLock::new(HashMap::new()), stub_constant_index: RwLock::new(HashMap::new()), resolved_class_cache: virtual_members::new_resolved_class_cache(), + 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())), php_version: Mutex::new(types::PhpVersion::default()), @@ -780,6 +803,7 @@ impl Backend { blade_source_maps: Arc::new(RwLock::new(HashMap::new())), blade_uris: Arc::new(RwLock::new(std::collections::HashSet::new())), workspace_indexed: Arc::new(std::sync::atomic::AtomicBool::new(false)), + sync_ast_updates: true, } } @@ -963,6 +987,11 @@ impl Backend { &self.phpcs_last_diags } + /// Clear the member completion cache. + pub fn clear_completion_cache(&self) { + self.member_completion_cache.lock().clear(); + } + /// Return the configured PHP version. pub fn php_version(&self) -> types::PhpVersion { *self.php_version.lock() @@ -1074,6 +1103,7 @@ impl Backend { uri_classes_index: Arc::clone(&self.uri_classes_index), symbol_maps: Arc::clone(&self.symbol_maps), parse_errors: Arc::clone(&self.parse_errors), + did_change_parse_locks: Arc::clone(&self.did_change_parse_locks), // RwLock fields are shared by Arc::clone — the diagnostic // worker reads them concurrently with the main Backend. client: self.client.clone(), @@ -1095,6 +1125,7 @@ impl Backend { class_not_found_cache: Arc::clone(&self.class_not_found_cache), stub_index: RwLock::new(self.stub_index.read().clone()), resolved_class_cache: Arc::clone(&self.resolved_class_cache), + member_completion_cache: Arc::clone(&self.member_completion_cache), method_store: Arc::clone(&self.method_store), gti_index: Arc::clone(&self.gti_index), stub_function_index: RwLock::new(self.stub_function_index.read().clone()), @@ -1136,6 +1167,7 @@ impl Backend { blade_source_maps: Arc::clone(&self.blade_source_maps), blade_uris: Arc::clone(&self.blade_uris), workspace_indexed: Arc::clone(&self.workspace_indexed), + sync_ast_updates: self.sync_ast_updates, } } diff --git a/src/parser/ast_update.rs b/src/parser/ast_update.rs index 9aa04de2..a5afeff4 100644 --- a/src/parser/ast_update.rs +++ b/src/parser/ast_update.rs @@ -674,6 +674,10 @@ impl Backend { ); } + if any_signature_changed { + self.member_completion_cache.lock().clear(); + } + any_signature_changed } @@ -760,6 +764,13 @@ impl Backend { class.laravel_mut().custom_collection = Some(coll.resolve_names(&resolver)); } + // Resolve custom builder class name to FQN + if let Some(builder) = class.laravel().and_then(|l| l.custom_builder.clone()) { + let resolver = + |name: &str| -> String { Self::resolve_name(name, use_map, namespace) }; + class.laravel_mut().custom_builder = Some(builder.resolve_names(&resolver)); + } + // Resolve cast class names to FQN so that custom cast // classes like `DecimalCast` (imported via `use`) are // loadable cross-file when `cast_type_to_php_type` calls diff --git a/src/parser/classes.rs b/src/parser/classes.rs index 7d7f33ea..b3201be5 100644 --- a/src/parser/classes.rs +++ b/src/parser/classes.rs @@ -292,6 +292,70 @@ fn extract_collected_by_attribute( None } +/// Extract the custom builder class name from a `#[UseEloquentBuilder(X::class)]` attribute. +fn extract_use_eloquent_builder_attribute( + attribute_lists: &Sequence<'_, AttributeList<'_>>, + content: &str, +) -> Option { + for attr_list in attribute_lists.iter() { + for attr in attr_list.attributes.iter() { + let short = attr.name.last_segment(); + if short != "UseEloquentBuilder" { + continue; + } + let arg_list = attr.argument_list.as_ref()?; + let first_arg = arg_list.arguments.first()?; + let span = first_arg.span(); + let start = span.start.offset as usize; + let end = span.end.offset as usize; + let text = content.get(start..end)?; + let class_name = text.trim_end_matches("::class").trim(); + if !class_name.is_empty() { + return Some(class_name.to_string()); + } + } + } + None +} + +/// Determine the custom builder class for an Eloquent model. +/// +/// Checks three sources in priority order: +/// +/// 1. `#[UseEloquentBuilder(CustomBuilder::class)]` attribute on the class. +/// 2. `/** @use HasBuilder */` in `use_generics`. +/// 3. A `newEloquentBuilder()` method override whose return type names the +/// custom builder class. +fn extract_custom_builder( + attribute_lists: &Sequence<'_, AttributeList<'_>>, + use_generics: &[(Atom, Vec)], + methods: &[MethodInfo], + content: &str, +) -> Option { + // 1. Try the #[UseEloquentBuilder] attribute first. + if let Some(name) = extract_use_eloquent_builder_attribute(attribute_lists, content) { + return Some(PhpType::Named(name)); + } + + // 2. Fall back to @use HasBuilder. + for (trait_name, args) in use_generics { + let short = trait_name.rsplit('\\').next().unwrap_or(trait_name); + if (short == "HasBuilder" || short == "CustomizeQueryBuilder") && !args.is_empty() { + return Some(args[0].clone()); + } + } + + // 3. Fall back to newEloquentBuilder() return type override. + let method = methods.iter().find(|m| m.name == "newEloquentBuilder")?; + let return_type = method.return_type.as_ref()?; + let base = return_type.base_name()?; + if base == "Illuminate\\Database\\Eloquent\\Builder" || base == "Builder" || base.is_empty() { + return None; + } + + Some(return_type.clone()) +} + /// Determine the custom collection class for an Eloquent model. /// /// Checks three sources in priority order: @@ -881,6 +945,13 @@ impl Backend { content, ); + let custom_builder = extract_custom_builder( + &class.attribute_lists, + &use_generics, + &methods, + content, + ); + let casts_definitions = extract_casts_definitions(class.members.iter(), content); @@ -946,6 +1017,7 @@ impl Backend { timestamps, created_at_name, updated_at_name, + custom_builder, })), }); diff --git a/src/references/mod.rs b/src/references/mod.rs index de274ddf..8649930c 100644 --- a/src/references/mod.rs +++ b/src/references/mod.rs @@ -144,7 +144,8 @@ impl Backend { }); // Resolve the enclosing class to scope the search. - let hierarchy = self.resolve_member_declaration_hierarchy(uri, span_start); + let hierarchy = + self.resolve_member_declaration_hierarchy(uri, span_start, name, is_static); return self.find_member_references( name, is_static, @@ -176,8 +177,13 @@ impl Backend { } => { // Resolve the subject to determine the class hierarchy // so we only return references on related classes. - let hierarchy = - self.resolve_member_access_hierarchy(uri, subject_text, *is_static, span_start); + let hierarchy = self.resolve_member_access_hierarchy( + uri, + subject_text, + *is_static, + span_start, + member_name, + ); self.find_member_references( member_name, @@ -196,7 +202,8 @@ impl Backend { } SymbolKind::MemberDeclaration { name, is_static } => { // Resolve the enclosing class to scope the search. - let hierarchy = self.resolve_member_declaration_hierarchy(uri, span_start); + let hierarchy = + self.resolve_member_declaration_hierarchy(uri, span_start, name, *is_static); self.find_member_references( name, *is_static, @@ -767,18 +774,19 @@ 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 && *is_static == target_is_static => true, + } if member_name == target_member => { + hierarchy.is_some() || *is_static == target_is_static + } SymbolKind::MemberDeclaration { name, is_static } - if include_declaration - && name == target_member - && *is_static == target_is_static => + if include_declaration && name == target_member => { - true + hierarchy.is_some() || *is_static == target_is_static } _ => false, }); @@ -827,7 +835,17 @@ impl Backend { member_name, is_static, .. - } if member_name == target_member && *is_static == target_is_static => { + } if member_name == target_member => { + // For Laravel custom builders, we allow static-ness mismatch + // (Model::active() is static, UserBuilder->active() is instance). + if *is_static != target_is_static { + // Only allow mismatch if we have a hierarchy to verify + // that they are indeed related (one is Model, one is Builder). + if hierarchy.is_none() { + continue; + } + } + // Check if the subject belongs to the target hierarchy. if let Some(hier) = hierarchy { if file_content.is_none() { @@ -871,10 +889,12 @@ impl Backend { }); } SymbolKind::MemberDeclaration { name, is_static } - if include_declaration - && name == target_member - && *is_static == target_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. if let Some(hier) = hierarchy { let ctx = file_ctx_cell.get_or_init(|| self.file_context(file_uri)); @@ -1191,6 +1211,7 @@ impl Backend { subject_text: &str, is_static: bool, span_start: u32, + member_name: &str, ) -> Option> { let ctx = self.file_context(uri); let content = self.get_file_content(uri)?; @@ -1199,7 +1220,7 @@ impl Backend { if fqns.is_empty() { return None; } - Some(self.collect_hierarchy_for_fqns(&fqns)) + Some(self.collect_hierarchy_for_fqns(&fqns, Some(member_name), is_static)) } /// Resolve the class hierarchy for a `MemberDeclaration` at a given offset. @@ -1209,6 +1230,8 @@ impl Backend { &self, uri: &str, offset: u32, + member_name: &str, + is_static: bool, ) -> Option> { let classes: Vec> = self .uri_classes_index @@ -1227,7 +1250,7 @@ impl Backend { .min_by_key(|c| c.start_offset) })?; let fqn = current_class.fqn().to_string(); - Some(self.collect_hierarchy_for_fqns(&[fqn])) + Some(self.collect_hierarchy_for_fqns(&[fqn], Some(member_name), is_static)) } /// Resolve a member access subject to zero or more class FQNs. @@ -1293,7 +1316,12 @@ impl Backend { /// - All ancestor FQNs (parent chain, interfaces, traits) /// - All descendant FQNs (classes that extend/implement any class in /// the hierarchy) - fn collect_hierarchy_for_fqns(&self, seed_fqns: &[String]) -> HashSet { + fn collect_hierarchy_for_fqns( + &self, + seed_fqns: &[String], + member_name: Option<&str>, + is_static: bool, + ) -> HashSet { let mut hierarchy = HashSet::new(); let class_loader = |name: &str| -> Option> { self.find_or_load_class(name) }; @@ -1308,10 +1336,72 @@ impl Backend { self.collect_ancestors(&fqn, &class_loader, &mut hierarchy); } - // Walk down: collect all descendants using the global GTI index - // (reverse inheritance). This is O(hierarchy size) rather than - // O(total classes). - let mut queue: std::collections::VecDeque = hierarchy.iter().cloned().collect(); + // Bridge Laravel Models and their Custom Builders. + // If a class in the hierarchy is a Model with a custom builder, + // add that builder to the hierarchy. + let mut extensions = Vec::new(); + for fqn in &hierarchy { + if let Some(cls) = class_loader(fqn) + && let Some(builder_fqn) = cls + .laravel() + .and_then(|l| l.custom_builder.as_ref()) + .and_then(|b| b.base_name()) + { + extensions.push(normalize_fqn(builder_fqn).to_string()); + } + } + for ext_fqn in extensions { + if hierarchy.insert(ext_fqn.clone()) { + self.collect_ancestors(&ext_fqn, &class_loader, &mut hierarchy); + } + } + + // Bridge Laravel Builders back to their Models. + // If a class in the hierarchy is a custom builder, find which models + // use it and add them to the hierarchy. + let mut model_seeds = Vec::new(); + { + let class_index = self.fqn_class_index.read(); + for (class_fqn, class_info) in class_index.iter() { + if let Some(laravel) = class_info.laravel() { + if let Some(normalized) = laravel + .custom_builder + .as_ref() + .and_then(|b| b.base_name()) + .map(normalize_fqn) + { + if hierarchy.contains(normalized.as_str()) { + model_seeds.push(class_fqn.clone()); + } + } else if hierarchy + .contains(crate::virtual_members::laravel::ELOQUENT_BUILDER_FQN) + { + // All models use the base Eloquent Builder by default. + model_seeds.push(class_fqn.clone()); + } + } + } + } + for model_fqn in model_seeds { + if hierarchy.insert(normalize_fqn(&model_fqn).to_string()) { + self.collect_ancestors(&model_fqn, &class_loader, &mut hierarchy); + } + } + + // Walk down: collect all descendants using the global GTI index. + // We only walk down from a class if it actually defines the member + // (or if no member name was provided). + let mut queue: std::collections::VecDeque = std::collections::VecDeque::new(); + if let Some(name) = member_name { + for fqn in &hierarchy { + if self.defines_member(fqn, name, is_static, &class_loader) { + queue.push_back(fqn.clone()); + } + } + } else { + queue.extend(hierarchy.iter().cloned()); + } + let gti = self.gti_index.read(); while let Some(fqn) = queue.pop_front() { if let Some(descendants) = gti.get(&fqn) { @@ -1327,6 +1417,60 @@ impl Backend { hierarchy } + fn defines_member( + &self, + fqn: &str, + name: &str, + is_static: bool, + class_loader: &dyn Fn(&str) -> Option>, + ) -> bool { + let Some(cls) = class_loader(fqn) else { + return false; + }; + + // Real methods + if cls + .methods + .iter() + .any(|m| m.name.eq_ignore_ascii_case(name) && m.is_static == is_static) + { + return true; + } + + // Laravel forwarded methods + if let Some(laravel) = cls.laravel() { + if let Some(builder_cls) = laravel + .custom_builder + .as_ref() + .and_then(|b| b.base_name()) + .and_then(class_loader) + { + // Forwarded methods are instance methods on the builder + // but called statically on the model. + if builder_cls + .methods + .iter() + .any(|m| m.name.eq_ignore_ascii_case(name) && (!is_static || !m.is_static)) + { + return true; + } + } + // Standard builder forwarding + if class_loader(crate::virtual_members::laravel::ELOQUENT_BUILDER_FQN) + .filter(|bc| { + bc.methods + .iter() + .any(|m| m.name.eq_ignore_ascii_case(name) && (!is_static || !m.is_static)) + }) + .is_some() + { + return true; + } + } + + false + } + /// Walk up the inheritance chain and collect all ancestor FQNs. fn collect_ancestors( &self, diff --git a/src/server.rs b/src/server.rs index c36c61c3..e0d02640 100644 --- a/src/server.rs +++ b/src/server.rs @@ -127,7 +127,6 @@ impl LanguageServer for Backend { "'".to_string(), "\"".to_string(), "[".to_string(), - " ".to_string(), "\\".to_string(), "/".to_string(), "*".to_string(), @@ -297,6 +296,15 @@ impl LanguageServer for Backend { } } + if let Some(ref tok) = progress_token { + self.progress_report(tok, 90, Some("Warming Laravel completions".to_string())) + .await; + } + let warmed = self.warm_laravel_completion_cache(); + if warmed > 0 { + tracing::info!("PHPantom: warmed {} Laravel completion classes", warmed); + } + if let Some(ref tok) = progress_token { let classmap_count = self.fqn_uri_index.read().len(); self.progress_end(tok, Some(format!("Indexed {} classes", classmap_count))) @@ -387,38 +395,36 @@ impl LanguageServer for Backend { // Files opened during startup (before indexing finished) were // not diagnosed because `schedule_diagnostics` skips work when - // `init_complete` is false. Now that the index is ready, - // diagnose every open file so the user sees results without - // having to edit. - // - // We compute diagnostics eagerly here (via - // `publish_diagnostics_for_file`) so the editor sees fast - // diagnostics immediately. In pull mode, slow diagnostics - // are cached but only pushed as fast-only; we send a - // `workspace/diagnostic/refresh` afterwards so the editor - // re-pulls and gets the full set (fast + slow). - { - let file_snapshots: Vec<(String, Arc)> = self + // `init_complete` is false. Queue that catch-up work after + // `initialized` returns so early completion requests are not + // stuck behind diagnostics for the active file. + let diagnostics_backend = self.clone_for_diagnostic_worker(); + tokio::spawn(async move { + let file_snapshots: Vec<(String, Arc)> = diagnostics_backend .open_files .read() .iter() .map(|(uri, content)| (uri.clone(), Arc::clone(content))) .collect(); for (uri, content) in &file_snapshots { - self.schedule_diagnostics(uri.clone()); - self.publish_diagnostics_for_file(uri, content).await; + diagnostics_backend.schedule_diagnostics(uri.clone()); + diagnostics_backend + .publish_diagnostics_for_file(uri, content) + .await; } - } - // In pull mode the eager publish above only pushed fast - // diagnostics. The full set (including slow diagnostics) is - // now cached in `diag_last_full`. Send a refresh so the - // editor re-pulls and receives the complete diagnostics. - if self.supports_pull_diagnostics.load(Ordering::Acquire) - && let Some(ref client) = self.client - { - let _ = client.workspace_diagnostic_refresh().await; - } + // In pull mode the eager publish above only pushed fast + // diagnostics. The full set (including slow diagnostics) is + // now cached in `diag_last_full`. Send a refresh so the + // editor re-pulls and receives the complete diagnostics. + if diagnostics_backend + .supports_pull_diagnostics + .load(Ordering::Acquire) + && let Some(ref client) = diagnostics_backend.client + { + let _ = client.workspace_diagnostic_refresh().await; + } + }); } async fn shutdown(&self) -> Result<()> { @@ -502,20 +508,65 @@ impl LanguageServer for Backend { .write() .insert(uri.clone(), Arc::clone(&text)); - // Re-parse and update AST map, use map, and namespace map - let signature_changed = self.update_ast(&uri, &text); + // Re-parse in a blocking background task so typing does not + // monopolize the LSP service loop and delay completion requests. + // + // Until this task completes, hover/completion may use the + // previous symbol map for this file. That is preferable to + // queuing interactive requests behind a full parse on every + // keystroke; `update_ast` already tolerates stale maps when + // incomplete code cannot be parsed. + if self.sync_ast_updates { + let changed = self.update_ast(&uri, &text); + if changed { + self.schedule_diagnostics_for_open_files(&uri); + } + self.schedule_diagnostics(uri.clone()); + } else { + let backend = self.clone_for_blocking(); + tokio::spawn(async move { + let uri_for_diagnostics = uri.clone(); + let signature_changed = tokio::task::spawn_blocking(move || { + let parse_lock = { + let mut locks = backend.did_change_parse_locks.lock(); + Arc::clone( + locks + .entry(uri.clone()) + .or_insert_with(|| Arc::new(parking_lot::Mutex::new(()))), + ) + }; + let _parse_guard = parse_lock.lock(); + let is_latest_text = backend + .open_files + .read() + .get(&uri) + .is_some_and(|current| Arc::ptr_eq(current, &text)); + if !is_latest_text { + return false; + } - // Schedule diagnostics in a background task with debouncing. - // This returns immediately so that completion, hover, and - // signature help are never blocked by diagnostic computation. - self.schedule_diagnostics(uri.clone()); + let started = std::time::Instant::now(); + let changed = backend.update_ast(&uri, &text); + let elapsed = started.elapsed(); + if elapsed >= std::time::Duration::from_millis(100) { + tracing::debug!( + target: "performance", + "PHPantom: didChange parse took {:?}", + elapsed + ); + } + if changed { + backend.schedule_diagnostics_for_open_files(&uri); + } + backend.schedule_diagnostics(uri_for_diagnostics); + changed + }) + .await; - // When a class signature changed (method/property added, - // removed, or modified; class renamed; parent changed; etc.) - // other open files may have stale diagnostics that reference - // the affected classes. Queue them all for a re-check. - if signature_changed { - self.schedule_diagnostics_for_open_files(&uri); + if let Err(err) = signature_changed { + tracing::error!("PHPantom: didChange parse task failed: {}", err); + } + }); } } @@ -523,6 +574,7 @@ impl LanguageServer for Backend { let uri = params.text_document.uri.to_string(); self.open_files.write().remove(&uri); + self.did_change_parse_locks.lock().remove(&uri); // Clean up Blade preprocessor state for the closed file. if self.is_blade_file(&uri) { @@ -703,7 +755,22 @@ impl LanguageServer for Backend { } async fn completion(&self, params: CompletionParams) -> Result> { - self.handle_completion(params).await + let started = std::time::Instant::now(); + let result = self.handle_completion(params).await; + let elapsed = started.elapsed(); + let item_count = match &result { + Ok(Some(CompletionResponse::Array(items))) => items.len(), + Ok(Some(CompletionResponse::List(list))) => list.items.len(), + _ => 0, + }; + tracing::debug!( + target: "performance", + "PHPantom: completion took {:?}, returned {} items", + elapsed, + item_count + ); + + result } async fn completion_resolve(&self, params: CompletionItem) -> Result { @@ -1442,6 +1509,35 @@ impl Backend { // ── Initialization helpers ─────────────────────────────────────────── + /// Pre-resolve Laravel's shared builder classes so the first + /// `Model::query()->` or `Model::with()->` completion does not pay + /// the full inheritance + mixin + patch cost on the editor hot path. + /// + /// This intentionally warms only framework-level classes. Per-model + /// generic specialisations like `Builder` still depend on the + /// concrete model and are resolved on demand. + fn warm_laravel_completion_cache(&self) -> usize { + let loader = |name: &str| self.find_or_load_class(name); + let mut warmed = 0usize; + + for fqn in [ + crate::virtual_members::laravel::ELOQUENT_BUILDER_FQN, + "Illuminate\\Database\\Query\\Builder", + ] { + let Some(class_info) = self.find_or_load_class(fqn) else { + continue; + }; + crate::virtual_members::resolve_class_fully_cached( + &class_info, + &loader, + &self.resolved_class_cache, + ); + warmed += 1; + } + + warmed + } + /// 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/types.rs b/src/types.rs index 6d9adf9e..f9d0b7c1 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1407,6 +1407,20 @@ pub struct LaravelMetadata { /// property should be synthesized. /// - `Some(Some("modified"))` — custom column name. pub updated_at_name: Option>, + /// Custom Eloquent builder class for the model. + /// + /// Detected from three Laravel mechanisms: + /// + /// 1. The `#[UseEloquentBuilder(CustomBuilder::class)]` attribute on + /// the model class (Laravel 11+). + /// 2. The `/** @use HasBuilder */` docblock + /// annotation on a `use HasBuilder;` trait usage. + /// 3. A `newEloquentBuilder()` method override returning a custom type. + /// + /// When set, the `LaravelModelProvider` uses this class instead of + /// the standard `Illuminate\Database\Eloquent\Builder` for + /// builder-as-static forwarding and `query()` resolution. + pub custom_builder: Option, } /// Stores extracted class information from a parsed PHP file. diff --git a/src/virtual_members/laravel/builder.rs b/src/virtual_members/laravel/builder.rs index 6bb82055..bd0c1b3c 100644 --- a/src/virtual_members/laravel/builder.rs +++ b/src/virtual_members/laravel/builder.rs @@ -17,7 +17,9 @@ use std::sync::Arc; use crate::inheritance::apply_substitution_to_conditional; use crate::php_type::PhpType; -use crate::types::{ClassInfo, ELOQUENT_COLLECTION_FQN, MethodInfo, Visibility}; +use crate::types::{ + ClassInfo, ELOQUENT_COLLECTION_FQN, MAX_INHERITANCE_DEPTH, MethodInfo, Visibility, +}; use crate::virtual_members::ResolvedClassCache; use super::ELOQUENT_BUILDER_FQN; @@ -91,9 +93,37 @@ pub(super) fn build_builder_forwarded_methods( class_loader: &dyn Fn(&str) -> Option>, cache: Option<&ResolvedClassCache>, ) -> Vec { - // Load the Eloquent Builder class. - let builder_class = match class_loader(ELOQUENT_BUILDER_FQN) { - Some(c) => c, + // Walk the parent chain to find a custom builder definition. + // Laravel's #[UseEloquentBuilder] and HasBuilder are effectively inherited. + let mut requested_builder_fqn = ELOQUENT_BUILDER_FQN.to_string(); + let mut current = Some(class.clone()); + for _ in 0..MAX_INHERITANCE_DEPTH { + let Some(curr) = current else { break }; + if let Some(name) = curr + .laravel() + .and_then(|l| l.custom_builder.as_ref()) + .and_then(|b| b.base_name()) + { + requested_builder_fqn = name.to_string(); + break; + } + current = curr + .parent_class + .as_ref() + .and_then(|p| class_loader(p)) + .map(Arc::unwrap_or_clone); + } + + // Load the Eloquent Builder class (or custom builder). + let (builder_class, builder_fqn) = match class_loader(&requested_builder_fqn) { + Some(c) => (c, requested_builder_fqn), + // Fallback to standard builder if custom builder fails to load. + None if requested_builder_fqn != ELOQUENT_BUILDER_FQN => { + match class_loader(ELOQUENT_BUILDER_FQN) { + Some(c) => (c, ELOQUENT_BUILDER_FQN.to_string()), + None => return Vec::new(), + } + } None => return Vec::new(), }; @@ -101,33 +131,36 @@ pub(super) fn build_builder_forwarded_methods( // including @mixin Query\Builder). This is safe because Builder // does not extend Model, so the LaravelModelProvider will not // recurse. - // - // With topological population, the base Builder at cache key - // ("Illuminate\\Database\\Eloquent\\Builder", []) is already - // fully resolved in the cache when model providers run. Scope - // injection happens at a higher layer (`try_inject_builder_scopes` - // in type resolution), not during Builder resolution, so the - // cached value is correct to use here. let resolved_builder = crate::virtual_members::resolve_class_fully_maybe_cached( &builder_class, class_loader, cache, ); + let effective_methods = builder_methods_with_unsubstituted_parent_templates( + &builder_class, + &resolved_builder, + class_loader, + cache, + ); // Build a substitution map: TModel → concrete model class name, // and static/$this/self → Builder. let builder_self_type = PhpType::Generic( - ELOQUENT_BUILDER_FQN.to_string(), + builder_fqn.clone(), vec![PhpType::Named(class.name.to_string())], ); - let mut subs = super::self_ref_subs(builder_self_type); - for param in &builder_class.template_params { - subs.insert(param.to_string(), PhpType::Named(class.name.to_string())); - } + let mut subs = super::self_ref_subs(builder_self_type.clone()); + insert_builder_template_substitutions( + &mut subs, + &builder_class, + class, + &builder_fqn, + class_loader, + ); let mut methods = Vec::new(); - for method in &resolved_builder.methods { + for method in &effective_methods { if method.visibility != Visibility::Public { continue; } @@ -176,9 +209,114 @@ pub(super) fn build_builder_forwarded_methods( methods.push(forwarded); } + // ── query() / newQuery() / newModelQuery() ────────────────────── + // When a model has a custom builder, User::query() should return + // UserBuilder instead of the default Builder. + for name in ["query", "newQuery", "newModelQuery"] { + methods.push(MethodInfo { + is_static: true, + ..MethodInfo::virtual_method_typed(name, Some(&builder_self_type)) + }); + } + methods } +fn builder_methods_with_unsubstituted_parent_templates( + builder_class: &ClassInfo, + resolved_builder: &ClassInfo, + class_loader: &dyn Fn(&str) -> Option>, + cache: Option<&ResolvedClassCache>, +) -> Vec> { + let mut methods: Vec> = resolved_builder.methods.iter().cloned().collect(); + if builder_class.fqn() == ELOQUENT_BUILDER_FQN || builder_class.name == ELOQUENT_BUILDER_FQN { + return methods; + } + + let Some(base_builder) = class_loader(ELOQUENT_BUILDER_FQN) else { + return methods; + }; + let resolved_base = crate::virtual_members::resolve_class_fully_maybe_cached( + &base_builder, + class_loader, + cache, + ); + + for parent_method in &resolved_base.methods { + if custom_builder_chain_declares_method(builder_class, &parent_method.name, class_loader) { + continue; + } + + if let Some(existing) = methods + .iter_mut() + .find(|m| m.name.eq_ignore_ascii_case(&parent_method.name)) + { + *existing = Arc::clone(parent_method); + } else { + methods.push(Arc::clone(parent_method)); + } + } + + methods +} + +fn custom_builder_chain_declares_method( + builder_class: &ClassInfo, + method_name: &str, + class_loader: &dyn Fn(&str) -> Option>, +) -> bool { + let mut class = builder_class.clone(); + loop { + if class.fqn() == ELOQUENT_BUILDER_FQN || class.name == ELOQUENT_BUILDER_FQN { + return false; + } + + if class + .methods + .iter() + .any(|m| m.name.eq_ignore_ascii_case(method_name)) + { + return true; + } + + let Some(parent) = class.parent_class.as_deref() else { + return false; + }; + if parent == ELOQUENT_BUILDER_FQN { + return false; + } + + let Some(parent_class) = class_loader(parent) else { + return false; + }; + class = parent_class.as_ref().clone(); + } +} + +fn insert_builder_template_substitutions( + subs: &mut std::collections::HashMap, + builder_class: &ClassInfo, + model_class: &ClassInfo, + builder_fqn: &str, + class_loader: &dyn Fn(&str) -> Option>, +) { + let model_type = PhpType::Named(model_class.name.to_string()); + for param in &builder_class.template_params { + subs.insert(param.to_string(), model_type.clone()); + } + + if builder_fqn != ELOQUENT_BUILDER_FQN + && let Some(base_builder) = class_loader(ELOQUENT_BUILDER_FQN) + { + for param in &base_builder.template_params { + subs.entry(param.to_string()) + .or_insert_with(|| model_type.clone()); + } + } + + subs.entry("TModel".to_string()).or_insert(model_type); +} + // ─── Tests ────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/src/virtual_members/laravel/builder_tests.rs b/src/virtual_members/laravel/builder_tests.rs index 97a2051b..ac5b6a37 100644 --- a/src/virtual_members/laravel/builder_tests.rs +++ b/src/virtual_members/laravel/builder_tests.rs @@ -77,7 +77,8 @@ fn builder_forwarding_converts_instance_to_static() { }; let result = build_builder_forwarded_methods(&user, &loader, None); - assert_eq!(result.len(), 1); + // 1 real + 3 synthesized (query, newQuery, newModelQuery) + assert_eq!(result.len(), 4); assert!(result[0].is_static, "Forwarded method should be static"); assert_eq!(result[0].name, "where"); } @@ -96,7 +97,7 @@ fn builder_forwarding_maps_static_to_builder_self_type() { }; let result = build_builder_forwarded_methods(&user, &loader, None); - assert_eq!(result.len(), 1); + assert_eq!(result.len(), 4); assert_eq!( result[0].return_type_str().as_deref(), Some("Illuminate\\Database\\Eloquent\\Builder"), @@ -118,7 +119,7 @@ fn builder_forwarding_maps_this_to_builder_self_type() { }; let result = build_builder_forwarded_methods(&user, &loader, None); - assert_eq!(result.len(), 1); + assert_eq!(result.len(), 4); assert_eq!( result[0].return_type_str().as_deref(), Some("Illuminate\\Database\\Eloquent\\Builder"), @@ -140,7 +141,7 @@ fn builder_forwarding_maps_self_to_builder_self_type() { }; let result = build_builder_forwarded_methods(&user, &loader, None); - assert_eq!(result.len(), 1); + assert_eq!(result.len(), 4); assert_eq!( result[0].return_type_str().as_deref(), Some("Illuminate\\Database\\Eloquent\\Builder"), @@ -162,7 +163,7 @@ fn builder_forwarding_maps_tmodel_to_concrete_class() { }; let result = build_builder_forwarded_methods(&user, &loader, None); - assert_eq!(result.len(), 1); + assert_eq!(result.len(), 4); assert_eq!( result[0].return_type_str().as_deref(), Some("App\\Models\\User|null"), @@ -170,6 +171,39 @@ fn builder_forwarding_maps_tmodel_to_concrete_class() { ); } +#[test] +fn custom_builder_forwarding_maps_parent_tmodel_to_concrete_class() { + let builder = make_builder(vec![make_method("first", Some("TModel|null"))]); + let mut custom_builder = make_class("App\\Models\\UserBuilder"); + custom_builder.parent_class = Some(atom(ELOQUENT_BUILDER_FQN)); + let mut user = make_class("App\\Models\\User"); + user.laravel = Some(Box::new(crate::types::LaravelMetadata { + custom_builder: Some(PhpType::Named("App\\Models\\UserBuilder".to_string())), + ..Default::default() + })); + + let loader = |name: &str| -> Option> { + if name == "App\\Models\\UserBuilder" { + Some(Arc::new(custom_builder.clone())) + } else if name == ELOQUENT_BUILDER_FQN { + Some(Arc::new(builder.clone())) + } else { + None + } + }; + + let result = build_builder_forwarded_methods(&user, &loader, None); + let first = result + .iter() + .find(|m| m.name == "first") + .expect("first() should be inherited from the parent Builder"); + assert_eq!( + first.return_type_str().as_deref(), + Some("App\\Models\\User|null"), + "Inherited parent Builder methods should substitute TModel" + ); +} + #[test] fn builder_forwarding_maps_generic_collection_return() { let builder = make_builder(vec![make_method( @@ -187,7 +221,7 @@ fn builder_forwarding_maps_generic_collection_return() { }; let result = build_builder_forwarded_methods(&user, &loader, None); - assert_eq!(result.len(), 1); + assert_eq!(result.len(), 4); assert_eq!( result[0].return_type_str().as_deref(), Some("Illuminate\\Database\\Eloquent\\Collection"), @@ -209,7 +243,7 @@ fn builder_forwarding_maps_static_in_union() { }; let result = build_builder_forwarded_methods(&user, &loader, None); - assert_eq!(result.len(), 1); + assert_eq!(result.len(), 4); assert_eq!( result[0].return_type_str().as_deref(), Some("Illuminate\\Database\\Eloquent\\Builder|null"), @@ -237,7 +271,7 @@ fn builder_forwarding_skips_magic_methods() { let result = build_builder_forwarded_methods(&user, &loader, None); assert_eq!( result.len(), - 1, + 4, "Only non-magic methods should be forwarded" ); assert_eq!(result[0].name, "where"); @@ -261,7 +295,7 @@ fn builder_forwarding_skips_non_public_methods() { }; let result = build_builder_forwarded_methods(&user, &loader, None); - assert_eq!(result.len(), 1, "Only public methods should be forwarded"); + assert_eq!(result.len(), 4, "Only public methods should be forwarded"); assert_eq!(result[0].name, "where"); } @@ -288,7 +322,7 @@ fn builder_forwarding_skips_methods_already_on_model() { let result = build_builder_forwarded_methods(&user, &loader, None); assert_eq!( result.len(), - 1, + 4, "Should skip 'myMethod' because the model already has it as static" ); assert_eq!(result[0].name, "where"); @@ -316,7 +350,7 @@ fn builder_forwarding_does_not_skip_instance_method_with_same_name() { let result = build_builder_forwarded_methods(&user, &loader, None); assert_eq!( result.len(), - 1, + 4, "Static forwarded method should be added even when an instance method with the same name exists" ); assert!(result[0].is_static); @@ -340,7 +374,7 @@ fn builder_forwarding_maps_parameter_types() { }; let result = build_builder_forwarded_methods(&user, &loader, None); - assert_eq!(result.len(), 1); + assert_eq!(result.len(), 4); assert_eq!( result[0].parameters[0].type_hint_str().as_deref(), Some("App\\Models\\User"), @@ -372,7 +406,7 @@ fn builder_forwarding_preserves_method_metadata() { }; let result = build_builder_forwarded_methods(&user, &loader, None); - assert_eq!(result.len(), 1); + assert_eq!(result.len(), 4); assert!( result[0].deprecation_message.is_some(), "Deprecated flag should be preserved" @@ -404,7 +438,7 @@ fn builder_forwarding_multiple_methods() { }; let result = build_builder_forwarded_methods(&user, &loader, None); - assert_eq!(result.len(), 4); + assert_eq!(result.len(), 7); let names: Vec<&str> = result.iter().map(|m| m.name.as_str()).collect(); assert!(names.contains(&"where")); assert!(names.contains(&"orderBy")); @@ -427,7 +461,7 @@ fn builder_forwarding_with_no_return_type() { }; let result = build_builder_forwarded_methods(&user, &loader, None); - assert_eq!(result.len(), 1); + assert_eq!(result.len(), 4); assert!( result[0].return_type.is_none(), "None return type should stay None" @@ -451,7 +485,7 @@ fn builder_forwarding_preserves_non_template_return_types() { }; let result = build_builder_forwarded_methods(&user, &loader, None); - assert_eq!(result.len(), 2); + assert_eq!(result.len(), 5); assert_eq!(result[0].return_type_str().as_deref(), Some("string")); assert_eq!(result[1].return_type_str().as_deref(), Some("bool")); } diff --git a/src/virtual_members/laravel/helpers.rs b/src/virtual_members/laravel/helpers.rs index ce79a60a..faac1be8 100644 --- a/src/virtual_members/laravel/helpers.rs +++ b/src/virtual_members/laravel/helpers.rs @@ -77,6 +77,20 @@ pub fn extends_eloquent_model( walks_parent_chain(class, class_loader, is_eloquent_model) } +/// Determine whether `class_name` is the Eloquent Builder base class. +pub(in crate::virtual_members::laravel) fn is_eloquent_builder(class_name: &str) -> bool { + class_name == super::ELOQUENT_BUILDER_FQN +} + +/// Walk the parent chain of `class` looking for +/// `Illuminate\Database\Eloquent\Builder`. +pub fn extends_eloquent_builder( + class: &ClassInfo, + class_loader: &dyn Fn(&str) -> Option>, +) -> bool { + walks_parent_chain(class, class_loader, is_eloquent_builder) +} + /// Convert a camelCase or PascalCase string to snake_case. /// /// Inserts an underscore before each uppercase letter that follows a diff --git a/src/virtual_members/laravel/mod.rs b/src/virtual_members/laravel/mod.rs index 876434c6..b27332ab 100644 --- a/src/virtual_members/laravel/mod.rs +++ b/src/virtual_members/laravel/mod.rs @@ -349,7 +349,7 @@ pub(crate) fn try_inject_builder_scopes( generic_args: &[PhpType], class_loader: &dyn Fn(&str) -> Option>, ) { - if !is_eloquent_builder_fqn(base_fqn, raw_cls) || generic_args.is_empty() { + if !is_eloquent_builder_fqn(base_fqn, raw_cls, class_loader) || generic_args.is_empty() { return; } @@ -645,10 +645,17 @@ fn inject_model_virtual_methods( /// 2. The `ClassInfo.name` field (short name or FQN depending on source). /// 3. The FQN constructed from `file_namespace + name` (PSR-4 loaded classes /// where `name` is the short name only). -fn is_eloquent_builder_fqn(base_fqn: &str, cls: &ClassInfo) -> bool { +/// +/// Also checks whether the class extends the base Eloquent Builder. +fn is_eloquent_builder_fqn( + base_fqn: &str, + cls: &ClassInfo, + class_loader: &dyn Fn(&str) -> Option>, +) -> bool { base_fqn == ELOQUENT_BUILDER_FQN || cls.name == ELOQUENT_BUILDER_FQN || cls.fqn() == ELOQUENT_BUILDER_FQN + || helpers::extends_eloquent_builder(cls, class_loader) } /// Find a class in a slice by name (short or FQN). diff --git a/src/virtual_members/laravel/where_property.rs b/src/virtual_members/laravel/where_property.rs index 49079b31..7b1d7f81 100644 --- a/src/virtual_members/laravel/where_property.rs +++ b/src/virtual_members/laravel/where_property.rs @@ -130,8 +130,14 @@ pub fn build_where_property_methods_for_class( } // Build the return type: Builder. + let builder_fqn = class + .laravel() + .and_then(|l| l.custom_builder.as_ref()) + .and_then(|t| t.base_name()) + .unwrap_or(ELOQUENT_BUILDER_FQN); + let return_type = PhpType::Generic( - ELOQUENT_BUILDER_FQN.to_string(), + builder_fqn.to_string(), vec![PhpType::Named(class.name.to_string())], ); diff --git a/src/virtual_members/mod.rs b/src/virtual_members/mod.rs index 020dd0fe..db3d9c15 100644 --- a/src/virtual_members/mod.rs +++ b/src/virtual_members/mod.rs @@ -398,9 +398,12 @@ pub fn merge_virtual_members(class: &mut ClassInfo, virtual_members: VirtualMemb for method in virtual_members.methods { let key = (method.name.to_string(), method.is_static); if let Some(&idx) = method_index.get(&key) { - if class.methods[idx].has_scope_attribute { - // Replace the #[Scope]-attributed original with the - // synthesized virtual scope method. + if class.methods[idx].has_scope_attribute + || matches!(method.name.as_str(), "query" | "newQuery" | "newModelQuery") + { + // Replace the original with the synthesized virtual method. + // For scope attributes, the original is an implementation detail. + // For query methods, we want to return the custom builder. class.methods.make_mut()[idx] = Arc::new(method); } // Otherwise: real declared member — keep the original. @@ -690,7 +693,17 @@ pub fn resolve_class_fully_maybe_cached( class_loader: &dyn Fn(&str) -> Option>, cache: Option<&ResolvedClassCache>, ) -> Arc { - resolve_class_fully_inner(class, class_loader, cache, &[]) + let started = std::time::Instant::now(); + let result = resolve_class_fully_inner(class, class_loader, cache, &[]); + let elapsed = started.elapsed(); + if elapsed >= std::time::Duration::from_millis(50) { + tracing::info!( + "PHPantom: resolve_class_fully_cached({}) took {:?}", + class.fqn(), + elapsed + ); + } + result } /// Resolve a class fully and apply generic type argument substitution, @@ -733,6 +746,7 @@ pub fn resolve_class_fully_with_generics( } } + let started = std::time::Instant::now(); // Resolve the base class (cached at (FQN, [])). let base = resolve_class_fully_inner(class, class_loader, cache, &[]); @@ -748,9 +762,47 @@ pub fn resolve_class_fully_with_generics( c.lock().insert(cache_key, Arc::clone(&result)); } + let elapsed = started.elapsed(); + if elapsed >= std::time::Duration::from_millis(50) { + tracing::info!( + "PHPantom: resolve_class_fully_with_generics({}, {:?}) took {:?}", + class.fqn(), + generic_arg_strings, + elapsed + ); + } + result } +/// Resolve a class fully and apply generic type arguments, deriving the +/// cache key strings from the structured [`PhpType`] arguments. +/// +/// Use this on hot paths that already have concrete type arguments and would +/// otherwise call `resolve_class_fully_maybe_cached` followed by +/// `apply_generic_args`. The substituted result is cached under +/// `(FQN, generic_args)` while the base resolved class is still shared under +/// `(FQN, [])`. +pub fn resolve_class_fully_with_type_args( + class: &ClassInfo, + class_loader: &dyn Fn(&str) -> Option>, + cache: Option<&ResolvedClassCache>, + generic_args: &[crate::php_type::PhpType], +) -> Arc { + if generic_args.is_empty() { + return resolve_class_fully_inner(class, class_loader, cache, &[]); + } + + let generic_arg_strings: Vec = generic_args.iter().map(|arg| arg.to_string()).collect(); + resolve_class_fully_with_generics( + class, + class_loader, + cache, + &generic_arg_strings, + generic_args, + ) +} + /// Shared implementation behind [`resolve_class_fully`] and /// [`resolve_class_fully_cached`]. fn resolve_class_fully_inner( @@ -818,6 +870,12 @@ fn resolve_class_fully_inner( merged.mixins.push(mixin); } } + if fqn.as_str() == "Illuminate\\Database\\Eloquent\\Builder" { + let mixin = atom("Illuminate\\Database\\Query\\Builder"); + if !merged.mixins.contains(&mixin) { + merged.mixins.push(mixin); + } + } let providers = default_providers(); if !providers.is_empty() { diff --git a/src/virtual_members/tests.rs b/src/virtual_members/tests.rs index 2566990a..6aeac4cd 100644 --- a/src/virtual_members/tests.rs +++ b/src/virtual_members/tests.rs @@ -1019,6 +1019,42 @@ fn make_cache() -> HashMap> { HashMap::new() } +#[test] +fn resolve_class_fully_with_type_args_caches_specialisation() { + let mut collection = make_class("Collection"); + collection.template_params.push(atom("TValue")); + collection + .methods + .push(Arc::new(make_method("first", Some("TValue")))); + + let cache = new_resolved_class_cache(); + let class_loader = |_name: &str| None; + let args = vec![PhpType::parse("User")]; + + let first = resolve_class_fully_with_type_args(&collection, &class_loader, Some(&cache), &args); + let second = + resolve_class_fully_with_type_args(&collection, &class_loader, Some(&cache), &args); + + assert!( + Arc::ptr_eq(&first, &second), + "same generic specialisation should come from cache" + ); + assert_eq!( + first.get_method("first").and_then(|m| m.return_type_str()), + Some("User".to_string()) + ); + + let map = cache.lock(); + assert!( + map.contains_key(&(atom("Collection"), Vec::new())), + "base resolved class should be cached separately" + ); + assert!( + map.contains_key(&(atom("Collection"), vec!["User".to_string()])), + "generic specialisation should be cached by type arguments" + ); +} + #[test] fn evict_removes_direct_match() { let mut cache = make_cache(); diff --git a/tests/integration/laravel_custom_builder.rs b/tests/integration/laravel_custom_builder.rs new file mode 100644 index 00000000..1a14f7fa --- /dev/null +++ b/tests/integration/laravel_custom_builder.rs @@ -0,0 +1,1041 @@ +use crate::common::create_psr4_workspace; +use tower_lsp::LanguageServer; +use tower_lsp::lsp_types::*; + +#[tokio::test] +async fn test_custom_eloquent_builder_attribute() { + let (backend, dir) = create_psr4_workspace( + r#"{ "autoload": { "psr-4": { "App\\": "src/", "Illuminate\\": "vendor/illuminate/" } } }"#, + &[ + ( + "vendor/illuminate/Model.php", + " = match items { + CompletionResponse::Array(arr) => arr.into_iter().map(|i| i.label).collect(), + _ => panic!("Expected array"), + }; + assert!( + labels.iter().any(|l| l.starts_with("active")), + "Should suggest custom builder method. Labels: {:?}", + labels + ); + + // Test query() return type + let uri2 = Url::from_file_path(dir.path().join("test2.php")).unwrap(); + let content2 = "act"; + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri2.clone(), + language_id: "php".to_string(), + version: 1, + text: content2.to_string(), + }, + }) + .await; + + let req = CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier::new(uri2.clone()), + position: Position::new(2, 18), + }, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + context: None, + }; + let items = backend.completion(req).await.unwrap().unwrap(); + let custom_items = match items { + CompletionResponse::Array(arr) => arr, + _ => panic!("Expected array"), + }; + let labels: Vec<_> = custom_items.iter().map(|i| i.label.clone()).collect(); + assert!( + labels.iter().any(|l| l.starts_with("active")), + "query() should return custom builder. Labels: {:?}", + labels + ); + let active = custom_items + .iter() + .find(|i| i.label.starts_with("active")) + .expect("active completion should be present"); + assert_eq!( + active + .label_details + .as_ref() + .and_then(|d| d.description.as_deref()), + Some("UserBuilder"), + "custom builder methods should keep the custom builder label" + ); + + let where_item = custom_items + .iter() + .find(|i| i.label.starts_with("where")) + .expect("where completion should be present"); + assert_eq!( + where_item + .label_details + .as_ref() + .and_then(|d| d.description.as_deref()), + Some("Builder"), + "inherited base builder methods should show their declaring builder, not the custom builder" + ); + + // Test orWhereBetween on model (forwarded from base Builder via custom builder) + let uri3 = Url::from_file_path(dir.path().join("test3.php")).unwrap(); + let content3 = " = match items { + CompletionResponse::Array(arr) => arr.into_iter().map(|i| i.label).collect(), + _ => panic!("Expected array"), + }; + assert!( + labels.iter().any(|l| l.starts_with("orWhereBetween")), + "Should suggest forwarded builder method. Labels: {:?}", + labels + ); +} + +#[tokio::test] +async fn test_default_eloquent_builder_completion_label() { + let (backend, dir) = create_psr4_workspace( + r#"{ "autoload": { "psr-4": { "App\\": "src/", "Illuminate\\": "vendor/illuminate/" } } }"#, + &[ + ( + "vendor/illuminate/Model.php", + "whe"; + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "php".to_string(), + version: 1, + text: content.to_string(), + }, + }) + .await; + + let req = CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier::new(uri), + position: Position::new(2, 18), + }, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + context: None, + }; + let items = backend.completion(req).await.unwrap().unwrap(); + let items = match items { + CompletionResponse::Array(arr) => arr, + _ => panic!("Expected array"), + }; + let where_item = items + .iter() + .find(|i| i.label.starts_with("where")) + .expect("where completion should be present for default Eloquent Builder"); + assert_eq!( + where_item + .label_details + .as_ref() + .and_then(|d| d.description.as_deref()), + Some("Builder") + ); +} + +#[tokio::test] +async fn test_goto_definition_forwarded_builder_method() { + let (backend, dir) = create_psr4_workspace( + r#"{ "autoload": { "psr-4": { "App\\": "src/", "Illuminate\\": "vendor/illuminate/" } } }"#, + &[ + ( + "vendor/illuminate/Model.php", + " vec![l], + Some(GotoDefinitionResponse::Array(a)) => a, + None => panic!("Should have found a location for 'orWhereBetween'"), + _ => panic!("Expected locations"), + }; + + assert!( + !locs.is_empty(), + "Should resolve orWhereBetween to Builder method" + ); + let uri_res = locs[0].uri.to_string(); + assert!( + uri_res.contains("Builder.php"), + "Should point to Builder.php, got {}", + uri_res + ); + // orWhereBetween is on line 3 (0-indexed 2) + assert_eq!(locs[0].range.start.line, 2); +} + +#[tokio::test] +async fn test_goto_definition_custom_builder_static_call() { + let (backend, dir) = create_psr4_workspace( + r#"{ "autoload": { "psr-4": { "App\\": "src/", "Illuminate\\": "vendor/illuminate/" } } }"#, + &[ + ( + "vendor/illuminate/Model.php", + " vec![l], + Some(GotoDefinitionResponse::Array(a)) => a, + None => panic!("Should have found a location for 'active'"), + _ => panic!("Expected locations"), + }; + + assert!( + !locs.is_empty(), + "Should resolve active to custom UserBuilder method" + ); + let uri_res = locs[0].uri.to_string(); + assert!( + uri_res.contains("UserBuilder.php"), + "Should point to UserBuilder.php, got {}", + uri_res + ); + // active() is on line 5 (0-indexed 4) + assert_eq!(locs[0].range.start.line, 5); +} + +#[tokio::test] +async fn test_goto_definition_custom_builder_same_namespace_no_use() { + let (backend, dir) = create_psr4_workspace( + r#"{ "autoload": { "psr-4": { "App\\Models\\": "src/Models/", "Illuminate\\": "vendor/illuminate/" } } }"#, + &[ + ( + "vendor/illuminate/Model.php", + " vec![l], + Some(GotoDefinitionResponse::Array(a)) => a, + None => panic!("Should have found a location for 'active'"), + _ => panic!("Expected locations"), + }; + + assert!( + !locs.is_empty(), + "Should resolve active to custom MemberBuilder method" + ); + let uri_res = locs[0].uri.to_string(); + assert!( + uri_res.contains("MemberBuilder.php"), + "Should point to MemberBuilder.php, got {}", + uri_res + ); + assert_eq!(locs[0].range.start.line, 5); +} + +#[tokio::test] +async fn test_custom_builder_inherited_override_beats_framework_builder() { + let (backend, dir) = create_psr4_workspace( + r#"{ "autoload": { "psr-4": { "App\\": "src/", "Illuminate\\": "vendor/illuminate/" } } }"#, + &[ + ( + "vendor/illuminate/Model.php", + "whe"; + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "php".to_string(), + version: 1, + text: content.to_string(), + }, + }) + .await; + + let items = backend + .completion(CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier::new(uri.clone()), + position: Position::new(2, 18), + }, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + context: None, + }) + .await + .unwrap() + .unwrap(); + let items = match items { + CompletionResponse::Array(arr) => arr, + _ => panic!("Expected array"), + }; + let where_item = items + .iter() + .find(|i| i.label.starts_with("where")) + .expect("where completion should be present"); + assert_eq!( + where_item + .label_details + .as_ref() + .and_then(|d| d.description.as_deref()), + Some("BaseBuilder"), + "inherited custom builder override should keep its declaring builder" + ); + + let uri2 = Url::from_file_path(dir.path().join("test2.php")).unwrap(); + let content2 = "where('id', 1);"; + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri2.clone(), + language_id: "php".to_string(), + version: 1, + text: content2.to_string(), + }, + }) + .await; + + let resp = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier::new(uri2), + position: Position::new(2, 17), + }, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }) + .await + .unwrap(); + let locs = match resp { + Some(GotoDefinitionResponse::Scalar(l)) => vec![l], + Some(GotoDefinitionResponse::Array(a)) => a, + None => panic!("Should have found a location for custom where"), + _ => panic!("Expected locations"), + }; + assert!( + locs[0].uri.to_string().contains("BaseBuilder.php"), + "Should point to BaseBuilder.php, got {}", + locs[0].uri + ); +} + +#[tokio::test] +async fn test_missing_custom_builder_falls_back_to_eloquent_builder_type() { + let (backend, dir) = create_psr4_workspace( + r#"{ "autoload": { "psr-4": { "App\\": "src/", "Illuminate\\": "vendor/illuminate/" } } }"#, + &[ + ( + "vendor/illuminate/Model.php", + "whe"; + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "php".to_string(), + version: 1, + text: content.to_string(), + }, + }) + .await; + + let items = backend + .completion(CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier::new(uri), + position: Position::new(2, 18), + }, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + context: None, + }) + .await + .unwrap() + .unwrap(); + let labels: Vec<_> = match items { + CompletionResponse::Array(arr) => arr.into_iter().map(|i| i.label).collect(), + _ => panic!("Expected array"), + }; + assert!( + labels.iter().any(|l| l.starts_with("where")), + "missing custom builder should fall back to Builder. Labels: {:?}", + labels + ); +} + +#[tokio::test] +async fn test_goto_definition_custom_builder_inherited_attribute() { + let (backend, dir) = create_psr4_workspace( + r#"{ "autoload": { "psr-4": { "App\\": "src/", "Illuminate\\": "vendor/illuminate/" } } }"#, + &[ + ( + "vendor/illuminate/Model.php", + " vec![l], + Some(GotoDefinitionResponse::Array(a)) => a, + None => panic!("Should have found a location for 'active'"), + _ => panic!("Expected locations"), + }; + + assert!( + !locs.is_empty(), + "Should resolve active to custom UserBuilder method" + ); + let uri_res = locs[0].uri.to_string(); + assert!( + uri_res.contains("UserBuilder.php"), + "Should point to UserBuilder.php, got {}", + uri_res + ); +} + +#[tokio::test] +async fn test_completion_custom_builder_inherited_attribute() { + let (backend, dir) = create_psr4_workspace( + r#"{ "autoload": { "psr-4": { "App\\": "src/", "Illuminate\\": "vendor/illuminate/" } } }"#, + &[ + ( + "vendor/illuminate/Model.php", + " l.items, + Some(CompletionResponse::Array(a)) => a, + None => Vec::new(), + }; + let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect(); + + assert!( + labels.contains(&"active()"), + "Should contain 'active()' from inherited custom builder. Labels: {:?}", + labels + ); +} diff --git a/tests/integration/laravel_references.rs b/tests/integration/laravel_references.rs new file mode 100644 index 00000000..92715042 --- /dev/null +++ b/tests/integration/laravel_references.rs @@ -0,0 +1,426 @@ +use crate::common::create_psr4_workspace; +use tower_lsp::LanguageServer; +use tower_lsp::lsp_types::*; + +#[tokio::test] +async fn test_laravel_custom_builder_references_from_builder() { + let (backend, dir) = create_psr4_workspace( + r#"{ "autoload": { "psr-4": { "App\\": "src/", "Illuminate\\": "vendor/illuminate/" } } }"#, + &[ + ( + "vendor/illuminate/Model.php", + "active(); +"#, + ), + ], + ); + + // Open all files to index them + for path in [ + "vendor/illuminate/Builder.php", + "vendor/illuminate/Model.php", + "src/Models/UserBuilder.php", + "src/Models/User.php", + "src/Models/PostBuilder.php", + "src/Models/Post.php", + "usage.php", + ] { + let uri = Url::from_file_path(dir.path().join(path)).unwrap(); + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri, + language_id: "php".to_string(), + version: 1, + text: std::fs::read_to_string(dir.path().join(path)).unwrap(), + }, + }) + .await; + } + + let builder_uri = Url::from_file_path(dir.path().join("src/Models/UserBuilder.php")).unwrap(); + + // Find references for UserBuilder::active() declaration (line 5, col 21) + let params = ReferenceParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier::new(builder_uri), + position: Position::new(5, 21), + }, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + context: ReferenceContext { + include_declaration: true, + }, + }; + + let locations = backend + .references(params) + .await + .unwrap() + .unwrap_or_default(); + + let usage_uri = Url::from_file_path(dir.path().join("usage.php")).unwrap(); + let usage_locs: Vec<_> = locations.iter().filter(|l| l.uri == usage_uri).collect(); + + let lines: Vec<_> = usage_locs.iter().map(|l| l.range.start.line).collect(); + + assert!( + lines.contains(&4), + "Should find User::active(). Found at lines: {:?}", + lines + ); + assert!( + lines.contains(&6), + "Should find User::query()->active(). Found at lines: {:?}", + lines + ); + assert!( + !lines.contains(&5), + "Should NOT find Post::active(). Found at lines: {:?}", + lines + ); + assert_eq!( + usage_locs.len(), + 2, + "Should find exactly 2 references in usage.php, but found: {:?}", + lines + ); +} + +#[tokio::test] +async fn test_laravel_custom_builder_references_from_model() { + let (backend, dir) = create_psr4_workspace( + r#"{ "autoload": { "psr-4": { "App\\": "src/", "Illuminate\\": "vendor/illuminate/" } } }"#, + &[ + ( + "vendor/illuminate/Model.php", + "active(); +"#, + ), + ], + ); + + // Open all files to index them + for path in [ + "vendor/illuminate/Builder.php", + "vendor/illuminate/Model.php", + "src/Models/UserBuilder.php", + "src/Models/User.php", + "src/Models/PostBuilder.php", + "src/Models/Post.php", + "usage.php", + ] { + let uri = Url::from_file_path(dir.path().join(path)).unwrap(); + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri, + language_id: "php".to_string(), + version: 1, + text: std::fs::read_to_string(dir.path().join(path)).unwrap(), + }, + }) + .await; + } + + let usage_uri = Url::from_file_path(dir.path().join("usage.php")).unwrap(); + + // Find references for User::active() usage (line 4, col 6) + let params = ReferenceParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier::new(usage_uri.clone()), + position: Position::new(4, 6), + }, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + context: ReferenceContext { + include_declaration: true, + }, + }; + + let locations = backend + .references(params) + .await + .unwrap() + .unwrap_or_default(); + + let usage_locs: Vec<_> = locations.iter().filter(|l| l.uri == usage_uri).collect(); + let lines: Vec<_> = usage_locs.iter().map(|l| l.range.start.line).collect(); + + assert!( + lines.contains(&4), + "Should find User::active(). Found at lines: {:?}", + lines + ); + assert!( + lines.contains(&6), + "Should find User::query()->active(). Found at lines: {:?}", + lines + ); + assert!( + !lines.contains(&5), + "Should NOT find Post::active(). Found at lines: {:?}", + lines + ); + assert_eq!( + usage_locs.len(), + 2, + "Should find exactly 2 references in usage.php, but found: {:?}", + lines + ); + + // Also should find the declaration in UserBuilder.php + let builder_locs: Vec<_> = locations + .iter() + .filter(|l| l.uri.to_string().contains("UserBuilder.php")) + .collect(); + assert_eq!( + builder_locs.len(), + 1, + "Should find declaration in UserBuilder.php" + ); +} + +#[tokio::test] +async fn test_laravel_custom_builder_references_inherited() { + let (backend, dir) = create_psr4_workspace( + r#"{ "autoload": { "psr-4": { "App\\": "src/", "Illuminate\\": "vendor/illuminate/" } } }"#, + &[ + ( + "vendor/illuminate/Model.php", + "active(); +"#, + ), + ], + ); + + // Open all files to index them + for path in [ + "vendor/illuminate/Builder.php", + "vendor/illuminate/Model.php", + "src/Models/UserBuilder.php", + "src/Models/BaseModel.php", + "src/Models/Member.php", + "usage.php", + ] { + let uri = Url::from_file_path(dir.path().join(path)).unwrap(); + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri, + language_id: "php".to_string(), + version: 1, + text: std::fs::read_to_string(dir.path().join(path)).unwrap(), + }, + }) + .await; + } + + let builder_uri = Url::from_file_path(dir.path().join("src/Models/UserBuilder.php")).unwrap(); + + // Find references for UserBuilder::active() declaration (line 5, col 21) + let params = ReferenceParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier::new(builder_uri), + position: Position::new(5, 21), + }, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + context: ReferenceContext { + include_declaration: true, + }, + }; + + let locations = backend + .references(params) + .await + .unwrap() + .unwrap_or_default(); + + let usage_uri = Url::from_file_path(dir.path().join("usage.php")).unwrap(); + let usage_locs: Vec<_> = locations.iter().filter(|l| l.uri == usage_uri).collect(); + let lines: Vec<_> = usage_locs.iter().map(|l| l.range.start.line).collect(); + + assert!( + lines.contains(&3), + "Should find Member::active(). Found at lines: {:?}", + lines + ); + assert!( + lines.contains(&4), + "Should find Member::query()->active(). Found at lines: {:?}", + lines + ); + assert_eq!( + usage_locs.len(), + 2, + "Should find exactly 2 references in usage.php" + ); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index ca2388f8..c93aa47d 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -100,6 +100,8 @@ mod folding_ranges; mod hover; mod implementation; mod inlay_hints; +mod laravel_custom_builder; +mod laravel_references; mod linked_editing; mod parser; mod php_version;