Skip to main content

php_lsp/backend/
server.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicBool, Ordering};
4
5use tower_lsp_server::LanguageServer;
6use tower_lsp_server::jsonrpc::Result;
7use tower_lsp_server::ls_types::*;
8
9use super::DiagnosticPublish;
10use super::panic_guard::{guard_async, guard_async_result};
11use crate::analysis::callable_info::{callable_info_for_name, format_declared_params};
12use crate::completion::{CompletionCtx, filtered_completions_at};
13use crate::document::ast::ParsedDoc;
14use crate::document::open_files::compute_open_file_diagnostics;
15use crate::hover::{extract_static_class_before_cursor, hover_info_with_maps};
16use crate::index::file_index::ClassKind;
17use crate::index::workspace_scan::{scan_workspace, send_refresh_requests};
18use crate::lang::config::LspConfig;
19use crate::lang::is_unresolvable_bareword_at;
20use crate::navigation::symbols::{
21    document_symbols, resolve_workspace_symbol, workspace_symbols_from_workspace,
22};
23use crate::text::word_at_position;
24
25use crate::navigation::call_hierarchy::{
26    incoming_calls_indexed, outgoing_calls_via_mir, prepare_call_hierarchy_indexed,
27};
28use crate::navigation::declaration::{goto_declaration, goto_declaration_from_index};
29use crate::navigation::moniker::moniker_at;
30use crate::navigation::type_definition::type_class_fqns;
31use crate::navigation::type_hierarchy::{
32    prepare_type_hierarchy_from_fqn, subtypes_from_sites, supertypes_of_from_workspace,
33};
34
35use crate::analysis::code_lens::{code_lenses, subtype_site_to_location};
36use crate::analysis::diagnostics::{diagnostics_from_doc, parse_document_no_diags};
37use crate::analysis::document_highlight::document_highlights;
38use crate::analysis::inlay_hints::inlay_hints;
39use crate::analysis::inline_value::inline_values_in_range;
40use crate::analysis::semantic_tokens::{
41    compute_token_delta, semantic_tokens, semantic_tokens_range, token_hash,
42};
43
44use crate::editing::document_link::document_links;
45use crate::editing::folding::folding_ranges;
46use crate::editing::formatting::{format_document, format_range};
47use crate::editing::on_type_format::on_type_format;
48use crate::editing::rename::{prepare_rename, rename_variable};
49use crate::editing::selection_range::selection_ranges;
50use crate::editing::signature_help::signature_help;
51
52use super::helpers::{cursor_is_on_property_decl, promoted_property_at_cursor, run_phpunit};
53use super::{Backend, publish_with_dependents};
54
55/// Idle time after the last edit before the background analysis re-warm runs.
56/// Long enough that a typing burst triggers one sweep, not one per pause;
57/// short enough that references asked "shortly after editing" are warm again.
58const WARM_RESWEEP_IDLE_MS: u64 = 1_000;
59
60fn normalized_php_fqn(name: &str) -> &str {
61    name.trim_start_matches('\\')
62}
63
64fn is_bare_php_name(name: &str) -> bool {
65    crate::text::fqn_short_name(name) == name
66}
67
68fn class_lookup_fqn(doc: &ParsedDoc, name: &str, imports: &HashMap<String, String>) -> String {
69    if is_bare_php_name(name) {
70        crate::navigation::moniker::resolve_fqn(doc, name, imports)
71    } else {
72        normalized_php_fqn(name).to_string()
73    }
74}
75
76fn inlay_hint_symbol_from_data(data: &serde_json::Value) -> Option<mir_analyzer::Name> {
77    let kind = data.get("php_lsp_symbol_kind")?.as_str()?;
78    let name = data.get("php_lsp_fn")?.as_str()?;
79    match kind {
80        "function" => Some(mir_analyzer::Name::function(name)),
81        "method" => {
82            let class = data.get("php_lsp_class")?.as_str()?;
83            Some(mir_analyzer::Name::method(class, name))
84        }
85        "class" => Some(mir_analyzer::Name::class(name)),
86        _ => None,
87    }
88}
89
90fn completion_symbol_from_item(
91    name: &str,
92    kind: Option<CompletionItemKind>,
93    class_hint: Option<&str>,
94) -> Option<mir_analyzer::Name> {
95    if let Some(class_name) = class_hint {
96        return Some(mir_analyzer::Name::method(class_name, name));
97    }
98    match kind {
99        Some(CompletionItemKind::FUNCTION) => Some(mir_analyzer::Name::function(name)),
100        Some(CompletionItemKind::CLASS)
101        | Some(CompletionItemKind::INTERFACE)
102        | Some(CompletionItemKind::ENUM) => Some(mir_analyzer::Name::class(name)),
103        _ => None,
104    }
105}
106
107fn hover_markup(value: String) -> Hover {
108    Hover {
109        contents: HoverContents::Markup(MarkupContent {
110            kind: MarkupKind::Markdown,
111            value,
112        }),
113        range: None,
114    }
115}
116
117fn hover_display_class_name(snapshot: &mir_analyzer::AnalysisSnapshot, name: &str) -> String {
118    let normalized = normalized_php_fqn(name);
119    if !is_bare_php_name(normalized) {
120        return normalized.to_string();
121    }
122    if let Ok(true) = snapshot.is_builtin_class(normalized) {
123        return format!("\\{normalized}");
124    }
125    normalized.to_string()
126}
127
128fn class_hover_for_fqcn(session: &mir_analyzer::AnalysisSnapshot, fqcn: &str) -> Option<Hover> {
129    let class = {
130        let db = session.db();
131        let here = mir_analyzer::db::Fqcn::from_str(db, fqcn);
132        mir_analyzer::db::find_class_like(db, here)?
133    };
134    let sig = match &class {
135        mir_analyzer::db::ClassLike::Class(c) => {
136            let mut sig = if c.is_abstract {
137                format!("abstract class {}", c.short_name)
138            } else if c.is_readonly {
139                format!("readonly class {}", c.short_name)
140            } else if c.is_final {
141                format!("final class {}", c.short_name)
142            } else {
143                format!("class {}", c.short_name)
144            };
145            if let Some(parent) = &c.parent {
146                sig.push_str(&format!(
147                    " extends {}",
148                    hover_display_class_name(session, parent)
149                ));
150            }
151            if !c.interfaces.is_empty() {
152                let list: Vec<String> = c
153                    .interfaces
154                    .iter()
155                    .map(|s| hover_display_class_name(session, s))
156                    .collect();
157                sig.push_str(&format!(" implements {}", list.join(", ")));
158            }
159            sig
160        }
161        mir_analyzer::db::ClassLike::Interface(i) => {
162            let mut sig = format!("interface {}", i.short_name);
163            if !i.extends.is_empty() {
164                let list: Vec<String> = i
165                    .extends
166                    .iter()
167                    .map(|s| hover_display_class_name(session, s))
168                    .collect();
169                sig.push_str(&format!(" extends {}", list.join(", ")));
170            }
171            sig
172        }
173        mir_analyzer::db::ClassLike::Trait(t) => format!("trait {}", t.short_name),
174        mir_analyzer::db::ClassLike::Enum(e) => {
175            let mut sig = if let Some(scalar) = &e.scalar_type {
176                format!("enum {}: {}", e.short_name, scalar)
177            } else {
178                format!("enum {}", e.short_name)
179            };
180            if !e.interfaces.is_empty() {
181                let list: Vec<String> = e
182                    .interfaces
183                    .iter()
184                    .map(|s| hover_display_class_name(session, s))
185                    .collect();
186                sig.push_str(&format!(" implements {}", list.join(", ")));
187            }
188            sig
189        }
190    };
191    Some(hover_markup(crate::hover::formatting::wrap_php(&sig)))
192}
193
194fn method_hover_for_fqcn(
195    session: &mir_analyzer::AnalysisSnapshot,
196    fqcn: &str,
197    method_name: &str,
198) -> Option<Hover> {
199    let db = session.db();
200    let (_, m) = mir_analyzer::db::find_method_in_chain(
201        db,
202        mir_analyzer::db::Fqcn::from_str(db, fqcn),
203        method_name,
204    )?;
205    let ret = m
206        .return_type
207        .as_deref()
208        .or(m.inferred_return_type.as_deref())
209        .map(|r| format!(": {}", r))
210        .unwrap_or_default();
211    let sig = format!(
212        "{}::{}({}){}",
213        crate::text::fqn_short_name(fqcn),
214        m.name,
215        format_declared_params(&m.params),
216        ret
217    );
218    let mut value = crate::hover::formatting::wrap_php(&sig);
219    if let Some(docstring) = m.docstring.as_deref().filter(|s| !s.is_empty()) {
220        value.push_str("\n\n---\n\n");
221        value.push_str(docstring);
222    }
223    Some(hover_markup(value))
224}
225
226fn callable_signature_for_symbol(
227    db: &mir_analyzer::db::MirDbStorage,
228    symbol: &mir_analyzer::Name,
229) -> Option<(String, Option<String>)> {
230    let info = callable_info_for_name(db, symbol)?;
231    match symbol {
232        mir_analyzer::Name::Function(fqn) => {
233            let short = fqn.rsplit('\\').next().unwrap_or(fqn.as_ref());
234            if mir_analyzer::is_builtin_function(short) {
235                return None;
236            }
237            let ret = info
238                .return_type
239                .as_deref()
240                .map(|ty| format!(": {ty}"))
241                .unwrap_or_default();
242            Some((
243                format!("function {}({}){}", short, info.params, ret),
244                info.documentation,
245            ))
246        }
247        mir_analyzer::Name::Method { class: _, name } => {
248            let ret = info
249                .return_type
250                .as_deref()
251                .map(|ty| format!(": {ty}"))
252                .unwrap_or_default();
253            Some((
254                format!("function {}({}){}", name, info.params, ret),
255                info.documentation,
256            ))
257        }
258        mir_analyzer::Name::Class(_) => Some((
259            format!("function __construct({})", info.params),
260            info.documentation,
261        )),
262        _ => None,
263    }
264}
265
266fn inlay_hint_tooltip_for_symbol(
267    db: &mir_analyzer::db::MirDbStorage,
268    symbol: &mir_analyzer::Name,
269) -> Option<String> {
270    let (signature, docstring) = callable_signature_for_symbol(db, symbol)?;
271    let mut value = format!("```php\n{signature}\n```");
272    if let Some(docstring) = docstring.filter(|s| !s.is_empty()) {
273        value.push_str("\n\n---\n\n");
274        value.push_str(&docstring);
275    }
276    Some(value)
277}
278
279fn completion_signature_and_docstring_for_symbol(
280    db: &mir_analyzer::db::MirDbStorage,
281    symbol: &mir_analyzer::Name,
282) -> (Option<String>, Option<String>) {
283    if let Some((signature, docstring)) = callable_signature_for_symbol(db, symbol) {
284        return (Some(signature), docstring.filter(|s| !s.is_empty()));
285    }
286    if let mir_analyzer::Name::Function(fqn) = symbol {
287        let short = fqn.rsplit('\\').next().unwrap_or(fqn.as_ref());
288        if mir_analyzer::is_builtin_function(short) {
289            return (
290                None,
291                Some(format!(
292                    "```php\nfunction {}()\n```\n\n[php.net documentation]({})",
293                    short,
294                    crate::lang::php_names::php_doc_url(short)
295                )),
296            );
297        }
298    }
299    (None, None)
300}
301
302impl LanguageServer for Backend {
303    async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
304        self.handle_initialize(params).await
305    }
306
307    async fn initialized(&self, _params: InitializedParams) {
308        guard_async("initialized", async move {
309            self.handle_initialized(_params).await
310        })
311        .await
312    }
313
314    async fn did_change_configuration(&self, _params: DidChangeConfigurationParams) {
315        guard_async("did_change_configuration", async move {
316            // Pull the current configuration from the client rather than parsing the
317            // (often-null) params.settings, which not all clients populate.
318            let items = vec![ConfigurationItem {
319                scope_uri: None,
320                section: Some("php-lsp".to_string()),
321            }];
322            if let Ok(values) = self.client.configuration(items).await
323                && let Some(value) = values.into_iter().next()
324            {
325                let roots = self.root_paths.load_full();
326
327                // Re-read .php-lsp.json so a user who edits the file and then
328                // triggers a configuration reload picks up the latest values.
329                let file_cfg = crate::lang::autoload::load_project_config_json(&roots);
330
331                if let Some(ver) = value.get("phpVersion").and_then(|v| v.as_str())
332                    && !crate::lang::autoload::is_valid_php_version(ver)
333                {
334                    self.client
335                        .log_message(
336                            tower_lsp_server::ls_types::MessageType::WARNING,
337                            format!(
338                                "php-lsp: unsupported phpVersion {ver:?} — valid values: {}",
339                                crate::lang::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
340                            ),
341                        )
342                        .await;
343                }
344
345                let file_obj = file_cfg.as_ref().filter(|v| v.is_object());
346                let merged = LspConfig::merge_project_configs(file_obj, Some(&value));
347                let mut cfg = LspConfig::from_value(&merged);
348
349                // Resolve the PHP version and log what was chosen and why.
350                let (ver, source) = self.resolve_php_version(cfg.php_version.as_deref());
351                self.client
352                    .log_message(
353                        tower_lsp_server::ls_types::MessageType::INFO,
354                        format!("php-lsp: using PHP {ver} ({source})"),
355                    )
356                    .await;
357                // Clamp unsupported versions to the nearest supported one and warn.
358                let ver = if source != "set by editor"
359                    && !crate::lang::autoload::is_valid_php_version(&ver)
360                {
361                    let clamped = crate::lang::autoload::clamp_php_version(&ver);
362                    self.client
363                        .show_message(
364                            tower_lsp_server::ls_types::MessageType::WARNING,
365                            format!(
366                                "php-lsp: detected PHP {ver} is outside the supported range ({}); \
367                             using PHP {clamped} for analysis",
368                                crate::lang::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
369                            ),
370                        )
371                        .await;
372                    clamped.to_string()
373                } else {
374                    ver
375                };
376                cfg.php_version = Some(ver.clone());
377                let version_changed = ver
378                    .parse::<mir_analyzer::PhpVersion>()
379                    .is_ok_and(|pv| self.docs.set_php_version(pv));
380                self.config.store(Arc::new(cfg));
381                if version_changed {
382                    // set_php_version's drop_session_scoped_state cleared
383                    // lsp_ws_files and nothing else repopulates it — without
384                    // this, workspace_file_paths() (and everything scoped by
385                    // it: references, rename, ...) silently sees an empty
386                    // workspace until files are individually re-touched. The
387                    // rescan below walks disk files but explicitly skips ones
388                    // already open, so open buffers need re-mirroring too.
389                    self.remirror_open_files();
390                    self.rescan_roots_after_version_change(&roots).await;
391                }
392                send_refresh_requests(&self.client).await;
393            }
394        })
395        .await
396    }
397
398    async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
399        guard_async("did_change_workspace_folders", async move {
400            // Remove folders from our tracked roots.
401            {
402                let mut roots = (**self.root_paths.load()).clone();
403                for removed in &params.event.removed {
404                    if let Some(path) = removed.uri.to_file_path() {
405                        roots.retain(|r| r.as_path() != path.as_ref());
406                    }
407                }
408                self.root_paths.store(Arc::new(roots));
409            }
410
411            // Add new folders and kick off background scans for each.
412            let (exclude_paths, include_paths, max_indexed_files, cache_path) = {
413                let cfg = self.config.load();
414                let mut exclude = cfg.exclude_paths.clone();
415                if !cfg.index_vendor && !exclude.iter().any(|p| p == "vendor" || p == "vendor/") {
416                    exclude.push("vendor/".to_string());
417                }
418                (
419                    exclude,
420                    cfg.include_paths.clone(),
421                    cfg.max_indexed_files,
422                    cfg.cache_path.clone(),
423                )
424            };
425            for added in &params.event.added {
426                if let Some(path) = added.uri.to_file_path().map(|c| c.into_owned()) {
427                    let is_new = {
428                        let mut roots = (**self.root_paths.load()).clone();
429                        if !roots.contains(&path) {
430                            roots.push(path.clone());
431                            self.root_paths.store(Arc::new(roots));
432                            true
433                        } else {
434                            false
435                        }
436                    };
437                    if is_new {
438                        let docs = Arc::clone(&self.docs);
439                        let open_files = self.open_files.clone();
440                        let open_urls = open_files.urls();
441                        let ex = exclude_paths.clone();
442                        let ip = include_paths.clone();
443                        let extensions = self.config.load().php_file_extensions();
444                        let path_clone = path.clone();
445                        let client = self.client.clone();
446                        let cp = cache_path.clone();
447                        let warm_analysis = self.config.load().warm_analysis;
448                        tokio::spawn(async move {
449                            let cache = if let Some(p) = cp {
450                                Some(crate::index::cache::WorkspaceCache::with_dir(p))
451                            } else {
452                                crate::index::cache::WorkspaceCache::new(&path_clone)
453                            };
454                            scan_workspace(
455                                path_clone,
456                                Arc::clone(&docs),
457                                open_files,
458                                cache,
459                                &ex,
460                                &ip,
461                                &extensions,
462                                max_indexed_files,
463                                None,
464                            )
465                            .await;
466                            // Replay disk-cached index postings/subtype edges for
467                            // this folder's files, then run the reference-warm
468                            // phase (Mir's returned priority set, plus the
469                            // ambient sweep when configured) — same as the boot
470                            // path, so a folder added at runtime ends up as warm
471                            // as the startup roots, not just scan-mirrored.
472                            super::offload::run("workspaceFolders.warmStart", move || {
473                                docs.get_workspace_index_salsa();
474                                let untrusted = docs.warm_start_indexes();
475                                docs.warm_references_phase(
476                                    untrusted,
477                                    &open_urls,
478                                    warm_analysis,
479                                    None,
480                                );
481                            })
482                            .await;
483                            send_refresh_requests(&client).await;
484                        });
485                    }
486                }
487            }
488        })
489        .await
490    }
491
492    async fn shutdown(&self) -> Result<()> {
493        self.docs.cancel_background_work();
494        // Postings committed since the last warm sweep (on-demand query
495        // freshness passes, edit re-sweeps) reach disk only via a flush.
496        let docs = Arc::clone(&self.docs);
497        self.blocking("shutdown.flush", move || docs.flush_analysis_cache())
498            .await;
499        Ok(())
500    }
501
502    #[tracing::instrument(skip_all)]
503    async fn did_open(&self, params: DidOpenTextDocumentParams) {
504        guard_async("did_open", async move {
505            let uri = params.text_document.uri;
506            let text = params.text_document.text;
507            let version = params.text_document.version;
508            let lifecycle_generation = super::lifecycle_generation();
509
510            // The transport recorded a later close before this future got a
511            // chance to run. Do not resurrect a document the client has
512            // already closed.
513            if !self
514                .open_files
515                .should_apply_lifecycle(&uri, lifecycle_generation, true)
516            {
517                return;
518            }
519
520            // Store text immediately so other features work while parsing.
521            // This also mirrors the new text into salsa, so the codebase query
522            // sees it when semantic_diagnostics runs below.
523            self.set_open_text(uri.clone(), text);
524            // didOpen can make a brand-new declaring file visible to sibling
525            // open files that were analyzed earlier. Mir invalidates its own
526            // resolver caches; sync only php-lsp's retained analysis_cache.
527            self.docs.sync_analysis_cache_declarations(&uri);
528            // Seed parse diagnostics from the salsa-cached doc. On the fast
529            // path this is a lock-free DashMap lookup — no re-parse — but a
530            // cache miss triggers a full parse, so keep it off the async task.
531            let docs = Arc::clone(&self.docs);
532            let uri_for_task = uri.clone();
533            let parse_diags = self
534                .blocking_gated(super::debug_gate::GATE_DID_OPEN_PARSE, move || {
535                    let target = docs.workspace_php_version();
536                    docs.get_doc_salsa(&uri_for_task)
537                        .map(|doc| diagnostics_from_doc(&doc, target))
538                        .unwrap_or_default()
539                })
540                .await
541                .unwrap_or_default();
542            if !self
543                .open_files
544                .should_apply_lifecycle(&uri, lifecycle_generation, true)
545            {
546                return;
547            }
548            self.set_parse_diagnostics(&uri, parse_diags);
549
550            publish_with_dependents(
551                self.client.clone(),
552                Arc::clone(&self.docs),
553                self.open_files.clone(),
554                uri,
555                DiagnosticPublish::Open {
556                    version,
557                    generation: lifecycle_generation,
558                },
559                self.config.load().diagnostics.clone(),
560                self.laravel.load_full().is_laravel,
561            )
562            .await;
563        })
564        .await
565    }
566
567    #[tracing::instrument(skip_all)]
568    async fn did_change(&self, params: DidChangeTextDocumentParams) {
569        guard_async("did_change", async move {
570            let uri = params.text_document.uri;
571            let document_version = params.text_document.version;
572            // Incremental sync: apply changes in order to the live buffer.
573            // Each ranged change refers to the document state produced by the
574            // previous one; a change without a range is a full-document
575            // replacement (clients may still send those under INCREMENTAL).
576            let mut updated: Option<String> = None;
577            for change in params.content_changes {
578                match change.range {
579                    None => updated = Some(change.text),
580                    Some(range) => {
581                        let mut cur = match updated.take() {
582                            Some(t) => t,
583                            None => self.get_open_text(&uri).unwrap_or_default().to_string(),
584                        };
585                        crate::text::apply_content_change(&mut cur, range, &change.text);
586                        updated = Some(cur);
587                    }
588                }
589            }
590            let Some(text) = updated else { return };
591
592            // Store text immediately and capture the version token.
593            // Features (completion, hover, …) see the new text instantly while
594            // the parse runs in the background.
595            let version = self.set_open_text(uri.clone(), text);
596
597            let docs = Arc::clone(&self.docs);
598            let open_files = self.open_files.clone();
599            let client = self.client.clone();
600            let cfg = self.config.load();
601            let diag_cfg = cfg.diagnostics.clone();
602            let debounce_ms = cfg.debounce_ms;
603            let warm_analysis = cfg.warm_analysis;
604            let is_laravel = self.laravel.load_full().is_laravel;
605            tokio::spawn(async move {
606                // Debounce: if another edit arrives before we parse, the version
607                // gate below will discard this result.
608                tokio::time::sleep(std::time::Duration::from_millis(debounce_ms)).await;
609
610                // Skip the expensive parse+analyze if a newer edit already
611                // superseded this one. Collapses N rapid keystrokes into 1
612                // spawn_blocking call instead of N.
613                if open_files.current_version(&uri) != Some(version) {
614                    return;
615                }
616
617                // Reuse the salsa-memoized parse that semantic analysis shares.
618                let parse_docs = Arc::clone(&docs);
619                let parse_uri = uri.clone();
620                let parse_diags = super::offload::run("did_change.parse", move || {
621                    let target = parse_docs.workspace_php_version();
622                    parse_docs
623                        .get_doc_salsa(&parse_uri)
624                        .map(|doc| diagnostics_from_doc(&doc, target))
625                        .unwrap_or_default()
626                })
627                .await
628                .unwrap_or_default();
629
630                // Only apply if no newer edit arrived while we were parsing.
631                if open_files.current_version(&uri) != Some(version) {
632                    return;
633                }
634                open_files.set_parse_diagnostics(&uri, parse_diags);
635                publish_with_dependents(
636                    client,
637                    Arc::clone(&docs),
638                    open_files.clone(),
639                    uri.clone(),
640                    DiagnosticPublish::Other(Some(document_version)),
641                    diag_cfg,
642                    is_laravel,
643                )
644                .await;
645
646                // Re-warm the analysis memos this edit invalidated once typing
647                // goes idle, so the next references/rename stays a memo hit.
648                // Unaffected files revalidate without re-analysis, so the sweep
649                // cost tracks what the edit actually touched.
650                if warm_analysis && docs.is_index_ready() {
651                    tokio::time::sleep(std::time::Duration::from_millis(WARM_RESWEEP_IDLE_MS))
652                        .await;
653                    if open_files.current_version(&uri) != Some(version) {
654                        return;
655                    }
656                    let open_urls = open_files.urls();
657                    // Deliberately not `offload::run`: spawn_blocking schedules
658                    // synchronously at the call, but `run`'s async body is lazy
659                    // and would never even start the sweep without an `.await`
660                    // this fire-and-forget site intentionally omits.
661                    drop(tokio::task::spawn_blocking(move || {
662                        let cancel = docs.begin_warm_sweep();
663                        docs.warm_analysis_sweep(&open_urls, &cancel);
664                    }));
665                }
666            });
667        })
668        .await
669    }
670
671    async fn did_close(&self, params: DidCloseTextDocumentParams) {
672        guard_async("did_close", async move {
673            let uri = params.text_document.uri;
674            let lifecycle_generation = super::lifecycle_generation();
675            // `didOpen` may have arrived after this close but begun execution
676            // first. The transport ingress state is authoritative, so an old
677            // close must not erase that reopened buffer.
678            if !self
679                .open_files
680                .should_apply_lifecycle(&uri, lifecycle_generation, false)
681            {
682                return;
683            }
684            self.close_open_file(&uri);
685            // The salsa-mirrored text still holds the last edited buffer, which may
686            // include unsaved changes the user just discarded on close. Re-sync from
687            // disk so cross-file features resolve against the on-disk content; if the
688            // file is gone (never saved / deleted), drop it from the index.
689            let disk_path = uri.to_file_path().map(|path| path.into_owned());
690            let disk_text = self
691                .blocking_gated(super::debug_gate::GATE_DID_CLOSE_DISK_SYNC, move || {
692                    disk_path.and_then(|path| std::fs::read_to_string(path).ok())
693                })
694                .await
695                .flatten();
696            if !self
697                .open_files
698                .should_apply_lifecycle(&uri, lifecycle_generation, false)
699            {
700                return;
701            }
702            // The disk read awaited above; a reopen may have raced in. Only mutate
703            // the index if the file is still closed, so we never clobber a fresh
704            // buffer.
705            if !self.open_files.contains(&uri) {
706                match disk_text {
707                    Some(text) => self.docs.ingest(uri.clone(), &text),
708                    None => self.docs.remove(&uri),
709                }
710            }
711            // Clear editor diagnostics; the file stays indexed for cross-file features
712            self.client.publish_diagnostics(uri, vec![], None).await;
713        })
714        .await
715    }
716
717    async fn will_save(&self, _params: WillSaveTextDocumentParams) {}
718
719    async fn will_save_wait_until(
720        &self,
721        params: WillSaveTextDocumentParams,
722    ) -> Result<Option<Vec<TextEdit>>> {
723        guard_async_result("will_save_wait_until", async move {
724            let source = self
725                .get_open_text(&params.text_document.uri)
726                .unwrap_or_default();
727            // Spawns and blocks on an external formatter process; keep that
728            // wait off the async runtime worker.
729            Ok(self
730                .blocking("willSaveWaitUntil.format", move || format_document(&source))
731                .await
732                .unwrap_or_default())
733        })
734        .await
735    }
736
737    async fn did_save(&self, params: DidSaveTextDocumentParams) {
738        guard_async("did_save", async move {
739            let uri = params.text_document.uri;
740            // Re-publish diagnostics on save so editors that defer diagnostics
741            // until save (rather than on every keystroke) see up-to-date results.
742            // Must include semantic diagnostics — publishDiagnostics replaces the
743            // prior set entirely, so omitting them would clear errors the editor
744            // showed after the last did_change.
745            let diag_cfg = self.config.load().diagnostics.clone();
746            let is_laravel = self.laravel.load_full().is_laravel;
747            // Includes the semantic (mir) analysis pass, same as
748            // `publish_with_dependents` — keep it off the async runtime worker
749            // here too, not just on the did_open/did_change path.
750            let docs = Arc::clone(&self.docs);
751            let open_files = self.open_files.clone();
752            let uri_for_diags = uri.clone();
753            let all = self
754                .blocking_gated(super::debug_gate::GATE_DID_SAVE_DIAGNOSTICS, move || {
755                    compute_open_file_diagnostics(
756                        &docs,
757                        &open_files,
758                        &uri_for_diags,
759                        &diag_cfg,
760                        is_laravel,
761                    )
762                })
763                .await
764                .unwrap_or_default();
765            self.open_files
766                .note_published(&uri, super::diagnostics_content_hash(&all));
767            self.client
768                .publish_diagnostics(uri.clone(), all, None)
769                .await;
770
771            // Persist the FileIndex to the disk cache so that a server restart
772            // can skip re-parsing this file even for edits that happened between
773            // workspace scans. Content-keyed so the entry matches the scan's key
774            // on restart regardless of mtime granularity.
775            let cache_path = self.config.load().cache_path.clone();
776            if let Some(path) = uri.to_file_path().map(|c| c.into_owned()) {
777                let root = self.root_paths.load().first().cloned();
778                // Deliberately not `offload::run` — see the fire-and-forget
779                // comment on `did_change`'s warm-resweep; this cache write is
780                // never awaited either.
781                tokio::task::spawn_blocking({
782                    let path = path.clone();
783                    let uri = uri.clone();
784                    let root = root.clone();
785                    move || {
786                        let cache = if let Some(p) = cache_path {
787                            crate::index::cache::WorkspaceCache::with_dir(p)
788                        } else if let Some(r) = &root {
789                            let Some(c) = crate::index::cache::WorkspaceCache::new(r) else {
790                                return;
791                            };
792                            c
793                        } else {
794                            return;
795                        };
796                        let Ok(text) = std::fs::read_to_string(&path) else {
797                            return;
798                        };
799                        let key = crate::index::cache::WorkspaceCache::key_for(uri.as_str(), &text);
800                        let doc = parse_document_no_diags(&text);
801                        let index = crate::index::file_index::FileIndex::extract(&doc);
802                        let _ = cache.write(&key, &index);
803                    }
804                });
805
806                // External tools (PHPStan/PHPCS) are opt-in and much slower
807                // than the in-process analyzer, so they run in the background
808                // and republish once done rather than delaying the publish
809                // above. Guarded by the version token so an edit that lands
810                // while the tool is still running discards its result instead
811                // of showing diagnostics against text the buffer no longer
812                // holds.
813                let ext_cfg = self.config.load().external_tools.clone();
814                if (ext_cfg.phpstan.enabled || ext_cfg.phpcs.enabled)
815                    && let Some(version) = self.open_files.current_version(&uri)
816                {
817                    let client = self.client.clone();
818                    let docs = Arc::clone(&self.docs);
819                    let open_files = self.open_files.clone();
820                    let diag_cfg = self.config.load().diagnostics.clone();
821                    let uri = uri.clone();
822                    tokio::spawn(async move {
823                        let diagnostics = crate::analysis::external::run_external_diagnostics(
824                            &ext_cfg,
825                            &path,
826                            root.as_deref(),
827                        )
828                        .await;
829                        if open_files.current_version(&uri) != Some(version) {
830                            return;
831                        }
832                        open_files.set_external_diagnostics(&uri, version, diagnostics);
833                        publish_with_dependents(
834                            client,
835                            docs,
836                            open_files,
837                            uri,
838                            DiagnosticPublish::Other(None),
839                            diag_cfg,
840                            is_laravel,
841                        )
842                        .await;
843                    });
844                }
845            }
846        })
847        .await
848    }
849
850    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
851        guard_async("did_change_watched_files", async move {
852            enum Change {
853                Upsert(Uri, String),
854                Delete(Uri),
855            }
856            let mut changes = Vec::with_capacity(params.changes.len());
857            for change in params.changes {
858                match change.typ {
859                    FileChangeType::CREATED | FileChangeType::CHANGED => {
860                        if let Some(path) = change.uri.to_file_path()
861                            && let Ok(text) = tokio::fs::read_to_string(&path).await
862                        {
863                            changes.push(Change::Upsert(change.uri.clone(), text));
864                        }
865                    }
866                    FileChangeType::DELETED => changes.push(Change::Delete(change.uri.clone())),
867                    _ => {}
868                }
869            }
870
871            // Parsing is CPU-bound and a bulk change (git checkout, a
872            // generator run) can cover hundreds of files at once; process
873            // the whole batch in one spawn_blocking call instead of
874            // re-entering the blocking pool per file.
875            let docs = Arc::clone(&self.docs);
876            let open_files = self.open_files.clone();
877            self.blocking_gated(
878                super::debug_gate::GATE_DID_CHANGE_WATCHED_FILES,
879                move || {
880                    for change in changes {
881                        match change {
882                            Change::Upsert(uri, text) => {
883                                // Salsa path: ingest_from_doc mirrors the new text into
884                                // the SourceFile input. On the next codebase() call,
885                                // salsa re-runs file_definitions for this file and the
886                                // aggregator re-folds — no manual remove/collect/finalize.
887                                let doc = parse_document_no_diags(&text);
888                                if !open_files.contains(&uri) {
889                                    docs.ingest_from_doc(uri.clone(), &doc);
890                                }
891                                // Keep php-lsp's retained FileAnalysis cache
892                                // coherent for consumers analyzed before this
893                                // external file change landed. Mir handles its
894                                // own workspace invalidation.
895                                docs.sync_analysis_cache_declarations(&uri);
896                            }
897                            Change::Delete(uri) => docs.remove(&uri),
898                        }
899                    }
900                },
901            )
902            .await;
903            // File changes may affect cross-file features — refresh all live editors.
904            send_refresh_requests(&self.client).await;
905
906            // Bulk external changes (git checkout, generators) invalidate many
907            // analysis memos at once; re-warm so references stay fast after a
908            // branch switch, not just after in-editor edits.
909            if self.config.load().warm_analysis && self.docs.is_index_ready() {
910                let docs = Arc::clone(&self.docs);
911                let open_urls = self.open_files.urls();
912                // Deliberately not `offload::run` — see the matching comment
913                // in `did_change`'s warm-resweep.
914                drop(tokio::task::spawn_blocking(move || {
915                    let cancel = docs.begin_warm_sweep();
916                    docs.warm_analysis_sweep(&open_urls, &cancel);
917                }));
918            }
919        })
920        .await
921    }
922
923    #[tracing::instrument(skip_all)]
924    async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
925        guard_async_result("completion", async move {
926            // Held for the request's lifetime so a concurrent background
927            // warm/reanalysis sweep yields at its next chunk boundary
928            // instead of racing this interactive read for CPU/rayon workers.
929            let _interactive = self.docs.interactive_read_guard();
930            let uri = &params.text_document_position.text_document.uri;
931            let position = params.text_document_position.position;
932            let cancel_rev = self.docs.write_rev();
933            let source = self.get_open_text(uri).unwrap_or_default();
934            let doc = match self.get_doc(uri) {
935                Some(d) => d,
936                None => return Ok(Some(CompletionResponse::Array(vec![]))),
937            };
938            let other_docs: Vec<Arc<ParsedDoc>> = self
939                .docs
940                .other_docs(uri, &self.open_urls())
941                .into_iter()
942                .map(|(_, d)| d)
943                .collect();
944            // Clone to owned so the closure is 'static + Send.
945            let trigger = params
946                .context
947                .as_ref()
948                .and_then(|c| c.trigger_character.clone());
949            let laravel_arc = self.laravel.load_full();
950            let imports = self.file_imports(uri);
951            let wi = self.workspace_index_async().await;
952            let wi_for_class_search = Arc::clone(&wi);
953            let uri_for_class_search = uri.clone();
954            let docs_for_lookup = Arc::clone(&self.docs);
955            let doc_for_lookup = Arc::clone(&doc);
956            let imports_for_lookup = imports.clone();
957            let find_class_doc_fn = move |name: &str| -> Option<Arc<ParsedDoc>> {
958                let fqn = class_lookup_fqn(&doc_for_lookup, name, &imports_for_lookup);
959                let cr = docs_for_lookup
960                    .resolve_class_ref_by_fqn(&wi, &fqn)
961                    .or_else(|| {
962                        if !is_bare_php_name(name) {
963                            return None;
964                        }
965                        let name_lc = name.to_lowercase();
966                        let table = &wi.classes_by_lowercase_name;
967                        let pos =
968                            table.partition_point(|(short, _)| short.as_ref() < name_lc.as_str());
969                        table.get(pos).and_then(|(short, cr)| {
970                            (short.as_ref() == name_lc.as_str()).then_some(*cr)
971                        })
972                    })?;
973                let (uri, _) = wi.at(cr)?;
974                docs_for_lookup.get_doc_salsa(uri)
975            };
976            // Finds classes anywhere in the workspace index — including
977            // vendor/ and other files that are never opened as editor
978            // buffers — whose short name starts with `prefix`. Complements
979            // `other_docs`, which only covers currently open documents.
980            //
981            // `classes_by_lowercase_name` is sorted once per workspace
982            // revision (shared via `Arc` across every completion request
983            // until a file changes), so this does a binary search for the
984            // prefix range instead of scanning + re-lowercasing every class
985            // name on every keystroke.
986            const WORKSPACE_CLASS_SEARCH_LIMIT: usize = 50;
987            // Set when the cap drops a match, so the client re-queries as the
988            // prefix grows instead of filtering a truncated list locally.
989            let class_search_truncated = Arc::new(AtomicBool::new(false));
990            let truncated_for_search = Arc::clone(&class_search_truncated);
991            let workspace_class_search_fn =
992                move |prefix: &str| -> Vec<(String, CompletionItemKind, String)> {
993                    let prefix_lc = prefix.to_lowercase();
994                    let table = &wi_for_class_search.classes_by_lowercase_name;
995                    let start =
996                        table.partition_point(|(name, _)| name.as_ref() < prefix_lc.as_str());
997                    let mut out = Vec::new();
998                    for (name, cr) in &table[start..] {
999                        if !name.starts_with(prefix_lc.as_str()) {
1000                            break;
1001                        }
1002                        let Some((file_uri, cls)) = wi_for_class_search.at(*cr) else {
1003                            continue;
1004                        };
1005                        if *file_uri == uri_for_class_search {
1006                            continue;
1007                        }
1008                        if out.len() >= WORKSPACE_CLASS_SEARCH_LIMIT {
1009                            truncated_for_search.store(true, Ordering::Relaxed);
1010                            break;
1011                        }
1012                        let kind = match cls.kind {
1013                            ClassKind::Class | ClassKind::Trait => CompletionItemKind::CLASS,
1014                            ClassKind::Interface => CompletionItemKind::INTERFACE,
1015                            ClassKind::Enum => CompletionItemKind::ENUM,
1016                        };
1017                        out.push((cls.name.to_string(), kind, cls.fqn.to_string()));
1018                    }
1019                    out
1020                };
1021            let analysis = self
1022                .cached_analysis_async_cancellable(uri, cancel_rev)
1023                .await;
1024            let docs = Arc::clone(&self.docs);
1025            let uri_owned = uri.clone();
1026            // Offload to spawn_blocking: filtered_completions_at walks the full
1027            // AST + workspace index which can take tens of milliseconds on large
1028            // files, blocking the async executor from unrelated requests.
1029            let items = self
1030                .blocking("completion", move || {
1031                    let ctx = CompletionCtx {
1032                        source: Some(&source),
1033                        position: Some(position),
1034                        doc_uri: Some(&uri_owned),
1035                        file_imports: Some(&imports),
1036                        find_class_doc: Some(&find_class_doc_fn),
1037                        workspace_class_search: Some(&workspace_class_search_fn),
1038                        analysis: analysis.as_deref(),
1039                        session: Some(&docs),
1040                        laravel: Some(&laravel_arc),
1041                    };
1042                    filtered_completions_at(&doc, &other_docs, trigger.as_deref(), &ctx)
1043                })
1044                .await
1045                .unwrap_or_default();
1046            Ok(Some(CompletionResponse::List(CompletionList {
1047                is_incomplete: class_search_truncated.load(Ordering::Relaxed),
1048                items,
1049            })))
1050        })
1051        .await
1052    }
1053
1054    async fn completion_resolve(&self, mut item: CompletionItem) -> Result<CompletionItem> {
1055        guard_async_result("completion_resolve", async move {
1056            if item.documentation.is_some() && item.detail.is_some() {
1057                return Ok(item);
1058            }
1059            // Strip trailing ':' from named-argument labels (e.g. "param:") before lookup.
1060            let name = item.label.trim_end_matches(':').to_string();
1061            // Method completion items carry their owning class in `data` (see
1062            // member.rs's all_members) so two unrelated classes declaring a
1063            // same-named method don't resolve to whichever is indexed first.
1064            let class_hint = item
1065                .data
1066                .as_ref()
1067                .and_then(|d| d.get("class"))
1068                .and_then(|v| v.as_str())
1069                .map(str::to_string);
1070            let symbol = completion_symbol_from_item(&name, item.kind, class_hint.as_deref());
1071            let need_detail = item.detail.is_none();
1072            let need_docs = item.documentation.is_none();
1073            let docs = Arc::clone(&self.docs);
1074            let (detail, documentation) = self
1075                .blocking_gated(super::debug_gate::GATE_COMPLETION_RESOLVE, move || {
1076                    let Some(symbol) = symbol.as_ref() else {
1077                        return (None, None);
1078                    };
1079                    let (resolved_detail, resolved_docstring) = docs
1080                        .read_snapshot(|snap| {
1081                            completion_signature_and_docstring_for_symbol(snap.db(), symbol)
1082                        })
1083                        .unwrap_or_default();
1084                    let detail = if need_detail {
1085                        resolved_detail.clone()
1086                    } else {
1087                        None
1088                    };
1089                    let documentation = need_docs
1090                        .then(|| {
1091                            match (resolved_detail.as_deref(), resolved_docstring.as_deref()) {
1092                                (Some(_), Some(doc)) if need_detail => Some(doc.to_string()),
1093                                (Some(sig), Some(doc)) => {
1094                                    Some(format!("```php\n{sig}\n```\n\n---\n\n{doc}"))
1095                                }
1096                                (Some(sig), None) => Some(format!("```php\n{sig}\n```")),
1097                                (None, Some(doc)) => Some(doc.to_string()),
1098                                (None, None) => None,
1099                            }
1100                        })
1101                        .flatten();
1102                    (detail, documentation)
1103                })
1104                .await
1105                .unwrap_or_default();
1106            if let Some(sig) = detail {
1107                item.detail = Some(sig);
1108            }
1109            if let Some(md) = documentation {
1110                item.documentation = Some(Documentation::MarkupContent(MarkupContent {
1111                    kind: MarkupKind::Markdown,
1112                    value: md,
1113                }));
1114            }
1115            Ok(item)
1116        })
1117        .await
1118    }
1119
1120    async fn goto_definition(
1121        &self,
1122        params: GotoDefinitionParams,
1123    ) -> Result<Option<GotoDefinitionResponse>> {
1124        guard_async_result("goto_definition", async move {
1125            self.handle_goto_definition(params).await
1126        })
1127        .await
1128    }
1129
1130    async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
1131        guard_async_result(
1132            "references",
1133            async move { self.handle_references(params).await },
1134        )
1135        .await
1136    }
1137
1138    async fn prepare_rename(
1139        &self,
1140        params: TextDocumentPositionParams,
1141    ) -> Result<Option<PrepareRenameResponse>> {
1142        guard_async_result("prepare_rename", async move {
1143            let uri = &params.text_document.uri;
1144            let doc = match self.get_doc(uri) {
1145                Some(d) => d,
1146                None => return Ok(None),
1147            };
1148            let position = params.position;
1149            // For a keyword-shaped identifier (`match`, `list`, ...),
1150            // `prepare_rename` walks the whole AST to check whether it's used
1151            // as a real member name; keep that off the async runtime worker.
1152            let range = self
1153                .blocking_gated(super::debug_gate::GATE_PREPARE_RENAME, move || {
1154                    prepare_rename(&doc, position)
1155                })
1156                .await
1157                .unwrap_or_default();
1158            Ok(range.map(PrepareRenameResponse::Range))
1159        })
1160        .await
1161    }
1162
1163    async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
1164        guard_async_result("rename", async move {
1165            let uri = &params.text_document_position.text_document.uri;
1166            let position = params.text_document_position.position;
1167            let source = self.get_open_text(uri).unwrap_or_default();
1168            let word = match word_at_position(&source, position) {
1169                Some(w) => w,
1170                None => return Ok(None),
1171            };
1172            if word.starts_with('$') {
1173                let doc = match self.get_doc(uri) {
1174                    Some(d) => d,
1175                    None => return Ok(None),
1176                };
1177                // Cursor on a property declaration (`public int $x`) or a promoted
1178                // constructor parameter (`private string $x`) — both act as property
1179                // declarations and take the cross-file indexed path below. Plain
1180                // variables stay on the single-document scope walker. Both checks
1181                // and the walker itself scan every class member/statement in the
1182                // document, so keep them off the async runtime worker.
1183                let word_task = word.clone();
1184                let new_name = params.new_name.clone();
1185                let uri_task = uri.clone();
1186                let doc_task = Arc::clone(&doc);
1187                let source_task = Arc::clone(&source);
1188                let variable_edit = self
1189                    .blocking_gated(super::debug_gate::GATE_RENAME_VARIABLE, move || {
1190                        let on_property_decl = cursor_is_on_property_decl(
1191                            &source_task,
1192                            &doc_task.program().stmts,
1193                            position,
1194                        )
1195                        .is_some()
1196                            || promoted_property_at_cursor(
1197                                &source_task,
1198                                &doc_task.program().stmts,
1199                                position,
1200                            )
1201                            .is_some();
1202                        if on_property_decl {
1203                            None
1204                        } else {
1205                            Some(rename_variable(
1206                                &word_task, &new_name, &uri_task, &doc_task, position,
1207                            ))
1208                        }
1209                    })
1210                    .await
1211                    .flatten();
1212                if let Some(edit) = variable_edit {
1213                    return Ok(Some(edit));
1214                }
1215            }
1216            // Everything else — classes, functions, methods, properties,
1217            // constants — renames from mir's posting lists (declaration
1218            // token and `use` import lines included).
1219            Ok(self.indexed_rename(uri, position, &params.new_name).await)
1220        })
1221        .await
1222    }
1223
1224    async fn signature_help(&self, params: SignatureHelpParams) -> Result<Option<SignatureHelp>> {
1225        guard_async_result("signature_help", async move {
1226            let uri = &params.text_document_position_params.text_document.uri;
1227            let position = params.text_document_position_params.position;
1228            let source = self.get_open_text(uri).unwrap_or_default();
1229            let doc = match self.get_doc(uri) {
1230                Some(d) => d,
1231                None => return Ok(None),
1232            };
1233            let analysis = self.cached_analysis_async(uri).await;
1234            let docs = Arc::clone(&self.docs);
1235            let doc_clone = Arc::clone(&doc);
1236            let result = self
1237                .blocking("signature_help", move || {
1238                    docs.read_snapshot(|snap| {
1239                        signature_help(
1240                            &source,
1241                            &doc_clone,
1242                            position,
1243                            analysis.as_deref(),
1244                            Some(snap.db()),
1245                        )
1246                    })
1247                    .ok()
1248                })
1249                .await
1250                .flatten()
1251                .flatten();
1252            Ok(result)
1253        })
1254        .await
1255    }
1256
1257    #[tracing::instrument(skip_all)]
1258    async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
1259        guard_async_result("hover", async move {
1260            // Held for the request's lifetime so a concurrent background
1261            // warm/reanalysis sweep yields at its next chunk boundary
1262            // instead of racing this interactive read for CPU/rayon workers.
1263            let _interactive = self.docs.interactive_read_guard();
1264            let uri = &params.text_document_position_params.text_document.uri;
1265            let position = params.text_document_position_params.position;
1266            let mut source = self.get_open_text(uri).unwrap_or_default();
1267            let doc = match self.get_doc(uri) {
1268                Some(d) => d,
1269                None => {
1270                    // tower-lsp-server polls concurrent inbound messages out
1271                    // of order. A hover sent immediately after didClose then
1272                    // didOpen can therefore run while the earlier didOpen is
1273                    // queued but has not yet installed its buffer. Yield one
1274                    // turn only on this closed-document path so that lifecycle
1275                    // notification can publish its state; ordinary hovers keep
1276                    // the zero-yield fast path.
1277                    tokio::task::yield_now().await;
1278                    source = self.get_open_text(uri).unwrap_or_default();
1279                    match self.get_doc(uri) {
1280                        Some(d) => d,
1281                        None => return Ok(None),
1282                    }
1283                }
1284            };
1285            // Laravel string-key calls (`env('KEY')`, `config('a.b')`, ...) —
1286            // resolved before the general hover pipeline below, since a
1287            // string literal is never a `word_at_position` match. Mirrors
1288            // `handle_goto_definition`'s same early check.
1289            let laravel = self.laravel.load();
1290            let laravel_root = self.root_paths.load().first().cloned();
1291            let laravel_hover = crate::laravel::hover_for_string_key(
1292                &doc,
1293                position,
1294                &laravel,
1295                laravel_root.as_deref(),
1296            )
1297            .or_else(|| {
1298                crate::laravel::blade::hover(
1299                    uri,
1300                    &source,
1301                    position,
1302                    &laravel,
1303                    laravel_root.as_deref(),
1304                )
1305            });
1306            drop(laravel);
1307            if let Some(hover) = laravel_hover {
1308                return Ok(Some(hover));
1309            }
1310            let open = self.open_urls();
1311            let other_docs = self.docs.other_docs(uri, &open);
1312            let other_maps = self.docs.other_symbol_maps(uri, &open);
1313            let analysis = self.cached_analysis_async(uri).await;
1314            let docs_for_hover = Arc::clone(&self.docs);
1315            let source_clone = source.clone();
1316            let doc_clone = Arc::clone(&doc);
1317            // Lets mir-member/static-prop hover resolve a class's declaring doc
1318            // via the workspace index even when that file isn't open in the
1319            // editor, instead of only searching `other_docs` (open files).
1320            // Cached across this request: nothing between here and the
1321            // fallback branch below lazily ingests a new vendor file, so the
1322            // same Arc is safe to reuse instead of re-awaiting the index.
1323            let mut wi_cache: Option<Arc<crate::db::workspace_index::WorkspaceIndexData>> = None;
1324            let wi = self.workspace_index_cached(&mut wi_cache).await;
1325            let docs_for_lookup = Arc::clone(&self.docs);
1326            let imports_for_lookup = doc.file_imports();
1327            let doc_for_lookup = Arc::clone(&doc);
1328            let find_class_doc_fn = move |name: &str| -> Option<Arc<ParsedDoc>> {
1329                let fqn = class_lookup_fqn(&doc_for_lookup, name, &imports_for_lookup);
1330                let cr = docs_for_lookup
1331                    .resolve_class_ref_by_fqn(&wi, &fqn)
1332                    .or_else(|| {
1333                        if !is_bare_php_name(name) {
1334                            return None;
1335                        }
1336                        let name_lc = name.to_lowercase();
1337                        let table = &wi.classes_by_lowercase_name;
1338                        let pos =
1339                            table.partition_point(|(short, _)| short.as_ref() < name_lc.as_str());
1340                        table.get(pos).and_then(|(short, cr)| {
1341                            (short.as_ref() == name_lc.as_str()).then_some(*cr)
1342                        })
1343                    })?;
1344                let (uri, _) = wi.at(cr)?;
1345                docs_for_lookup.get_doc_salsa(uri)
1346            };
1347            let result = self
1348                .blocking("hover", move || {
1349                    hover_info_with_maps(
1350                        &source_clone,
1351                        &doc_clone,
1352                        analysis.as_deref(),
1353                        position,
1354                        &other_docs,
1355                        &other_maps,
1356                        Some(&docs_for_hover),
1357                        Some(&find_class_doc_fn),
1358                    )
1359                })
1360                .await
1361                .flatten();
1362            if result.is_some() {
1363                return Ok(result);
1364            }
1365            // Fallback: look up the word in the workspace index so class names in
1366            // extends clauses and parameter types resolve even when their defining
1367            // file is never opened.  Also try the alias-resolved name so that
1368            // `use Foo as Bar` works even when Foo is only in the index.
1369            if let Some(word) = crate::text::word_at_position(&source, position) {
1370                let wi = self.workspace_index_cached(&mut wi_cache).await;
1371                let docs = Arc::clone(&self.docs);
1372                let doc = Arc::clone(&doc);
1373                let source = source.clone();
1374                let uri = uri.clone();
1375                // Off the async worker: every step below takes the session lock.
1376                let fallback = self
1377                    .blocking("hover_fallback", move || {
1378                        let fallback_imports = doc.file_imports();
1379                        let fallback_class_fqcn = |name: &str| {
1380                            let fqn = class_lookup_fqn(&doc, name, &fallback_imports);
1381                            docs.resolve_class_ref_by_fqn(&wi, &fqn).and_then(|cr| {
1382                                wi.at(cr)
1383                                    .map(|(_, cls)| normalized_php_fqn(&cls.fqn).to_string())
1384                            })
1385                        };
1386                        // mir resolves the cursor class through imports, aliases and the
1387                        // namespace, so same-short-named classes can't be confused.
1388                        if let Some(offset) = crate::text::word_range_at(&source, position)
1389                            .map(|r| doc.view().byte_of_position(r.start))
1390                            && let Ok(Some(mir_analyzer::Name::Class(fqcn))) =
1391                                docs.mir_name_at(&uri, offset)
1392                            && let Some(h) = docs
1393                                .read_snapshot(|snap| class_hover_for_fqcn(snap, &fqcn))
1394                                .ok()
1395                                .flatten()
1396                        {
1397                            return Some(h);
1398                        }
1399                        // Try static method hover: `ClassName::method(…)`.
1400                        if let Some(line_text) = source.lines().nth(position.line as usize)
1401                            && let Some(class_token) = extract_static_class_before_cursor(
1402                                line_text,
1403                                position.character as usize,
1404                            )
1405                        {
1406                            if let Some(fqcn) = fallback_class_fqcn(&class_token)
1407                                && let Some(h) = docs
1408                                    .read_snapshot(|snap| method_hover_for_fqcn(snap, &fqcn, &word))
1409                                    .ok()
1410                                    .flatten()
1411                            {
1412                                return Some(h);
1413                            }
1414                            if let Some(resolved_class) =
1415                                crate::hover::resolve_use_alias(&doc.program().stmts, &class_token)
1416                                && let Some(fqcn) = fallback_class_fqcn(&resolved_class)
1417                                && let Some(h) = docs
1418                                    .read_snapshot(|snap| method_hover_for_fqcn(snap, &fqcn, &word))
1419                                    .ok()
1420                                    .flatten()
1421                            {
1422                                return Some(h);
1423                            }
1424                        }
1425                        None
1426                    })
1427                    .await
1428                    .flatten();
1429                return Ok(fallback);
1430            }
1431            Ok(None)
1432        })
1433        .await
1434    }
1435
1436    async fn document_symbol(
1437        &self,
1438        params: DocumentSymbolParams,
1439    ) -> Result<Option<DocumentSymbolResponse>> {
1440        guard_async_result("document_symbol", async move {
1441            let uri = &params.text_document.uri;
1442            let doc = match self.get_doc(uri) {
1443                Some(d) => d,
1444                None => return Ok(None),
1445            };
1446            // The AST walk is CPU-bound; keep it off the async runtime worker.
1447            let symbols = self
1448                .blocking_gated(super::debug_gate::GATE_DOCUMENT_SYMBOL, move || {
1449                    document_symbols(doc.source(), &doc)
1450                })
1451                .await
1452                .unwrap_or_default();
1453            Ok(Some(DocumentSymbolResponse::Nested(symbols)))
1454        })
1455        .await
1456    }
1457
1458    async fn folding_range(&self, params: FoldingRangeParams) -> Result<Option<Vec<FoldingRange>>> {
1459        guard_async_result("folding_range", async move {
1460            let uri = &params.text_document.uri;
1461            let doc = match self.get_doc(uri) {
1462                Some(d) => d,
1463                None => return Ok(None),
1464            };
1465            // The AST walk plus full-text comment/region scans are CPU-bound;
1466            // keep them off the async runtime worker.
1467            let ranges = self
1468                .blocking_gated(super::debug_gate::GATE_FOLDING_RANGE, move || {
1469                    folding_ranges(doc.source(), &doc)
1470                })
1471                .await
1472                .unwrap_or_default();
1473            Ok(if ranges.is_empty() {
1474                None
1475            } else {
1476                Some(ranges)
1477            })
1478        })
1479        .await
1480    }
1481
1482    async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
1483        guard_async_result("inlay_hint", async move {
1484            let uri = &params.text_document.uri;
1485            let doc = match self.get_doc(uri) {
1486                Some(d) => d,
1487                None => return Ok(None),
1488            };
1489            let analysis = self.cached_analysis_async(uri).await;
1490            let docs = Arc::clone(&self.docs);
1491            let hints = self
1492                .blocking("inlay_hint", move || {
1493                    docs.read_snapshot(|snap| {
1494                        inlay_hints(
1495                            doc.source(),
1496                            &doc,
1497                            analysis.as_deref(),
1498                            Some(snap.db()),
1499                            params.range,
1500                        )
1501                    })
1502                    .unwrap_or_default()
1503                })
1504                .await
1505                .unwrap_or_default();
1506            Ok(Some(hints))
1507        })
1508        .await
1509    }
1510
1511    async fn inlay_hint_resolve(&self, mut item: InlayHint) -> Result<InlayHint> {
1512        let fallback = item.clone();
1513        let resolved = guard_async("inlay_hint_resolve", async move {
1514            if item.tooltip.is_some() {
1515                return Some(item);
1516            }
1517            let symbol = item.data.as_ref().and_then(inlay_hint_symbol_from_data);
1518            if let Some(symbol) = symbol {
1519                let docs = Arc::clone(&self.docs);
1520                let tooltip = self
1521                    .blocking_gated(super::debug_gate::GATE_INLAY_HINT_RESOLVE, move || {
1522                        docs.read_snapshot(|snap| inlay_hint_tooltip_for_symbol(snap.db(), &symbol))
1523                            .ok()
1524                    })
1525                    .await
1526                    .flatten()
1527                    .flatten();
1528                if let Some(md) = tooltip {
1529                    item.tooltip = Some(InlayHintTooltip::MarkupContent(MarkupContent {
1530                        kind: MarkupKind::Markdown,
1531                        value: md,
1532                    }));
1533                }
1534            }
1535            Some(item)
1536        })
1537        .await;
1538        Ok(resolved.unwrap_or(fallback))
1539    }
1540
1541    async fn symbol(
1542        &self,
1543        params: WorkspaceSymbolParams,
1544    ) -> Result<Option<WorkspaceSymbolResponse>> {
1545        guard_async_result("symbol", async move {
1546            // Phase J: read through the salsa-memoized aggregate so repeated
1547            // workspace-symbol queries (every keystroke in the picker) share the
1548            // same `Arc` until a file changes.
1549            let wi = self.workspace_index_async().await;
1550            let query = params.query;
1551            let results = self
1552                .blocking("workspace/symbol", move || {
1553                    workspace_symbols_from_workspace(&query, &wi)
1554                })
1555                .await
1556                .unwrap_or_default();
1557            Ok(Some(WorkspaceSymbolResponse::Flat(results)))
1558        })
1559        .await
1560    }
1561
1562    async fn symbol_resolve(&self, params: WorkspaceSymbol) -> Result<WorkspaceSymbol> {
1563        // For resolve, we need the full range from the ParsedDoc of open files.
1564        let docs = self.docs.docs_for(&self.open_urls());
1565        let fallback = params.clone();
1566        let resolved = guard_async("symbol_resolve", async move {
1567            Some(resolve_workspace_symbol(params, &docs))
1568        })
1569        .await;
1570        Ok(resolved.unwrap_or(fallback))
1571    }
1572
1573    #[tracing::instrument(skip_all)]
1574    async fn semantic_tokens_full(
1575        &self,
1576        params: SemanticTokensParams,
1577    ) -> Result<Option<SemanticTokensResult>> {
1578        guard_async_result("semantic_tokens_full", async move {
1579            let uri = params.text_document.uri;
1580            let doc = match self.get_doc(&uri) {
1581                Some(d) => d,
1582                None => {
1583                    return Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
1584                        result_id: None,
1585                        data: vec![],
1586                    })));
1587                }
1588            };
1589            let docs = Arc::clone(&self.docs);
1590            let uri_for_task = uri.clone();
1591            // The AST walk is CPU-bound; keep it off the async runtime worker
1592            // (see `document_highlight` for the same pattern).
1593            let (result_id, data) = self
1594                .blocking_gated(super::debug_gate::GATE_SEMANTIC_TOKENS_FULL, move || {
1595                    let tokens = semantic_tokens(doc.source(), &doc);
1596                    let result_id = token_hash(&tokens);
1597                    let tokens_arc = Arc::new(tokens);
1598                    docs.store_token_cache(
1599                        &uri_for_task,
1600                        result_id.clone(),
1601                        Arc::clone(&tokens_arc),
1602                    );
1603                    let data = Arc::try_unwrap(tokens_arc).unwrap_or_else(|arc| (*arc).clone());
1604                    (result_id, data)
1605                })
1606                .await
1607                .unwrap_or_default();
1608            Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
1609                result_id: Some(result_id),
1610                data,
1611            })))
1612        })
1613        .await
1614    }
1615
1616    async fn semantic_tokens_range(
1617        &self,
1618        params: SemanticTokensRangeParams,
1619    ) -> Result<Option<SemanticTokensRangeResult>> {
1620        guard_async_result("semantic_tokens_range", async move {
1621            let uri = params.text_document.uri;
1622            let doc = match self.get_doc(&uri) {
1623                Some(d) => d,
1624                None => {
1625                    return Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
1626                        result_id: None,
1627                        data: vec![],
1628                    })));
1629                }
1630            };
1631            let range = params.range;
1632            let tokens = self
1633                .blocking_gated(super::debug_gate::GATE_SEMANTIC_TOKENS_RANGE, move || {
1634                    semantic_tokens_range(doc.source(), &doc, range)
1635                })
1636                .await
1637                .unwrap_or_default();
1638            Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
1639                result_id: None,
1640                data: tokens,
1641            })))
1642        })
1643        .await
1644    }
1645
1646    async fn semantic_tokens_full_delta(
1647        &self,
1648        params: SemanticTokensDeltaParams,
1649    ) -> Result<Option<SemanticTokensFullDeltaResult>> {
1650        guard_async_result("semantic_tokens_full_delta", async move {
1651            let uri = params.text_document.uri;
1652            let doc = match self.get_doc(&uri) {
1653                Some(d) => d,
1654                None => return Ok(None),
1655            };
1656
1657            let docs = Arc::clone(&self.docs);
1658            let uri_for_task = uri.clone();
1659            let prev_id = params.previous_result_id;
1660            let result = self
1661                .blocking_gated(
1662                    super::debug_gate::GATE_SEMANTIC_TOKENS_FULL_DELTA,
1663                    move || {
1664                        let new_tokens = Arc::new(semantic_tokens(doc.source(), &doc));
1665                        let new_result_id = token_hash(&new_tokens);
1666
1667                        let result = match docs.get_token_cache(&uri_for_task, &prev_id) {
1668                            Some(old_tokens) => {
1669                                let edits = compute_token_delta(&old_tokens, &new_tokens);
1670                                SemanticTokensFullDeltaResult::TokensDelta(SemanticTokensDelta {
1671                                    result_id: Some(new_result_id.clone()),
1672                                    edits,
1673                                })
1674                            }
1675                            // Unknown previous result — fall back to full tokens
1676                            None => SemanticTokensFullDeltaResult::Tokens(SemanticTokens {
1677                                result_id: Some(new_result_id.clone()),
1678                                data: (*new_tokens).clone(),
1679                            }),
1680                        };
1681
1682                        docs.store_token_cache(&uri_for_task, new_result_id, new_tokens);
1683                        result
1684                    },
1685                )
1686                .await;
1687            let Some(result) = result else {
1688                return Ok(None);
1689            };
1690            Ok(Some(result))
1691        })
1692        .await
1693    }
1694
1695    async fn selection_range(
1696        &self,
1697        params: SelectionRangeParams,
1698    ) -> Result<Option<Vec<SelectionRange>>> {
1699        guard_async_result("selection_range", async move {
1700            let uri = &params.text_document.uri;
1701            let doc = match self.get_doc(uri) {
1702                Some(d) => d,
1703                None => return Ok(None),
1704            };
1705            // Walks every top-level statement per requested position; keep
1706            // that off the async runtime worker, same as document_symbol
1707            // and folding_range's whole-document walks.
1708            let ranges = self
1709                .blocking_gated(super::debug_gate::GATE_SELECTION_RANGE, move || {
1710                    selection_ranges(&doc, &params.positions)
1711                })
1712                .await
1713                .unwrap_or_default();
1714            Ok(if ranges.is_empty() {
1715                None
1716            } else {
1717                Some(ranges)
1718            })
1719        })
1720        .await
1721    }
1722
1723    async fn prepare_call_hierarchy(
1724        &self,
1725        params: CallHierarchyPrepareParams,
1726    ) -> Result<Option<Vec<CallHierarchyItem>>> {
1727        guard_async_result("prepare_call_hierarchy", async move {
1728            let uri = &params.text_document_position_params.text_document.uri;
1729            let position = params.text_document_position_params.position;
1730            let source = self.get_open_text(uri).unwrap_or_default();
1731            let word = match word_at_position(&source, position) {
1732                Some(w) => w,
1733                None => return Ok(None),
1734            };
1735            // A bare keyword can never be a callable name; skip it here so it
1736            // doesn't fall through to the trait-alias exhaustive scan in
1737            // `prepare_call_hierarchy_indexed`.
1738            if is_unresolvable_bareword_at(&source, position, &word) {
1739                return Ok(None);
1740            }
1741            // O(matches) lookup via mir's mention index instead of scanning
1742            // every workspace doc, but a miss falls through to a
1743            // workspace-wide trait-alias scan — keep that off the async
1744            // runtime worker, same as incoming_calls/outgoing_calls below.
1745            let wi = self.workspace_index_async().await;
1746            let docs = Arc::clone(&self.docs);
1747            let cursor_uri = uri.clone();
1748            let item = self
1749                .blocking_gated(super::debug_gate::GATE_PREPARE_CALL_HIERARCHY, move || {
1750                    let get_doc = |u: &Uri| docs.get_doc_salsa(u);
1751                    let mention_candidates =
1752                        |name: &str| docs.declaration_candidate_files(&wi, name);
1753                    prepare_call_hierarchy_indexed(
1754                        &word,
1755                        (&cursor_uri, position),
1756                        &wi,
1757                        &get_doc,
1758                        &mention_candidates,
1759                    )
1760                })
1761                .await
1762                .flatten();
1763            Ok(item.map(|item| vec![item]))
1764        })
1765        .await
1766    }
1767
1768    async fn incoming_calls(
1769        &self,
1770        params: CallHierarchyIncomingCallsParams,
1771    ) -> Result<Option<Vec<CallHierarchyIncomingCall>>> {
1772        guard_async_result("incoming_calls", async move {
1773            // Call sites come from mir's posting lists; only the documents
1774            // containing them are parsed to resolve the enclosing caller.
1775            let docs = Arc::clone(&self.docs);
1776            let item = params.item;
1777            let calls = self
1778                .blocking("incoming_calls", move || {
1779                    // Pause the background scan and snapshot a settled revision so
1780                    // only a genuine user edit cancels the lookup.
1781                    let (_interactive, cancel_rev) = docs.settled_write_rev_guard();
1782                    incoming_calls_indexed(&item, &docs, Some(cancel_rev))
1783                })
1784                .await
1785                .unwrap_or_default();
1786            Ok(if calls.is_empty() { None } else { Some(calls) })
1787        })
1788        .await
1789    }
1790
1791    async fn outgoing_calls(
1792        &self,
1793        params: CallHierarchyOutgoingCallsParams,
1794    ) -> Result<Option<Vec<CallHierarchyOutgoingCall>>> {
1795        guard_async_result("outgoing_calls", async move {
1796            let docs = Arc::clone(&self.docs);
1797            let item = params.item;
1798            let calls = self
1799                .blocking("outgoing_calls", move || {
1800                    outgoing_calls_via_mir(&docs, &item).unwrap_or_default()
1801                })
1802                .await
1803                .unwrap_or_default();
1804            Ok(if calls.is_empty() { None } else { Some(calls) })
1805        })
1806        .await
1807    }
1808
1809    async fn document_highlight(
1810        &self,
1811        params: DocumentHighlightParams,
1812    ) -> Result<Option<Vec<DocumentHighlight>>> {
1813        guard_async_result("document_highlight", async move {
1814            let uri = &params.text_document_position_params.text_document.uri;
1815            let position = params.text_document_position_params.position;
1816            let source = self.get_open_text(uri).unwrap_or_default();
1817            // Stale-tolerant: a highlight fired on every cursor move must never
1818            // spin on the salsa `Cancelled` retry loop while the user types.
1819            let doc = match self.get_doc_stale(uri) {
1820                Some(d) => d,
1821                None => return Ok(None),
1822            };
1823            // The AST walk is CPU-bound; keep it off the async runtime worker.
1824            let highlights = self
1825                .blocking("document_highlight", move || {
1826                    document_highlights(&source, &doc, position)
1827                })
1828                .await
1829                .unwrap_or_default();
1830            Ok(if highlights.is_empty() {
1831                None
1832            } else {
1833                Some(highlights)
1834            })
1835        })
1836        .await
1837    }
1838
1839    async fn linked_editing_range(
1840        &self,
1841        params: LinkedEditingRangeParams,
1842    ) -> Result<Option<LinkedEditingRanges>> {
1843        guard_async_result("linked_editing_range", async move {
1844            self.handle_linked_editing_range(params).await
1845        })
1846        .await
1847    }
1848
1849    async fn goto_implementation(
1850        &self,
1851        params: tower_lsp_server::ls_types::request::GotoImplementationParams,
1852    ) -> Result<Option<tower_lsp_server::ls_types::request::GotoImplementationResponse>> {
1853        guard_async_result("goto_implementation", async move {
1854            let uri = &params.text_document_position_params.text_document.uri;
1855            let position = params.text_document_position_params.position;
1856            let source = self.get_open_text(uri).unwrap_or_default();
1857            let imports = self.file_imports(uri);
1858            let raw_word = crate::text::word_at_position(&source, position).unwrap_or_default();
1859            if raw_word.is_empty() || is_unresolvable_bareword_at(&source, position, &raw_word) {
1860                return Ok(None);
1861            }
1862            // `word_at_position` includes `\` as a word character, so the cursor on
1863            // a use-statement import (`use A\B\Foo`) returns the full qualified name.
1864            let (word, fqn): (String, String) = if is_bare_php_name(&raw_word) {
1865                // Resolve via this file's imports + namespace; covers usages,
1866                // aliases, and the type's own declaration (which resolves to
1867                // `<current-namespace>\<word>`).
1868                let resolved = match self.get_doc(uri) {
1869                    Some(doc) => crate::navigation::moniker::resolve_fqn(&doc, &raw_word, &imports),
1870                    None => raw_word.clone(),
1871                };
1872                (raw_word, normalized_php_fqn(&resolved).to_string())
1873            } else {
1874                let short = crate::text::fqn_short_name(&raw_word).to_string();
1875                (short, normalized_php_fqn(&raw_word).to_string())
1876            };
1877
1878            // A method name can collide case-insensitively with a class name
1879            // (`Guard` interface vs `guard()` method in one namespace); mir's
1880            // symbol at the cursor decides which sense wins.
1881            let method_offset = self.get_doc(uri).and_then(|doc| {
1882                crate::text::word_range_at(&source, position)
1883                    .map(|r| doc.view().byte_of_position(r.start))
1884            });
1885            let docs_for_method_check = Arc::clone(&self.docs);
1886            let uri_for_method_check = uri.clone();
1887            let on_method_decl = self
1888                .blocking_gated(super::debug_gate::GATE_GOTO_IMPLEMENTATION, move || {
1889                    method_offset.is_some_and(|offset| {
1890                        matches!(
1891                            docs_for_method_check.mir_name_at(&uri_for_method_check, offset),
1892                            Ok(Some(mir_analyzer::Name::Method { .. }))
1893                        )
1894                    })
1895                })
1896                .await
1897                .unwrap_or(false);
1898
1899            // Subtypes of the type under the cursor, from mir's maintained
1900            // subtype edge index (aliased/FQN extends forms all resolve).
1901            let mut locs: Vec<Location> = if on_method_decl {
1902                Vec::new()
1903            } else {
1904                let docs = Arc::clone(&self.docs);
1905                let fqn_task = fqn.clone();
1906                self.blocking("goto_implementation.subtypes", move || {
1907                    docs.indexed_subtype_classes(&fqn_task, false)
1908                })
1909                .await
1910                .unwrap_or_else(|| Ok(Vec::new()))?
1911                .into_iter()
1912                .filter_map(|site| subtype_site_to_location(&site.file, &site.range))
1913                .collect()
1914            };
1915            if locs.is_empty() && !on_method_decl {
1916                let wi = self.workspace_index_async().await;
1917                let docs = Arc::clone(&self.docs);
1918                let target_fqn = fqn.clone();
1919                let target_short = word.clone();
1920                locs = self
1921                    .blocking("goto_implementation.index_fallback", move || {
1922                        let mention_candidates =
1923                            |name: &str| docs.declaration_candidate_files(&wi, name);
1924                        let get_doc = |uri: &Uri| docs.get_doc_salsa(uri);
1925                        implementation_locations_from_index(
1926                            &wi,
1927                            &target_fqn,
1928                            &target_short,
1929                            &mention_candidates,
1930                            &get_doc,
1931                        )
1932                    })
1933                    .await
1934                    .unwrap_or_default();
1935            }
1936
1937            // Cursor on a method name inside its declaring class/interface:
1938            // concrete overrides in every subtype.
1939            if locs.is_empty()
1940                && let Some(doc) = self.get_doc(uri)
1941                && let Some(enclosing) =
1942                    crate::types::type_map::enclosing_class_at(&source, &doc, position)
1943            {
1944                let resolved_enclosing =
1945                    crate::navigation::moniker::resolve_fqn(&doc, &enclosing, &imports);
1946                let enclosing_fqn = normalized_php_fqn(&resolved_enclosing).to_string();
1947                let docs = Arc::clone(&self.docs);
1948                let method = word.clone();
1949                let impls = self
1950                    .blocking("goto_implementation.method_impls", move || {
1951                        docs.indexed_method_implementations(&enclosing_fqn, &method)
1952                    })
1953                    .await
1954                    .unwrap_or_else(|| Ok(Vec::new()))?;
1955                locs = impls
1956                    .into_iter()
1957                    .filter_map(|(_, file, range)| subtype_site_to_location(&file, &range))
1958                    .collect();
1959            }
1960            if locs.is_empty() {
1961                Ok(None)
1962            } else {
1963                Ok(Some(GotoDefinitionResponse::Array(locs)))
1964            }
1965        })
1966        .await
1967    }
1968
1969    async fn goto_declaration(
1970        &self,
1971        params: tower_lsp_server::ls_types::request::GotoDeclarationParams,
1972    ) -> Result<Option<tower_lsp_server::ls_types::request::GotoDeclarationResponse>> {
1973        guard_async_result("goto_declaration", async move {
1974            let uri = &params.text_document_position_params.text_document.uri;
1975            let position = params.text_document_position_params.position;
1976            let source = self.get_open_text(uri).unwrap_or_default();
1977            if let Some(doc) = self.get_doc(uri)
1978                && let Some(offset) = crate::text::word_range_at(&source, position)
1979                    .map(|range| doc.view().byte_of_position(range.start))
1980            {
1981                let docs = Arc::clone(&self.docs);
1982                let uri_task = uri.clone();
1983                let implemented = self
1984                    .blocking("goto_declaration.implemented", move || {
1985                        crate::navigation::mir_definition::mir_implemented_declaration(
1986                            &docs, &uri_task, offset,
1987                        )
1988                    })
1989                    .await;
1990                if let Some(Ok(Some(loc))) = implemented {
1991                    return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1992                }
1993            }
1994            // First pass: open-file ParsedDocs give accurate character
1995            // positions. Walking every open doc's AST is CPU-bound; keep it
1996            // off the async runtime worker. A separate hop (rather than one
1997            // spawn_blocking around both passes) so a first-pass hit skips
1998            // the second pass's workspace-index fetch entirely, as before.
1999            let open_docs = self.docs.docs_for(&self.open_urls());
2000            let source_for_pass1 = source.clone();
2001            let first_pass = self
2002                .blocking("goto_declaration.open_docs", move || {
2003                    goto_declaration(&source_for_pass1, &open_docs, position)
2004                })
2005                .await
2006                .unwrap_or_default();
2007            if let Some(loc) = first_pass {
2008                return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
2009            }
2010            // Second pass: background files via the aggregated workspace index
2011            // (line-only positions for anything not covered by a mention hit).
2012            let wi = self.workspace_index_async().await;
2013            let docs = Arc::clone(&self.docs);
2014            let second_pass = self
2015                .blocking("goto_declaration.index", move || {
2016                    let mention_candidates =
2017                        |name: &str| docs.declaration_candidate_files(&wi, name);
2018                    let get_doc = |uri: &Uri| docs.get_doc_salsa(uri);
2019                    goto_declaration_from_index(
2020                        &source,
2021                        &wi,
2022                        position,
2023                        &get_doc,
2024                        &mention_candidates,
2025                    )
2026                })
2027                .await
2028                .unwrap_or_default();
2029            Ok(second_pass.map(GotoDefinitionResponse::Scalar))
2030        })
2031        .await
2032    }
2033
2034    async fn goto_type_definition(
2035        &self,
2036        params: tower_lsp_server::ls_types::request::GotoTypeDefinitionParams,
2037    ) -> Result<Option<tower_lsp_server::ls_types::request::GotoTypeDefinitionResponse>> {
2038        guard_async_result("goto_type_definition", async move {
2039            let uri = &params.text_document_position_params.text_document.uri;
2040            let position = params.text_document_position_params.position;
2041            let source = self.get_open_text(uri).unwrap_or_default();
2042            let doc = match self.get_doc(uri) {
2043                Some(d) => d,
2044                None => return Ok(None),
2045            };
2046            let analysis = self.cached_analysis_async(uri).await;
2047            let docs = Arc::clone(&self.docs);
2048
2049            let results = self
2050                .blocking_gated(super::debug_gate::GATE_GOTO_TYPE_DEFINITION, move || {
2051                    let fqns = type_class_fqns(&source, &doc, analysis.as_deref(), position);
2052                    crate::navigation::mir_definition::mir_class_locations(&docs, &fqns)
2053                })
2054                .await
2055                .unwrap_or_else(|| Ok(Vec::new()))?;
2056            Ok(match results.len() {
2057                0 => None,
2058                1 => Some(GotoDefinitionResponse::Scalar(
2059                    results.into_iter().next().unwrap(),
2060                )),
2061                _ => Some(GotoDefinitionResponse::Array(results)),
2062            })
2063        })
2064        .await
2065    }
2066
2067    async fn prepare_type_hierarchy(
2068        &self,
2069        params: TypeHierarchyPrepareParams,
2070    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
2071        guard_async_result("prepare_type_hierarchy", async move {
2072            let uri = &params.text_document_position_params.text_document.uri;
2073            let position = params.text_document_position_params.position;
2074            let wi = self.workspace_index_async().await;
2075            let class_ref = self
2076                .get_doc(uri)
2077                .and_then(|doc| {
2078                    word_at_position(doc.source(), position).map(|word| {
2079                        let imports = doc.file_imports();
2080                        crate::navigation::moniker::resolve_fqn(&doc, &word, &imports)
2081                    })
2082                })
2083                .and_then(|fqn| self.docs.class_ref_by_fqn(&wi, &fqn));
2084            let item = self
2085                .blocking_gated(super::debug_gate::GATE_TYPE_HIERARCHY, move || {
2086                    prepare_type_hierarchy_from_fqn(class_ref?, &wi)
2087                })
2088                .await
2089                .flatten();
2090            Ok(item.map(|item| vec![item]))
2091        })
2092        .await
2093    }
2094
2095    async fn supertypes(
2096        &self,
2097        params: TypeHierarchySupertypesParams,
2098    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
2099        guard_async_result("supertypes", async move {
2100            // Phase J: resolve parents via the aggregate, mention-index-narrowed.
2101            // Pre-load any direct vendor supertypes via PSR-4 so they appear in the
2102            // workspace index before the lookup runs.
2103            let wi = self.workspace_index_async().await;
2104            let Some(item_fqn) = crate::navigation::type_hierarchy::item_fqn(&params.item) else {
2105                return Ok(None);
2106            };
2107            let loaded_new = self.ensure_direct_supertypes_loaded(item_fqn, &wi).await;
2108            let wi = if loaded_new {
2109                self.workspace_index_async().await
2110            } else {
2111                wi
2112            };
2113            let docs = Arc::clone(&self.docs);
2114            let item_fqn = item_fqn.to_owned();
2115            let result = self
2116                .blocking_gated(super::debug_gate::GATE_TYPE_HIERARCHY, move || {
2117                    let supertype_fqns = docs.class_direct_supertypes(&item_fqn)?;
2118                    let resolve_class_ref = |fqn: &str| docs.resolve_class_ref_by_fqn(&wi, fqn);
2119                    Ok::<_, crate::document::document_store::ContentModified>(
2120                        supertypes_of_from_workspace(&supertype_fqns, &wi, &resolve_class_ref),
2121                    )
2122                })
2123                .await
2124                .unwrap_or_else(|| Ok(Vec::new()))?;
2125            Ok(if result.is_empty() {
2126                None
2127            } else {
2128                Some(result)
2129            })
2130        })
2131        .await
2132    }
2133
2134    async fn subtypes(
2135        &self,
2136        params: TypeHierarchySubtypesParams,
2137    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
2138        guard_async_result("subtypes", async move {
2139            let docs = Arc::clone(&self.docs);
2140            let item = params.item;
2141            let Some(item_fqn) = crate::navigation::type_hierarchy::item_fqn(&item) else {
2142                return Ok(None);
2143            };
2144            let item_fqn = item_fqn.to_owned();
2145            let result = self
2146                .blocking_gated(super::debug_gate::GATE_TYPE_HIERARCHY, move || {
2147                    let sites = docs.indexed_direct_subtype_classes(&item_fqn, true)?;
2148                    Ok::<_, crate::document::document_store::ContentModified>(subtypes_from_sites(
2149                        &sites,
2150                    ))
2151                })
2152                .await
2153                .unwrap_or_else(|| Ok(Vec::new()))?;
2154            Ok(if result.is_empty() {
2155                None
2156            } else {
2157                Some(result)
2158            })
2159        })
2160        .await
2161    }
2162
2163    async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
2164        guard_async_result("code_lens", async move {
2165            let uri = &params.text_document.uri;
2166            let doc = match self.get_doc(uri) {
2167                Some(d) => d,
2168                None => return Ok(None),
2169            };
2170            // Lens counts come from mir's inverted indexes; still run on the
2171            // blocking pool (index freshness passes may analyze on demand).
2172            // A write during computation (write_rev advances) cancels early —
2173            // stale lens counts are useless and the editor will request fresh
2174            // ones after the edit.
2175            let docs = Arc::clone(&self.docs);
2176            let docs_cancel = Arc::clone(&self.docs);
2177            let uri_owned = uri.clone();
2178            let imports = self.file_imports(uri);
2179            let lenses = self
2180                .blocking("code_lens", move || {
2181                    // Pause the background scan and snapshot a settled revision so
2182                    // only a genuine user edit cancels the sweep.
2183                    let (_interactive, cancel_rev) = docs.settled_write_rev_guard();
2184                    code_lenses(
2185                        &uri_owned,
2186                        &doc,
2187                        &docs,
2188                        &imports,
2189                        Some(cancel_rev),
2190                        move || docs_cancel.write_rev() != cancel_rev,
2191                    )
2192                })
2193                .await
2194                .unwrap_or_default();
2195            Ok(if lenses.is_empty() {
2196                None
2197            } else {
2198                Some(lenses)
2199            })
2200        })
2201        .await
2202    }
2203
2204    async fn code_lens_resolve(&self, params: CodeLens) -> Result<CodeLens> {
2205        let fallback = params.clone();
2206        let resolved = guard_async("code_lens_resolve", async move {
2207            // Lenses are fully populated by code_lens; nothing to add.
2208            Some(params)
2209        })
2210        .await;
2211        Ok(resolved.unwrap_or(fallback))
2212    }
2213
2214    async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
2215        guard_async_result("document_link", async move {
2216            let uri = params.text_document.uri;
2217            let doc = match self.get_doc(&uri) {
2218                Some(d) => d,
2219                None => return Ok(None),
2220            };
2221            let laravel = self.laravel.load_full();
2222            // The AST walk plus full-text `@link`/`@see` scan are CPU-bound;
2223            // keep them off the async runtime worker.
2224            let links = self
2225                .blocking_gated(super::debug_gate::GATE_DOCUMENT_LINK, move || {
2226                    let mut links = document_links(&uri, &doc, doc.source());
2227                    links.extend(crate::laravel::document_links(&doc, &laravel));
2228                    links.extend(crate::laravel::blade::document_links(
2229                        &uri,
2230                        doc.source(),
2231                        &laravel,
2232                    ));
2233                    links
2234                })
2235                .await
2236                .unwrap_or_default();
2237            Ok(if links.is_empty() { None } else { Some(links) })
2238        })
2239        .await
2240    }
2241
2242    async fn document_link_resolve(&self, params: DocumentLink) -> Result<DocumentLink> {
2243        let fallback = params.clone();
2244        let resolved = guard_async("document_link_resolve", async move {
2245            // Links already carry their target URI; nothing to add.
2246            Some(params)
2247        })
2248        .await;
2249        Ok(resolved.unwrap_or(fallback))
2250    }
2251
2252    async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
2253        guard_async_result("formatting", async move {
2254            let uri = &params.text_document.uri;
2255            let source = self.get_open_text(uri).unwrap_or_default();
2256            // Spawns and blocks on an external formatter process; keep that
2257            // wait off the async runtime worker.
2258            Ok(self
2259                .blocking("formatting", move || format_document(&source))
2260                .await
2261                .unwrap_or_default())
2262        })
2263        .await
2264    }
2265
2266    async fn range_formatting(
2267        &self,
2268        params: DocumentRangeFormattingParams,
2269    ) -> Result<Option<Vec<TextEdit>>> {
2270        guard_async_result("range_formatting", async move {
2271            let uri = &params.text_document.uri;
2272            let source = self.get_open_text(uri).unwrap_or_default();
2273            let range = params.range;
2274            Ok(self
2275                .blocking("range_formatting", move || format_range(&source, range))
2276                .await
2277                .unwrap_or_default())
2278        })
2279        .await
2280    }
2281
2282    async fn on_type_formatting(
2283        &self,
2284        params: DocumentOnTypeFormattingParams,
2285    ) -> Result<Option<Vec<TextEdit>>> {
2286        guard_async_result("on_type_formatting", async move {
2287            let uri = &params.text_document_position.text_document.uri;
2288            let source = self.get_open_text(uri).unwrap_or_default();
2289            let position = params.text_document_position.position;
2290            // `}` fires a whole-document char-vec + string-literal-mask scan
2291            // to find the matching brace; every keystroke fires this handler,
2292            // so keep it off the async runtime worker like the other
2293            // whole-document walks.
2294            let edits = self
2295                .blocking_gated(super::debug_gate::GATE_ON_TYPE_FORMATTING, move || {
2296                    on_type_format(&source, position, &params.ch, &params.options)
2297                })
2298                .await
2299                .unwrap_or_default();
2300            Ok(if edits.is_empty() { None } else { Some(edits) })
2301        })
2302        .await
2303    }
2304
2305    async fn execute_command(
2306        &self,
2307        params: ExecuteCommandParams,
2308    ) -> Result<Option<serde_json::Value>> {
2309        guard_async_result("execute_command", async move {
2310            match params.command.as_str() {
2311                "php-lsp.runTest" => {
2312                    // Arguments: [uri_string, "ClassName::methodName"]
2313                    let file_uri = params
2314                        .arguments
2315                        .first()
2316                        .and_then(|v| v.as_str())
2317                        .and_then(|s| (s).parse::<Uri>().ok());
2318                    let filter = params
2319                        .arguments
2320                        .get(1)
2321                        .and_then(|v| v.as_str())
2322                        .unwrap_or("")
2323                        .to_string();
2324
2325                    let root = self.root_paths.load().first().cloned();
2326                    let client = self.client.clone();
2327
2328                    tokio::spawn(async move {
2329                        run_phpunit(&client, &filter, root.as_deref(), file_uri.as_ref()).await;
2330                    });
2331
2332                    Ok(None)
2333                }
2334                _ => Ok(None),
2335            }
2336        })
2337        .await
2338    }
2339
2340    async fn will_rename_files(&self, params: RenameFilesParams) -> Result<Option<WorkspaceEdit>> {
2341        guard_async_result("will_rename_files", async move {
2342            self.handle_will_rename_files(params).await
2343        })
2344        .await
2345    }
2346
2347    async fn did_rename_files(&self, params: RenameFilesParams) {
2348        guard_async("did_rename_files", async move {
2349            self.handle_did_rename_files(params).await
2350        })
2351        .await
2352    }
2353
2354    async fn will_create_files(&self, params: CreateFilesParams) -> Result<Option<WorkspaceEdit>> {
2355        guard_async_result("will_create_files", async move {
2356            self.handle_will_create_files(params).await
2357        })
2358        .await
2359    }
2360
2361    async fn did_create_files(&self, params: CreateFilesParams) {
2362        guard_async("did_create_files", async move {
2363            self.handle_did_create_files(params).await
2364        })
2365        .await
2366    }
2367
2368    /// Before a file is deleted, return workspace edits that remove every
2369    /// `use` import referencing its PSR-4 class name.
2370    async fn will_delete_files(&self, params: DeleteFilesParams) -> Result<Option<WorkspaceEdit>> {
2371        guard_async_result("will_delete_files", async move {
2372            self.handle_will_delete_files(params).await
2373        })
2374        .await
2375    }
2376
2377    async fn did_delete_files(&self, params: DeleteFilesParams) {
2378        guard_async("did_delete_files", async move {
2379            self.handle_did_delete_files(params).await
2380        })
2381        .await
2382    }
2383
2384    async fn moniker(&self, params: MonikerParams) -> Result<Option<Vec<Moniker>>> {
2385        guard_async_result("moniker", async move {
2386            let uri = &params.text_document_position_params.text_document.uri;
2387            let position = params.text_document_position_params.position;
2388            let source = self.get_open_text(uri).unwrap_or_default();
2389            let doc = match self.get_doc(uri) {
2390                Some(d) => d,
2391                None => return Ok(None),
2392            };
2393            let imports = self.file_imports(uri);
2394            let moniker = self
2395                .blocking_gated(super::debug_gate::GATE_MONIKER, move || {
2396                    moniker_at(&source, &doc, position, &imports)
2397                })
2398                .await
2399                .flatten();
2400            Ok(moniker.map(|m| vec![m]))
2401        })
2402        .await
2403    }
2404
2405    async fn inline_value(&self, params: InlineValueParams) -> Result<Option<Vec<InlineValue>>> {
2406        guard_async_result("inline_value", async move {
2407            let uri = &params.text_document.uri;
2408            let source = self.get_open_text(uri).unwrap_or_default();
2409            let range = params.range;
2410            let values = self
2411                .blocking_gated(super::debug_gate::GATE_INLINE_VALUE, move || {
2412                    inline_values_in_range(&source, range)
2413                })
2414                .await
2415                .unwrap_or_default();
2416            Ok(if values.is_empty() {
2417                None
2418            } else {
2419                Some(values)
2420            })
2421        })
2422        .await
2423    }
2424
2425    async fn diagnostic(
2426        &self,
2427        params: DocumentDiagnosticParams,
2428    ) -> Result<DocumentDiagnosticReportResult> {
2429        let report = guard_async("diagnostic", async move {
2430            Some(self.handle_diagnostic(params).await)
2431        })
2432        .await;
2433        match report {
2434            Some(r) => r,
2435            None => Ok(DocumentDiagnosticReportResult::Report(
2436                DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport::default()),
2437            )),
2438        }
2439    }
2440
2441    async fn workspace_diagnostic(
2442        &self,
2443        params: WorkspaceDiagnosticParams,
2444    ) -> Result<WorkspaceDiagnosticReportResult> {
2445        let report = guard_async("workspace_diagnostic", async move {
2446            Some(self.handle_workspace_diagnostic(params).await)
2447        })
2448        .await;
2449        match report {
2450            Some(r) => r,
2451            None => Ok(WorkspaceDiagnosticReportResult::Report(
2452                WorkspaceDiagnosticReport::default(),
2453            )),
2454        }
2455    }
2456
2457    async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
2458        guard_async_result("code_action", async move {
2459            self.handle_code_action(params).await
2460        })
2461        .await
2462    }
2463
2464    async fn code_action_resolve(&self, item: CodeAction) -> Result<CodeAction> {
2465        guard_async_result("code_action_resolve", async move {
2466            self.handle_code_action_resolve(item).await
2467        })
2468        .await
2469    }
2470}
2471
2472/// Fallback for `goto_implementation` when mir has no resolved subtype sites.
2473///
2474/// The primary path above is the authoritative one: mir's graph has already
2475/// resolved aliases, leading slashes, and namespace context. This fallback keeps
2476/// interactive behavior useful while the graph is cold or while a workspace has
2477/// incomplete/invalid PHP by narrowing to files that mention the target short
2478/// name, then verifying each candidate's declaration-level `extends`/
2479/// `implements` clauses against its own namespace/import context. Exact FQN
2480/// matches are preferred; the short-name pass preserves the legacy behavior for
2481/// unresolved symbols without scanning the whole workspace.
2482fn implementation_locations_from_index(
2483    wi: &crate::db::workspace_index::WorkspaceIndexData,
2484    target_fqn: &str,
2485    target_short: &str,
2486    mention_candidates: &dyn Fn(&str) -> Vec<Uri>,
2487    get_doc: &dyn Fn(&Uri) -> Option<Arc<ParsedDoc>>,
2488) -> Vec<Location> {
2489    let mut exact = Vec::new();
2490    let mut short_name = Vec::new();
2491    let candidate_uris = mention_candidates(target_short);
2492    wi.for_each_class_in_uris(&candidate_uris, |uri, cls| {
2493        let Some(doc) = get_doc(uri) else {
2494            return;
2495        };
2496        let imports = doc.file_imports();
2497        let mut saw_short_name_match = false;
2498        let mut relation_matches = |name: &str| {
2499            let resolved = crate::navigation::moniker::resolve_fqn(&doc, name, &imports);
2500            if normalized_php_fqn(&resolved).eq_ignore_ascii_case(target_fqn) {
2501                return true;
2502            }
2503            if crate::text::fqn_short_name(name).eq_ignore_ascii_case(target_short) {
2504                saw_short_name_match = true;
2505            }
2506            false
2507        };
2508        let exact_match = cls.parent.as_deref().is_some_and(&mut relation_matches)
2509            || cls
2510                .implements
2511                .iter()
2512                .any(|iface| relation_matches(iface.as_ref()));
2513
2514        let loc = class_index_location(uri, cls);
2515        if exact_match {
2516            exact.push(loc);
2517        } else if saw_short_name_match {
2518            short_name.push(loc);
2519        }
2520    });
2521
2522    let mut result = if exact.is_empty() { short_name } else { exact };
2523    result.sort_by(|a, b| {
2524        a.uri
2525            .as_str()
2526            .cmp(b.uri.as_str())
2527            .then(a.range.start.line.cmp(&b.range.start.line))
2528            .then(a.range.start.character.cmp(&b.range.start.character))
2529    });
2530    result.dedup_by(|a, b| a.uri == b.uri && a.range == b.range);
2531    result
2532}
2533
2534fn class_index_location(uri: &Uri, cls: &crate::index::file_index::ClassDef) -> Location {
2535    let start = Position {
2536        line: cls.start_line,
2537        character: cls.name_char,
2538    };
2539    Location {
2540        uri: uri.clone(),
2541        range: Range {
2542            start,
2543            end: Position {
2544                line: cls.start_line,
2545                character: cls.name_char + cls.name.len() as u32,
2546            },
2547        },
2548    }
2549}
2550
2551#[cfg(test)]
2552mod tests {
2553    use super::*;
2554
2555    /// Resolving a bare builtin interface loads its stub (a salsa input
2556    /// write) — the hover's own db handle must be gone by then. Mirrored via
2557    /// `upsert_source_file`, like the workspace scan, so no stub is preloaded.
2558    #[test]
2559    fn class_hover_loads_builtin_interface_stub_without_deadlock() {
2560        let (tx, rx) = std::sync::mpsc::channel();
2561        std::thread::spawn(move || {
2562            let mut session = mir_analyzer::AnalysisSession::new(mir_analyzer::PhpVersion::LATEST);
2563            session.upsert_source_file(
2564                Arc::from("file:///foo.php"),
2565                Arc::from("<?php\nclass Foo implements Countable {\n    public function count(): int { return 0; }\n}\n"),
2566                salsa::Durability::LOW,
2567            );
2568            let hover = class_hover_for_fqcn(&session.snapshot(), "Foo");
2569            let _ = tx.send(hover.map(|h| match h.contents {
2570                HoverContents::Markup(m) => m.value,
2571                _ => String::new(),
2572            }));
2573        });
2574        let value = rx
2575            .recv_timeout(std::time::Duration::from_secs(30))
2576            .expect("class hover deadlocked")
2577            .expect("class hover for Foo");
2578        assert!(value.contains("implements \\Countable"), "{value}");
2579    }
2580}