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