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