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