Skip to main content

php_lsp/backend/
server.rs

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