Skip to main content

php_lsp/backend/
server.rs

1use std::sync::Arc;
2
3use tower_lsp::jsonrpc::Result;
4use tower_lsp::lsp_types::*;
5use tower_lsp::{LanguageServer, async_trait};
6
7use super::panic_guard::{guard_async, guard_async_result};
8use crate::completion::{CompletionCtx, filtered_completions_at};
9use crate::document::ast::ParsedDoc;
10use crate::document::open_files::compute_open_file_diagnostics;
11use crate::hover::{
12    class_hover_from_index, docs_for_symbol_from_index, docs_for_symbol_from_index_scoped,
13    extract_static_class_before_cursor, hover_info_with_maps, method_hover_from_index,
14    signature_for_symbol_from_index_scoped,
15};
16use crate::index::file_index::ClassKind;
17use crate::index::workspace_scan::{scan_workspace, send_refresh_requests};
18use crate::lang::config::LspConfig;
19use crate::navigation::symbols::{
20    document_symbols, resolve_workspace_symbol, workspace_symbols_from_workspace,
21};
22use crate::text::word_at_position;
23
24use crate::navigation::call_hierarchy::{
25    incoming_calls, outgoing_calls_indexed, prepare_call_hierarchy_indexed,
26};
27use crate::navigation::declaration::{goto_declaration, goto_declaration_from_index};
28use crate::navigation::implementation::{
29    find_implementations, find_implementations_mir_backed, find_method_implementations_mir_backed,
30};
31use crate::navigation::moniker::moniker_at;
32use crate::navigation::type_definition::{
33    goto_type_definition_exact, goto_type_definition_from_index_exact,
34    goto_type_definition_from_index_short_name_fallback, goto_type_definition_short_name_fallback,
35};
36use crate::navigation::type_hierarchy::{
37    prepare_type_hierarchy_from_workspace, subtypes_of_mir_backed, supertypes_of_from_workspace,
38};
39
40use crate::analysis::code_lens::code_lenses;
41use crate::analysis::diagnostics::{diagnostics_from_doc, parse_document, parse_document_no_diags};
42use crate::analysis::document_highlight::document_highlights;
43use crate::analysis::inlay_hints::inlay_hints;
44use crate::analysis::inline_value::inline_values_in_range;
45use crate::analysis::semantic_tokens::{
46    compute_token_delta, semantic_tokens, semantic_tokens_range, token_hash,
47};
48
49use crate::editing::document_link::document_links;
50use crate::editing::folding::folding_ranges;
51use crate::editing::formatting::{format_document, format_range};
52use crate::editing::on_type_format::on_type_format;
53use crate::editing::rename::{prepare_rename, rename_property, rename_variable, rename_with_kind};
54use crate::editing::selection_range::selection_ranges;
55use crate::editing::signature_help::signature_help;
56
57use super::helpers::{
58    cursor_is_on_property_decl, is_after_arrow, promoted_property_at_cursor, run_phpunit,
59    symbol_kind_at,
60};
61use super::{Backend, publish_with_dependents};
62
63#[async_trait]
64impl LanguageServer for Backend {
65    async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
66        self.handle_initialize(params).await
67    }
68
69    async fn initialized(&self, _params: InitializedParams) {
70        guard_async("initialized", async move {
71            self.handle_initialized(_params).await
72        })
73        .await
74    }
75
76    async fn did_change_configuration(&self, _params: DidChangeConfigurationParams) {
77        guard_async("did_change_configuration", async move {
78            // Pull the current configuration from the client rather than parsing the
79            // (often-null) params.settings, which not all clients populate.
80            let items = vec![ConfigurationItem {
81                scope_uri: None,
82                section: Some("php-lsp".to_string()),
83            }];
84            if let Ok(values) = self.client.configuration(items).await
85                && let Some(value) = values.into_iter().next()
86            {
87                let roots = self.root_paths.load_full();
88
89                // Re-read .php-lsp.json so a user who edits the file and then
90                // triggers a configuration reload picks up the latest values.
91                let file_cfg = crate::lang::autoload::load_project_config_json(&roots);
92
93                if let Some(ver) = value.get("phpVersion").and_then(|v| v.as_str())
94                    && !crate::lang::autoload::is_valid_php_version(ver)
95                {
96                    self.client
97                        .log_message(
98                            tower_lsp::lsp_types::MessageType::WARNING,
99                            format!(
100                                "php-lsp: unsupported phpVersion {ver:?} — valid values: {}",
101                                crate::lang::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
102                            ),
103                        )
104                        .await;
105                }
106
107                let file_obj = file_cfg.as_ref().filter(|v| v.is_object());
108                let merged = LspConfig::merge_project_configs(file_obj, Some(&value));
109                let mut cfg = LspConfig::from_value(&merged);
110
111                // Resolve the PHP version and log what was chosen and why.
112                let (ver, source) = self.resolve_php_version(cfg.php_version.as_deref());
113                self.client
114                    .log_message(
115                        tower_lsp::lsp_types::MessageType::INFO,
116                        format!("php-lsp: using PHP {ver} ({source})"),
117                    )
118                    .await;
119                // Clamp unsupported versions to the nearest supported one and warn.
120                let ver = if source != "set by editor"
121                    && !crate::lang::autoload::is_valid_php_version(&ver)
122                {
123                    let clamped = crate::lang::autoload::clamp_php_version(&ver);
124                    self.client
125                        .show_message(
126                            tower_lsp::lsp_types::MessageType::WARNING,
127                            format!(
128                                "php-lsp: detected PHP {ver} is outside the supported range ({}); \
129                             using PHP {clamped} for analysis",
130                                crate::lang::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
131                            ),
132                        )
133                        .await;
134                    clamped.to_string()
135                } else {
136                    ver
137                };
138                cfg.php_version = Some(ver.clone());
139                if let Ok(pv) = ver.parse::<mir_analyzer::PhpVersion>() {
140                    self.docs.set_php_version(pv);
141                }
142                self.config.store(Arc::new(cfg));
143                send_refresh_requests(&self.client).await;
144            }
145        })
146        .await
147    }
148
149    async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
150        guard_async("did_change_workspace_folders", async move {
151            // Remove folders from our tracked roots.
152            {
153                let mut roots = (**self.root_paths.load()).clone();
154                for removed in &params.event.removed {
155                    if let Ok(path) = removed.uri.to_file_path() {
156                        roots.retain(|r| r != &path);
157                    }
158                }
159                self.root_paths.store(Arc::new(roots));
160            }
161
162            // Add new folders and kick off background scans for each.
163            let (exclude_paths, include_paths, max_indexed_files, cache_path) = {
164                let cfg = self.config.load();
165                (
166                    cfg.exclude_paths.clone(),
167                    cfg.include_paths.clone(),
168                    cfg.max_indexed_files,
169                    cfg.cache_path.clone(),
170                )
171            };
172            for added in &params.event.added {
173                if let Ok(path) = added.uri.to_file_path() {
174                    let is_new = {
175                        let mut roots = (**self.root_paths.load()).clone();
176                        if !roots.contains(&path) {
177                            roots.push(path.clone());
178                            self.root_paths.store(Arc::new(roots));
179                            true
180                        } else {
181                            false
182                        }
183                    };
184                    if is_new {
185                        let docs = Arc::clone(&self.docs);
186                        let open_files = self.open_files.clone();
187                        let ex = exclude_paths.clone();
188                        let ip = include_paths.clone();
189                        let path_clone = path.clone();
190                        let client = self.client.clone();
191                        let cp = cache_path.clone();
192                        tokio::spawn(async move {
193                            let cache = if let Some(p) = cp {
194                                Some(crate::index::cache::WorkspaceCache::with_dir(p))
195                            } else {
196                                crate::index::cache::WorkspaceCache::new(&path_clone)
197                            };
198                            scan_workspace(
199                                path_clone,
200                                docs,
201                                open_files,
202                                cache,
203                                &ex,
204                                &ip,
205                                max_indexed_files,
206                                None,
207                            )
208                            .await;
209                            send_refresh_requests(&client).await;
210                        });
211                    }
212                }
213            }
214        })
215        .await
216    }
217
218    async fn shutdown(&self) -> Result<()> {
219        Ok(())
220    }
221
222    #[tracing::instrument(skip_all)]
223    async fn did_open(&self, params: DidOpenTextDocumentParams) {
224        guard_async("did_open", async move {
225            let uri = params.text_document.uri;
226            let text = params.text_document.text;
227
228            // Store text immediately so other features work while parsing.
229            // This also mirrors the new text into salsa, so the codebase query
230            // sees it when semantic_diagnostics runs below.
231            self.set_open_text(uri.clone(), text);
232
233            // Seed parse diagnostics from the salsa-cached doc. On the fast
234            // path this is a lock-free DashMap lookup — no re-parse.
235            let parse_diags = self
236                .docs
237                .get_doc_salsa(&uri)
238                .map(|doc| diagnostics_from_doc(&doc))
239                .unwrap_or_default();
240            self.set_parse_diagnostics(&uri, parse_diags);
241
242            publish_with_dependents(
243                self.client.clone(),
244                Arc::clone(&self.docs),
245                self.open_files.clone(),
246                uri,
247                self.config.load().diagnostics.clone(),
248            )
249            .await;
250        })
251        .await
252    }
253
254    #[tracing::instrument(skip_all)]
255    async fn did_change(&self, params: DidChangeTextDocumentParams) {
256        guard_async("did_change", async move {
257            let uri = params.text_document.uri;
258            // Incremental sync: apply changes in order to the live buffer.
259            // Each ranged change refers to the document state produced by the
260            // previous one; a change without a range is a full-document
261            // replacement (clients may still send those under INCREMENTAL).
262            let mut updated: Option<String> = None;
263            for change in params.content_changes {
264                match change.range {
265                    None => updated = Some(change.text),
266                    Some(range) => {
267                        let mut cur = match updated.take() {
268                            Some(t) => t,
269                            None => self.get_open_text(&uri).unwrap_or_default(),
270                        };
271                        crate::text::apply_content_change(&mut cur, range, &change.text);
272                        updated = Some(cur);
273                    }
274                }
275            }
276            let Some(text) = updated else { return };
277
278            // Store text immediately and capture the version token.
279            // Features (completion, hover, …) see the new text instantly while
280            // the parse runs in the background.
281            let version = self.set_open_text(uri.clone(), text.clone());
282
283            let docs = Arc::clone(&self.docs);
284            let open_files = self.open_files.clone();
285            let client = self.client.clone();
286            let cfg = self.config.load();
287            let diag_cfg = cfg.diagnostics.clone();
288            let debounce_ms = cfg.debounce_ms;
289            tokio::spawn(async move {
290                // Debounce: if another edit arrives before we parse, the version
291                // gate below will discard this result.
292                tokio::time::sleep(std::time::Duration::from_millis(debounce_ms)).await;
293
294                // Skip the expensive parse+analyze if a newer edit already
295                // superseded this one. Collapses N rapid keystrokes into 1
296                // spawn_blocking call instead of N.
297                if open_files.current_version(&uri) != Some(version) {
298                    return;
299                }
300
301                let (_doc, parse_diags) =
302                    tokio::task::spawn_blocking(move || parse_document(&text))
303                        .await
304                        .unwrap_or_else(|e| {
305                            tracing::warn!("parse_document panicked for {uri}: {e}");
306                            (ParsedDoc::default(), vec![])
307                        });
308
309                // Only apply if no newer edit arrived while we were parsing.
310                if open_files.current_version(&uri) == Some(version) {
311                    open_files.set_parse_diagnostics(&uri, parse_diags);
312                    publish_with_dependents(client, docs, open_files, uri, diag_cfg).await;
313                }
314            });
315        })
316        .await
317    }
318
319    async fn did_close(&self, params: DidCloseTextDocumentParams) {
320        guard_async("did_close", async move {
321            let uri = params.text_document.uri;
322            self.close_open_file(&uri);
323            // The salsa-mirrored text still holds the last edited buffer, which may
324            // include unsaved changes the user just discarded on close. Re-sync from
325            // disk so cross-file features resolve against the on-disk content; if the
326            // file is gone (never saved / deleted), drop it from the index.
327            let disk_text = match uri.to_file_path() {
328                Ok(path) => tokio::fs::read_to_string(&path).await.ok(),
329                Err(_) => None,
330            };
331            // The disk read awaited above; a reopen may have raced in. Only mutate
332            // the index if the file is still closed, so we never clobber a fresh
333            // buffer.
334            if !self.open_files.contains(&uri) {
335                match disk_text {
336                    Some(text) => self.docs.ingest(uri.clone(), &text),
337                    None => self.docs.remove(&uri),
338                }
339            }
340            // Clear editor diagnostics; the file stays indexed for cross-file features
341            self.client.publish_diagnostics(uri, vec![], None).await;
342        })
343        .await
344    }
345
346    async fn will_save(&self, _params: WillSaveTextDocumentParams) {}
347
348    async fn will_save_wait_until(
349        &self,
350        params: WillSaveTextDocumentParams,
351    ) -> Result<Option<Vec<TextEdit>>> {
352        guard_async_result("will_save_wait_until", async move {
353            let source = self
354                .get_open_text(&params.text_document.uri)
355                .unwrap_or_default();
356            Ok(format_document(&source))
357        })
358        .await
359    }
360
361    async fn did_save(&self, params: DidSaveTextDocumentParams) {
362        guard_async("did_save", async move {
363            let uri = params.text_document.uri;
364            // Re-publish diagnostics on save so editors that defer diagnostics
365            // until save (rather than on every keystroke) see up-to-date results.
366            // Must include semantic diagnostics — publishDiagnostics replaces the
367            // prior set entirely, so omitting them would clear errors the editor
368            // showed after the last did_change.
369            let diag_cfg = self.config.load().diagnostics.clone();
370            let all = compute_open_file_diagnostics(&self.docs, &self.open_files, &uri, &diag_cfg);
371            self.open_files
372                .note_published(&uri, super::diagnostics_content_hash(&all));
373            self.client
374                .publish_diagnostics(uri.clone(), all, None)
375                .await;
376
377            // Persist the FileIndex to the disk cache so that a server restart
378            // can skip re-parsing this file even for edits that happened between
379            // workspace scans. Content-keyed so the entry matches the scan's key
380            // on restart regardless of mtime granularity.
381            let cache_path = self.config.load().cache_path.clone();
382            if let Ok(path) = uri.to_file_path() {
383                let root = self.root_paths.load().first().cloned();
384                tokio::task::spawn_blocking(move || {
385                    let cache = if let Some(p) = cache_path {
386                        crate::index::cache::WorkspaceCache::with_dir(p)
387                    } else if let Some(r) = root {
388                        let Some(c) = crate::index::cache::WorkspaceCache::new(&r) else {
389                            return;
390                        };
391                        c
392                    } else {
393                        return;
394                    };
395                    let Ok(text) = std::fs::read_to_string(&path) else {
396                        return;
397                    };
398                    let key = crate::index::cache::WorkspaceCache::key_for(uri.as_str(), &text);
399                    let doc = parse_document_no_diags(&text);
400                    let index = crate::index::file_index::FileIndex::extract(&doc);
401                    let _ = cache.write(&key, &index);
402                });
403            }
404        })
405        .await
406    }
407
408    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
409        guard_async("did_change_watched_files", async move {
410            for change in params.changes {
411                match change.typ {
412                    FileChangeType::CREATED | FileChangeType::CHANGED => {
413                        if let Ok(path) = change.uri.to_file_path()
414                            && let Ok(text) = tokio::fs::read_to_string(&path).await
415                        {
416                            // Salsa path: ingest_from_doc mirrors the new text into
417                            // the SourceFile input. On the next codebase() call,
418                            // salsa re-runs file_definitions for this file and the
419                            // aggregator re-folds — no manual remove/collect/finalize.
420                            let doc = parse_document_no_diags(&text);
421                            self.ingest_from_doc_if_not_open(change.uri.clone(), &doc);
422                        }
423                    }
424                    FileChangeType::DELETED => {
425                        self.docs.remove(&change.uri);
426                    }
427                    _ => {}
428                }
429            }
430            // File changes may affect cross-file features — refresh all live editors.
431            send_refresh_requests(&self.client).await;
432        })
433        .await
434    }
435
436    #[tracing::instrument(skip_all)]
437    async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
438        guard_async_result("completion", async move {
439            let uri = &params.text_document_position.text_document.uri;
440            let position = params.text_document_position.position;
441            let source = self.get_open_text(uri).unwrap_or_default();
442            let doc = match self.get_doc(uri) {
443                Some(d) => d,
444                None => return Ok(Some(CompletionResponse::Array(vec![]))),
445            };
446            let other_docs: Vec<Arc<ParsedDoc>> = self
447                .docs
448                .other_docs(uri, &self.open_urls())
449                .into_iter()
450                .map(|(_, d)| d)
451                .collect();
452            // Clone to owned so the closure is 'static + Send.
453            let trigger = params
454                .context
455                .as_ref()
456                .and_then(|c| c.trigger_character.clone());
457            // load_full() returns Arc<PhpStormMeta> — avoids a non-'static borrow
458            // that would prevent moving into spawn_blocking.
459            let meta_arc = self.meta.load_full();
460            let imports = self.file_imports(uri);
461            let wi = self.workspace_index_async().await;
462            let wi_for_class_search = Arc::clone(&wi);
463            let uri_for_class_search = uri.clone();
464            let docs_for_lookup = Arc::clone(&self.docs);
465            let find_class_doc_fn = move |name: &str| -> Option<Arc<ParsedDoc>> {
466                let cr = wi.resolve_class_ref(name)?;
467                let (uri, _) = wi.at(cr)?;
468                docs_for_lookup.get_doc_salsa(uri)
469            };
470            // Finds classes anywhere in the workspace index — including
471            // vendor/ and other files that are never opened as editor
472            // buffers — whose short name starts with `prefix`. Complements
473            // `other_docs`, which only covers currently open documents.
474            //
475            // `classes_by_lowercase_name` is sorted once per workspace
476            // revision (shared via `Arc` across every completion request
477            // until a file changes), so this does a binary search for the
478            // prefix range instead of scanning + re-lowercasing every class
479            // name on every keystroke.
480            const WORKSPACE_CLASS_SEARCH_LIMIT: usize = 50;
481            let workspace_class_search_fn =
482                move |prefix: &str| -> Vec<(String, CompletionItemKind, String)> {
483                    let prefix_lc = prefix.to_lowercase();
484                    let table = &wi_for_class_search.classes_by_lowercase_name;
485                    let start =
486                        table.partition_point(|(name, _)| name.as_ref() < prefix_lc.as_str());
487                    let mut out = Vec::new();
488                    for (name, cr) in &table[start..] {
489                        if !name.starts_with(prefix_lc.as_str()) {
490                            break;
491                        }
492                        let Some((file_uri, cls)) = wi_for_class_search.at(*cr) else {
493                            continue;
494                        };
495                        if *file_uri == uri_for_class_search {
496                            continue;
497                        }
498                        let kind = match cls.kind {
499                            ClassKind::Class | ClassKind::Trait => CompletionItemKind::CLASS,
500                            ClassKind::Interface => CompletionItemKind::INTERFACE,
501                            ClassKind::Enum => CompletionItemKind::ENUM,
502                        };
503                        out.push((cls.name.to_string(), kind, cls.fqn.to_string()));
504                        if out.len() >= WORKSPACE_CLASS_SEARCH_LIMIT {
505                            break;
506                        }
507                    }
508                    out
509                };
510            let analysis = self.cached_analysis_async(uri).await;
511            // Cross-request TypeMap cache: rebuilt only when the document text
512            // (or PHPStorm meta) changes, instead of one full AST walk per
513            // completion request.
514            let docs_for_tm = Arc::clone(&self.docs);
515            let doc_for_tm = Arc::clone(&doc);
516            let uri_for_tm = uri.clone();
517            let meta_arc_for_tm = Arc::clone(&meta_arc);
518            let get_type_map = move || {
519                let meta = if meta_arc_for_tm.is_empty() {
520                    None
521                } else {
522                    Some(&*meta_arc_for_tm)
523                };
524                docs_for_tm.cached_type_map(&uri_for_tm, &doc_for_tm, meta)
525            };
526            let session = self
527                .docs
528                .analysis_session(self.docs.workspace_php_version());
529            let uri_owned = uri.clone();
530            let uri_str = uri.to_string();
531            // Offload to spawn_blocking: filtered_completions_at walks the full
532            // AST + workspace index which can take tens of milliseconds on large
533            // files, blocking the async executor from unrelated requests.
534            let items = match tokio::task::spawn_blocking(move || {
535                let meta_opt = if meta_arc.is_empty() {
536                    None
537                } else {
538                    Some(&*meta_arc)
539                };
540                let ctx = CompletionCtx {
541                    source: Some(&source),
542                    position: Some(position),
543                    meta: meta_opt,
544                    doc_uri: Some(&uri_owned),
545                    file_imports: Some(&imports),
546                    find_class_doc: Some(&find_class_doc_fn),
547                    workspace_class_search: Some(&workspace_class_search_fn),
548                    analysis: analysis.as_deref(),
549                    type_map: Some(&get_type_map),
550                    session: Some(session),
551                };
552                filtered_completions_at(&doc, &other_docs, trigger.as_deref(), &ctx)
553            })
554            .await
555            {
556                Ok(items) => items,
557                Err(e) => {
558                    tracing::warn!("completion panicked for {uri_str}: {e}");
559                    vec![]
560                }
561            };
562            Ok(Some(CompletionResponse::Array(items)))
563        })
564        .await
565    }
566
567    async fn completion_resolve(&self, mut item: CompletionItem) -> Result<CompletionItem> {
568        guard_async_result("completion_resolve", async move {
569            if item.documentation.is_some() && item.detail.is_some() {
570                return Ok(item);
571            }
572            // Strip trailing ':' from named-argument labels (e.g. "param:") before lookup.
573            let name = item.label.trim_end_matches(':');
574            // Method completion items carry their owning class in `data` (see
575            // member.rs's all_members) so two unrelated classes declaring a
576            // same-named method don't resolve to whichever is indexed first.
577            let class_hint = item
578                .data
579                .as_ref()
580                .and_then(|d| d.get("class"))
581                .and_then(|v| v.as_str())
582                .map(str::to_string);
583            let all_indexes = self.docs.all_indexes();
584            if item.detail.is_none()
585                && let Some(sig) = signature_for_symbol_from_index_scoped(
586                    name,
587                    &all_indexes,
588                    class_hint.as_deref(),
589                )
590            {
591                item.detail = Some(sig);
592            }
593            if item.documentation.is_none()
594                && let Some(md) =
595                    docs_for_symbol_from_index_scoped(name, &all_indexes, class_hint.as_deref())
596            {
597                item.documentation = Some(Documentation::MarkupContent(MarkupContent {
598                    kind: MarkupKind::Markdown,
599                    value: md,
600                }));
601            }
602            Ok(item)
603        })
604        .await
605    }
606
607    async fn goto_definition(
608        &self,
609        params: GotoDefinitionParams,
610    ) -> Result<Option<GotoDefinitionResponse>> {
611        guard_async_result("goto_definition", async move {
612            self.handle_goto_definition(params).await
613        })
614        .await
615    }
616
617    async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
618        guard_async_result(
619            "references",
620            async move { self.handle_references(params).await },
621        )
622        .await
623    }
624
625    async fn prepare_rename(
626        &self,
627        params: TextDocumentPositionParams,
628    ) -> Result<Option<PrepareRenameResponse>> {
629        guard_async_result("prepare_rename", async move {
630            let uri = &params.text_document.uri;
631            let source = self.get_open_text(uri).unwrap_or_default();
632            Ok(prepare_rename(&source, params.position).map(PrepareRenameResponse::Range))
633        })
634        .await
635    }
636
637    async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
638        guard_async_result("rename", async move {
639            let uri = &params.text_document_position.text_document.uri;
640            let position = params.text_document_position.position;
641            let source = self.get_open_text(uri).unwrap_or_default();
642            let word = match word_at_position(&source, position) {
643                Some(w) => w,
644                None => return Ok(None),
645            };
646            if word.starts_with('$') {
647                let doc = match self.get_doc(uri) {
648                    Some(d) => d,
649                    None => return Ok(None),
650                };
651                // Cursor on a property declaration (`public int $x`) or a promoted
652                // constructor parameter (`private string $x`) — both act as property
653                // declarations and must use the cross-file property rename path.
654                let prop_name = cursor_is_on_property_decl(&source, &doc.program().stmts, position)
655                    .or_else(|| {
656                        promoted_property_at_cursor(&source, &doc.program().stmts, position)
657                    });
658                if let Some(prop_name) = prop_name {
659                    let all_docs = self.docs.all_docs_for_scan();
660                    return Ok(Some(rename_property(
661                        &prop_name,
662                        &params.new_name,
663                        &all_docs,
664                    )));
665                }
666                Ok(Some(rename_variable(
667                    &word,
668                    &params.new_name,
669                    uri,
670                    &doc,
671                    position,
672                )))
673            } else if is_after_arrow(&source, position) {
674                let all_docs = self.docs.all_docs_for_scan();
675                Ok(Some(rename_property(&word, &params.new_name, &all_docs)))
676            } else {
677                let all_docs = self.docs.all_docs_for_scan();
678                let doc_opt = self.get_doc(uri);
679                let target_fqn: Option<String> = doc_opt.as_ref().map(|doc| {
680                    let imports = self.file_imports(uri);
681                    crate::navigation::moniker::resolve_fqn(doc, &word, &imports)
682                });
683                // Class renames need `symbol_kind_at`'s cursor context (not just
684                // FQN resolution, which also succeeds for functions/constants) so
685                // `rename_with_kind` knows to merge `use`-import edits with the
686                // type-hint-aware class walker. Every other kind keeps the
687                // existing general-walker behavior unchanged.
688                let kind = symbol_kind_at(&source, position, &word);
689                Ok(Some(rename_with_kind(
690                    &word,
691                    &params.new_name,
692                    &all_docs,
693                    kind,
694                    target_fqn.as_deref(),
695                )))
696            }
697        })
698        .await
699    }
700
701    async fn signature_help(&self, params: SignatureHelpParams) -> Result<Option<SignatureHelp>> {
702        guard_async_result("signature_help", async move {
703            let uri = &params.text_document_position_params.text_document.uri;
704            let position = params.text_document_position_params.position;
705            let source = self.get_open_text(uri).unwrap_or_default();
706            let doc = match self.get_doc(uri) {
707                Some(d) => d,
708                None => return Ok(None),
709            };
710            let all_indexes = self.docs.all_indexes();
711            Ok(signature_help(&source, &doc, position, &all_indexes))
712        })
713        .await
714    }
715
716    #[tracing::instrument(skip_all)]
717    async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
718        guard_async_result("hover", async move {
719            let uri = &params.text_document_position_params.text_document.uri;
720            let position = params.text_document_position_params.position;
721            let source = self.get_open_text(uri).unwrap_or_default();
722            let doc = match self.get_doc(uri) {
723                Some(d) => d,
724                None => return Ok(None),
725            };
726            let other_docs = self.docs.other_docs(uri, &self.open_urls());
727            let other_maps = self.docs.other_symbol_maps(uri, &self.open_urls());
728            let analysis = self.cached_analysis_async(uri).await;
729            let hover_session = self
730                .docs
731                .analysis_session(self.docs.workspace_php_version());
732            let source_clone = source.clone();
733            let doc_clone = Arc::clone(&doc);
734            let uri_str = uri.to_string();
735            let result = match tokio::task::spawn_blocking(move || {
736                hover_info_with_maps(
737                    &source_clone,
738                    &doc_clone,
739                    analysis.as_deref(),
740                    position,
741                    &other_docs,
742                    &other_maps,
743                    Some(&hover_session),
744                )
745            })
746            .await
747            {
748                Ok(r) => r,
749                Err(e) => {
750                    tracing::warn!("hover panicked for {uri_str}: {e}");
751                    None
752                }
753            };
754            if result.is_some() {
755                return Ok(result);
756            }
757            // Fallback: look up the word in the workspace index so class names in
758            // extends clauses and parameter types resolve even when their defining
759            // file is never opened.  Also try the alias-resolved name so that
760            // `use Foo as Bar` works even when Foo is only in the index.
761            if let Some(word) = crate::text::word_at_position(&source, position) {
762                let wi = self.workspace_index_async().await;
763                // Try the literal word first.
764                if let Some(h) = class_hover_from_index(&word, None, &wi.files) {
765                    return Ok(Some(h));
766                }
767                // Try alias resolution. The resolved FQN disambiguates between
768                // same-named classes in different namespaces (e.g. many
769                // vendored `Factory` classes all aliased to `FactoryContract`).
770                if let Some((resolved, resolved_fqn)) =
771                    crate::hover::resolve_use_alias_fqn(&doc.program().stmts, &word)
772                    && let Some(h) =
773                        class_hover_from_index(&resolved, Some(&resolved_fqn), &wi.files)
774                {
775                    return Ok(Some(h));
776                }
777                // Try static method hover: `ClassName::method(…)`.
778                if let Some(line_text) = source.lines().nth(position.line as usize)
779                    && let Some(class_token) =
780                        extract_static_class_before_cursor(line_text, position.character as usize)
781                {
782                    if let Some(h) = method_hover_from_index(&class_token, &word, &wi.files) {
783                        return Ok(Some(h));
784                    }
785                    if let Some(resolved_class) =
786                        crate::hover::resolve_use_alias(&doc.program().stmts, &class_token)
787                        && let Some(h) = method_hover_from_index(&resolved_class, &word, &wi.files)
788                    {
789                        return Ok(Some(h));
790                    }
791                }
792            }
793            Ok(None)
794        })
795        .await
796    }
797
798    async fn document_symbol(
799        &self,
800        params: DocumentSymbolParams,
801    ) -> Result<Option<DocumentSymbolResponse>> {
802        guard_async_result("document_symbol", async move {
803            let uri = &params.text_document.uri;
804            let doc = match self.get_doc(uri) {
805                Some(d) => d,
806                None => return Ok(None),
807            };
808            Ok(Some(DocumentSymbolResponse::Nested(document_symbols(
809                doc.source(),
810                &doc,
811            ))))
812        })
813        .await
814    }
815
816    async fn folding_range(&self, params: FoldingRangeParams) -> Result<Option<Vec<FoldingRange>>> {
817        guard_async_result("folding_range", async move {
818            let uri = &params.text_document.uri;
819            let doc = match self.get_doc(uri) {
820                Some(d) => d,
821                None => return Ok(None),
822            };
823            let ranges = folding_ranges(doc.source(), &doc);
824            Ok(if ranges.is_empty() {
825                None
826            } else {
827                Some(ranges)
828            })
829        })
830        .await
831    }
832
833    async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
834        guard_async_result("inlay_hint", async move {
835            let uri = &params.text_document.uri;
836            let doc = match self.get_doc(uri) {
837                Some(d) => d,
838                None => return Ok(None),
839            };
840            let analysis = self.cached_analysis_async(uri).await;
841            let wi = self.workspace_index_async().await;
842            let uri_str = uri.to_string();
843            let hints = match tokio::task::spawn_blocking(move || {
844                inlay_hints(
845                    doc.source(),
846                    &doc,
847                    analysis.as_deref(),
848                    params.range,
849                    &wi.files,
850                )
851            })
852            .await
853            {
854                Ok(h) => h,
855                Err(e) => {
856                    tracing::warn!("inlay_hint panicked for {uri_str}: {e}");
857                    Vec::new()
858                }
859            };
860            Ok(Some(hints))
861        })
862        .await
863    }
864
865    async fn inlay_hint_resolve(&self, mut item: InlayHint) -> Result<InlayHint> {
866        let fallback = item.clone();
867        let resolved = guard_async("inlay_hint_resolve", async move {
868            if item.tooltip.is_some() {
869                return Some(item);
870            }
871            let func_name = item
872                .data
873                .as_ref()
874                .and_then(|d| d.get("php_lsp_fn"))
875                .and_then(|v| v.as_str())
876                .map(str::to_string);
877            if let Some(name) = func_name {
878                let all_indexes = self.docs.all_indexes();
879                if let Some(md) = docs_for_symbol_from_index(&name, &all_indexes) {
880                    item.tooltip = Some(InlayHintTooltip::MarkupContent(MarkupContent {
881                        kind: MarkupKind::Markdown,
882                        value: md,
883                    }));
884                }
885            }
886            Some(item)
887        })
888        .await;
889        Ok(resolved.unwrap_or(fallback))
890    }
891
892    async fn symbol(
893        &self,
894        params: WorkspaceSymbolParams,
895    ) -> Result<Option<Vec<SymbolInformation>>> {
896        guard_async_result("symbol", async move {
897            // Phase J: read through the salsa-memoized aggregate so repeated
898            // workspace-symbol queries (every keystroke in the picker) share the
899            // same `Arc` until a file changes.
900            let wi = self.workspace_index_async().await;
901            let query = params.query;
902            let results = match tokio::task::spawn_blocking(move || {
903                workspace_symbols_from_workspace(&query, &wi)
904            })
905            .await
906            {
907                Ok(r) => r,
908                Err(e) => {
909                    tracing::warn!("workspace/symbol panicked: {e}");
910                    Vec::new()
911                }
912            };
913            Ok(Some(results))
914        })
915        .await
916    }
917
918    async fn symbol_resolve(&self, params: WorkspaceSymbol) -> Result<WorkspaceSymbol> {
919        // For resolve, we need the full range from the ParsedDoc of open files.
920        let docs = self.docs.docs_for(&self.open_urls());
921        let fallback = params.clone();
922        let resolved = guard_async("symbol_resolve", async move {
923            Some(resolve_workspace_symbol(params, &docs))
924        })
925        .await;
926        Ok(resolved.unwrap_or(fallback))
927    }
928
929    #[tracing::instrument(skip_all)]
930    async fn semantic_tokens_full(
931        &self,
932        params: SemanticTokensParams,
933    ) -> Result<Option<SemanticTokensResult>> {
934        guard_async_result("semantic_tokens_full", async move {
935            let uri = &params.text_document.uri;
936            let doc = match self.get_doc(uri) {
937                Some(d) => d,
938                None => {
939                    return Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
940                        result_id: None,
941                        data: vec![],
942                    })));
943                }
944            };
945            let tokens = semantic_tokens(doc.source(), &doc);
946            let result_id = token_hash(&tokens);
947            let tokens_arc = Arc::new(tokens);
948            self.docs
949                .store_token_cache(uri, result_id.clone(), Arc::clone(&tokens_arc));
950            let data = Arc::try_unwrap(tokens_arc).unwrap_or_else(|arc| (*arc).clone());
951            Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
952                result_id: Some(result_id),
953                data,
954            })))
955        })
956        .await
957    }
958
959    async fn semantic_tokens_range(
960        &self,
961        params: SemanticTokensRangeParams,
962    ) -> Result<Option<SemanticTokensRangeResult>> {
963        guard_async_result("semantic_tokens_range", async move {
964            let uri = &params.text_document.uri;
965            let doc = match self.get_doc(uri) {
966                Some(d) => d,
967                None => {
968                    return Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
969                        result_id: None,
970                        data: vec![],
971                    })));
972                }
973            };
974            let tokens = semantic_tokens_range(doc.source(), &doc, params.range);
975            Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
976                result_id: None,
977                data: tokens,
978            })))
979        })
980        .await
981    }
982
983    async fn semantic_tokens_full_delta(
984        &self,
985        params: SemanticTokensDeltaParams,
986    ) -> Result<Option<SemanticTokensFullDeltaResult>> {
987        guard_async_result("semantic_tokens_full_delta", async move {
988            let uri = &params.text_document.uri;
989            let doc = match self.get_doc(uri) {
990                Some(d) => d,
991                None => return Ok(None),
992            };
993
994            let new_tokens = Arc::new(semantic_tokens(doc.source(), &doc));
995            let new_result_id = token_hash(&new_tokens);
996            let prev_id = &params.previous_result_id;
997
998            let result = match self.docs.get_token_cache(uri, prev_id) {
999                Some(old_tokens) => {
1000                    let edits = compute_token_delta(&old_tokens, &new_tokens);
1001                    SemanticTokensFullDeltaResult::TokensDelta(SemanticTokensDelta {
1002                        result_id: Some(new_result_id.clone()),
1003                        edits,
1004                    })
1005                }
1006                // Unknown previous result — fall back to full tokens
1007                None => SemanticTokensFullDeltaResult::Tokens(SemanticTokens {
1008                    result_id: Some(new_result_id.clone()),
1009                    data: (*new_tokens).clone(),
1010                }),
1011            };
1012
1013            self.docs.store_token_cache(uri, new_result_id, new_tokens);
1014            Ok(Some(result))
1015        })
1016        .await
1017    }
1018
1019    async fn selection_range(
1020        &self,
1021        params: SelectionRangeParams,
1022    ) -> Result<Option<Vec<SelectionRange>>> {
1023        guard_async_result("selection_range", async move {
1024            let uri = &params.text_document.uri;
1025            let doc = match self.get_doc(uri) {
1026                Some(d) => d,
1027                None => return Ok(None),
1028            };
1029            let ranges = selection_ranges(&doc, &params.positions);
1030            Ok(if ranges.is_empty() {
1031                None
1032            } else {
1033                Some(ranges)
1034            })
1035        })
1036        .await
1037    }
1038
1039    async fn prepare_call_hierarchy(
1040        &self,
1041        params: CallHierarchyPrepareParams,
1042    ) -> Result<Option<Vec<CallHierarchyItem>>> {
1043        guard_async_result("prepare_call_hierarchy", async move {
1044            let uri = &params.text_document_position_params.text_document.uri;
1045            let position = params.text_document_position_params.position;
1046            let source = self.get_open_text(uri).unwrap_or_default();
1047            let word = match word_at_position(&source, position) {
1048                Some(w) => w,
1049                None => return Ok(None),
1050            };
1051            // O(matches) lookup via the aggregate's `decls_by_name` map instead
1052            // of scanning every workspace doc.
1053            let wi = self.workspace_index_async().await;
1054            let docs = Arc::clone(&self.docs);
1055            let get_doc = move |u: &Url| docs.get_doc_salsa(u);
1056            Ok(prepare_call_hierarchy_indexed(&word, &wi, &get_doc).map(|item| vec![item]))
1057        })
1058        .await
1059    }
1060
1061    async fn incoming_calls(
1062        &self,
1063        params: CallHierarchyIncomingCallsParams,
1064    ) -> Result<Option<Vec<CallHierarchyIncomingCall>>> {
1065        guard_async_result("incoming_calls", async move {
1066            // Genuinely needs every doc (call sites are body-level, not indexed);
1067            // run the workspace scan on the blocking pool.
1068            let docs = Arc::clone(&self.docs);
1069            let item_uri = params.item.uri.to_string();
1070            let item = params.item;
1071            let calls = match tokio::task::spawn_blocking(move || {
1072                let all_docs = docs.all_docs_for_scan();
1073                incoming_calls(&item, &all_docs)
1074            })
1075            .await
1076            {
1077                Ok(c) => c,
1078                Err(e) => {
1079                    tracing::warn!("incoming_calls panicked for {item_uri}: {e}");
1080                    vec![]
1081                }
1082            };
1083            Ok(if calls.is_empty() { None } else { Some(calls) })
1084        })
1085        .await
1086    }
1087
1088    async fn outgoing_calls(
1089        &self,
1090        params: CallHierarchyOutgoingCallsParams,
1091    ) -> Result<Option<Vec<CallHierarchyOutgoingCall>>> {
1092        guard_async_result("outgoing_calls", async move {
1093            // Per-callee declaration lookups go through `decls_by_name` — the old
1094            // path re-scanned the whole workspace once per distinct callee.
1095            let wi = self.workspace_index_async().await;
1096            let docs = Arc::clone(&self.docs);
1097            let item_uri = params.item.uri.to_string();
1098            let item = params.item;
1099            let calls = match tokio::task::spawn_blocking(move || {
1100                let get_doc = |u: &Url| docs.get_doc_salsa(u);
1101                outgoing_calls_indexed(&item, &wi, &get_doc)
1102            })
1103            .await
1104            {
1105                Ok(c) => c,
1106                Err(e) => {
1107                    tracing::warn!("outgoing_calls panicked for {item_uri}: {e}");
1108                    vec![]
1109                }
1110            };
1111            Ok(if calls.is_empty() { None } else { Some(calls) })
1112        })
1113        .await
1114    }
1115
1116    async fn document_highlight(
1117        &self,
1118        params: DocumentHighlightParams,
1119    ) -> Result<Option<Vec<DocumentHighlight>>> {
1120        guard_async_result("document_highlight", async move {
1121            let uri = &params.text_document_position_params.text_document.uri;
1122            let position = params.text_document_position_params.position;
1123            let source = self.get_open_text(uri).unwrap_or_default();
1124            // Stale-tolerant: a highlight fired on every cursor move must never
1125            // spin on the salsa `Cancelled` retry loop while the user types.
1126            let doc = match self.get_doc_stale(uri) {
1127                Some(d) => d,
1128                None => return Ok(None),
1129            };
1130            let uri_str = uri.to_string();
1131            // The AST walk is CPU-bound; keep it off the async runtime worker.
1132            let highlights = match tokio::task::spawn_blocking(move || {
1133                document_highlights(&source, &doc, position)
1134            })
1135            .await
1136            {
1137                Ok(h) => h,
1138                Err(e) => {
1139                    tracing::warn!("document_highlight panicked for {uri_str}: {e}");
1140                    Vec::new()
1141                }
1142            };
1143            Ok(if highlights.is_empty() {
1144                None
1145            } else {
1146                Some(highlights)
1147            })
1148        })
1149        .await
1150    }
1151
1152    async fn linked_editing_range(
1153        &self,
1154        params: LinkedEditingRangeParams,
1155    ) -> Result<Option<LinkedEditingRanges>> {
1156        guard_async_result("linked_editing_range", async move {
1157            self.handle_linked_editing_range(params).await
1158        })
1159        .await
1160    }
1161
1162    async fn goto_implementation(
1163        &self,
1164        params: tower_lsp::lsp_types::request::GotoImplementationParams,
1165    ) -> Result<Option<tower_lsp::lsp_types::request::GotoImplementationResponse>> {
1166        guard_async_result("goto_implementation", async move {
1167            let uri = &params.text_document_position_params.text_document.uri;
1168            let position = params.text_document_position_params.position;
1169            let source = self.get_open_text(uri).unwrap_or_default();
1170            let imports = self.file_imports(uri);
1171            let raw_word = crate::text::word_at_position(&source, position).unwrap_or_default();
1172            let wi = self.workspace_index_async().await;
1173            // `word_at_position` includes `\` as a word character, so the cursor on
1174            // a use-statement import (`use A\B\Foo`) returns the full qualified name.
1175            // Split to recover the short name and treat the rest as the FQN so the
1176            // workspace index lookup (keyed by short name) still finds subtypes.
1177            let (word, mut fqn_owned): (String, Option<String>) = if raw_word.contains('\\') {
1178                let short = raw_word
1179                    .rsplit('\\')
1180                    .next()
1181                    .unwrap_or(&raw_word)
1182                    .to_string();
1183                let full = raw_word.trim_start_matches('\\').to_string();
1184                (short, Some(full))
1185            } else {
1186                let fqn = imports.get(&raw_word).cloned();
1187                (raw_word, fqn)
1188            };
1189            // The cursor may sit on the class/interface's own declaration name
1190            // rather than a usage — there is no `use` import for a symbol to
1191            // reference itself, so `imports` above finds nothing. Resolve the
1192            // FQN from the workspace index instead, scoped to this file, so a
1193            // same-named class declared elsewhere can't be mismatched onto it.
1194            if fqn_owned.is_none() {
1195                fqn_owned = wi
1196                    .classes_by_name
1197                    .get(&word)
1198                    .and_then(|refs| {
1199                        refs.iter()
1200                            .filter_map(|r| wi.at(*r))
1201                            .find(|(u, _)| *u == uri)
1202                    })
1203                    .map(|(_, cls)| cls.fqn.as_ref().to_string());
1204            }
1205            let fqn = fqn_owned.as_deref();
1206            // First pass: open-file ParsedDocs give accurate character positions.
1207            let open_docs = self.docs.docs_for(&self.open_urls());
1208            let mut locs = find_implementations(&word, fqn, &open_docs);
1209            // Second pass: mir-backed subtype graph (fixes aliased/FQN extends),
1210            // falling back to the raw-name subtypes_of map when mir is cold.
1211            if locs.is_empty() {
1212                let fqn_for_mir = fqn.unwrap_or(&word).to_string();
1213                let subtype_urls = self.docs.class_subtype_urls(&fqn_for_mir);
1214                locs = find_implementations_mir_backed(&word, fqn, &wi, &subtype_urls);
1215            }
1216            // Third pass: treat word as a method name inside its declaring
1217            // class/interface — returns concrete overrides in every subtype.
1218            // Handles cursor on an interface method like `Factory::guard()`.
1219            if locs.is_empty()
1220                && let Some(doc) = self.get_doc(uri)
1221                && let Some(enclosing) =
1222                    crate::types::type_map::enclosing_class_at(&source, &doc, position)
1223            {
1224                // Resolve the enclosing class's FQN for mir's subtype graph.
1225                let enclosing_fqn = wi
1226                    .classes_by_name
1227                    .get(&enclosing)
1228                    .and_then(|refs| refs.first())
1229                    .and_then(|r| wi.at(*r))
1230                    .map(|(_, cls)| cls.fqn.as_ref().to_string());
1231                let method_subtype_urls = enclosing_fqn
1232                    .as_deref()
1233                    .map(|f| self.docs.class_subtype_urls(f))
1234                    .unwrap_or_default();
1235                locs = find_method_implementations_mir_backed(
1236                    &word,
1237                    &enclosing,
1238                    &wi,
1239                    &method_subtype_urls,
1240                );
1241            }
1242            if locs.is_empty() {
1243                Ok(None)
1244            } else {
1245                Ok(Some(GotoDefinitionResponse::Array(locs)))
1246            }
1247        })
1248        .await
1249    }
1250
1251    async fn goto_declaration(
1252        &self,
1253        params: tower_lsp::lsp_types::request::GotoDeclarationParams,
1254    ) -> Result<Option<tower_lsp::lsp_types::request::GotoDeclarationResponse>> {
1255        guard_async_result("goto_declaration", async move {
1256            let uri = &params.text_document_position_params.text_document.uri;
1257            let position = params.text_document_position_params.position;
1258            let source = self.get_open_text(uri).unwrap_or_default();
1259            // First pass: open-file ParsedDocs give accurate character positions.
1260            let open_docs = self.docs.docs_for(&self.open_urls());
1261            if let Some(loc) = goto_declaration(&source, &open_docs, position) {
1262                return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1263            }
1264            // Second pass: background files via FileIndex (line-only positions).
1265            let all_indexes = self.docs.all_indexes();
1266            Ok(goto_declaration_from_index(&source, &all_indexes, position)
1267                .map(GotoDefinitionResponse::Scalar))
1268        })
1269        .await
1270    }
1271
1272    async fn goto_type_definition(
1273        &self,
1274        params: tower_lsp::lsp_types::request::GotoTypeDefinitionParams,
1275    ) -> Result<Option<tower_lsp::lsp_types::request::GotoTypeDefinitionResponse>> {
1276        guard_async_result("goto_type_definition", async move {
1277            let uri = &params.text_document_position_params.text_document.uri;
1278            let position = params.text_document_position_params.position;
1279            let source = self.get_open_text(uri).unwrap_or_default();
1280            let doc = match self.get_doc(uri) {
1281                Some(d) => d,
1282                None => return Ok(None),
1283            };
1284            let analysis = self.cached_analysis_async(uri).await;
1285            let open_docs = self.docs.docs_for(&self.open_urls());
1286            let all_indexes = self.docs.all_indexes();
1287
1288            // Exact FQN/namespace matches (open docs, then background index)
1289            // outrank *either* source's short-name fallback, so an unrelated
1290            // same-short-named class in another open file can never preempt
1291            // a correctly-namespaced match that only lives in the index.
1292            let mut results = goto_type_definition_exact(
1293                &source,
1294                &doc,
1295                analysis.as_deref(),
1296                &open_docs,
1297                position,
1298            );
1299            if results.is_empty() {
1300                results = goto_type_definition_from_index_exact(
1301                    &source,
1302                    &doc,
1303                    analysis.as_deref(),
1304                    &all_indexes,
1305                    position,
1306                );
1307            }
1308            if results.is_empty() {
1309                results = goto_type_definition_short_name_fallback(
1310                    &source,
1311                    &doc,
1312                    analysis.as_deref(),
1313                    &open_docs,
1314                    position,
1315                );
1316            }
1317            if results.is_empty() {
1318                results = goto_type_definition_from_index_short_name_fallback(
1319                    &source,
1320                    &doc,
1321                    analysis.as_deref(),
1322                    &all_indexes,
1323                    position,
1324                );
1325            }
1326
1327            // Format response: scalar for single result, array for multiple, none for empty
1328            let response = match results.len() {
1329                0 => None,
1330                1 => Some(GotoDefinitionResponse::Scalar(
1331                    results.into_iter().next().unwrap(),
1332                )),
1333                _ => Some(GotoDefinitionResponse::Array(results)),
1334            };
1335            Ok(response)
1336        })
1337        .await
1338    }
1339
1340    async fn prepare_type_hierarchy(
1341        &self,
1342        params: TypeHierarchyPrepareParams,
1343    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1344        guard_async_result("prepare_type_hierarchy", async move {
1345            let uri = &params.text_document_position_params.text_document.uri;
1346            let position = params.text_document_position_params.position;
1347            let source = self.get_open_text(uri).unwrap_or_default();
1348            // Phase J: use the salsa-memoized aggregate's `classes_by_name` map.
1349            let wi = self.workspace_index_async().await;
1350            Ok(
1351                prepare_type_hierarchy_from_workspace(&source, uri, &wi, position)
1352                    .map(|item| vec![item]),
1353            )
1354        })
1355        .await
1356    }
1357
1358    async fn supertypes(
1359        &self,
1360        params: TypeHierarchySupertypesParams,
1361    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1362        guard_async_result("supertypes", async move {
1363            // Phase J: resolve parents via the aggregate's `classes_by_name` map.
1364            // Pre-load any direct vendor supertypes via PSR-4 so they appear in the
1365            // workspace index before the lookup runs.
1366            let wi = self.workspace_index_async().await;
1367            let loaded_new = self
1368                .ensure_direct_supertypes_loaded(&params.item.name, &wi)
1369                .await;
1370            let wi = if loaded_new {
1371                self.workspace_index_async().await
1372            } else {
1373                wi
1374            };
1375            let result = supertypes_of_from_workspace(&params.item, &wi);
1376            Ok(if result.is_empty() {
1377                None
1378            } else {
1379                Some(result)
1380            })
1381        })
1382        .await
1383    }
1384
1385    async fn subtypes(
1386        &self,
1387        params: TypeHierarchySubtypesParams,
1388    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1389        guard_async_result("subtypes", async move {
1390            let wi = self.workspace_index_async().await;
1391            // Resolve the item's FQCN for mir's subtype graph. `classes_by_name`
1392            // is keyed by short name only, so a name shared by many classes
1393            // across the workspace (e.g. Laravel's ~16 `Factory` classes) needs
1394            // `params.item.uri` to pick the right one instead of an arbitrary
1395            // first match.
1396            let item_fqn = wi
1397                .classes_by_name
1398                .get(&params.item.name)
1399                .and_then(|refs| {
1400                    refs.iter()
1401                        .filter_map(|r| wi.at(*r))
1402                        .find(|(u, _)| **u == params.item.uri)
1403                        .or_else(|| refs.first().and_then(|r| wi.at(*r)))
1404                })
1405                .map(|(_, cls)| cls.fqn.as_ref().to_string());
1406            let subtype_urls = item_fqn
1407                .as_deref()
1408                .map(|f| self.docs.class_subtype_urls(f))
1409                .unwrap_or_default();
1410            let result =
1411                subtypes_of_mir_backed(&params.item, item_fqn.as_deref(), &wi, &subtype_urls);
1412            Ok(if result.is_empty() {
1413                None
1414            } else {
1415                Some(result)
1416            })
1417        })
1418        .await
1419    }
1420
1421    async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
1422        guard_async_result("code_lens", async move {
1423            let uri = &params.text_document.uri;
1424            let doc = match self.get_doc(uri) {
1425                Some(d) => d,
1426                None => return Ok(None),
1427            };
1428            // Reference-count lenses scan every doc per declaration; run the
1429            // whole computation on the blocking pool. A write during computation
1430            // (write_rev advances) cancels the sweep early — stale lens counts
1431            // are useless and the editor will request fresh ones after the edit.
1432            let docs = Arc::clone(&self.docs);
1433            let docs_cancel = Arc::clone(&self.docs);
1434            let uri_owned = uri.clone();
1435            let uri_str = uri.to_string();
1436            let lenses = match tokio::task::spawn_blocking(move || {
1437                // Pause the background scan and snapshot a settled revision so
1438                // only a genuine user edit cancels the sweep.
1439                let (_interactive, cancel_rev) = docs.settled_write_rev_guard();
1440                let all_docs = docs.all_docs_for_scan();
1441                code_lenses(&uri_owned, &doc, &all_docs, move || {
1442                    docs_cancel.write_rev() != cancel_rev
1443                })
1444            })
1445            .await
1446            {
1447                Ok(l) => l,
1448                Err(e) => {
1449                    tracing::warn!("code_lens panicked for {uri_str}: {e}");
1450                    vec![]
1451                }
1452            };
1453            Ok(if lenses.is_empty() {
1454                None
1455            } else {
1456                Some(lenses)
1457            })
1458        })
1459        .await
1460    }
1461
1462    async fn code_lens_resolve(&self, params: CodeLens) -> Result<CodeLens> {
1463        let fallback = params.clone();
1464        let resolved = guard_async("code_lens_resolve", async move {
1465            // Lenses are fully populated by code_lens; nothing to add.
1466            Some(params)
1467        })
1468        .await;
1469        Ok(resolved.unwrap_or(fallback))
1470    }
1471
1472    async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
1473        guard_async_result("document_link", async move {
1474            let uri = &params.text_document.uri;
1475            let doc = match self.get_doc(uri) {
1476                Some(d) => d,
1477                None => return Ok(None),
1478            };
1479            let links = document_links(uri, &doc, doc.source());
1480            Ok(if links.is_empty() { None } else { Some(links) })
1481        })
1482        .await
1483    }
1484
1485    async fn document_link_resolve(&self, params: DocumentLink) -> Result<DocumentLink> {
1486        let fallback = params.clone();
1487        let resolved = guard_async("document_link_resolve", async move {
1488            // Links already carry their target URI; nothing to add.
1489            Some(params)
1490        })
1491        .await;
1492        Ok(resolved.unwrap_or(fallback))
1493    }
1494
1495    async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
1496        guard_async_result("formatting", async move {
1497            let uri = &params.text_document.uri;
1498            let source = self.get_open_text(uri).unwrap_or_default();
1499            Ok(format_document(&source))
1500        })
1501        .await
1502    }
1503
1504    async fn range_formatting(
1505        &self,
1506        params: DocumentRangeFormattingParams,
1507    ) -> Result<Option<Vec<TextEdit>>> {
1508        guard_async_result("range_formatting", async move {
1509            let uri = &params.text_document.uri;
1510            let source = self.get_open_text(uri).unwrap_or_default();
1511            Ok(format_range(&source, params.range))
1512        })
1513        .await
1514    }
1515
1516    async fn on_type_formatting(
1517        &self,
1518        params: DocumentOnTypeFormattingParams,
1519    ) -> Result<Option<Vec<TextEdit>>> {
1520        guard_async_result("on_type_formatting", async move {
1521            let uri = &params.text_document_position.text_document.uri;
1522            let source = self.get_open_text(uri).unwrap_or_default();
1523            let edits = on_type_format(
1524                &source,
1525                params.text_document_position.position,
1526                &params.ch,
1527                &params.options,
1528            );
1529            Ok(if edits.is_empty() { None } else { Some(edits) })
1530        })
1531        .await
1532    }
1533
1534    async fn execute_command(
1535        &self,
1536        params: ExecuteCommandParams,
1537    ) -> Result<Option<serde_json::Value>> {
1538        guard_async_result("execute_command", async move {
1539            match params.command.as_str() {
1540                "php-lsp.runTest" => {
1541                    // Arguments: [uri_string, "ClassName::methodName"]
1542                    let file_uri = params
1543                        .arguments
1544                        .first()
1545                        .and_then(|v| v.as_str())
1546                        .and_then(|s| Url::parse(s).ok());
1547                    let filter = params
1548                        .arguments
1549                        .get(1)
1550                        .and_then(|v| v.as_str())
1551                        .unwrap_or("")
1552                        .to_string();
1553
1554                    let root = self.root_paths.load().first().cloned();
1555                    let client = self.client.clone();
1556
1557                    tokio::spawn(async move {
1558                        run_phpunit(&client, &filter, root.as_deref(), file_uri.as_ref()).await;
1559                    });
1560
1561                    Ok(None)
1562                }
1563                _ => Ok(None),
1564            }
1565        })
1566        .await
1567    }
1568
1569    async fn will_rename_files(&self, params: RenameFilesParams) -> Result<Option<WorkspaceEdit>> {
1570        guard_async_result("will_rename_files", async move {
1571            self.handle_will_rename_files(params).await
1572        })
1573        .await
1574    }
1575
1576    async fn did_rename_files(&self, params: RenameFilesParams) {
1577        guard_async("did_rename_files", async move {
1578            self.handle_did_rename_files(params).await
1579        })
1580        .await
1581    }
1582
1583    async fn will_create_files(&self, params: CreateFilesParams) -> Result<Option<WorkspaceEdit>> {
1584        guard_async_result("will_create_files", async move {
1585            self.handle_will_create_files(params).await
1586        })
1587        .await
1588    }
1589
1590    async fn did_create_files(&self, params: CreateFilesParams) {
1591        guard_async("did_create_files", async move {
1592            self.handle_did_create_files(params).await
1593        })
1594        .await
1595    }
1596
1597    /// Before a file is deleted, return workspace edits that remove every
1598    /// `use` import referencing its PSR-4 class name.
1599    async fn will_delete_files(&self, params: DeleteFilesParams) -> Result<Option<WorkspaceEdit>> {
1600        guard_async_result("will_delete_files", async move {
1601            self.handle_will_delete_files(params).await
1602        })
1603        .await
1604    }
1605
1606    async fn did_delete_files(&self, params: DeleteFilesParams) {
1607        guard_async("did_delete_files", async move {
1608            self.handle_did_delete_files(params).await
1609        })
1610        .await
1611    }
1612
1613    async fn moniker(&self, params: MonikerParams) -> Result<Option<Vec<Moniker>>> {
1614        guard_async_result("moniker", async move {
1615            let uri = &params.text_document_position_params.text_document.uri;
1616            let position = params.text_document_position_params.position;
1617            let source = self.get_open_text(uri).unwrap_or_default();
1618            let doc = match self.get_doc(uri) {
1619                Some(d) => d,
1620                None => return Ok(None),
1621            };
1622            let imports = self.file_imports(uri);
1623            Ok(moniker_at(&source, &doc, position, &imports).map(|m| vec![m]))
1624        })
1625        .await
1626    }
1627
1628    async fn inline_value(&self, params: InlineValueParams) -> Result<Option<Vec<InlineValue>>> {
1629        guard_async_result("inline_value", async move {
1630            let uri = &params.text_document.uri;
1631            let source = self.get_open_text(uri).unwrap_or_default();
1632            let values = inline_values_in_range(&source, params.range);
1633            Ok(if values.is_empty() {
1634                None
1635            } else {
1636                Some(values)
1637            })
1638        })
1639        .await
1640    }
1641
1642    async fn diagnostic(
1643        &self,
1644        params: DocumentDiagnosticParams,
1645    ) -> Result<DocumentDiagnosticReportResult> {
1646        let report = guard_async("diagnostic", async move {
1647            Some(self.handle_diagnostic(params).await)
1648        })
1649        .await;
1650        match report {
1651            Some(r) => r,
1652            None => Ok(DocumentDiagnosticReportResult::Report(
1653                DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport::default()),
1654            )),
1655        }
1656    }
1657
1658    async fn workspace_diagnostic(
1659        &self,
1660        params: WorkspaceDiagnosticParams,
1661    ) -> Result<WorkspaceDiagnosticReportResult> {
1662        let report = guard_async("workspace_diagnostic", async move {
1663            Some(self.handle_workspace_diagnostic(params).await)
1664        })
1665        .await;
1666        match report {
1667            Some(r) => r,
1668            None => Ok(WorkspaceDiagnosticReportResult::Report(
1669                WorkspaceDiagnosticReport::default(),
1670            )),
1671        }
1672    }
1673
1674    async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
1675        guard_async_result("code_action", async move {
1676            self.handle_code_action(params).await
1677        })
1678        .await
1679    }
1680
1681    async fn code_action_resolve(&self, item: CodeAction) -> Result<CodeAction> {
1682        guard_async_result("code_action_resolve", async move {
1683            self.handle_code_action_resolve(item).await
1684        })
1685        .await
1686    }
1687}