Skip to main content

php_lsp/backend/
server.rs

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