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 imports = self.file_imports(uri);
504            let wi = self.workspace_index_async().await;
505            let wi_for_class_search = Arc::clone(&wi);
506            let uri_for_class_search = uri.clone();
507            let docs_for_lookup = Arc::clone(&self.docs);
508            let find_class_doc_fn = move |name: &str| -> Option<Arc<ParsedDoc>> {
509                let cr = wi.resolve_class_ref(name)?;
510                let (uri, _) = wi.at(cr)?;
511                docs_for_lookup.get_doc_salsa(uri)
512            };
513            // Finds classes anywhere in the workspace index — including
514            // vendor/ and other files that are never opened as editor
515            // buffers — whose short name starts with `prefix`. Complements
516            // `other_docs`, which only covers currently open documents.
517            //
518            // `classes_by_lowercase_name` is sorted once per workspace
519            // revision (shared via `Arc` across every completion request
520            // until a file changes), so this does a binary search for the
521            // prefix range instead of scanning + re-lowercasing every class
522            // name on every keystroke.
523            const WORKSPACE_CLASS_SEARCH_LIMIT: usize = 50;
524            let workspace_class_search_fn =
525                move |prefix: &str| -> Vec<(String, CompletionItemKind, String)> {
526                    let prefix_lc = prefix.to_lowercase();
527                    let table = &wi_for_class_search.classes_by_lowercase_name;
528                    let start =
529                        table.partition_point(|(name, _)| name.as_ref() < prefix_lc.as_str());
530                    let mut out = Vec::new();
531                    for (name, cr) in &table[start..] {
532                        if !name.starts_with(prefix_lc.as_str()) {
533                            break;
534                        }
535                        let Some((file_uri, cls)) = wi_for_class_search.at(*cr) else {
536                            continue;
537                        };
538                        if *file_uri == uri_for_class_search {
539                            continue;
540                        }
541                        let kind = match cls.kind {
542                            ClassKind::Class | ClassKind::Trait => CompletionItemKind::CLASS,
543                            ClassKind::Interface => CompletionItemKind::INTERFACE,
544                            ClassKind::Enum => CompletionItemKind::ENUM,
545                        };
546                        out.push((cls.name.to_string(), kind, cls.fqn.to_string()));
547                        if out.len() >= WORKSPACE_CLASS_SEARCH_LIMIT {
548                            break;
549                        }
550                    }
551                    out
552                };
553            let analysis = self.cached_analysis_async(uri).await;
554            // Cross-request TypeMap cache: rebuilt only when the document text
555            // (or PHPStorm meta) changes, instead of one full AST walk per
556            // completion request.
557            let docs_for_tm = Arc::clone(&self.docs);
558            let doc_for_tm = Arc::clone(&doc);
559            let uri_for_tm = uri.clone();
560            let meta_arc_for_tm = Arc::clone(&meta_arc);
561            let get_type_map = move || {
562                let meta = if meta_arc_for_tm.is_empty() {
563                    None
564                } else {
565                    Some(&*meta_arc_for_tm)
566                };
567                docs_for_tm.cached_type_map(&uri_for_tm, &doc_for_tm, meta)
568            };
569            let session = self
570                .docs
571                .analysis_session(self.docs.workspace_php_version());
572            let uri_owned = uri.clone();
573            let uri_str = uri.to_string();
574            // Offload to spawn_blocking: filtered_completions_at walks the full
575            // AST + workspace index which can take tens of milliseconds on large
576            // files, blocking the async executor from unrelated requests.
577            let items = match tokio::task::spawn_blocking(move || {
578                let meta_opt = if meta_arc.is_empty() {
579                    None
580                } else {
581                    Some(&*meta_arc)
582                };
583                let ctx = CompletionCtx {
584                    source: Some(&source),
585                    position: Some(position),
586                    meta: meta_opt,
587                    doc_uri: Some(&uri_owned),
588                    file_imports: Some(&imports),
589                    find_class_doc: Some(&find_class_doc_fn),
590                    workspace_class_search: Some(&workspace_class_search_fn),
591                    analysis: analysis.as_deref(),
592                    type_map: Some(&get_type_map),
593                    session: Some(session),
594                };
595                filtered_completions_at(&doc, &other_docs, trigger.as_deref(), &ctx)
596            })
597            .await
598            {
599                Ok(items) => items,
600                Err(e) => {
601                    tracing::warn!("completion panicked for {uri_str}: {e}");
602                    vec![]
603                }
604            };
605            Ok(Some(CompletionResponse::Array(items)))
606        })
607        .await
608    }
609
610    async fn completion_resolve(&self, mut item: CompletionItem) -> Result<CompletionItem> {
611        guard_async_result("completion_resolve", async move {
612            if item.documentation.is_some() && item.detail.is_some() {
613                return Ok(item);
614            }
615            // Strip trailing ':' from named-argument labels (e.g. "param:") before lookup.
616            let name = item.label.trim_end_matches(':');
617            // Method completion items carry their owning class in `data` (see
618            // member.rs's all_members) so two unrelated classes declaring a
619            // same-named method don't resolve to whichever is indexed first.
620            let class_hint = item
621                .data
622                .as_ref()
623                .and_then(|d| d.get("class"))
624                .and_then(|v| v.as_str())
625                .map(str::to_string);
626            let all_indexes = self.docs.all_indexes();
627            if item.detail.is_none()
628                && let Some(sig) = signature_for_symbol_from_index_scoped(
629                    name,
630                    &all_indexes,
631                    class_hint.as_deref(),
632                )
633            {
634                item.detail = Some(sig);
635            }
636            if item.documentation.is_none()
637                && let Some(md) =
638                    docs_for_symbol_from_index_scoped(name, &all_indexes, class_hint.as_deref())
639            {
640                item.documentation = Some(Documentation::MarkupContent(MarkupContent {
641                    kind: MarkupKind::Markdown,
642                    value: md,
643                }));
644            }
645            Ok(item)
646        })
647        .await
648    }
649
650    async fn goto_definition(
651        &self,
652        params: GotoDefinitionParams,
653    ) -> Result<Option<GotoDefinitionResponse>> {
654        guard_async_result("goto_definition", async move {
655            self.handle_goto_definition(params).await
656        })
657        .await
658    }
659
660    async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
661        guard_async_result(
662            "references",
663            async move { self.handle_references(params).await },
664        )
665        .await
666    }
667
668    async fn prepare_rename(
669        &self,
670        params: TextDocumentPositionParams,
671    ) -> Result<Option<PrepareRenameResponse>> {
672        guard_async_result("prepare_rename", async move {
673            let uri = &params.text_document.uri;
674            let source = self.get_open_text(uri).unwrap_or_default();
675            Ok(prepare_rename(&source, params.position).map(PrepareRenameResponse::Range))
676        })
677        .await
678    }
679
680    async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
681        guard_async_result("rename", async move {
682            let uri = &params.text_document_position.text_document.uri;
683            let position = params.text_document_position.position;
684            let source = self.get_open_text(uri).unwrap_or_default();
685            let word = match word_at_position(&source, position) {
686                Some(w) => w,
687                None => return Ok(None),
688            };
689            if word.starts_with('$') {
690                let doc = match self.get_doc(uri) {
691                    Some(d) => d,
692                    None => return Ok(None),
693                };
694                // Cursor on a property declaration (`public int $x`) or a promoted
695                // constructor parameter (`private string $x`) — both act as property
696                // declarations and take the cross-file indexed path below. Plain
697                // variables stay on the single-document scope walker.
698                let on_property_decl =
699                    cursor_is_on_property_decl(&source, &doc.program().stmts, position).is_some()
700                        || promoted_property_at_cursor(&source, &doc.program().stmts, position)
701                            .is_some();
702                if !on_property_decl {
703                    return Ok(Some(rename_variable(
704                        &word,
705                        &params.new_name,
706                        uri,
707                        &doc,
708                        position,
709                    )));
710                }
711            }
712            // Everything else — classes, functions, methods, properties,
713            // constants — renames from mir's posting lists (declaration
714            // token and `use` import lines included).
715            Ok(self.indexed_rename(uri, position, &params.new_name).await)
716        })
717        .await
718    }
719
720    async fn signature_help(&self, params: SignatureHelpParams) -> Result<Option<SignatureHelp>> {
721        guard_async_result("signature_help", async move {
722            let uri = &params.text_document_position_params.text_document.uri;
723            let position = params.text_document_position_params.position;
724            let source = self.get_open_text(uri).unwrap_or_default();
725            let doc = match self.get_doc(uri) {
726                Some(d) => d,
727                None => return Ok(None),
728            };
729            let all_indexes = self.docs.all_indexes();
730            Ok(signature_help(&source, &doc, position, &all_indexes))
731        })
732        .await
733    }
734
735    #[tracing::instrument(skip_all)]
736    async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
737        guard_async_result("hover", async move {
738            let uri = &params.text_document_position_params.text_document.uri;
739            let position = params.text_document_position_params.position;
740            let source = self.get_open_text(uri).unwrap_or_default();
741            let doc = match self.get_doc(uri) {
742                Some(d) => d,
743                None => return Ok(None),
744            };
745            let other_docs = self.docs.other_docs(uri, &self.open_urls());
746            let other_maps = self.docs.other_symbol_maps(uri, &self.open_urls());
747            let analysis = self.cached_analysis_async(uri).await;
748            let hover_session = self
749                .docs
750                .analysis_session(self.docs.workspace_php_version());
751            let source_clone = source.clone();
752            let doc_clone = Arc::clone(&doc);
753            let uri_str = uri.to_string();
754            let result = match tokio::task::spawn_blocking(move || {
755                hover_info_with_maps(
756                    &source_clone,
757                    &doc_clone,
758                    analysis.as_deref(),
759                    position,
760                    &other_docs,
761                    &other_maps,
762                    Some(&hover_session),
763                )
764            })
765            .await
766            {
767                Ok(r) => r,
768                Err(e) => {
769                    tracing::warn!("hover panicked for {uri_str}: {e}");
770                    None
771                }
772            };
773            if result.is_some() {
774                return Ok(result);
775            }
776            // Fallback: look up the word in the workspace index so class names in
777            // extends clauses and parameter types resolve even when their defining
778            // file is never opened.  Also try the alias-resolved name so that
779            // `use Foo as Bar` works even when Foo is only in the index.
780            if let Some(word) = crate::text::word_at_position(&source, position) {
781                let wi = self.workspace_index_async().await;
782                // Try the literal word first.
783                if let Some(h) = class_hover_from_index(&word, None, &wi.files) {
784                    return Ok(Some(h));
785                }
786                // Try alias resolution. The resolved FQN disambiguates between
787                // same-named classes in different namespaces (e.g. many
788                // vendored `Factory` classes all aliased to `FactoryContract`).
789                if let Some((resolved, resolved_fqn)) =
790                    crate::hover::resolve_use_alias_fqn(&doc.program().stmts, &word)
791                    && let Some(h) =
792                        class_hover_from_index(&resolved, Some(&resolved_fqn), &wi.files)
793                {
794                    return Ok(Some(h));
795                }
796                // Try static method hover: `ClassName::method(…)`.
797                if let Some(line_text) = source.lines().nth(position.line as usize)
798                    && let Some(class_token) =
799                        extract_static_class_before_cursor(line_text, position.character as usize)
800                {
801                    if let Some(h) = method_hover_from_index(&class_token, &word, &wi.files) {
802                        return Ok(Some(h));
803                    }
804                    if let Some(resolved_class) =
805                        crate::hover::resolve_use_alias(&doc.program().stmts, &class_token)
806                        && let Some(h) = method_hover_from_index(&resolved_class, &word, &wi.files)
807                    {
808                        return Ok(Some(h));
809                    }
810                }
811            }
812            Ok(None)
813        })
814        .await
815    }
816
817    async fn document_symbol(
818        &self,
819        params: DocumentSymbolParams,
820    ) -> Result<Option<DocumentSymbolResponse>> {
821        guard_async_result("document_symbol", async move {
822            let uri = &params.text_document.uri;
823            let doc = match self.get_doc(uri) {
824                Some(d) => d,
825                None => return Ok(None),
826            };
827            Ok(Some(DocumentSymbolResponse::Nested(document_symbols(
828                doc.source(),
829                &doc,
830            ))))
831        })
832        .await
833    }
834
835    async fn folding_range(&self, params: FoldingRangeParams) -> Result<Option<Vec<FoldingRange>>> {
836        guard_async_result("folding_range", async move {
837            let uri = &params.text_document.uri;
838            let doc = match self.get_doc(uri) {
839                Some(d) => d,
840                None => return Ok(None),
841            };
842            let ranges = folding_ranges(doc.source(), &doc);
843            Ok(if ranges.is_empty() {
844                None
845            } else {
846                Some(ranges)
847            })
848        })
849        .await
850    }
851
852    async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
853        guard_async_result("inlay_hint", 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 analysis = self.cached_analysis_async(uri).await;
860            let wi = self.workspace_index_async().await;
861            let uri_str = uri.to_string();
862            let hints = match tokio::task::spawn_blocking(move || {
863                inlay_hints(
864                    doc.source(),
865                    &doc,
866                    analysis.as_deref(),
867                    params.range,
868                    &wi.files,
869                )
870            })
871            .await
872            {
873                Ok(h) => h,
874                Err(e) => {
875                    tracing::warn!("inlay_hint panicked for {uri_str}: {e}");
876                    Vec::new()
877                }
878            };
879            Ok(Some(hints))
880        })
881        .await
882    }
883
884    async fn inlay_hint_resolve(&self, mut item: InlayHint) -> Result<InlayHint> {
885        let fallback = item.clone();
886        let resolved = guard_async("inlay_hint_resolve", async move {
887            if item.tooltip.is_some() {
888                return Some(item);
889            }
890            let func_name = item
891                .data
892                .as_ref()
893                .and_then(|d| d.get("php_lsp_fn"))
894                .and_then(|v| v.as_str())
895                .map(str::to_string);
896            if let Some(name) = func_name {
897                let all_indexes = self.docs.all_indexes();
898                if let Some(md) = docs_for_symbol_from_index(&name, &all_indexes) {
899                    item.tooltip = Some(InlayHintTooltip::MarkupContent(MarkupContent {
900                        kind: MarkupKind::Markdown,
901                        value: md,
902                    }));
903                }
904            }
905            Some(item)
906        })
907        .await;
908        Ok(resolved.unwrap_or(fallback))
909    }
910
911    async fn symbol(
912        &self,
913        params: WorkspaceSymbolParams,
914    ) -> Result<Option<Vec<SymbolInformation>>> {
915        guard_async_result("symbol", async move {
916            // Phase J: read through the salsa-memoized aggregate so repeated
917            // workspace-symbol queries (every keystroke in the picker) share the
918            // same `Arc` until a file changes.
919            let wi = self.workspace_index_async().await;
920            let query = params.query;
921            let results = match tokio::task::spawn_blocking(move || {
922                workspace_symbols_from_workspace(&query, &wi)
923            })
924            .await
925            {
926                Ok(r) => r,
927                Err(e) => {
928                    tracing::warn!("workspace/symbol panicked: {e}");
929                    Vec::new()
930                }
931            };
932            Ok(Some(results))
933        })
934        .await
935    }
936
937    async fn symbol_resolve(&self, params: WorkspaceSymbol) -> Result<WorkspaceSymbol> {
938        // For resolve, we need the full range from the ParsedDoc of open files.
939        let docs = self.docs.docs_for(&self.open_urls());
940        let fallback = params.clone();
941        let resolved = guard_async("symbol_resolve", async move {
942            Some(resolve_workspace_symbol(params, &docs))
943        })
944        .await;
945        Ok(resolved.unwrap_or(fallback))
946    }
947
948    #[tracing::instrument(skip_all)]
949    async fn semantic_tokens_full(
950        &self,
951        params: SemanticTokensParams,
952    ) -> Result<Option<SemanticTokensResult>> {
953        guard_async_result("semantic_tokens_full", async move {
954            let uri = &params.text_document.uri;
955            let doc = match self.get_doc(uri) {
956                Some(d) => d,
957                None => {
958                    return Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
959                        result_id: None,
960                        data: vec![],
961                    })));
962                }
963            };
964            let tokens = semantic_tokens(doc.source(), &doc);
965            let result_id = token_hash(&tokens);
966            let tokens_arc = Arc::new(tokens);
967            self.docs
968                .store_token_cache(uri, result_id.clone(), Arc::clone(&tokens_arc));
969            let data = Arc::try_unwrap(tokens_arc).unwrap_or_else(|arc| (*arc).clone());
970            Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
971                result_id: Some(result_id),
972                data,
973            })))
974        })
975        .await
976    }
977
978    async fn semantic_tokens_range(
979        &self,
980        params: SemanticTokensRangeParams,
981    ) -> Result<Option<SemanticTokensRangeResult>> {
982        guard_async_result("semantic_tokens_range", async move {
983            let uri = &params.text_document.uri;
984            let doc = match self.get_doc(uri) {
985                Some(d) => d,
986                None => {
987                    return Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
988                        result_id: None,
989                        data: vec![],
990                    })));
991                }
992            };
993            let tokens = semantic_tokens_range(doc.source(), &doc, params.range);
994            Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
995                result_id: None,
996                data: tokens,
997            })))
998        })
999        .await
1000    }
1001
1002    async fn semantic_tokens_full_delta(
1003        &self,
1004        params: SemanticTokensDeltaParams,
1005    ) -> Result<Option<SemanticTokensFullDeltaResult>> {
1006        guard_async_result("semantic_tokens_full_delta", async move {
1007            let uri = &params.text_document.uri;
1008            let doc = match self.get_doc(uri) {
1009                Some(d) => d,
1010                None => return Ok(None),
1011            };
1012
1013            let new_tokens = Arc::new(semantic_tokens(doc.source(), &doc));
1014            let new_result_id = token_hash(&new_tokens);
1015            let prev_id = &params.previous_result_id;
1016
1017            let result = match self.docs.get_token_cache(uri, prev_id) {
1018                Some(old_tokens) => {
1019                    let edits = compute_token_delta(&old_tokens, &new_tokens);
1020                    SemanticTokensFullDeltaResult::TokensDelta(SemanticTokensDelta {
1021                        result_id: Some(new_result_id.clone()),
1022                        edits,
1023                    })
1024                }
1025                // Unknown previous result — fall back to full tokens
1026                None => SemanticTokensFullDeltaResult::Tokens(SemanticTokens {
1027                    result_id: Some(new_result_id.clone()),
1028                    data: (*new_tokens).clone(),
1029                }),
1030            };
1031
1032            self.docs.store_token_cache(uri, new_result_id, new_tokens);
1033            Ok(Some(result))
1034        })
1035        .await
1036    }
1037
1038    async fn selection_range(
1039        &self,
1040        params: SelectionRangeParams,
1041    ) -> Result<Option<Vec<SelectionRange>>> {
1042        guard_async_result("selection_range", async move {
1043            let uri = &params.text_document.uri;
1044            let doc = match self.get_doc(uri) {
1045                Some(d) => d,
1046                None => return Ok(None),
1047            };
1048            let ranges = selection_ranges(&doc, &params.positions);
1049            Ok(if ranges.is_empty() {
1050                None
1051            } else {
1052                Some(ranges)
1053            })
1054        })
1055        .await
1056    }
1057
1058    async fn prepare_call_hierarchy(
1059        &self,
1060        params: CallHierarchyPrepareParams,
1061    ) -> Result<Option<Vec<CallHierarchyItem>>> {
1062        guard_async_result("prepare_call_hierarchy", async move {
1063            let uri = &params.text_document_position_params.text_document.uri;
1064            let position = params.text_document_position_params.position;
1065            let source = self.get_open_text(uri).unwrap_or_default();
1066            let word = match word_at_position(&source, position) {
1067                Some(w) => w,
1068                None => return Ok(None),
1069            };
1070            // O(matches) lookup via the aggregate's `decls_by_name` map instead
1071            // of scanning every workspace doc.
1072            let wi = self.workspace_index_async().await;
1073            let docs = Arc::clone(&self.docs);
1074            let get_doc = move |u: &Url| docs.get_doc_salsa(u);
1075            Ok(prepare_call_hierarchy_indexed(&word, &wi, &get_doc).map(|item| vec![item]))
1076        })
1077        .await
1078    }
1079
1080    async fn incoming_calls(
1081        &self,
1082        params: CallHierarchyIncomingCallsParams,
1083    ) -> Result<Option<Vec<CallHierarchyIncomingCall>>> {
1084        guard_async_result("incoming_calls", async move {
1085            // Call sites come from mir's posting lists; only the documents
1086            // containing them are parsed to resolve the enclosing caller.
1087            let docs = Arc::clone(&self.docs);
1088            let item_uri = params.item.uri.to_string();
1089            let item = params.item;
1090            let calls = match tokio::task::spawn_blocking(move || {
1091                // Pause the background scan and snapshot a settled revision so
1092                // only a genuine user edit cancels the lookup.
1093                let (_interactive, cancel_rev) = docs.settled_write_rev_guard();
1094                incoming_calls_indexed(&item, &docs, Some(cancel_rev))
1095            })
1096            .await
1097            {
1098                Ok(c) => c,
1099                Err(e) => {
1100                    tracing::warn!("incoming_calls panicked for {item_uri}: {e}");
1101                    vec![]
1102                }
1103            };
1104            Ok(if calls.is_empty() { None } else { Some(calls) })
1105        })
1106        .await
1107    }
1108
1109    async fn outgoing_calls(
1110        &self,
1111        params: CallHierarchyOutgoingCallsParams,
1112    ) -> Result<Option<Vec<CallHierarchyOutgoingCall>>> {
1113        guard_async_result("outgoing_calls", async move {
1114            // Per-callee declaration lookups go through `decls_by_name` — the old
1115            // path re-scanned the whole workspace once per distinct callee.
1116            let wi = self.workspace_index_async().await;
1117            let docs = Arc::clone(&self.docs);
1118            let item_uri = params.item.uri.to_string();
1119            let item = params.item;
1120            let calls = match tokio::task::spawn_blocking(move || {
1121                let get_doc = |u: &Url| docs.get_doc_salsa(u);
1122                outgoing_calls_indexed(&item, &wi, &get_doc)
1123            })
1124            .await
1125            {
1126                Ok(c) => c,
1127                Err(e) => {
1128                    tracing::warn!("outgoing_calls panicked for {item_uri}: {e}");
1129                    vec![]
1130                }
1131            };
1132            Ok(if calls.is_empty() { None } else { Some(calls) })
1133        })
1134        .await
1135    }
1136
1137    async fn document_highlight(
1138        &self,
1139        params: DocumentHighlightParams,
1140    ) -> Result<Option<Vec<DocumentHighlight>>> {
1141        guard_async_result("document_highlight", 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            // Stale-tolerant: a highlight fired on every cursor move must never
1146            // spin on the salsa `Cancelled` retry loop while the user types.
1147            let doc = match self.get_doc_stale(uri) {
1148                Some(d) => d,
1149                None => return Ok(None),
1150            };
1151            let uri_str = uri.to_string();
1152            // The AST walk is CPU-bound; keep it off the async runtime worker.
1153            let highlights = match tokio::task::spawn_blocking(move || {
1154                document_highlights(&source, &doc, position)
1155            })
1156            .await
1157            {
1158                Ok(h) => h,
1159                Err(e) => {
1160                    tracing::warn!("document_highlight panicked for {uri_str}: {e}");
1161                    Vec::new()
1162                }
1163            };
1164            Ok(if highlights.is_empty() {
1165                None
1166            } else {
1167                Some(highlights)
1168            })
1169        })
1170        .await
1171    }
1172
1173    async fn linked_editing_range(
1174        &self,
1175        params: LinkedEditingRangeParams,
1176    ) -> Result<Option<LinkedEditingRanges>> {
1177        guard_async_result("linked_editing_range", async move {
1178            self.handle_linked_editing_range(params).await
1179        })
1180        .await
1181    }
1182
1183    async fn goto_implementation(
1184        &self,
1185        params: tower_lsp::lsp_types::request::GotoImplementationParams,
1186    ) -> Result<Option<tower_lsp::lsp_types::request::GotoImplementationResponse>> {
1187        guard_async_result("goto_implementation", async move {
1188            let uri = &params.text_document_position_params.text_document.uri;
1189            let position = params.text_document_position_params.position;
1190            let source = self.get_open_text(uri).unwrap_or_default();
1191            let imports = self.file_imports(uri);
1192            let raw_word = crate::text::word_at_position(&source, position).unwrap_or_default();
1193            if raw_word.is_empty() {
1194                return Ok(None);
1195            }
1196            // `word_at_position` includes `\` as a word character, so the cursor on
1197            // a use-statement import (`use A\B\Foo`) returns the full qualified name.
1198            let (word, fqn): (String, String) = if raw_word.contains('\\') {
1199                let short = raw_word
1200                    .rsplit('\\')
1201                    .next()
1202                    .unwrap_or(&raw_word)
1203                    .to_string();
1204                (short, raw_word.trim_start_matches('\\').to_string())
1205            } else {
1206                // Resolve via this file's imports + namespace; covers usages,
1207                // aliases, and the type's own declaration (which resolves to
1208                // `<current-namespace>\<word>`).
1209                let resolved = match self.get_doc(uri) {
1210                    Some(doc) => crate::navigation::moniker::resolve_fqn(&doc, &raw_word, &imports),
1211                    None => raw_word.clone(),
1212                };
1213                (raw_word, resolved.trim_start_matches('\\').to_string())
1214            };
1215
1216            // A method declaration name can collide case-insensitively with a
1217            // class name (`Guard` interface vs `guard()` method in one
1218            // namespace); the cursor context decides which sense wins.
1219            let on_method_decl = self.get_doc(uri).is_some_and(|doc| {
1220                cursor_is_on_method_decl(doc.source(), &doc.program().stmts, position)
1221            });
1222
1223            // Subtypes of the type under the cursor, from mir's maintained
1224            // subtype edge index (aliased/FQN extends forms all resolve).
1225            let mut locs: Vec<Location> = if on_method_decl {
1226                Vec::new()
1227            } else {
1228                let docs = Arc::clone(&self.docs);
1229                let fqn_task = fqn.clone();
1230                tokio::task::spawn_blocking(move || docs.indexed_subtype_classes(&fqn_task, false))
1231                    .await
1232                    .unwrap_or_default()
1233                    .into_iter()
1234                    .filter_map(|site| subtype_site_to_location(&site.file, &site.range))
1235                    .collect()
1236            };
1237
1238            // Cursor on a method name inside its declaring class/interface:
1239            // concrete overrides in every subtype.
1240            if locs.is_empty()
1241                && let Some(doc) = self.get_doc(uri)
1242                && let Some(enclosing) =
1243                    crate::types::type_map::enclosing_class_at(&source, &doc, position)
1244            {
1245                let enclosing_fqn =
1246                    crate::navigation::moniker::resolve_fqn(&doc, &enclosing, &imports)
1247                        .trim_start_matches('\\')
1248                        .to_string();
1249                let docs = Arc::clone(&self.docs);
1250                let method = word.clone();
1251                let impls = tokio::task::spawn_blocking(move || {
1252                    docs.indexed_method_implementations(&enclosing_fqn, &method)
1253                })
1254                .await
1255                .unwrap_or_default();
1256                locs = impls
1257                    .into_iter()
1258                    .filter_map(|(_, file, range)| subtype_site_to_location(&file, &range))
1259                    .collect();
1260            }
1261            if locs.is_empty() {
1262                Ok(None)
1263            } else {
1264                Ok(Some(GotoDefinitionResponse::Array(locs)))
1265            }
1266        })
1267        .await
1268    }
1269
1270    async fn goto_declaration(
1271        &self,
1272        params: tower_lsp::lsp_types::request::GotoDeclarationParams,
1273    ) -> Result<Option<tower_lsp::lsp_types::request::GotoDeclarationResponse>> {
1274        guard_async_result("goto_declaration", async move {
1275            let uri = &params.text_document_position_params.text_document.uri;
1276            let position = params.text_document_position_params.position;
1277            let source = self.get_open_text(uri).unwrap_or_default();
1278            // First pass: open-file ParsedDocs give accurate character positions.
1279            let open_docs = self.docs.docs_for(&self.open_urls());
1280            if let Some(loc) = goto_declaration(&source, &open_docs, position) {
1281                return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1282            }
1283            // Second pass: background files via FileIndex (line-only positions).
1284            let all_indexes = self.docs.all_indexes();
1285            Ok(goto_declaration_from_index(&source, &all_indexes, position)
1286                .map(GotoDefinitionResponse::Scalar))
1287        })
1288        .await
1289    }
1290
1291    async fn goto_type_definition(
1292        &self,
1293        params: tower_lsp::lsp_types::request::GotoTypeDefinitionParams,
1294    ) -> Result<Option<tower_lsp::lsp_types::request::GotoTypeDefinitionResponse>> {
1295        guard_async_result("goto_type_definition", async move {
1296            let uri = &params.text_document_position_params.text_document.uri;
1297            let position = params.text_document_position_params.position;
1298            let source = self.get_open_text(uri).unwrap_or_default();
1299            let doc = match self.get_doc(uri) {
1300                Some(d) => d,
1301                None => return Ok(None),
1302            };
1303            let analysis = self.cached_analysis_async(uri).await;
1304            let open_docs = self.docs.docs_for(&self.open_urls());
1305            let all_indexes = self.docs.all_indexes();
1306
1307            // Exact FQN/namespace matches (open docs, then background index)
1308            // outrank *either* source's short-name fallback, so an unrelated
1309            // same-short-named class in another open file can never preempt
1310            // a correctly-namespaced match that only lives in the index.
1311            let mut results = goto_type_definition_exact(
1312                &source,
1313                &doc,
1314                analysis.as_deref(),
1315                &open_docs,
1316                position,
1317            );
1318            if results.is_empty() {
1319                results = goto_type_definition_from_index_exact(
1320                    &source,
1321                    &doc,
1322                    analysis.as_deref(),
1323                    &all_indexes,
1324                    position,
1325                );
1326            }
1327            if results.is_empty() {
1328                results = goto_type_definition_short_name_fallback(
1329                    &source,
1330                    &doc,
1331                    analysis.as_deref(),
1332                    &open_docs,
1333                    position,
1334                );
1335            }
1336            if results.is_empty() {
1337                results = goto_type_definition_from_index_short_name_fallback(
1338                    &source,
1339                    &doc,
1340                    analysis.as_deref(),
1341                    &all_indexes,
1342                    position,
1343                );
1344            }
1345
1346            // Format response: scalar for single result, array for multiple, none for empty
1347            let response = match results.len() {
1348                0 => None,
1349                1 => Some(GotoDefinitionResponse::Scalar(
1350                    results.into_iter().next().unwrap(),
1351                )),
1352                _ => Some(GotoDefinitionResponse::Array(results)),
1353            };
1354            Ok(response)
1355        })
1356        .await
1357    }
1358
1359    async fn prepare_type_hierarchy(
1360        &self,
1361        params: TypeHierarchyPrepareParams,
1362    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1363        guard_async_result("prepare_type_hierarchy", async move {
1364            let uri = &params.text_document_position_params.text_document.uri;
1365            let position = params.text_document_position_params.position;
1366            let source = self.get_open_text(uri).unwrap_or_default();
1367            // Phase J: use the salsa-memoized aggregate's `classes_by_name` map.
1368            let wi = self.workspace_index_async().await;
1369            Ok(
1370                prepare_type_hierarchy_from_workspace(&source, uri, &wi, position)
1371                    .map(|item| vec![item]),
1372            )
1373        })
1374        .await
1375    }
1376
1377    async fn supertypes(
1378        &self,
1379        params: TypeHierarchySupertypesParams,
1380    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1381        guard_async_result("supertypes", async move {
1382            // Phase J: resolve parents via the aggregate's `classes_by_name` map.
1383            // Pre-load any direct vendor supertypes via PSR-4 so they appear in the
1384            // workspace index before the lookup runs.
1385            let wi = self.workspace_index_async().await;
1386            let loaded_new = self
1387                .ensure_direct_supertypes_loaded(&params.item.name, &wi)
1388                .await;
1389            let wi = if loaded_new {
1390                self.workspace_index_async().await
1391            } else {
1392                wi
1393            };
1394            let result = supertypes_of_from_workspace(&params.item, &wi);
1395            Ok(if result.is_empty() {
1396                None
1397            } else {
1398                Some(result)
1399            })
1400        })
1401        .await
1402    }
1403
1404    async fn subtypes(
1405        &self,
1406        params: TypeHierarchySubtypesParams,
1407    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1408        guard_async_result("subtypes", async move {
1409            let wi = self.workspace_index_async().await;
1410            // Resolve the item's FQCN for mir's subtype graph. `classes_by_name`
1411            // is keyed by short name only, so a name shared by many classes
1412            // across the workspace (e.g. Laravel's ~16 `Factory` classes) needs
1413            // `params.item.uri` to pick the right one instead of an arbitrary
1414            // first match.
1415            let item_fqn = wi
1416                .classes_by_name
1417                .get(&params.item.name)
1418                .and_then(|refs| {
1419                    refs.iter()
1420                        .filter_map(|r| wi.at(*r))
1421                        .find(|(u, _)| **u == params.item.uri)
1422                        .or_else(|| refs.first().and_then(|r| wi.at(*r)))
1423                })
1424                .map(|(_, cls)| cls.fqn.as_ref().to_string());
1425            let subtype_urls = item_fqn
1426                .as_deref()
1427                .map(|f| self.docs.class_subtype_urls(f))
1428                .unwrap_or_default();
1429            let result =
1430                subtypes_of_mir_backed(&params.item, item_fqn.as_deref(), &wi, &subtype_urls);
1431            Ok(if result.is_empty() {
1432                None
1433            } else {
1434                Some(result)
1435            })
1436        })
1437        .await
1438    }
1439
1440    async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
1441        guard_async_result("code_lens", async move {
1442            let uri = &params.text_document.uri;
1443            let doc = match self.get_doc(uri) {
1444                Some(d) => d,
1445                None => return Ok(None),
1446            };
1447            // Lens counts come from mir's inverted indexes; still run on the
1448            // blocking pool (index freshness passes may analyze on demand).
1449            // A write during computation (write_rev advances) cancels early —
1450            // stale lens counts are useless and the editor will request fresh
1451            // ones after the edit.
1452            let docs = Arc::clone(&self.docs);
1453            let docs_cancel = Arc::clone(&self.docs);
1454            let uri_owned = uri.clone();
1455            let uri_str = uri.to_string();
1456            let imports = self.file_imports(uri);
1457            let lenses = match tokio::task::spawn_blocking(move || {
1458                // Pause the background scan and snapshot a settled revision so
1459                // only a genuine user edit cancels the sweep.
1460                let (_interactive, cancel_rev) = docs.settled_write_rev_guard();
1461                code_lenses(
1462                    &uri_owned,
1463                    &doc,
1464                    &docs,
1465                    &imports,
1466                    Some(cancel_rev),
1467                    move || docs_cancel.write_rev() != cancel_rev,
1468                )
1469            })
1470            .await
1471            {
1472                Ok(l) => l,
1473                Err(e) => {
1474                    tracing::warn!("code_lens panicked for {uri_str}: {e}");
1475                    vec![]
1476                }
1477            };
1478            Ok(if lenses.is_empty() {
1479                None
1480            } else {
1481                Some(lenses)
1482            })
1483        })
1484        .await
1485    }
1486
1487    async fn code_lens_resolve(&self, params: CodeLens) -> Result<CodeLens> {
1488        let fallback = params.clone();
1489        let resolved = guard_async("code_lens_resolve", async move {
1490            // Lenses are fully populated by code_lens; nothing to add.
1491            Some(params)
1492        })
1493        .await;
1494        Ok(resolved.unwrap_or(fallback))
1495    }
1496
1497    async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
1498        guard_async_result("document_link", async move {
1499            let uri = &params.text_document.uri;
1500            let doc = match self.get_doc(uri) {
1501                Some(d) => d,
1502                None => return Ok(None),
1503            };
1504            let links = document_links(uri, &doc, doc.source());
1505            Ok(if links.is_empty() { None } else { Some(links) })
1506        })
1507        .await
1508    }
1509
1510    async fn document_link_resolve(&self, params: DocumentLink) -> Result<DocumentLink> {
1511        let fallback = params.clone();
1512        let resolved = guard_async("document_link_resolve", async move {
1513            // Links already carry their target URI; nothing to add.
1514            Some(params)
1515        })
1516        .await;
1517        Ok(resolved.unwrap_or(fallback))
1518    }
1519
1520    async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
1521        guard_async_result("formatting", async move {
1522            let uri = &params.text_document.uri;
1523            let source = self.get_open_text(uri).unwrap_or_default();
1524            Ok(format_document(&source))
1525        })
1526        .await
1527    }
1528
1529    async fn range_formatting(
1530        &self,
1531        params: DocumentRangeFormattingParams,
1532    ) -> Result<Option<Vec<TextEdit>>> {
1533        guard_async_result("range_formatting", async move {
1534            let uri = &params.text_document.uri;
1535            let source = self.get_open_text(uri).unwrap_or_default();
1536            Ok(format_range(&source, params.range))
1537        })
1538        .await
1539    }
1540
1541    async fn on_type_formatting(
1542        &self,
1543        params: DocumentOnTypeFormattingParams,
1544    ) -> Result<Option<Vec<TextEdit>>> {
1545        guard_async_result("on_type_formatting", async move {
1546            let uri = &params.text_document_position.text_document.uri;
1547            let source = self.get_open_text(uri).unwrap_or_default();
1548            let edits = on_type_format(
1549                &source,
1550                params.text_document_position.position,
1551                &params.ch,
1552                &params.options,
1553            );
1554            Ok(if edits.is_empty() { None } else { Some(edits) })
1555        })
1556        .await
1557    }
1558
1559    async fn execute_command(
1560        &self,
1561        params: ExecuteCommandParams,
1562    ) -> Result<Option<serde_json::Value>> {
1563        guard_async_result("execute_command", async move {
1564            match params.command.as_str() {
1565                "php-lsp.runTest" => {
1566                    // Arguments: [uri_string, "ClassName::methodName"]
1567                    let file_uri = params
1568                        .arguments
1569                        .first()
1570                        .and_then(|v| v.as_str())
1571                        .and_then(|s| Url::parse(s).ok());
1572                    let filter = params
1573                        .arguments
1574                        .get(1)
1575                        .and_then(|v| v.as_str())
1576                        .unwrap_or("")
1577                        .to_string();
1578
1579                    let root = self.root_paths.load().first().cloned();
1580                    let client = self.client.clone();
1581
1582                    tokio::spawn(async move {
1583                        run_phpunit(&client, &filter, root.as_deref(), file_uri.as_ref()).await;
1584                    });
1585
1586                    Ok(None)
1587                }
1588                _ => Ok(None),
1589            }
1590        })
1591        .await
1592    }
1593
1594    async fn will_rename_files(&self, params: RenameFilesParams) -> Result<Option<WorkspaceEdit>> {
1595        guard_async_result("will_rename_files", async move {
1596            self.handle_will_rename_files(params).await
1597        })
1598        .await
1599    }
1600
1601    async fn did_rename_files(&self, params: RenameFilesParams) {
1602        guard_async("did_rename_files", async move {
1603            self.handle_did_rename_files(params).await
1604        })
1605        .await
1606    }
1607
1608    async fn will_create_files(&self, params: CreateFilesParams) -> Result<Option<WorkspaceEdit>> {
1609        guard_async_result("will_create_files", async move {
1610            self.handle_will_create_files(params).await
1611        })
1612        .await
1613    }
1614
1615    async fn did_create_files(&self, params: CreateFilesParams) {
1616        guard_async("did_create_files", async move {
1617            self.handle_did_create_files(params).await
1618        })
1619        .await
1620    }
1621
1622    /// Before a file is deleted, return workspace edits that remove every
1623    /// `use` import referencing its PSR-4 class name.
1624    async fn will_delete_files(&self, params: DeleteFilesParams) -> Result<Option<WorkspaceEdit>> {
1625        guard_async_result("will_delete_files", async move {
1626            self.handle_will_delete_files(params).await
1627        })
1628        .await
1629    }
1630
1631    async fn did_delete_files(&self, params: DeleteFilesParams) {
1632        guard_async("did_delete_files", async move {
1633            self.handle_did_delete_files(params).await
1634        })
1635        .await
1636    }
1637
1638    async fn moniker(&self, params: MonikerParams) -> Result<Option<Vec<Moniker>>> {
1639        guard_async_result("moniker", async move {
1640            let uri = &params.text_document_position_params.text_document.uri;
1641            let position = params.text_document_position_params.position;
1642            let source = self.get_open_text(uri).unwrap_or_default();
1643            let doc = match self.get_doc(uri) {
1644                Some(d) => d,
1645                None => return Ok(None),
1646            };
1647            let imports = self.file_imports(uri);
1648            Ok(moniker_at(&source, &doc, position, &imports).map(|m| vec![m]))
1649        })
1650        .await
1651    }
1652
1653    async fn inline_value(&self, params: InlineValueParams) -> Result<Option<Vec<InlineValue>>> {
1654        guard_async_result("inline_value", async move {
1655            let uri = &params.text_document.uri;
1656            let source = self.get_open_text(uri).unwrap_or_default();
1657            let values = inline_values_in_range(&source, params.range);
1658            Ok(if values.is_empty() {
1659                None
1660            } else {
1661                Some(values)
1662            })
1663        })
1664        .await
1665    }
1666
1667    async fn diagnostic(
1668        &self,
1669        params: DocumentDiagnosticParams,
1670    ) -> Result<DocumentDiagnosticReportResult> {
1671        let report = guard_async("diagnostic", async move {
1672            Some(self.handle_diagnostic(params).await)
1673        })
1674        .await;
1675        match report {
1676            Some(r) => r,
1677            None => Ok(DocumentDiagnosticReportResult::Report(
1678                DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport::default()),
1679            )),
1680        }
1681    }
1682
1683    async fn workspace_diagnostic(
1684        &self,
1685        params: WorkspaceDiagnosticParams,
1686    ) -> Result<WorkspaceDiagnosticReportResult> {
1687        let report = guard_async("workspace_diagnostic", async move {
1688            Some(self.handle_workspace_diagnostic(params).await)
1689        })
1690        .await;
1691        match report {
1692            Some(r) => r,
1693            None => Ok(WorkspaceDiagnosticReportResult::Report(
1694                WorkspaceDiagnosticReport::default(),
1695            )),
1696        }
1697    }
1698
1699    async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
1700        guard_async_result("code_action", async move {
1701            self.handle_code_action(params).await
1702        })
1703        .await
1704    }
1705
1706    async fn code_action_resolve(&self, item: CodeAction) -> Result<CodeAction> {
1707        guard_async_result("code_action_resolve", async move {
1708            self.handle_code_action_resolve(item).await
1709        })
1710        .await
1711    }
1712}
1713
1714/// Convert a mir subtype/implementation hit — file path plus a name range in
1715/// mir coordinates (1-based line, 0-based char columns) — to an LSP Location.
1716fn subtype_site_to_location(file: &str, range: &mir_analyzer::Range) -> Option<Location> {
1717    let uri = Url::parse(file).ok()?;
1718    let line = range.start.line.saturating_sub(1);
1719    Some(Location {
1720        uri,
1721        range: tower_lsp::lsp_types::Range {
1722            start: tower_lsp::lsp_types::Position {
1723                line,
1724                character: range.start.column,
1725            },
1726            end: tower_lsp::lsp_types::Position {
1727                line,
1728                character: range.end.column,
1729            },
1730        },
1731    })
1732}