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