From 3710feb048e2b15f5f22d668f2adfcb31bf48f8a Mon Sep 17 00:00:00 2001 From: Teemo Date: Wed, 13 May 2026 18:44:24 +0800 Subject: [PATCH 1/4] feat(laravel): support custom Eloquent builders and optimize references - Implement support for #[UseEloquentBuilder] and HasBuilder trait. - Support inherited custom builder attributes by walking the Model parent chain. - Fix Go-to-Definition and Completion for forwarded methods on inherited custom builders. - Fix 'Find References' for forwarded builder methods by bridging Model/Builder hierarchies. - Improve reference accuracy by scoping hierarchy 'walk-down' to classes defining the member. - Relax is_static check for Laravel-related member references. - Optimize LSP responsiveness with background parsing and completion caching. - Add comprehensive integration tests for Laravel custom builder resolution and references. --- Cargo.toml | 8 + benches/custom_builder.rs | 121 ++ benches/laravel_completion.rs | 119 ++ examples/laravel/app/Models/Baker.php | 2 + examples/laravel/app/Models/BakerBuilder.php | 20 + src/completion/builder.rs | 45 +- src/completion/call_resolution.rs | 26 +- src/completion/handler.rs | 326 ++++-- src/completion/variable/rhs_resolution.rs | 35 +- src/definition/member/mod.rs | 35 +- src/lib.rs | 32 + src/parser/ast_update.rs | 11 + src/parser/classes.rs | 72 ++ src/references/mod.rs | 166 ++- src/server.rs | 172 ++- src/types.rs | 14 + src/virtual_members/laravel/builder.rs | 172 ++- src/virtual_members/laravel/builder_tests.rs | 66 +- src/virtual_members/laravel/helpers.rs | 14 + src/virtual_members/laravel/mod.rs | 11 +- src/virtual_members/laravel/where_property.rs | 8 +- src/virtual_members/mod.rs | 66 +- src/virtual_members/tests.rs | 36 + tests/integration/laravel_custom_builder.rs | 1042 +++++++++++++++++ tests/integration/laravel_references.rs | 361 ++++++ tests/integration/main.rs | 2 + 26 files changed, 2748 insertions(+), 234 deletions(-) create mode 100644 benches/custom_builder.rs create mode 100644 benches/laravel_completion.rs create mode 100644 examples/laravel/app/Models/BakerBuilder.php create mode 100644 tests/integration/laravel_custom_builder.rs create mode 100644 tests/integration/laravel_references.rs 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/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..40d93194 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(laravel) = curr.laravel() { + if let Some(builder) = &laravel.custom_builder { + if let Some(name) = builder.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..7c6f2fdd 100644 --- a/src/references/mod.rs +++ b/src/references/mod.rs @@ -144,7 +144,7 @@ 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, @@ -177,7 +177,7 @@ 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); + self.resolve_member_access_hierarchy(uri, subject_text, *is_static, span_start, member_name); self.find_member_references( member_name, @@ -196,7 +196,7 @@ 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 +767,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 +828,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 +882,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 +1204,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 +1213,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 +1223,8 @@ impl Backend { &self, uri: &str, offset: u32, + member_name: &str, + is_static: bool, ) -> Option> { let classes: Vec> = self .uri_classes_index @@ -1227,7 +1243,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 +1309,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 +1329,69 @@ 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) { + if let Some(laravel) = cls.laravel() { + if let Some(builder) = &laravel.custom_builder { + if let Some(builder_fqn) = builder.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(builder) = &laravel.custom_builder { + if let Some(builder_fqn) = builder.base_name() { + let normalized = normalize_fqn(builder_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 +1407,54 @@ 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) = &laravel.custom_builder { + if let Some(builder_fqn) = builder.base_name() { + if let Some(builder_cls) = class_loader(builder_fqn) { + // 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 let Some(builder_cls) = class_loader(crate::virtual_members::laravel::ELOQUENT_BUILDER_FQN) { + if builder_cls.methods.iter().any(|m| { + m.name.eq_ignore_ascii_case(name) && (!is_static || !m.is_static) + }) { + 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..17d37c8c 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(laravel) = curr.laravel() { + if let Some(builder) = &laravel.custom_builder { + if let Some(name) = builder.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..68d7cb80 --- /dev/null +++ b/tests/integration/laravel_custom_builder.rs @@ -0,0 +1,1042 @@ +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..4ad8e91e --- /dev/null +++ b/tests/integration/laravel_references.rs @@ -0,0 +1,361 @@ +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; From cd065b57eadbdd27c5461cf108ee0a4d5acb6dcf Mon Sep 17 00:00:00 2001 From: Teemo Date: Wed, 13 May 2026 18:44:24 +0800 Subject: [PATCH 2/4] docs: update roadmap and changelog for Laravel and performance work --- docs/CHANGELOG.md | 4 +++ docs/todo.md | 9 ++--- docs/todo/laravel.md | 39 ---------------------- docs/todo/performance.md | 72 ---------------------------------------- 4 files changed, 7 insertions(+), 117 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3b0f0e7e..2cc85f16 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. - **Blade template support.** Completion, hover, go-to-definition, diagnostics, semantic tokens, and inlay hints work inside `.blade.php` files. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/100. - **Blade keyword highlighting.** Blade directives, echo delimiters, PHP keywords, cast types, comments, and PHPDoc tags inside `.blade.php` files now receive semantic tokens for proper syntax coloring. - **Blade view directive navigation.** Go-to-definition works on view names inside Blade directives (`@include`, `@extends`, `@includeIf`, `@includeWhen`, `@includeUnless`, `@includeFirst`, `@component`, `@each`), jumping to the referenced template file. @@ -52,6 +53,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. +- **Member completion caching.** Unfiltered member lists are cached per-target to speed up subsequent completions during keyword entry. +- **Laravel startup performance.** Common Laravel builder classes are warmed in the background at startup to eliminate the first-access penalty on Eloquent completions. - **Find References performance and freshness.** Project-wide Find References now avoids more unnecessary file work while still returning references through aliased class and function imports, and it refreshes newly added workspace PHP files on later searches. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/116. - **Incremental text sync.** The server now uses incremental document sync, receiving only changed ranges from the editor instead of the full file content on every keystroke. - **LSP responsiveness.** Hover, go-to-definition, signature help, code actions, rename, and other handlers now run on background threads. Slow requests no longer block other requests or cancellations. diff --git a/docs/todo.md b/docs/todo.md index b04a108a..581b4199 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 | @@ -164,7 +163,6 @@ unlikely to move the needle for most users. | | **[Performance](todo/performance.md) / [Eager Resolution](todo/eager-resolution.md)** | | | | ER5 | [Mago-style separated metadata](todo/eager-resolution.md#er5--mago-style-separated-metadata) | High | High | | P14 | [Eager docblock parsing into structured fields](todo/performance.md#p14-eager-docblock-parsing-into-structured-fields) | Medium | Medium | -| P9 | [`resolved_class_cache` generic-arg specialisation](todo/performance.md#p9-resolved_class_cache-generic-arg-specialisation) | Medium | Medium | | P11 | [Uncached base-resolution in `build_scope_methods_for_builder`](todo/performance.md#p11-uncached-base-resolution-in-build_scope_methods_for_builder) | Low-Medium | Low | | P3 | Parallel pre-filter in `find_implementors` | Low-Medium | Medium | | P4 | `memmem` for block comment terminator search | Low | Low | @@ -173,7 +171,6 @@ unlikely to move the needle for most users. | P7 | `diag_pending_uris` uses `Vec::contains` for dedup | Low | Low | | P8 | `find_class_in_uri_classes_index` linear fallback scan | Low | Low | | P12 | [`find_or_load_function` Phase 1.75 serial bottleneck](todo/performance.md#p12-find_or_load_function-phase-175-serial-bottleneck) | Low | Low | -| P17 | [`mago-names` resolution on the parse hot path](todo/performance.md#p17-mago-names-resolution-on-the-parse-hot-path) | Medium | Low | | P18 | [Subtype result caching](todo/performance.md#p18-subtype-result-caching) (per-request HashMap for hierarchy walks) | Medium | Low | | P19 | [Arena reuse on the parse hot path](todo/performance.md#p19-arena-reuse-on-the-parse-hot-path) (thread-local `Bump::reset()` instead of `Bump::new()`) | Medium | Low | | P20 | [Content-hash gated resolution cache persistence](todo/performance.md#p20-content-hash-gated-resolution-cache-persistence) | Medium | Medium | 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/docs/todo/performance.md b/docs/todo/performance.md index dcbea199..e8fd088f 100644 --- a/docs/todo/performance.md +++ b/docs/todo/performance.md @@ -190,36 +190,6 @@ window. --- -## P9. `resolved_class_cache` generic-arg specialisation - -**Impact: Medium · Effort: Medium** - -The resolved-class cache is keyed by `(FQN, Vec)`. Every -distinct generic instantiation of the same class (e.g. -`Builder`, `Builder`, `Builder`) triggers a -full `resolve_class_fully` call, even though the base resolution -(inheritance merging, trait merging, virtual member injection) is -identical. Only the final generic substitution differs. - -In a Laravel codebase with hundreds of Eloquent models, this means -`Builder` is fully resolved hundreds of times, once per model. - -### Fix - -Cache the base-resolved class (before generic substitution) -separately, keyed by FQN alone. When a generic instantiation is -requested, look up the base-resolved class and apply -`apply_substitution` on top. The substitution step is cheap -(tree walk) compared to the full resolution (inheritance walking, -trait merging, virtual member providers). - -This requires splitting `resolve_class_fully` into two stages: -base resolution (cached by FQN) and generic specialisation (cached -by `(FQN, Vec)` as today, but with a much cheaper miss -path). - ---- - ## P11. Uncached base-resolution in `build_scope_methods_for_builder` **Impact: Low-Medium · Effort: Low** @@ -506,48 +476,6 @@ essentially free for both eager and deferred indexing paths. --- -## P17. `mago-names` resolution on the parse hot path - -**Impact: Medium · Effort: Low** - -The `mago-names` name resolver runs synchronously inside -`update_ast_inner`, adding a full AST walk plus an owned `HashMap` -copy on every `didChange` event. Measured regression from `6a0737a` -("Migrate to use mago-names"): - -| Benchmark | Before | After | Δ | -| ---------------- | ------ | ----- | ---- | -| with_narrowing | 12 ms | 15 ms | +25% | -| 5_methods_chain | 8 ms | 10 ms | +25% | -| carbon_class | 250 ms | 340 ms | +36% | -| large_file | 150 ms | 210 ms | +40% | - -The resolved names are currently consumed only by diagnostics (which -run asynchronously) and `FileContext::resolve_name_at()`. Nothing on -the completion hot path requires this data to be computed eagerly. - -### Fix - -Defer name resolution out of `update_ast_inner`. Options: - -- **Lazy resolution:** compute `OwnedResolvedNames` on first access - per file version, invalidate on the next `update_ast`. Moves the - cost off the typing hot path entirely. -- **Diagnostic-worker resolution:** run the resolver in the - diagnostic worker clone of `Backend`, since diagnostics are the - primary consumer. - -### When to implement - -Low priority. The `mago-names` migration is complete, but the -`use_map` is still used by several consumers. Further refactoring -(migrating more consumers to byte-offset lookups, eventually -removing `use_map`) will change the access patterns. Optimizing -now would likely be reworked. Revisit once `use_map` usage is -significantly reduced. - ---- - ## P18. Subtype result caching **Impact: Medium · Effort: Low** From 15942596a2b8dabea714dc8ac0f060b923a985e0 Mon Sep 17 00:00:00 2001 From: Teemo Date: Wed, 13 May 2026 21:10:14 +0800 Subject: [PATCH 3/4] chore: apply PR review feedback (formatting and clippy) - Fix formatting across multiple files using 'cargo fmt'. - Collapse nested 'if let' statements into single chains with 'and_then' or 'let_chains'. - Remove redundant closures. - Fix unused variable warning in 'src/references/mod.rs'. --- src/definition/member/mod.rs | 14 +-- src/references/mod.rs | 88 ++++++++------ src/virtual_members/laravel/builder.rs | 14 +-- tests/integration/laravel_custom_builder.rs | 1 - tests/integration/laravel_references.rs | 123 +++++++++++++++----- 5 files changed, 160 insertions(+), 80 deletions(-) diff --git a/src/definition/member/mod.rs b/src/definition/member/mod.rs index 40d93194..d1bd0571 100644 --- a/src/definition/member/mod.rs +++ b/src/definition/member/mod.rs @@ -811,13 +811,13 @@ impl Backend { let mut current = Some(class.clone()); for _ in 0..MAX_INHERITANCE_DEPTH { let Some(curr) = current else { break }; - if let Some(laravel) = curr.laravel() { - if let Some(builder) = &laravel.custom_builder { - if let Some(name) = builder.base_name() { - builder_fqn = name.to_string(); - 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 diff --git a/src/references/mod.rs b/src/references/mod.rs index 7c6f2fdd..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, name, is_static); + 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, member_name); + 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, name, *is_static); + let hierarchy = + self.resolve_member_declaration_hierarchy(uri, span_start, name, *is_static); self.find_member_references( name, *is_static, @@ -1334,14 +1341,13 @@ impl Backend { // add that builder to the hierarchy. let mut extensions = Vec::new(); for fqn in &hierarchy { - if let Some(cls) = class_loader(fqn) { - if let Some(laravel) = cls.laravel() { - if let Some(builder) = &laravel.custom_builder { - if let Some(builder_fqn) = builder.base_name() { - extensions.push(normalize_fqn(builder_fqn).to_string()); - } - } - } + 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 { @@ -1358,14 +1364,18 @@ impl Backend { 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(builder) = &laravel.custom_builder { - if let Some(builder_fqn) = builder.base_name() { - let normalized = normalize_fqn(builder_fqn); - if hierarchy.contains(normalized.as_str()) { - model_seeds.push(class_fqn.clone()); - } + 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) { + } 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()); } @@ -1429,26 +1439,32 @@ impl Backend { // Laravel forwarded methods if let Some(laravel) = cls.laravel() { - if let Some(builder) = &laravel.custom_builder { - if let Some(builder_fqn) = builder.base_name() { - if let Some(builder_cls) = class_loader(builder_fqn) { - // 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; - } - } + 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 let Some(builder_cls) = class_loader(crate::virtual_members::laravel::ELOQUENT_BUILDER_FQN) { - if builder_cls.methods.iter().any(|m| { - m.name.eq_ignore_ascii_case(name) && (!is_static || !m.is_static) - }) { - return true; - } + 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; } } diff --git a/src/virtual_members/laravel/builder.rs b/src/virtual_members/laravel/builder.rs index 17d37c8c..bd0c1b3c 100644 --- a/src/virtual_members/laravel/builder.rs +++ b/src/virtual_members/laravel/builder.rs @@ -99,13 +99,13 @@ pub(super) fn build_builder_forwarded_methods( let mut current = Some(class.clone()); for _ in 0..MAX_INHERITANCE_DEPTH { let Some(curr) = current else { break }; - if let Some(laravel) = curr.laravel() { - if let Some(builder) = &laravel.custom_builder { - if let Some(name) = builder.base_name() { - requested_builder_fqn = name.to_string(); - 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 diff --git a/tests/integration/laravel_custom_builder.rs b/tests/integration/laravel_custom_builder.rs index 68d7cb80..1a14f7fa 100644 --- a/tests/integration/laravel_custom_builder.rs +++ b/tests/integration/laravel_custom_builder.rs @@ -820,7 +820,6 @@ class User extends Model {} ); } - #[tokio::test] async fn test_goto_definition_custom_builder_inherited_attribute() { let (backend, dir) = create_psr4_workspace( diff --git a/tests/integration/laravel_references.rs b/tests/integration/laravel_references.rs index 4ad8e91e..92715042 100644 --- a/tests/integration/laravel_references.rs +++ b/tests/integration/laravel_references.rs @@ -100,7 +100,7 @@ User::query()->active(); } 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 { @@ -113,18 +113,39 @@ User::query()->active(); include_declaration: true, }, }; - - let locations = backend.references(params).await.unwrap().unwrap_or_default(); - + + 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); + + 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] @@ -225,7 +246,7 @@ User::query()->active(); } 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 { @@ -238,20 +259,48 @@ User::query()->active(); include_declaration: true, }, }; - - let locations = backend.references(params).await.unwrap().unwrap_or_default(); - + + 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); + + 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"); + 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] @@ -335,7 +384,7 @@ Member::query()->active(); } 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 { @@ -348,14 +397,30 @@ Member::query()->active(); include_declaration: true, }, }; - - let locations = backend.references(params).await.unwrap().unwrap_or_default(); - + + 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"); + + 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" + ); } From 8a4977563dcc6d75c83d0a244c4a57578dc1c4f8 Mon Sep 17 00:00:00 2001 From: Anders Jenbo Date: Sun, 17 May 2026 20:44:46 +0200 Subject: [PATCH 4/4] Update changelog and performance todo --- docs/CHANGELOG.md | 14 +++++--- docs/todo.md | 2 ++ docs/todo/performance.md | 72 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 4 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2cc85f16..16c6983d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,17 +7,26 @@ 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 -- **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. - **Blade template support.** Completion, hover, go-to-definition, diagnostics, semantic tokens, and inlay hints work inside `.blade.php` files. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/100. - **Blade keyword highlighting.** Blade directives, echo delimiters, PHP keywords, cast types, comments, and PHPDoc tags inside `.blade.php` files now receive semantic tokens for proper syntax coloring. - **Blade view directive navigation.** Go-to-definition works on view names inside Blade directives (`@include`, `@extends`, `@includeIf`, `@includeWhen`, `@includeUnless`, `@includeFirst`, `@component`, `@each`), jumping to the referenced template file. @@ -53,9 +62,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. -- **Member completion caching.** Unfiltered member lists are cached per-target to speed up subsequent completions during keyword entry. -- **Laravel startup performance.** Common Laravel builder classes are warmed in the background at startup to eliminate the first-access penalty on Eloquent completions. - **Find References performance and freshness.** Project-wide Find References now avoids more unnecessary file work while still returning references through aliased class and function imports, and it refreshes newly added workspace PHP files on later searches. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/116. - **Incremental text sync.** The server now uses incremental document sync, receiving only changed ranges from the editor instead of the full file content on every keystroke. - **LSP responsiveness.** Hover, go-to-definition, signature help, code actions, rename, and other handlers now run on background threads. Slow requests no longer block other requests or cancellations. diff --git a/docs/todo.md b/docs/todo.md index 581b4199..4a02dee4 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -163,6 +163,7 @@ unlikely to move the needle for most users. | | **[Performance](todo/performance.md) / [Eager Resolution](todo/eager-resolution.md)** | | | | ER5 | [Mago-style separated metadata](todo/eager-resolution.md#er5--mago-style-separated-metadata) | High | High | | P14 | [Eager docblock parsing into structured fields](todo/performance.md#p14-eager-docblock-parsing-into-structured-fields) | Medium | Medium | +| P9 | [`resolved_class_cache` generic-arg specialisation](todo/performance.md#p9-resolved_class_cache-generic-arg-specialisation) | Medium | Medium | | P11 | [Uncached base-resolution in `build_scope_methods_for_builder`](todo/performance.md#p11-uncached-base-resolution-in-build_scope_methods_for_builder) | Low-Medium | Low | | P3 | Parallel pre-filter in `find_implementors` | Low-Medium | Medium | | P4 | `memmem` for block comment terminator search | Low | Low | @@ -171,6 +172,7 @@ unlikely to move the needle for most users. | P7 | `diag_pending_uris` uses `Vec::contains` for dedup | Low | Low | | P8 | `find_class_in_uri_classes_index` linear fallback scan | Low | Low | | P12 | [`find_or_load_function` Phase 1.75 serial bottleneck](todo/performance.md#p12-find_or_load_function-phase-175-serial-bottleneck) | Low | Low | +| P17 | [`mago-names` resolution on the parse hot path](todo/performance.md#p17-mago-names-resolution-on-the-parse-hot-path) | Medium | Low | | P18 | [Subtype result caching](todo/performance.md#p18-subtype-result-caching) (per-request HashMap for hierarchy walks) | Medium | Low | | P19 | [Arena reuse on the parse hot path](todo/performance.md#p19-arena-reuse-on-the-parse-hot-path) (thread-local `Bump::reset()` instead of `Bump::new()`) | Medium | Low | | P20 | [Content-hash gated resolution cache persistence](todo/performance.md#p20-content-hash-gated-resolution-cache-persistence) | Medium | Medium | diff --git a/docs/todo/performance.md b/docs/todo/performance.md index e8fd088f..dcbea199 100644 --- a/docs/todo/performance.md +++ b/docs/todo/performance.md @@ -190,6 +190,36 @@ window. --- +## P9. `resolved_class_cache` generic-arg specialisation + +**Impact: Medium · Effort: Medium** + +The resolved-class cache is keyed by `(FQN, Vec)`. Every +distinct generic instantiation of the same class (e.g. +`Builder`, `Builder`, `Builder`) triggers a +full `resolve_class_fully` call, even though the base resolution +(inheritance merging, trait merging, virtual member injection) is +identical. Only the final generic substitution differs. + +In a Laravel codebase with hundreds of Eloquent models, this means +`Builder` is fully resolved hundreds of times, once per model. + +### Fix + +Cache the base-resolved class (before generic substitution) +separately, keyed by FQN alone. When a generic instantiation is +requested, look up the base-resolved class and apply +`apply_substitution` on top. The substitution step is cheap +(tree walk) compared to the full resolution (inheritance walking, +trait merging, virtual member providers). + +This requires splitting `resolve_class_fully` into two stages: +base resolution (cached by FQN) and generic specialisation (cached +by `(FQN, Vec)` as today, but with a much cheaper miss +path). + +--- + ## P11. Uncached base-resolution in `build_scope_methods_for_builder` **Impact: Low-Medium · Effort: Low** @@ -476,6 +506,48 @@ essentially free for both eager and deferred indexing paths. --- +## P17. `mago-names` resolution on the parse hot path + +**Impact: Medium · Effort: Low** + +The `mago-names` name resolver runs synchronously inside +`update_ast_inner`, adding a full AST walk plus an owned `HashMap` +copy on every `didChange` event. Measured regression from `6a0737a` +("Migrate to use mago-names"): + +| Benchmark | Before | After | Δ | +| ---------------- | ------ | ----- | ---- | +| with_narrowing | 12 ms | 15 ms | +25% | +| 5_methods_chain | 8 ms | 10 ms | +25% | +| carbon_class | 250 ms | 340 ms | +36% | +| large_file | 150 ms | 210 ms | +40% | + +The resolved names are currently consumed only by diagnostics (which +run asynchronously) and `FileContext::resolve_name_at()`. Nothing on +the completion hot path requires this data to be computed eagerly. + +### Fix + +Defer name resolution out of `update_ast_inner`. Options: + +- **Lazy resolution:** compute `OwnedResolvedNames` on first access + per file version, invalidate on the next `update_ast`. Moves the + cost off the typing hot path entirely. +- **Diagnostic-worker resolution:** run the resolver in the + diagnostic worker clone of `Backend`, since diagnostics are the + primary consumer. + +### When to implement + +Low priority. The `mago-names` migration is complete, but the +`use_map` is still used by several consumers. Further refactoring +(migrating more consumers to byte-offset lookups, eventually +removing `use_map`) will change the access patterns. Optimizing +now would likely be reworked. Revisit once `use_map` usage is +significantly reduced. + +--- + ## P18. Subtype result caching **Impact: Medium · Effort: Low**