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