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            // Laravel string-key calls (`env('KEY')`, `config('a.b')`, ...) —
918            // resolved before the general hover pipeline below, since a
919            // string literal is never a `word_at_position` match. Mirrors
920            // `handle_goto_definition`'s same early check.
921            let laravel = self.laravel.load();
922            let laravel_root = self.root_paths.load().first().cloned();
923            let laravel_hover = crate::laravel::hover_for_string_key(
924                &doc,
925                position,
926                &laravel,
927                laravel_root.as_deref(),
928            )
929            .or_else(|| {
930                crate::laravel::blade::hover(
931                    uri,
932                    &source,
933                    position,
934                    &laravel,
935                    laravel_root.as_deref(),
936                )
937            });
938            drop(laravel);
939            if let Some(hover) = laravel_hover {
940                return Ok(Some(hover));
941            }
942            let open = self.open_urls();
943            let other_docs = self.docs.other_docs(uri, &open);
944            let other_maps = self.docs.other_symbol_maps(uri, &open);
945            let analysis = self.cached_analysis_async(uri).await;
946            let hover_session = self.docs.current_analysis_session();
947            let source_clone = source.clone();
948            let doc_clone = Arc::clone(&doc);
949            // Lets mir-member/static-prop hover resolve a class's declaring doc
950            // via the workspace index even when that file isn't open in the
951            // editor, instead of only searching `other_docs` (open files).
952            // Cached across this request: nothing between here and the
953            // fallback branch below lazily ingests a new vendor file, so the
954            // same Arc is safe to reuse instead of re-awaiting the index.
955            let mut wi_cache: Option<Arc<crate::db::workspace_index::WorkspaceIndexData>> = None;
956            let wi = self.workspace_index_cached(&mut wi_cache).await;
957            let docs_for_lookup = Arc::clone(&self.docs);
958            let find_class_doc_fn = move |name: &str| -> Option<Arc<ParsedDoc>> {
959                let cr = wi.resolve_class_ref(name)?;
960                let (uri, _) = wi.at(cr)?;
961                docs_for_lookup.get_doc_salsa(uri)
962            };
963            let result = self
964                .blocking("hover", move || {
965                    hover_info_with_maps(
966                        &source_clone,
967                        &doc_clone,
968                        analysis.as_deref(),
969                        position,
970                        &other_docs,
971                        &other_maps,
972                        Some(&hover_session),
973                        Some(&find_class_doc_fn),
974                    )
975                })
976                .await
977                .flatten();
978            if result.is_some() {
979                return Ok(result);
980            }
981            // Fallback: look up the word in the workspace index so class names in
982            // extends clauses and parameter types resolve even when their defining
983            // file is never opened.  Also try the alias-resolved name so that
984            // `use Foo as Bar` works even when Foo is only in the index.
985            if let Some(word) = crate::text::word_at_position(&source, position) {
986                let wi = self.workspace_index_cached(&mut wi_cache).await;
987                // Try the literal word first.
988                if let Some(h) = class_hover_from_workspace_index(&word, None, &wi) {
989                    return Ok(Some(h));
990                }
991                // Try alias resolution. The resolved FQN disambiguates between
992                // same-named classes in different namespaces (e.g. many
993                // vendored `Factory` classes all aliased to `FactoryContract`).
994                if let Some((resolved, resolved_fqn)) =
995                    crate::hover::resolve_use_alias_fqn(&doc.program().stmts, &word)
996                    && let Some(h) =
997                        class_hover_from_workspace_index(&resolved, Some(&resolved_fqn), &wi)
998                {
999                    return Ok(Some(h));
1000                }
1001                // Try static method hover: `ClassName::method(…)`.
1002                if let Some(line_text) = source.lines().nth(position.line as usize)
1003                    && let Some(class_token) =
1004                        extract_static_class_before_cursor(line_text, position.character as usize)
1005                {
1006                    if let Some(h) = method_hover_from_workspace_index(&class_token, &word, &wi) {
1007                        return Ok(Some(h));
1008                    }
1009                    if let Some(resolved_class) =
1010                        crate::hover::resolve_use_alias(&doc.program().stmts, &class_token)
1011                        && let Some(h) =
1012                            method_hover_from_workspace_index(&resolved_class, &word, &wi)
1013                    {
1014                        return Ok(Some(h));
1015                    }
1016                }
1017            }
1018            Ok(None)
1019        })
1020        .await
1021    }
1022
1023    async fn document_symbol(
1024        &self,
1025        params: DocumentSymbolParams,
1026    ) -> Result<Option<DocumentSymbolResponse>> {
1027        guard_async_result("document_symbol", async move {
1028            let uri = &params.text_document.uri;
1029            let doc = match self.get_doc(uri) {
1030                Some(d) => d,
1031                None => return Ok(None),
1032            };
1033            // The AST walk is CPU-bound; keep it off the async runtime worker.
1034            let symbols = self
1035                .blocking_gated(super::debug_gate::GATE_DOCUMENT_SYMBOL, move || {
1036                    document_symbols(doc.source(), &doc)
1037                })
1038                .await
1039                .unwrap_or_default();
1040            Ok(Some(DocumentSymbolResponse::Nested(symbols)))
1041        })
1042        .await
1043    }
1044
1045    async fn folding_range(&self, params: FoldingRangeParams) -> Result<Option<Vec<FoldingRange>>> {
1046        guard_async_result("folding_range", async move {
1047            let uri = &params.text_document.uri;
1048            let doc = match self.get_doc(uri) {
1049                Some(d) => d,
1050                None => return Ok(None),
1051            };
1052            // The AST walk plus full-text comment/region scans are CPU-bound;
1053            // keep them off the async runtime worker.
1054            let ranges = self
1055                .blocking_gated(super::debug_gate::GATE_FOLDING_RANGE, move || {
1056                    folding_ranges(doc.source(), &doc)
1057                })
1058                .await
1059                .unwrap_or_default();
1060            Ok(if ranges.is_empty() {
1061                None
1062            } else {
1063                Some(ranges)
1064            })
1065        })
1066        .await
1067    }
1068
1069    async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
1070        guard_async_result("inlay_hint", async move {
1071            let uri = &params.text_document.uri;
1072            let doc = match self.get_doc(uri) {
1073                Some(d) => d,
1074                None => return Ok(None),
1075            };
1076            let analysis = self.cached_analysis_async(uri).await;
1077            let wi = self.workspace_index_async().await;
1078            let hints = self
1079                .blocking("inlay_hint", move || {
1080                    // Built once per workspace-index revision and cached on `wi`
1081                    // (a salsa-memoized Arc); only actually walks the workspace on
1082                    // the first inlay_hint request after an edit.
1083                    let workspace_defs = wi.func_signatures();
1084                    inlay_hints(
1085                        doc.source(),
1086                        &doc,
1087                        analysis.as_deref(),
1088                        params.range,
1089                        &workspace_defs,
1090                    )
1091                })
1092                .await
1093                .unwrap_or_default();
1094            Ok(Some(hints))
1095        })
1096        .await
1097    }
1098
1099    async fn inlay_hint_resolve(&self, mut item: InlayHint) -> Result<InlayHint> {
1100        let fallback = item.clone();
1101        let resolved = guard_async("inlay_hint_resolve", async move {
1102            if item.tooltip.is_some() {
1103                return Some(item);
1104            }
1105            let func_name = item
1106                .data
1107                .as_ref()
1108                .and_then(|d| d.get("php_lsp_fn"))
1109                .and_then(|v| v.as_str())
1110                .map(str::to_string);
1111            if let Some(name) = func_name {
1112                let docs = Arc::clone(&self.docs);
1113                // Same whole-workspace-index scan as `completion_resolve`;
1114                // keep it off the async runtime worker.
1115                let tooltip = self
1116                    .blocking_gated(super::debug_gate::GATE_INLAY_HINT_RESOLVE, move || {
1117                        docs.with_all_indexes(|all_indexes| {
1118                            docs_for_symbol_from_index(&name, all_indexes)
1119                        })
1120                    })
1121                    .await
1122                    .flatten();
1123                if let Some(md) = tooltip {
1124                    item.tooltip = Some(InlayHintTooltip::MarkupContent(MarkupContent {
1125                        kind: MarkupKind::Markdown,
1126                        value: md,
1127                    }));
1128                }
1129            }
1130            Some(item)
1131        })
1132        .await;
1133        Ok(resolved.unwrap_or(fallback))
1134    }
1135
1136    async fn symbol(
1137        &self,
1138        params: WorkspaceSymbolParams,
1139    ) -> Result<Option<WorkspaceSymbolResponse>> {
1140        guard_async_result("symbol", async move {
1141            // Phase J: read through the salsa-memoized aggregate so repeated
1142            // workspace-symbol queries (every keystroke in the picker) share the
1143            // same `Arc` until a file changes.
1144            let wi = self.workspace_index_async().await;
1145            let query = params.query;
1146            let results = self
1147                .blocking("workspace/symbol", move || {
1148                    workspace_symbols_from_workspace(&query, &wi)
1149                })
1150                .await
1151                .unwrap_or_default();
1152            Ok(Some(WorkspaceSymbolResponse::Flat(results)))
1153        })
1154        .await
1155    }
1156
1157    async fn symbol_resolve(&self, params: WorkspaceSymbol) -> Result<WorkspaceSymbol> {
1158        // For resolve, we need the full range from the ParsedDoc of open files.
1159        let docs = self.docs.docs_for(&self.open_urls());
1160        let fallback = params.clone();
1161        let resolved = guard_async("symbol_resolve", async move {
1162            Some(resolve_workspace_symbol(params, &docs))
1163        })
1164        .await;
1165        Ok(resolved.unwrap_or(fallback))
1166    }
1167
1168    #[tracing::instrument(skip_all)]
1169    async fn semantic_tokens_full(
1170        &self,
1171        params: SemanticTokensParams,
1172    ) -> Result<Option<SemanticTokensResult>> {
1173        guard_async_result("semantic_tokens_full", async move {
1174            let uri = params.text_document.uri;
1175            let doc = match self.get_doc(&uri) {
1176                Some(d) => d,
1177                None => {
1178                    return Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
1179                        result_id: None,
1180                        data: vec![],
1181                    })));
1182                }
1183            };
1184            let docs = Arc::clone(&self.docs);
1185            let uri_for_task = uri.clone();
1186            // The AST walk is CPU-bound; keep it off the async runtime worker
1187            // (see `document_highlight` for the same pattern).
1188            let (result_id, data) = self
1189                .blocking_gated(super::debug_gate::GATE_SEMANTIC_TOKENS_FULL, move || {
1190                    let tokens = semantic_tokens(doc.source(), &doc);
1191                    let result_id = token_hash(&tokens);
1192                    let tokens_arc = Arc::new(tokens);
1193                    docs.store_token_cache(
1194                        &uri_for_task,
1195                        result_id.clone(),
1196                        Arc::clone(&tokens_arc),
1197                    );
1198                    let data = Arc::try_unwrap(tokens_arc).unwrap_or_else(|arc| (*arc).clone());
1199                    (result_id, data)
1200                })
1201                .await
1202                .unwrap_or_default();
1203            Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
1204                result_id: Some(result_id),
1205                data,
1206            })))
1207        })
1208        .await
1209    }
1210
1211    async fn semantic_tokens_range(
1212        &self,
1213        params: SemanticTokensRangeParams,
1214    ) -> Result<Option<SemanticTokensRangeResult>> {
1215        guard_async_result("semantic_tokens_range", async move {
1216            let uri = params.text_document.uri;
1217            let doc = match self.get_doc(&uri) {
1218                Some(d) => d,
1219                None => {
1220                    return Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
1221                        result_id: None,
1222                        data: vec![],
1223                    })));
1224                }
1225            };
1226            let range = params.range;
1227            let tokens = self
1228                .blocking_gated(super::debug_gate::GATE_SEMANTIC_TOKENS_RANGE, move || {
1229                    semantic_tokens_range(doc.source(), &doc, range)
1230                })
1231                .await
1232                .unwrap_or_default();
1233            Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
1234                result_id: None,
1235                data: tokens,
1236            })))
1237        })
1238        .await
1239    }
1240
1241    async fn semantic_tokens_full_delta(
1242        &self,
1243        params: SemanticTokensDeltaParams,
1244    ) -> Result<Option<SemanticTokensFullDeltaResult>> {
1245        guard_async_result("semantic_tokens_full_delta", async move {
1246            let uri = params.text_document.uri;
1247            let doc = match self.get_doc(&uri) {
1248                Some(d) => d,
1249                None => return Ok(None),
1250            };
1251
1252            let docs = Arc::clone(&self.docs);
1253            let uri_for_task = uri.clone();
1254            let prev_id = params.previous_result_id;
1255            let result = self
1256                .blocking_gated(
1257                    super::debug_gate::GATE_SEMANTIC_TOKENS_FULL_DELTA,
1258                    move || {
1259                        let new_tokens = Arc::new(semantic_tokens(doc.source(), &doc));
1260                        let new_result_id = token_hash(&new_tokens);
1261
1262                        let result = match docs.get_token_cache(&uri_for_task, &prev_id) {
1263                            Some(old_tokens) => {
1264                                let edits = compute_token_delta(&old_tokens, &new_tokens);
1265                                SemanticTokensFullDeltaResult::TokensDelta(SemanticTokensDelta {
1266                                    result_id: Some(new_result_id.clone()),
1267                                    edits,
1268                                })
1269                            }
1270                            // Unknown previous result — fall back to full tokens
1271                            None => SemanticTokensFullDeltaResult::Tokens(SemanticTokens {
1272                                result_id: Some(new_result_id.clone()),
1273                                data: (*new_tokens).clone(),
1274                            }),
1275                        };
1276
1277                        docs.store_token_cache(&uri_for_task, new_result_id, new_tokens);
1278                        result
1279                    },
1280                )
1281                .await;
1282            let Some(result) = result else {
1283                return Ok(None);
1284            };
1285            Ok(Some(result))
1286        })
1287        .await
1288    }
1289
1290    async fn selection_range(
1291        &self,
1292        params: SelectionRangeParams,
1293    ) -> Result<Option<Vec<SelectionRange>>> {
1294        guard_async_result("selection_range", async move {
1295            let uri = &params.text_document.uri;
1296            let doc = match self.get_doc(uri) {
1297                Some(d) => d,
1298                None => return Ok(None),
1299            };
1300            // Walks every top-level statement per requested position; keep
1301            // that off the async runtime worker, same as document_symbol
1302            // and folding_range's whole-document walks.
1303            let ranges = self
1304                .blocking_gated(super::debug_gate::GATE_SELECTION_RANGE, move || {
1305                    selection_ranges(&doc, &params.positions)
1306                })
1307                .await
1308                .unwrap_or_default();
1309            Ok(if ranges.is_empty() {
1310                None
1311            } else {
1312                Some(ranges)
1313            })
1314        })
1315        .await
1316    }
1317
1318    async fn prepare_call_hierarchy(
1319        &self,
1320        params: CallHierarchyPrepareParams,
1321    ) -> Result<Option<Vec<CallHierarchyItem>>> {
1322        guard_async_result("prepare_call_hierarchy", async move {
1323            let uri = &params.text_document_position_params.text_document.uri;
1324            let position = params.text_document_position_params.position;
1325            let source = self.get_open_text(uri).unwrap_or_default();
1326            let word = match word_at_position(&source, position) {
1327                Some(w) => w,
1328                None => return Ok(None),
1329            };
1330            // A bare keyword can never be a callable name; skip it here so a
1331            // `decls_by_name` miss doesn't fall through to the trait-alias
1332            // exhaustive scan in `prepare_call_hierarchy_indexed`.
1333            if is_bare_keyword_at(&source, position, &word) {
1334                return Ok(None);
1335            }
1336            // O(matches) lookup via the aggregate's `decls_by_name` map instead
1337            // of scanning every workspace doc, but a miss falls through to a
1338            // workspace-wide trait-alias scan — keep that off the async
1339            // runtime worker, same as incoming_calls/outgoing_calls below.
1340            let wi = self.workspace_index_async().await;
1341            let docs = Arc::clone(&self.docs);
1342            let item = self
1343                .blocking_gated(super::debug_gate::GATE_PREPARE_CALL_HIERARCHY, move || {
1344                    let get_doc = move |u: &Uri| docs.get_doc_salsa(u);
1345                    prepare_call_hierarchy_indexed(&word, &wi, &get_doc)
1346                })
1347                .await
1348                .flatten();
1349            Ok(item.map(|item| vec![item]))
1350        })
1351        .await
1352    }
1353
1354    async fn incoming_calls(
1355        &self,
1356        params: CallHierarchyIncomingCallsParams,
1357    ) -> Result<Option<Vec<CallHierarchyIncomingCall>>> {
1358        guard_async_result("incoming_calls", async move {
1359            // Call sites come from mir's posting lists; only the documents
1360            // containing them are parsed to resolve the enclosing caller.
1361            let docs = Arc::clone(&self.docs);
1362            let item = params.item;
1363            let calls = self
1364                .blocking("incoming_calls", move || {
1365                    // Pause the background scan and snapshot a settled revision so
1366                    // only a genuine user edit cancels the lookup.
1367                    let (_interactive, cancel_rev) = docs.settled_write_rev_guard();
1368                    incoming_calls_indexed(&item, &docs, Some(cancel_rev))
1369                })
1370                .await
1371                .unwrap_or_default();
1372            Ok(if calls.is_empty() { None } else { Some(calls) })
1373        })
1374        .await
1375    }
1376
1377    async fn outgoing_calls(
1378        &self,
1379        params: CallHierarchyOutgoingCallsParams,
1380    ) -> Result<Option<Vec<CallHierarchyOutgoingCall>>> {
1381        guard_async_result("outgoing_calls", async move {
1382            // Per-callee declaration lookups go through `decls_by_name` — the old
1383            // path re-scanned the whole workspace once per distinct callee.
1384            let wi = self.workspace_index_async().await;
1385            let docs = Arc::clone(&self.docs);
1386            let item = params.item;
1387            let calls = self
1388                .blocking("outgoing_calls", move || {
1389                    let get_doc = |u: &Uri| docs.get_doc_salsa(u);
1390                    outgoing_calls_indexed(&item, &wi, &get_doc)
1391                })
1392                .await
1393                .unwrap_or_default();
1394            Ok(if calls.is_empty() { None } else { Some(calls) })
1395        })
1396        .await
1397    }
1398
1399    async fn document_highlight(
1400        &self,
1401        params: DocumentHighlightParams,
1402    ) -> Result<Option<Vec<DocumentHighlight>>> {
1403        guard_async_result("document_highlight", async move {
1404            let uri = &params.text_document_position_params.text_document.uri;
1405            let position = params.text_document_position_params.position;
1406            let source = self.get_open_text(uri).unwrap_or_default();
1407            // Stale-tolerant: a highlight fired on every cursor move must never
1408            // spin on the salsa `Cancelled` retry loop while the user types.
1409            let doc = match self.get_doc_stale(uri) {
1410                Some(d) => d,
1411                None => return Ok(None),
1412            };
1413            // The AST walk is CPU-bound; keep it off the async runtime worker.
1414            let highlights = self
1415                .blocking("document_highlight", move || {
1416                    document_highlights(&source, &doc, position)
1417                })
1418                .await
1419                .unwrap_or_default();
1420            Ok(if highlights.is_empty() {
1421                None
1422            } else {
1423                Some(highlights)
1424            })
1425        })
1426        .await
1427    }
1428
1429    async fn linked_editing_range(
1430        &self,
1431        params: LinkedEditingRangeParams,
1432    ) -> Result<Option<LinkedEditingRanges>> {
1433        guard_async_result("linked_editing_range", async move {
1434            self.handle_linked_editing_range(params).await
1435        })
1436        .await
1437    }
1438
1439    async fn goto_implementation(
1440        &self,
1441        params: tower_lsp_server::ls_types::request::GotoImplementationParams,
1442    ) -> Result<Option<tower_lsp_server::ls_types::request::GotoImplementationResponse>> {
1443        guard_async_result("goto_implementation", async move {
1444            let uri = &params.text_document_position_params.text_document.uri;
1445            let position = params.text_document_position_params.position;
1446            let source = self.get_open_text(uri).unwrap_or_default();
1447            let imports = self.file_imports(uri);
1448            let raw_word = crate::text::word_at_position(&source, position).unwrap_or_default();
1449            if raw_word.is_empty() || is_bare_keyword_at(&source, position, &raw_word) {
1450                return Ok(None);
1451            }
1452            // `word_at_position` includes `\` as a word character, so the cursor on
1453            // a use-statement import (`use A\B\Foo`) returns the full qualified name.
1454            let (word, fqn): (String, String) = if raw_word.contains('\\') {
1455                let short = raw_word
1456                    .rsplit('\\')
1457                    .next()
1458                    .unwrap_or(&raw_word)
1459                    .to_string();
1460                (short, raw_word.trim_start_matches('\\').to_string())
1461            } else {
1462                // Resolve via this file's imports + namespace; covers usages,
1463                // aliases, and the type's own declaration (which resolves to
1464                // `<current-namespace>\<word>`).
1465                let resolved = match self.get_doc(uri) {
1466                    Some(doc) => crate::navigation::moniker::resolve_fqn(&doc, &raw_word, &imports),
1467                    None => raw_word.clone(),
1468                };
1469                (raw_word, resolved.trim_start_matches('\\').to_string())
1470            };
1471
1472            // A method declaration name can collide case-insensitively with a
1473            // class name (`Guard` interface vs `guard()` method in one
1474            // namespace); the cursor context decides which sense wins. The
1475            // check walks every member of every class in the document, so
1476            // keep it off the async runtime worker.
1477            let doc_for_method_check = self.get_doc(uri);
1478            let on_method_decl = self
1479                .blocking_gated(super::debug_gate::GATE_GOTO_IMPLEMENTATION, move || {
1480                    doc_for_method_check.is_some_and(|doc| {
1481                        cursor_is_on_method_decl(doc.source(), &doc.program().stmts, position)
1482                    })
1483                })
1484                .await
1485                .unwrap_or(false);
1486
1487            // Subtypes of the type under the cursor, from mir's maintained
1488            // subtype edge index (aliased/FQN extends forms all resolve).
1489            let mut locs: Vec<Location> = if on_method_decl {
1490                Vec::new()
1491            } else {
1492                let docs = Arc::clone(&self.docs);
1493                let fqn_task = fqn.clone();
1494                self.blocking("goto_implementation.subtypes", move || {
1495                    docs.indexed_subtype_classes(&fqn_task, false)
1496                })
1497                .await
1498                .unwrap_or_default()
1499                .into_iter()
1500                .filter_map(|site| subtype_site_to_location(&site.file, &site.range))
1501                .collect()
1502            };
1503
1504            // Cursor on a method name inside its declaring class/interface:
1505            // concrete overrides in every subtype.
1506            if locs.is_empty()
1507                && let Some(doc) = self.get_doc(uri)
1508                && let Some(enclosing) =
1509                    crate::types::type_map::enclosing_class_at(&source, &doc, position)
1510            {
1511                let enclosing_fqn =
1512                    crate::navigation::moniker::resolve_fqn(&doc, &enclosing, &imports)
1513                        .trim_start_matches('\\')
1514                        .to_string();
1515                let docs = Arc::clone(&self.docs);
1516                let method = word.clone();
1517                let impls = self
1518                    .blocking("goto_implementation.method_impls", move || {
1519                        docs.indexed_method_implementations(&enclosing_fqn, &method)
1520                    })
1521                    .await
1522                    .unwrap_or_default();
1523                locs = impls
1524                    .into_iter()
1525                    .filter_map(|(_, file, range)| subtype_site_to_location(&file, &range))
1526                    .collect();
1527            }
1528            if locs.is_empty() {
1529                Ok(None)
1530            } else {
1531                Ok(Some(GotoDefinitionResponse::Array(locs)))
1532            }
1533        })
1534        .await
1535    }
1536
1537    async fn goto_declaration(
1538        &self,
1539        params: tower_lsp_server::ls_types::request::GotoDeclarationParams,
1540    ) -> Result<Option<tower_lsp_server::ls_types::request::GotoDeclarationResponse>> {
1541        guard_async_result("goto_declaration", async move {
1542            let uri = &params.text_document_position_params.text_document.uri;
1543            let position = params.text_document_position_params.position;
1544            let source = self.get_open_text(uri).unwrap_or_default();
1545            // First pass: open-file ParsedDocs give accurate character
1546            // positions. Walking every open doc's AST is CPU-bound; keep it
1547            // off the async runtime worker. A separate hop (rather than one
1548            // spawn_blocking around both passes) so a first-pass hit skips
1549            // the second pass's workspace-index fetch entirely, as before.
1550            let open_docs = self.docs.docs_for(&self.open_urls());
1551            let source_for_pass1 = source.clone();
1552            let first_pass = self
1553                .blocking("goto_declaration.open_docs", move || {
1554                    goto_declaration(&source_for_pass1, &open_docs, position)
1555                })
1556                .await
1557                .unwrap_or_default();
1558            if let Some(loc) = first_pass {
1559                return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1560            }
1561            // Second pass: background files via the aggregated workspace index
1562            // (line-only positions for anything not covered by decls_by_name).
1563            let wi = self.workspace_index_async().await;
1564            let second_pass = self
1565                .blocking("goto_declaration.index", move || {
1566                    goto_declaration_from_index(&source, &wi, position)
1567                })
1568                .await
1569                .unwrap_or_default();
1570            Ok(second_pass.map(GotoDefinitionResponse::Scalar))
1571        })
1572        .await
1573    }
1574
1575    async fn goto_type_definition(
1576        &self,
1577        params: tower_lsp_server::ls_types::request::GotoTypeDefinitionParams,
1578    ) -> Result<Option<tower_lsp_server::ls_types::request::GotoTypeDefinitionResponse>> {
1579        guard_async_result("goto_type_definition", async move {
1580            let uri = &params.text_document_position_params.text_document.uri;
1581            let position = params.text_document_position_params.position;
1582            let source = self.get_open_text(uri).unwrap_or_default();
1583            let doc = match self.get_doc(uri) {
1584                Some(d) => d,
1585                None => return Ok(None),
1586            };
1587            let analysis = self.cached_analysis_async(uri).await;
1588            let open_docs = self.docs.docs_for(&self.open_urls());
1589            let docs = Arc::clone(&self.docs);
1590
1591            // Every fallback pass below is CPU-bound — the exact/short-name
1592            // passes walk every open doc's AST, the index passes do lookup
1593            // work over the aggregated index — so run the whole chain off
1594            // the async runtime worker in one hop, matching hover.
1595            let response = self
1596                .blocking_gated(super::debug_gate::GATE_GOTO_TYPE_DEFINITION, move || {
1597                    // Exact FQN/namespace matches (open docs, then background index)
1598                    // outrank *either* source's short-name fallback, so an unrelated
1599                    // same-short-named class in another open file can never preempt
1600                    // a correctly-namespaced match that only lives in the index.
1601                    let mut results = goto_type_definition_exact(
1602                        &source,
1603                        &doc,
1604                        analysis.as_deref(),
1605                        &open_docs,
1606                        position,
1607                    );
1608                    if results.is_empty() {
1609                        results = docs.with_all_indexes(|all_indexes| {
1610                            goto_type_definition_from_index_exact(
1611                                &source,
1612                                &doc,
1613                                analysis.as_deref(),
1614                                all_indexes,
1615                                position,
1616                            )
1617                        });
1618                    }
1619                    if results.is_empty() {
1620                        results = goto_type_definition_short_name_fallback(
1621                            &source,
1622                            &doc,
1623                            analysis.as_deref(),
1624                            &open_docs,
1625                            position,
1626                        );
1627                    }
1628                    if results.is_empty() {
1629                        results = docs.with_all_indexes(|all_indexes| {
1630                            goto_type_definition_from_index_short_name_fallback(
1631                                &source,
1632                                &doc,
1633                                analysis.as_deref(),
1634                                all_indexes,
1635                                position,
1636                            )
1637                        });
1638                    }
1639
1640                    // Format response: scalar for single result, array for multiple, none for empty
1641                    match results.len() {
1642                        0 => None,
1643                        1 => Some(GotoDefinitionResponse::Scalar(
1644                            results.into_iter().next().unwrap(),
1645                        )),
1646                        _ => Some(GotoDefinitionResponse::Array(results)),
1647                    }
1648                })
1649                .await
1650                .unwrap_or_default();
1651            Ok(response)
1652        })
1653        .await
1654    }
1655
1656    async fn prepare_type_hierarchy(
1657        &self,
1658        params: TypeHierarchyPrepareParams,
1659    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1660        guard_async_result("prepare_type_hierarchy", async move {
1661            let uri = &params.text_document_position_params.text_document.uri;
1662            let position = params.text_document_position_params.position;
1663            let source = self.get_open_text(uri).unwrap_or_default();
1664            // Phase J: use the salsa-memoized aggregate's `classes_by_name` map.
1665            let wi = self.workspace_index_async().await;
1666            Ok(
1667                prepare_type_hierarchy_from_workspace(&source, uri, &wi, position)
1668                    .map(|item| vec![item]),
1669            )
1670        })
1671        .await
1672    }
1673
1674    async fn supertypes(
1675        &self,
1676        params: TypeHierarchySupertypesParams,
1677    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1678        guard_async_result("supertypes", async move {
1679            // Phase J: resolve parents via the aggregate's `classes_by_name` map.
1680            // Pre-load any direct vendor supertypes via PSR-4 so they appear in the
1681            // workspace index before the lookup runs.
1682            let wi = self.workspace_index_async().await;
1683            let loaded_new = self
1684                .ensure_direct_supertypes_loaded(&params.item.name, &wi)
1685                .await;
1686            let wi = if loaded_new {
1687                self.workspace_index_async().await
1688            } else {
1689                wi
1690            };
1691            let result = supertypes_of_from_workspace(&params.item, &wi);
1692            Ok(if result.is_empty() {
1693                None
1694            } else {
1695                Some(result)
1696            })
1697        })
1698        .await
1699    }
1700
1701    async fn subtypes(
1702        &self,
1703        params: TypeHierarchySubtypesParams,
1704    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1705        guard_async_result("subtypes", async move {
1706            let wi = self.workspace_index_async().await;
1707            // Resolve the item's FQCN for mir's subtype graph. `classes_by_name`
1708            // is keyed by short name only, so a name shared by many classes
1709            // across the workspace (e.g. Laravel's ~16 `Factory` classes) needs
1710            // `params.item.uri` to pick the right one instead of an arbitrary
1711            // first match.
1712            let item_fqn = wi
1713                .classes_by_name
1714                .get(&params.item.name)
1715                .and_then(|refs| {
1716                    refs.iter()
1717                        .filter_map(|r| wi.at(*r))
1718                        .find(|(u, _)| **u == params.item.uri)
1719                        .or_else(|| refs.first().and_then(|r| wi.at(*r)))
1720                })
1721                .map(|(_, cls)| cls.fqn.as_ref().to_string());
1722            let subtype_urls = item_fqn
1723                .as_deref()
1724                .map(|f| self.docs.class_subtype_urls(f))
1725                .unwrap_or_default();
1726            let result =
1727                subtypes_of_mir_backed(&params.item, item_fqn.as_deref(), &wi, &subtype_urls);
1728            Ok(if result.is_empty() {
1729                None
1730            } else {
1731                Some(result)
1732            })
1733        })
1734        .await
1735    }
1736
1737    async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
1738        guard_async_result("code_lens", async move {
1739            let uri = &params.text_document.uri;
1740            let doc = match self.get_doc(uri) {
1741                Some(d) => d,
1742                None => return Ok(None),
1743            };
1744            // Lens counts come from mir's inverted indexes; still run on the
1745            // blocking pool (index freshness passes may analyze on demand).
1746            // A write during computation (write_rev advances) cancels early —
1747            // stale lens counts are useless and the editor will request fresh
1748            // ones after the edit.
1749            let docs = Arc::clone(&self.docs);
1750            let docs_cancel = Arc::clone(&self.docs);
1751            let uri_owned = uri.clone();
1752            let imports = self.file_imports(uri);
1753            let lenses = self
1754                .blocking("code_lens", move || {
1755                    // Pause the background scan and snapshot a settled revision so
1756                    // only a genuine user edit cancels the sweep.
1757                    let (_interactive, cancel_rev) = docs.settled_write_rev_guard();
1758                    code_lenses(
1759                        &uri_owned,
1760                        &doc,
1761                        &docs,
1762                        &imports,
1763                        Some(cancel_rev),
1764                        move || docs_cancel.write_rev() != cancel_rev,
1765                    )
1766                })
1767                .await
1768                .unwrap_or_default();
1769            Ok(if lenses.is_empty() {
1770                None
1771            } else {
1772                Some(lenses)
1773            })
1774        })
1775        .await
1776    }
1777
1778    async fn code_lens_resolve(&self, params: CodeLens) -> Result<CodeLens> {
1779        let fallback = params.clone();
1780        let resolved = guard_async("code_lens_resolve", async move {
1781            // Lenses are fully populated by code_lens; nothing to add.
1782            Some(params)
1783        })
1784        .await;
1785        Ok(resolved.unwrap_or(fallback))
1786    }
1787
1788    async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
1789        guard_async_result("document_link", async move {
1790            let uri = params.text_document.uri;
1791            let doc = match self.get_doc(&uri) {
1792                Some(d) => d,
1793                None => return Ok(None),
1794            };
1795            let laravel = self.laravel.load_full();
1796            // The AST walk plus full-text `@link`/`@see` scan are CPU-bound;
1797            // keep them off the async runtime worker.
1798            let links = self
1799                .blocking_gated(super::debug_gate::GATE_DOCUMENT_LINK, move || {
1800                    let mut links = document_links(&uri, &doc, doc.source());
1801                    links.extend(crate::laravel::document_links(&doc, &laravel));
1802                    links.extend(crate::laravel::blade::document_links(
1803                        &uri,
1804                        doc.source(),
1805                        &laravel,
1806                    ));
1807                    links
1808                })
1809                .await
1810                .unwrap_or_default();
1811            Ok(if links.is_empty() { None } else { Some(links) })
1812        })
1813        .await
1814    }
1815
1816    async fn document_link_resolve(&self, params: DocumentLink) -> Result<DocumentLink> {
1817        let fallback = params.clone();
1818        let resolved = guard_async("document_link_resolve", async move {
1819            // Links already carry their target URI; nothing to add.
1820            Some(params)
1821        })
1822        .await;
1823        Ok(resolved.unwrap_or(fallback))
1824    }
1825
1826    async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
1827        guard_async_result("formatting", async move {
1828            let uri = &params.text_document.uri;
1829            let source = self.get_open_text(uri).unwrap_or_default();
1830            // Spawns and blocks on an external formatter process; keep that
1831            // wait off the async runtime worker.
1832            Ok(self
1833                .blocking("formatting", move || format_document(&source))
1834                .await
1835                .unwrap_or_default())
1836        })
1837        .await
1838    }
1839
1840    async fn range_formatting(
1841        &self,
1842        params: DocumentRangeFormattingParams,
1843    ) -> Result<Option<Vec<TextEdit>>> {
1844        guard_async_result("range_formatting", async move {
1845            let uri = &params.text_document.uri;
1846            let source = self.get_open_text(uri).unwrap_or_default();
1847            let range = params.range;
1848            Ok(self
1849                .blocking("range_formatting", move || format_range(&source, range))
1850                .await
1851                .unwrap_or_default())
1852        })
1853        .await
1854    }
1855
1856    async fn on_type_formatting(
1857        &self,
1858        params: DocumentOnTypeFormattingParams,
1859    ) -> Result<Option<Vec<TextEdit>>> {
1860        guard_async_result("on_type_formatting", async move {
1861            let uri = &params.text_document_position.text_document.uri;
1862            let source = self.get_open_text(uri).unwrap_or_default();
1863            let position = params.text_document_position.position;
1864            // `}` fires a whole-document char-vec + string-literal-mask scan
1865            // to find the matching brace; every keystroke fires this handler,
1866            // so keep it off the async runtime worker like the other
1867            // whole-document walks.
1868            let edits = self
1869                .blocking_gated(super::debug_gate::GATE_ON_TYPE_FORMATTING, move || {
1870                    on_type_format(&source, position, &params.ch, &params.options)
1871                })
1872                .await
1873                .unwrap_or_default();
1874            Ok(if edits.is_empty() { None } else { Some(edits) })
1875        })
1876        .await
1877    }
1878
1879    async fn execute_command(
1880        &self,
1881        params: ExecuteCommandParams,
1882    ) -> Result<Option<serde_json::Value>> {
1883        guard_async_result("execute_command", async move {
1884            match params.command.as_str() {
1885                "php-lsp.runTest" => {
1886                    // Arguments: [uri_string, "ClassName::methodName"]
1887                    let file_uri = params
1888                        .arguments
1889                        .first()
1890                        .and_then(|v| v.as_str())
1891                        .and_then(|s| (s).parse::<Uri>().ok());
1892                    let filter = params
1893                        .arguments
1894                        .get(1)
1895                        .and_then(|v| v.as_str())
1896                        .unwrap_or("")
1897                        .to_string();
1898
1899                    let root = self.root_paths.load().first().cloned();
1900                    let client = self.client.clone();
1901
1902                    tokio::spawn(async move {
1903                        run_phpunit(&client, &filter, root.as_deref(), file_uri.as_ref()).await;
1904                    });
1905
1906                    Ok(None)
1907                }
1908                _ => Ok(None),
1909            }
1910        })
1911        .await
1912    }
1913
1914    async fn will_rename_files(&self, params: RenameFilesParams) -> Result<Option<WorkspaceEdit>> {
1915        guard_async_result("will_rename_files", async move {
1916            self.handle_will_rename_files(params).await
1917        })
1918        .await
1919    }
1920
1921    async fn did_rename_files(&self, params: RenameFilesParams) {
1922        guard_async("did_rename_files", async move {
1923            self.handle_did_rename_files(params).await
1924        })
1925        .await
1926    }
1927
1928    async fn will_create_files(&self, params: CreateFilesParams) -> Result<Option<WorkspaceEdit>> {
1929        guard_async_result("will_create_files", async move {
1930            self.handle_will_create_files(params).await
1931        })
1932        .await
1933    }
1934
1935    async fn did_create_files(&self, params: CreateFilesParams) {
1936        guard_async("did_create_files", async move {
1937            self.handle_did_create_files(params).await
1938        })
1939        .await
1940    }
1941
1942    /// Before a file is deleted, return workspace edits that remove every
1943    /// `use` import referencing its PSR-4 class name.
1944    async fn will_delete_files(&self, params: DeleteFilesParams) -> Result<Option<WorkspaceEdit>> {
1945        guard_async_result("will_delete_files", async move {
1946            self.handle_will_delete_files(params).await
1947        })
1948        .await
1949    }
1950
1951    async fn did_delete_files(&self, params: DeleteFilesParams) {
1952        guard_async("did_delete_files", async move {
1953            self.handle_did_delete_files(params).await
1954        })
1955        .await
1956    }
1957
1958    async fn moniker(&self, params: MonikerParams) -> Result<Option<Vec<Moniker>>> {
1959        guard_async_result("moniker", async move {
1960            let uri = &params.text_document_position_params.text_document.uri;
1961            let position = params.text_document_position_params.position;
1962            let source = self.get_open_text(uri).unwrap_or_default();
1963            let doc = match self.get_doc(uri) {
1964                Some(d) => d,
1965                None => return Ok(None),
1966            };
1967            let imports = self.file_imports(uri);
1968            Ok(moniker_at(&source, &doc, position, &imports).map(|m| vec![m]))
1969        })
1970        .await
1971    }
1972
1973    async fn inline_value(&self, params: InlineValueParams) -> Result<Option<Vec<InlineValue>>> {
1974        guard_async_result("inline_value", async move {
1975            let uri = &params.text_document.uri;
1976            let source = self.get_open_text(uri).unwrap_or_default();
1977            let values = inline_values_in_range(&source, params.range);
1978            Ok(if values.is_empty() {
1979                None
1980            } else {
1981                Some(values)
1982            })
1983        })
1984        .await
1985    }
1986
1987    async fn diagnostic(
1988        &self,
1989        params: DocumentDiagnosticParams,
1990    ) -> Result<DocumentDiagnosticReportResult> {
1991        let report = guard_async("diagnostic", async move {
1992            Some(self.handle_diagnostic(params).await)
1993        })
1994        .await;
1995        match report {
1996            Some(r) => r,
1997            None => Ok(DocumentDiagnosticReportResult::Report(
1998                DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport::default()),
1999            )),
2000        }
2001    }
2002
2003    async fn workspace_diagnostic(
2004        &self,
2005        params: WorkspaceDiagnosticParams,
2006    ) -> Result<WorkspaceDiagnosticReportResult> {
2007        let report = guard_async("workspace_diagnostic", async move {
2008            Some(self.handle_workspace_diagnostic(params).await)
2009        })
2010        .await;
2011        match report {
2012            Some(r) => r,
2013            None => Ok(WorkspaceDiagnosticReportResult::Report(
2014                WorkspaceDiagnosticReport::default(),
2015            )),
2016        }
2017    }
2018
2019    async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
2020        guard_async_result("code_action", async move {
2021            self.handle_code_action(params).await
2022        })
2023        .await
2024    }
2025
2026    async fn code_action_resolve(&self, item: CodeAction) -> Result<CodeAction> {
2027        guard_async_result("code_action_resolve", async move {
2028            self.handle_code_action_resolve(item).await
2029        })
2030        .await
2031    }
2032}
2033
2034/// Convert a mir subtype/implementation hit — file path plus a name range in
2035/// mir coordinates (1-based line, 0-based char columns) — to an LSP Location.
2036fn subtype_site_to_location(file: &str, range: &mir_analyzer::Range) -> Option<Location> {
2037    let uri = (file).parse::<Uri>().ok()?;
2038    let line = range.start.line.saturating_sub(1);
2039    Some(Location {
2040        uri,
2041        range: tower_lsp_server::ls_types::Range {
2042            start: tower_lsp_server::ls_types::Position {
2043                line,
2044                character: range.start.column,
2045            },
2046            end: tower_lsp_server::ls_types::Position {
2047                line,
2048                character: range.end.column,
2049            },
2050        },
2051    })
2052}