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