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, outgoing_calls_indexed, prepare_call_hierarchy_indexed,
26};
27use crate::navigation::declaration::{goto_declaration, goto_declaration_from_index};
28use crate::navigation::implementation::{
29    find_implementations, find_implementations_mir_backed, find_method_implementations_mir_backed,
30};
31use crate::navigation::moniker::moniker_at;
32use crate::navigation::type_definition::{
33    goto_type_definition_exact, goto_type_definition_from_index_exact,
34    goto_type_definition_from_index_short_name_fallback, goto_type_definition_short_name_fallback,
35};
36use crate::navigation::type_hierarchy::{
37    prepare_type_hierarchy_from_workspace, subtypes_of_mir_backed, supertypes_of_from_workspace,
38};
39
40use crate::analysis::code_lens::code_lenses;
41use crate::analysis::diagnostics::{diagnostics_from_doc, parse_document, parse_document_no_diags};
42use crate::analysis::document_highlight::document_highlights;
43use crate::analysis::inlay_hints::inlay_hints;
44use crate::analysis::inline_value::inline_values_in_range;
45use crate::analysis::semantic_tokens::{
46    compute_token_delta, semantic_tokens, semantic_tokens_range, token_hash,
47};
48
49use crate::editing::document_link::document_links;
50use crate::editing::folding::folding_ranges;
51use crate::editing::formatting::{format_document, format_range};
52use crate::editing::on_type_format::on_type_format;
53use crate::editing::rename::{prepare_rename, rename_property, rename_variable, rename_with_kind};
54use crate::editing::selection_range::selection_ranges;
55use crate::editing::signature_help::signature_help;
56
57use super::helpers::{
58    cursor_is_on_property_decl, is_after_arrow, promoted_property_at_cursor, run_phpunit,
59    symbol_kind_at,
60};
61use super::{Backend, publish_with_dependents};
62
63/// Idle time after the last edit before the background analysis re-warm runs.
64/// Long enough that a typing burst triggers one sweep, not one per pause;
65/// short enough that references asked "shortly after editing" are warm again.
66const WARM_RESWEEP_IDLE_MS: u64 = 1_000;
67
68#[async_trait]
69impl LanguageServer for Backend {
70    async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
71        self.handle_initialize(params).await
72    }
73
74    async fn initialized(&self, _params: InitializedParams) {
75        guard_async("initialized", async move {
76            self.handle_initialized(_params).await
77        })
78        .await
79    }
80
81    async fn did_change_configuration(&self, _params: DidChangeConfigurationParams) {
82        guard_async("did_change_configuration", async move {
83            // Pull the current configuration from the client rather than parsing the
84            // (often-null) params.settings, which not all clients populate.
85            let items = vec![ConfigurationItem {
86                scope_uri: None,
87                section: Some("php-lsp".to_string()),
88            }];
89            if let Ok(values) = self.client.configuration(items).await
90                && let Some(value) = values.into_iter().next()
91            {
92                let roots = self.root_paths.load_full();
93
94                // Re-read .php-lsp.json so a user who edits the file and then
95                // triggers a configuration reload picks up the latest values.
96                let file_cfg = crate::lang::autoload::load_project_config_json(&roots);
97
98                if let Some(ver) = value.get("phpVersion").and_then(|v| v.as_str())
99                    && !crate::lang::autoload::is_valid_php_version(ver)
100                {
101                    self.client
102                        .log_message(
103                            tower_lsp::lsp_types::MessageType::WARNING,
104                            format!(
105                                "php-lsp: unsupported phpVersion {ver:?} — valid values: {}",
106                                crate::lang::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
107                            ),
108                        )
109                        .await;
110                }
111
112                let file_obj = file_cfg.as_ref().filter(|v| v.is_object());
113                let merged = LspConfig::merge_project_configs(file_obj, Some(&value));
114                let mut cfg = LspConfig::from_value(&merged);
115
116                // Resolve the PHP version and log what was chosen and why.
117                let (ver, source) = self.resolve_php_version(cfg.php_version.as_deref());
118                self.client
119                    .log_message(
120                        tower_lsp::lsp_types::MessageType::INFO,
121                        format!("php-lsp: using PHP {ver} ({source})"),
122                    )
123                    .await;
124                // Clamp unsupported versions to the nearest supported one and warn.
125                let ver = if source != "set by editor"
126                    && !crate::lang::autoload::is_valid_php_version(&ver)
127                {
128                    let clamped = crate::lang::autoload::clamp_php_version(&ver);
129                    self.client
130                        .show_message(
131                            tower_lsp::lsp_types::MessageType::WARNING,
132                            format!(
133                                "php-lsp: detected PHP {ver} is outside the supported range ({}); \
134                             using PHP {clamped} for analysis",
135                                crate::lang::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
136                            ),
137                        )
138                        .await;
139                    clamped.to_string()
140                } else {
141                    ver
142                };
143                cfg.php_version = Some(ver.clone());
144                if let Ok(pv) = ver.parse::<mir_analyzer::PhpVersion>() {
145                    self.docs.set_php_version(pv);
146                }
147                self.config.store(Arc::new(cfg));
148                send_refresh_requests(&self.client).await;
149            }
150        })
151        .await
152    }
153
154    async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
155        guard_async("did_change_workspace_folders", async move {
156            // Remove folders from our tracked roots.
157            {
158                let mut roots = (**self.root_paths.load()).clone();
159                for removed in &params.event.removed {
160                    if let Ok(path) = removed.uri.to_file_path() {
161                        roots.retain(|r| r != &path);
162                    }
163                }
164                self.root_paths.store(Arc::new(roots));
165            }
166
167            // Add new folders and kick off background scans for each.
168            let (exclude_paths, include_paths, max_indexed_files, cache_path) = {
169                let cfg = self.config.load();
170                (
171                    cfg.exclude_paths.clone(),
172                    cfg.include_paths.clone(),
173                    cfg.max_indexed_files,
174                    cfg.cache_path.clone(),
175                )
176            };
177            for added in &params.event.added {
178                if let Ok(path) = added.uri.to_file_path() {
179                    let is_new = {
180                        let mut roots = (**self.root_paths.load()).clone();
181                        if !roots.contains(&path) {
182                            roots.push(path.clone());
183                            self.root_paths.store(Arc::new(roots));
184                            true
185                        } else {
186                            false
187                        }
188                    };
189                    if is_new {
190                        let docs = Arc::clone(&self.docs);
191                        let open_files = self.open_files.clone();
192                        let ex = exclude_paths.clone();
193                        let ip = include_paths.clone();
194                        let path_clone = path.clone();
195                        let client = self.client.clone();
196                        let cp = cache_path.clone();
197                        tokio::spawn(async move {
198                            let cache = if let Some(p) = cp {
199                                Some(crate::index::cache::WorkspaceCache::with_dir(p))
200                            } else {
201                                crate::index::cache::WorkspaceCache::new(&path_clone)
202                            };
203                            scan_workspace(
204                                path_clone,
205                                docs,
206                                open_files,
207                                cache,
208                                &ex,
209                                &ip,
210                                max_indexed_files,
211                                None,
212                            )
213                            .await;
214                            send_refresh_requests(&client).await;
215                        });
216                    }
217                }
218            }
219        })
220        .await
221    }
222
223    async fn shutdown(&self) -> Result<()> {
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 must use the cross-file property rename path.
697                let prop_name = cursor_is_on_property_decl(&source, &doc.program().stmts, position)
698                    .or_else(|| {
699                        promoted_property_at_cursor(&source, &doc.program().stmts, position)
700                    });
701                if let Some(prop_name) = prop_name {
702                    let all_docs = self.docs.all_docs_for_scan();
703                    return Ok(Some(rename_property(
704                        &prop_name,
705                        &params.new_name,
706                        &all_docs,
707                    )));
708                }
709                Ok(Some(rename_variable(
710                    &word,
711                    &params.new_name,
712                    uri,
713                    &doc,
714                    position,
715                )))
716            } else if is_after_arrow(&source, position) {
717                let all_docs = self.docs.all_docs_for_scan();
718                Ok(Some(rename_property(&word, &params.new_name, &all_docs)))
719            } else {
720                let all_docs = self.docs.all_docs_for_scan();
721                let doc_opt = self.get_doc(uri);
722                let target_fqn: Option<String> = doc_opt.as_ref().map(|doc| {
723                    let imports = self.file_imports(uri);
724                    crate::navigation::moniker::resolve_fqn(doc, &word, &imports)
725                });
726                // Class renames need `symbol_kind_at`'s cursor context (not just
727                // FQN resolution, which also succeeds for functions/constants) so
728                // `rename_with_kind` knows to merge `use`-import edits with the
729                // type-hint-aware class walker. Every other kind keeps the
730                // existing general-walker behavior unchanged.
731                let kind = symbol_kind_at(&source, position, &word);
732                Ok(Some(rename_with_kind(
733                    &word,
734                    &params.new_name,
735                    &all_docs,
736                    kind,
737                    target_fqn.as_deref(),
738                )))
739            }
740        })
741        .await
742    }
743
744    async fn signature_help(&self, params: SignatureHelpParams) -> Result<Option<SignatureHelp>> {
745        guard_async_result("signature_help", async move {
746            let uri = &params.text_document_position_params.text_document.uri;
747            let position = params.text_document_position_params.position;
748            let source = self.get_open_text(uri).unwrap_or_default();
749            let doc = match self.get_doc(uri) {
750                Some(d) => d,
751                None => return Ok(None),
752            };
753            let all_indexes = self.docs.all_indexes();
754            Ok(signature_help(&source, &doc, position, &all_indexes))
755        })
756        .await
757    }
758
759    #[tracing::instrument(skip_all)]
760    async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
761        guard_async_result("hover", async move {
762            let uri = &params.text_document_position_params.text_document.uri;
763            let position = params.text_document_position_params.position;
764            let source = self.get_open_text(uri).unwrap_or_default();
765            let doc = match self.get_doc(uri) {
766                Some(d) => d,
767                None => return Ok(None),
768            };
769            let other_docs = self.docs.other_docs(uri, &self.open_urls());
770            let other_maps = self.docs.other_symbol_maps(uri, &self.open_urls());
771            let analysis = self.cached_analysis_async(uri).await;
772            let hover_session = self
773                .docs
774                .analysis_session(self.docs.workspace_php_version());
775            let source_clone = source.clone();
776            let doc_clone = Arc::clone(&doc);
777            let uri_str = uri.to_string();
778            let result = match tokio::task::spawn_blocking(move || {
779                hover_info_with_maps(
780                    &source_clone,
781                    &doc_clone,
782                    analysis.as_deref(),
783                    position,
784                    &other_docs,
785                    &other_maps,
786                    Some(&hover_session),
787                )
788            })
789            .await
790            {
791                Ok(r) => r,
792                Err(e) => {
793                    tracing::warn!("hover panicked for {uri_str}: {e}");
794                    None
795                }
796            };
797            if result.is_some() {
798                return Ok(result);
799            }
800            // Fallback: look up the word in the workspace index so class names in
801            // extends clauses and parameter types resolve even when their defining
802            // file is never opened.  Also try the alias-resolved name so that
803            // `use Foo as Bar` works even when Foo is only in the index.
804            if let Some(word) = crate::text::word_at_position(&source, position) {
805                let wi = self.workspace_index_async().await;
806                // Try the literal word first.
807                if let Some(h) = class_hover_from_index(&word, None, &wi.files) {
808                    return Ok(Some(h));
809                }
810                // Try alias resolution. The resolved FQN disambiguates between
811                // same-named classes in different namespaces (e.g. many
812                // vendored `Factory` classes all aliased to `FactoryContract`).
813                if let Some((resolved, resolved_fqn)) =
814                    crate::hover::resolve_use_alias_fqn(&doc.program().stmts, &word)
815                    && let Some(h) =
816                        class_hover_from_index(&resolved, Some(&resolved_fqn), &wi.files)
817                {
818                    return Ok(Some(h));
819                }
820                // Try static method hover: `ClassName::method(…)`.
821                if let Some(line_text) = source.lines().nth(position.line as usize)
822                    && let Some(class_token) =
823                        extract_static_class_before_cursor(line_text, position.character as usize)
824                {
825                    if let Some(h) = method_hover_from_index(&class_token, &word, &wi.files) {
826                        return Ok(Some(h));
827                    }
828                    if let Some(resolved_class) =
829                        crate::hover::resolve_use_alias(&doc.program().stmts, &class_token)
830                        && let Some(h) = method_hover_from_index(&resolved_class, &word, &wi.files)
831                    {
832                        return Ok(Some(h));
833                    }
834                }
835            }
836            Ok(None)
837        })
838        .await
839    }
840
841    async fn document_symbol(
842        &self,
843        params: DocumentSymbolParams,
844    ) -> Result<Option<DocumentSymbolResponse>> {
845        guard_async_result("document_symbol", async move {
846            let uri = &params.text_document.uri;
847            let doc = match self.get_doc(uri) {
848                Some(d) => d,
849                None => return Ok(None),
850            };
851            Ok(Some(DocumentSymbolResponse::Nested(document_symbols(
852                doc.source(),
853                &doc,
854            ))))
855        })
856        .await
857    }
858
859    async fn folding_range(&self, params: FoldingRangeParams) -> Result<Option<Vec<FoldingRange>>> {
860        guard_async_result("folding_range", async move {
861            let uri = &params.text_document.uri;
862            let doc = match self.get_doc(uri) {
863                Some(d) => d,
864                None => return Ok(None),
865            };
866            let ranges = folding_ranges(doc.source(), &doc);
867            Ok(if ranges.is_empty() {
868                None
869            } else {
870                Some(ranges)
871            })
872        })
873        .await
874    }
875
876    async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
877        guard_async_result("inlay_hint", async move {
878            let uri = &params.text_document.uri;
879            let doc = match self.get_doc(uri) {
880                Some(d) => d,
881                None => return Ok(None),
882            };
883            let analysis = self.cached_analysis_async(uri).await;
884            let wi = self.workspace_index_async().await;
885            let uri_str = uri.to_string();
886            let hints = match tokio::task::spawn_blocking(move || {
887                inlay_hints(
888                    doc.source(),
889                    &doc,
890                    analysis.as_deref(),
891                    params.range,
892                    &wi.files,
893                )
894            })
895            .await
896            {
897                Ok(h) => h,
898                Err(e) => {
899                    tracing::warn!("inlay_hint panicked for {uri_str}: {e}");
900                    Vec::new()
901                }
902            };
903            Ok(Some(hints))
904        })
905        .await
906    }
907
908    async fn inlay_hint_resolve(&self, mut item: InlayHint) -> Result<InlayHint> {
909        let fallback = item.clone();
910        let resolved = guard_async("inlay_hint_resolve", async move {
911            if item.tooltip.is_some() {
912                return Some(item);
913            }
914            let func_name = item
915                .data
916                .as_ref()
917                .and_then(|d| d.get("php_lsp_fn"))
918                .and_then(|v| v.as_str())
919                .map(str::to_string);
920            if let Some(name) = func_name {
921                let all_indexes = self.docs.all_indexes();
922                if let Some(md) = docs_for_symbol_from_index(&name, &all_indexes) {
923                    item.tooltip = Some(InlayHintTooltip::MarkupContent(MarkupContent {
924                        kind: MarkupKind::Markdown,
925                        value: md,
926                    }));
927                }
928            }
929            Some(item)
930        })
931        .await;
932        Ok(resolved.unwrap_or(fallback))
933    }
934
935    async fn symbol(
936        &self,
937        params: WorkspaceSymbolParams,
938    ) -> Result<Option<Vec<SymbolInformation>>> {
939        guard_async_result("symbol", async move {
940            // Phase J: read through the salsa-memoized aggregate so repeated
941            // workspace-symbol queries (every keystroke in the picker) share the
942            // same `Arc` until a file changes.
943            let wi = self.workspace_index_async().await;
944            let query = params.query;
945            let results = match tokio::task::spawn_blocking(move || {
946                workspace_symbols_from_workspace(&query, &wi)
947            })
948            .await
949            {
950                Ok(r) => r,
951                Err(e) => {
952                    tracing::warn!("workspace/symbol panicked: {e}");
953                    Vec::new()
954                }
955            };
956            Ok(Some(results))
957        })
958        .await
959    }
960
961    async fn symbol_resolve(&self, params: WorkspaceSymbol) -> Result<WorkspaceSymbol> {
962        // For resolve, we need the full range from the ParsedDoc of open files.
963        let docs = self.docs.docs_for(&self.open_urls());
964        let fallback = params.clone();
965        let resolved = guard_async("symbol_resolve", async move {
966            Some(resolve_workspace_symbol(params, &docs))
967        })
968        .await;
969        Ok(resolved.unwrap_or(fallback))
970    }
971
972    #[tracing::instrument(skip_all)]
973    async fn semantic_tokens_full(
974        &self,
975        params: SemanticTokensParams,
976    ) -> Result<Option<SemanticTokensResult>> {
977        guard_async_result("semantic_tokens_full", async move {
978            let uri = &params.text_document.uri;
979            let doc = match self.get_doc(uri) {
980                Some(d) => d,
981                None => {
982                    return Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
983                        result_id: None,
984                        data: vec![],
985                    })));
986                }
987            };
988            let tokens = semantic_tokens(doc.source(), &doc);
989            let result_id = token_hash(&tokens);
990            let tokens_arc = Arc::new(tokens);
991            self.docs
992                .store_token_cache(uri, result_id.clone(), Arc::clone(&tokens_arc));
993            let data = Arc::try_unwrap(tokens_arc).unwrap_or_else(|arc| (*arc).clone());
994            Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
995                result_id: Some(result_id),
996                data,
997            })))
998        })
999        .await
1000    }
1001
1002    async fn semantic_tokens_range(
1003        &self,
1004        params: SemanticTokensRangeParams,
1005    ) -> Result<Option<SemanticTokensRangeResult>> {
1006        guard_async_result("semantic_tokens_range", async move {
1007            let uri = &params.text_document.uri;
1008            let doc = match self.get_doc(uri) {
1009                Some(d) => d,
1010                None => {
1011                    return Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
1012                        result_id: None,
1013                        data: vec![],
1014                    })));
1015                }
1016            };
1017            let tokens = semantic_tokens_range(doc.source(), &doc, params.range);
1018            Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
1019                result_id: None,
1020                data: tokens,
1021            })))
1022        })
1023        .await
1024    }
1025
1026    async fn semantic_tokens_full_delta(
1027        &self,
1028        params: SemanticTokensDeltaParams,
1029    ) -> Result<Option<SemanticTokensFullDeltaResult>> {
1030        guard_async_result("semantic_tokens_full_delta", async move {
1031            let uri = &params.text_document.uri;
1032            let doc = match self.get_doc(uri) {
1033                Some(d) => d,
1034                None => return Ok(None),
1035            };
1036
1037            let new_tokens = Arc::new(semantic_tokens(doc.source(), &doc));
1038            let new_result_id = token_hash(&new_tokens);
1039            let prev_id = &params.previous_result_id;
1040
1041            let result = match self.docs.get_token_cache(uri, prev_id) {
1042                Some(old_tokens) => {
1043                    let edits = compute_token_delta(&old_tokens, &new_tokens);
1044                    SemanticTokensFullDeltaResult::TokensDelta(SemanticTokensDelta {
1045                        result_id: Some(new_result_id.clone()),
1046                        edits,
1047                    })
1048                }
1049                // Unknown previous result — fall back to full tokens
1050                None => SemanticTokensFullDeltaResult::Tokens(SemanticTokens {
1051                    result_id: Some(new_result_id.clone()),
1052                    data: (*new_tokens).clone(),
1053                }),
1054            };
1055
1056            self.docs.store_token_cache(uri, new_result_id, new_tokens);
1057            Ok(Some(result))
1058        })
1059        .await
1060    }
1061
1062    async fn selection_range(
1063        &self,
1064        params: SelectionRangeParams,
1065    ) -> Result<Option<Vec<SelectionRange>>> {
1066        guard_async_result("selection_range", async move {
1067            let uri = &params.text_document.uri;
1068            let doc = match self.get_doc(uri) {
1069                Some(d) => d,
1070                None => return Ok(None),
1071            };
1072            let ranges = selection_ranges(&doc, &params.positions);
1073            Ok(if ranges.is_empty() {
1074                None
1075            } else {
1076                Some(ranges)
1077            })
1078        })
1079        .await
1080    }
1081
1082    async fn prepare_call_hierarchy(
1083        &self,
1084        params: CallHierarchyPrepareParams,
1085    ) -> Result<Option<Vec<CallHierarchyItem>>> {
1086        guard_async_result("prepare_call_hierarchy", async move {
1087            let uri = &params.text_document_position_params.text_document.uri;
1088            let position = params.text_document_position_params.position;
1089            let source = self.get_open_text(uri).unwrap_or_default();
1090            let word = match word_at_position(&source, position) {
1091                Some(w) => w,
1092                None => return Ok(None),
1093            };
1094            // O(matches) lookup via the aggregate's `decls_by_name` map instead
1095            // of scanning every workspace doc.
1096            let wi = self.workspace_index_async().await;
1097            let docs = Arc::clone(&self.docs);
1098            let get_doc = move |u: &Url| docs.get_doc_salsa(u);
1099            Ok(prepare_call_hierarchy_indexed(&word, &wi, &get_doc).map(|item| vec![item]))
1100        })
1101        .await
1102    }
1103
1104    async fn incoming_calls(
1105        &self,
1106        params: CallHierarchyIncomingCallsParams,
1107    ) -> Result<Option<Vec<CallHierarchyIncomingCall>>> {
1108        guard_async_result("incoming_calls", async move {
1109            // Genuinely needs every doc (call sites are body-level, not indexed);
1110            // run the workspace scan on the blocking pool.
1111            let docs = Arc::clone(&self.docs);
1112            let item_uri = params.item.uri.to_string();
1113            let item = params.item;
1114            let calls = match tokio::task::spawn_blocking(move || {
1115                let all_docs = docs.all_docs_for_scan();
1116                incoming_calls(&item, &all_docs)
1117            })
1118            .await
1119            {
1120                Ok(c) => c,
1121                Err(e) => {
1122                    tracing::warn!("incoming_calls panicked for {item_uri}: {e}");
1123                    vec![]
1124                }
1125            };
1126            Ok(if calls.is_empty() { None } else { Some(calls) })
1127        })
1128        .await
1129    }
1130
1131    async fn outgoing_calls(
1132        &self,
1133        params: CallHierarchyOutgoingCallsParams,
1134    ) -> Result<Option<Vec<CallHierarchyOutgoingCall>>> {
1135        guard_async_result("outgoing_calls", async move {
1136            // Per-callee declaration lookups go through `decls_by_name` — the old
1137            // path re-scanned the whole workspace once per distinct callee.
1138            let wi = self.workspace_index_async().await;
1139            let docs = Arc::clone(&self.docs);
1140            let item_uri = params.item.uri.to_string();
1141            let item = params.item;
1142            let calls = match tokio::task::spawn_blocking(move || {
1143                let get_doc = |u: &Url| docs.get_doc_salsa(u);
1144                outgoing_calls_indexed(&item, &wi, &get_doc)
1145            })
1146            .await
1147            {
1148                Ok(c) => c,
1149                Err(e) => {
1150                    tracing::warn!("outgoing_calls panicked for {item_uri}: {e}");
1151                    vec![]
1152                }
1153            };
1154            Ok(if calls.is_empty() { None } else { Some(calls) })
1155        })
1156        .await
1157    }
1158
1159    async fn document_highlight(
1160        &self,
1161        params: DocumentHighlightParams,
1162    ) -> Result<Option<Vec<DocumentHighlight>>> {
1163        guard_async_result("document_highlight", async move {
1164            let uri = &params.text_document_position_params.text_document.uri;
1165            let position = params.text_document_position_params.position;
1166            let source = self.get_open_text(uri).unwrap_or_default();
1167            // Stale-tolerant: a highlight fired on every cursor move must never
1168            // spin on the salsa `Cancelled` retry loop while the user types.
1169            let doc = match self.get_doc_stale(uri) {
1170                Some(d) => d,
1171                None => return Ok(None),
1172            };
1173            let uri_str = uri.to_string();
1174            // The AST walk is CPU-bound; keep it off the async runtime worker.
1175            let highlights = match tokio::task::spawn_blocking(move || {
1176                document_highlights(&source, &doc, position)
1177            })
1178            .await
1179            {
1180                Ok(h) => h,
1181                Err(e) => {
1182                    tracing::warn!("document_highlight panicked for {uri_str}: {e}");
1183                    Vec::new()
1184                }
1185            };
1186            Ok(if highlights.is_empty() {
1187                None
1188            } else {
1189                Some(highlights)
1190            })
1191        })
1192        .await
1193    }
1194
1195    async fn linked_editing_range(
1196        &self,
1197        params: LinkedEditingRangeParams,
1198    ) -> Result<Option<LinkedEditingRanges>> {
1199        guard_async_result("linked_editing_range", async move {
1200            self.handle_linked_editing_range(params).await
1201        })
1202        .await
1203    }
1204
1205    async fn goto_implementation(
1206        &self,
1207        params: tower_lsp::lsp_types::request::GotoImplementationParams,
1208    ) -> Result<Option<tower_lsp::lsp_types::request::GotoImplementationResponse>> {
1209        guard_async_result("goto_implementation", async move {
1210            let uri = &params.text_document_position_params.text_document.uri;
1211            let position = params.text_document_position_params.position;
1212            let source = self.get_open_text(uri).unwrap_or_default();
1213            let imports = self.file_imports(uri);
1214            let raw_word = crate::text::word_at_position(&source, position).unwrap_or_default();
1215            let wi = self.workspace_index_async().await;
1216            // `word_at_position` includes `\` as a word character, so the cursor on
1217            // a use-statement import (`use A\B\Foo`) returns the full qualified name.
1218            // Split to recover the short name and treat the rest as the FQN so the
1219            // workspace index lookup (keyed by short name) still finds subtypes.
1220            let (word, mut fqn_owned): (String, Option<String>) = if raw_word.contains('\\') {
1221                let short = raw_word
1222                    .rsplit('\\')
1223                    .next()
1224                    .unwrap_or(&raw_word)
1225                    .to_string();
1226                let full = raw_word.trim_start_matches('\\').to_string();
1227                (short, Some(full))
1228            } else {
1229                let fqn = imports.get(&raw_word).cloned();
1230                (raw_word, fqn)
1231            };
1232            // The cursor may sit on the class/interface's own declaration name
1233            // rather than a usage — there is no `use` import for a symbol to
1234            // reference itself, so `imports` above finds nothing. Resolve the
1235            // FQN from the workspace index instead, scoped to this file, so a
1236            // same-named class declared elsewhere can't be mismatched onto it.
1237            if fqn_owned.is_none() {
1238                fqn_owned = wi
1239                    .classes_by_name
1240                    .get(&word)
1241                    .and_then(|refs| {
1242                        refs.iter()
1243                            .filter_map(|r| wi.at(*r))
1244                            .find(|(u, _)| *u == uri)
1245                    })
1246                    .map(|(_, cls)| cls.fqn.as_ref().to_string());
1247            }
1248            let fqn = fqn_owned.as_deref();
1249            // First pass: open-file ParsedDocs give accurate character positions.
1250            let open_docs = self.docs.docs_for(&self.open_urls());
1251            let mut locs = find_implementations(&word, fqn, &open_docs);
1252            // Second pass: mir-backed subtype graph (fixes aliased/FQN extends),
1253            // falling back to the raw-name subtypes_of map when mir is cold.
1254            if locs.is_empty() {
1255                let fqn_for_mir = fqn.unwrap_or(&word).to_string();
1256                let subtype_urls = self.docs.class_subtype_urls(&fqn_for_mir);
1257                locs = find_implementations_mir_backed(&word, fqn, &wi, &subtype_urls);
1258            }
1259            // Third pass: treat word as a method name inside its declaring
1260            // class/interface — returns concrete overrides in every subtype.
1261            // Handles cursor on an interface method like `Factory::guard()`.
1262            if locs.is_empty()
1263                && let Some(doc) = self.get_doc(uri)
1264                && let Some(enclosing) =
1265                    crate::types::type_map::enclosing_class_at(&source, &doc, position)
1266            {
1267                // Resolve the enclosing class's FQN for mir's subtype graph.
1268                let enclosing_fqn = wi
1269                    .classes_by_name
1270                    .get(&enclosing)
1271                    .and_then(|refs| refs.first())
1272                    .and_then(|r| wi.at(*r))
1273                    .map(|(_, cls)| cls.fqn.as_ref().to_string());
1274                let method_subtype_urls = enclosing_fqn
1275                    .as_deref()
1276                    .map(|f| self.docs.class_subtype_urls(f))
1277                    .unwrap_or_default();
1278                locs = find_method_implementations_mir_backed(
1279                    &word,
1280                    &enclosing,
1281                    &wi,
1282                    &method_subtype_urls,
1283                );
1284            }
1285            if locs.is_empty() {
1286                Ok(None)
1287            } else {
1288                Ok(Some(GotoDefinitionResponse::Array(locs)))
1289            }
1290        })
1291        .await
1292    }
1293
1294    async fn goto_declaration(
1295        &self,
1296        params: tower_lsp::lsp_types::request::GotoDeclarationParams,
1297    ) -> Result<Option<tower_lsp::lsp_types::request::GotoDeclarationResponse>> {
1298        guard_async_result("goto_declaration", async move {
1299            let uri = &params.text_document_position_params.text_document.uri;
1300            let position = params.text_document_position_params.position;
1301            let source = self.get_open_text(uri).unwrap_or_default();
1302            // First pass: open-file ParsedDocs give accurate character positions.
1303            let open_docs = self.docs.docs_for(&self.open_urls());
1304            if let Some(loc) = goto_declaration(&source, &open_docs, position) {
1305                return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1306            }
1307            // Second pass: background files via FileIndex (line-only positions).
1308            let all_indexes = self.docs.all_indexes();
1309            Ok(goto_declaration_from_index(&source, &all_indexes, position)
1310                .map(GotoDefinitionResponse::Scalar))
1311        })
1312        .await
1313    }
1314
1315    async fn goto_type_definition(
1316        &self,
1317        params: tower_lsp::lsp_types::request::GotoTypeDefinitionParams,
1318    ) -> Result<Option<tower_lsp::lsp_types::request::GotoTypeDefinitionResponse>> {
1319        guard_async_result("goto_type_definition", async move {
1320            let uri = &params.text_document_position_params.text_document.uri;
1321            let position = params.text_document_position_params.position;
1322            let source = self.get_open_text(uri).unwrap_or_default();
1323            let doc = match self.get_doc(uri) {
1324                Some(d) => d,
1325                None => return Ok(None),
1326            };
1327            let analysis = self.cached_analysis_async(uri).await;
1328            let open_docs = self.docs.docs_for(&self.open_urls());
1329            let all_indexes = self.docs.all_indexes();
1330
1331            // Exact FQN/namespace matches (open docs, then background index)
1332            // outrank *either* source's short-name fallback, so an unrelated
1333            // same-short-named class in another open file can never preempt
1334            // a correctly-namespaced match that only lives in the index.
1335            let mut results = goto_type_definition_exact(
1336                &source,
1337                &doc,
1338                analysis.as_deref(),
1339                &open_docs,
1340                position,
1341            );
1342            if results.is_empty() {
1343                results = goto_type_definition_from_index_exact(
1344                    &source,
1345                    &doc,
1346                    analysis.as_deref(),
1347                    &all_indexes,
1348                    position,
1349                );
1350            }
1351            if results.is_empty() {
1352                results = goto_type_definition_short_name_fallback(
1353                    &source,
1354                    &doc,
1355                    analysis.as_deref(),
1356                    &open_docs,
1357                    position,
1358                );
1359            }
1360            if results.is_empty() {
1361                results = goto_type_definition_from_index_short_name_fallback(
1362                    &source,
1363                    &doc,
1364                    analysis.as_deref(),
1365                    &all_indexes,
1366                    position,
1367                );
1368            }
1369
1370            // Format response: scalar for single result, array for multiple, none for empty
1371            let response = match results.len() {
1372                0 => None,
1373                1 => Some(GotoDefinitionResponse::Scalar(
1374                    results.into_iter().next().unwrap(),
1375                )),
1376                _ => Some(GotoDefinitionResponse::Array(results)),
1377            };
1378            Ok(response)
1379        })
1380        .await
1381    }
1382
1383    async fn prepare_type_hierarchy(
1384        &self,
1385        params: TypeHierarchyPrepareParams,
1386    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1387        guard_async_result("prepare_type_hierarchy", async move {
1388            let uri = &params.text_document_position_params.text_document.uri;
1389            let position = params.text_document_position_params.position;
1390            let source = self.get_open_text(uri).unwrap_or_default();
1391            // Phase J: use the salsa-memoized aggregate's `classes_by_name` map.
1392            let wi = self.workspace_index_async().await;
1393            Ok(
1394                prepare_type_hierarchy_from_workspace(&source, uri, &wi, position)
1395                    .map(|item| vec![item]),
1396            )
1397        })
1398        .await
1399    }
1400
1401    async fn supertypes(
1402        &self,
1403        params: TypeHierarchySupertypesParams,
1404    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1405        guard_async_result("supertypes", async move {
1406            // Phase J: resolve parents via the aggregate's `classes_by_name` map.
1407            // Pre-load any direct vendor supertypes via PSR-4 so they appear in the
1408            // workspace index before the lookup runs.
1409            let wi = self.workspace_index_async().await;
1410            let loaded_new = self
1411                .ensure_direct_supertypes_loaded(&params.item.name, &wi)
1412                .await;
1413            let wi = if loaded_new {
1414                self.workspace_index_async().await
1415            } else {
1416                wi
1417            };
1418            let result = supertypes_of_from_workspace(&params.item, &wi);
1419            Ok(if result.is_empty() {
1420                None
1421            } else {
1422                Some(result)
1423            })
1424        })
1425        .await
1426    }
1427
1428    async fn subtypes(
1429        &self,
1430        params: TypeHierarchySubtypesParams,
1431    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1432        guard_async_result("subtypes", async move {
1433            let wi = self.workspace_index_async().await;
1434            // Resolve the item's FQCN for mir's subtype graph. `classes_by_name`
1435            // is keyed by short name only, so a name shared by many classes
1436            // across the workspace (e.g. Laravel's ~16 `Factory` classes) needs
1437            // `params.item.uri` to pick the right one instead of an arbitrary
1438            // first match.
1439            let item_fqn = wi
1440                .classes_by_name
1441                .get(&params.item.name)
1442                .and_then(|refs| {
1443                    refs.iter()
1444                        .filter_map(|r| wi.at(*r))
1445                        .find(|(u, _)| **u == params.item.uri)
1446                        .or_else(|| refs.first().and_then(|r| wi.at(*r)))
1447                })
1448                .map(|(_, cls)| cls.fqn.as_ref().to_string());
1449            let subtype_urls = item_fqn
1450                .as_deref()
1451                .map(|f| self.docs.class_subtype_urls(f))
1452                .unwrap_or_default();
1453            let result =
1454                subtypes_of_mir_backed(&params.item, item_fqn.as_deref(), &wi, &subtype_urls);
1455            Ok(if result.is_empty() {
1456                None
1457            } else {
1458                Some(result)
1459            })
1460        })
1461        .await
1462    }
1463
1464    async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
1465        guard_async_result("code_lens", async move {
1466            let uri = &params.text_document.uri;
1467            let doc = match self.get_doc(uri) {
1468                Some(d) => d,
1469                None => return Ok(None),
1470            };
1471            // Reference-count lenses scan every doc per declaration; run the
1472            // whole computation on the blocking pool. A write during computation
1473            // (write_rev advances) cancels the sweep early — stale lens counts
1474            // are useless and the editor will request fresh ones after the edit.
1475            let docs = Arc::clone(&self.docs);
1476            let docs_cancel = Arc::clone(&self.docs);
1477            let uri_owned = uri.clone();
1478            let uri_str = uri.to_string();
1479            let lenses = match tokio::task::spawn_blocking(move || {
1480                // Pause the background scan and snapshot a settled revision so
1481                // only a genuine user edit cancels the sweep.
1482                let (_interactive, cancel_rev) = docs.settled_write_rev_guard();
1483                let all_docs = docs.all_docs_for_scan();
1484                code_lenses(&uri_owned, &doc, &all_docs, move || {
1485                    docs_cancel.write_rev() != cancel_rev
1486                })
1487            })
1488            .await
1489            {
1490                Ok(l) => l,
1491                Err(e) => {
1492                    tracing::warn!("code_lens panicked for {uri_str}: {e}");
1493                    vec![]
1494                }
1495            };
1496            Ok(if lenses.is_empty() {
1497                None
1498            } else {
1499                Some(lenses)
1500            })
1501        })
1502        .await
1503    }
1504
1505    async fn code_lens_resolve(&self, params: CodeLens) -> Result<CodeLens> {
1506        let fallback = params.clone();
1507        let resolved = guard_async("code_lens_resolve", async move {
1508            // Lenses are fully populated by code_lens; nothing to add.
1509            Some(params)
1510        })
1511        .await;
1512        Ok(resolved.unwrap_or(fallback))
1513    }
1514
1515    async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
1516        guard_async_result("document_link", async move {
1517            let uri = &params.text_document.uri;
1518            let doc = match self.get_doc(uri) {
1519                Some(d) => d,
1520                None => return Ok(None),
1521            };
1522            let links = document_links(uri, &doc, doc.source());
1523            Ok(if links.is_empty() { None } else { Some(links) })
1524        })
1525        .await
1526    }
1527
1528    async fn document_link_resolve(&self, params: DocumentLink) -> Result<DocumentLink> {
1529        let fallback = params.clone();
1530        let resolved = guard_async("document_link_resolve", async move {
1531            // Links already carry their target URI; nothing to add.
1532            Some(params)
1533        })
1534        .await;
1535        Ok(resolved.unwrap_or(fallback))
1536    }
1537
1538    async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
1539        guard_async_result("formatting", async move {
1540            let uri = &params.text_document.uri;
1541            let source = self.get_open_text(uri).unwrap_or_default();
1542            Ok(format_document(&source))
1543        })
1544        .await
1545    }
1546
1547    async fn range_formatting(
1548        &self,
1549        params: DocumentRangeFormattingParams,
1550    ) -> Result<Option<Vec<TextEdit>>> {
1551        guard_async_result("range_formatting", async move {
1552            let uri = &params.text_document.uri;
1553            let source = self.get_open_text(uri).unwrap_or_default();
1554            Ok(format_range(&source, params.range))
1555        })
1556        .await
1557    }
1558
1559    async fn on_type_formatting(
1560        &self,
1561        params: DocumentOnTypeFormattingParams,
1562    ) -> Result<Option<Vec<TextEdit>>> {
1563        guard_async_result("on_type_formatting", async move {
1564            let uri = &params.text_document_position.text_document.uri;
1565            let source = self.get_open_text(uri).unwrap_or_default();
1566            let edits = on_type_format(
1567                &source,
1568                params.text_document_position.position,
1569                &params.ch,
1570                &params.options,
1571            );
1572            Ok(if edits.is_empty() { None } else { Some(edits) })
1573        })
1574        .await
1575    }
1576
1577    async fn execute_command(
1578        &self,
1579        params: ExecuteCommandParams,
1580    ) -> Result<Option<serde_json::Value>> {
1581        guard_async_result("execute_command", async move {
1582            match params.command.as_str() {
1583                "php-lsp.runTest" => {
1584                    // Arguments: [uri_string, "ClassName::methodName"]
1585                    let file_uri = params
1586                        .arguments
1587                        .first()
1588                        .and_then(|v| v.as_str())
1589                        .and_then(|s| Url::parse(s).ok());
1590                    let filter = params
1591                        .arguments
1592                        .get(1)
1593                        .and_then(|v| v.as_str())
1594                        .unwrap_or("")
1595                        .to_string();
1596
1597                    let root = self.root_paths.load().first().cloned();
1598                    let client = self.client.clone();
1599
1600                    tokio::spawn(async move {
1601                        run_phpunit(&client, &filter, root.as_deref(), file_uri.as_ref()).await;
1602                    });
1603
1604                    Ok(None)
1605                }
1606                _ => Ok(None),
1607            }
1608        })
1609        .await
1610    }
1611
1612    async fn will_rename_files(&self, params: RenameFilesParams) -> Result<Option<WorkspaceEdit>> {
1613        guard_async_result("will_rename_files", async move {
1614            self.handle_will_rename_files(params).await
1615        })
1616        .await
1617    }
1618
1619    async fn did_rename_files(&self, params: RenameFilesParams) {
1620        guard_async("did_rename_files", async move {
1621            self.handle_did_rename_files(params).await
1622        })
1623        .await
1624    }
1625
1626    async fn will_create_files(&self, params: CreateFilesParams) -> Result<Option<WorkspaceEdit>> {
1627        guard_async_result("will_create_files", async move {
1628            self.handle_will_create_files(params).await
1629        })
1630        .await
1631    }
1632
1633    async fn did_create_files(&self, params: CreateFilesParams) {
1634        guard_async("did_create_files", async move {
1635            self.handle_did_create_files(params).await
1636        })
1637        .await
1638    }
1639
1640    /// Before a file is deleted, return workspace edits that remove every
1641    /// `use` import referencing its PSR-4 class name.
1642    async fn will_delete_files(&self, params: DeleteFilesParams) -> Result<Option<WorkspaceEdit>> {
1643        guard_async_result("will_delete_files", async move {
1644            self.handle_will_delete_files(params).await
1645        })
1646        .await
1647    }
1648
1649    async fn did_delete_files(&self, params: DeleteFilesParams) {
1650        guard_async("did_delete_files", async move {
1651            self.handle_did_delete_files(params).await
1652        })
1653        .await
1654    }
1655
1656    async fn moniker(&self, params: MonikerParams) -> Result<Option<Vec<Moniker>>> {
1657        guard_async_result("moniker", async move {
1658            let uri = &params.text_document_position_params.text_document.uri;
1659            let position = params.text_document_position_params.position;
1660            let source = self.get_open_text(uri).unwrap_or_default();
1661            let doc = match self.get_doc(uri) {
1662                Some(d) => d,
1663                None => return Ok(None),
1664            };
1665            let imports = self.file_imports(uri);
1666            Ok(moniker_at(&source, &doc, position, &imports).map(|m| vec![m]))
1667        })
1668        .await
1669    }
1670
1671    async fn inline_value(&self, params: InlineValueParams) -> Result<Option<Vec<InlineValue>>> {
1672        guard_async_result("inline_value", async move {
1673            let uri = &params.text_document.uri;
1674            let source = self.get_open_text(uri).unwrap_or_default();
1675            let values = inline_values_in_range(&source, params.range);
1676            Ok(if values.is_empty() {
1677                None
1678            } else {
1679                Some(values)
1680            })
1681        })
1682        .await
1683    }
1684
1685    async fn diagnostic(
1686        &self,
1687        params: DocumentDiagnosticParams,
1688    ) -> Result<DocumentDiagnosticReportResult> {
1689        let report = guard_async("diagnostic", async move {
1690            Some(self.handle_diagnostic(params).await)
1691        })
1692        .await;
1693        match report {
1694            Some(r) => r,
1695            None => Ok(DocumentDiagnosticReportResult::Report(
1696                DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport::default()),
1697            )),
1698        }
1699    }
1700
1701    async fn workspace_diagnostic(
1702        &self,
1703        params: WorkspaceDiagnosticParams,
1704    ) -> Result<WorkspaceDiagnosticReportResult> {
1705        let report = guard_async("workspace_diagnostic", async move {
1706            Some(self.handle_workspace_diagnostic(params).await)
1707        })
1708        .await;
1709        match report {
1710            Some(r) => r,
1711            None => Ok(WorkspaceDiagnosticReportResult::Report(
1712                WorkspaceDiagnosticReport::default(),
1713            )),
1714        }
1715    }
1716
1717    async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
1718        guard_async_result("code_action", async move {
1719            self.handle_code_action(params).await
1720        })
1721        .await
1722    }
1723
1724    async fn code_action_resolve(&self, item: CodeAction) -> Result<CodeAction> {
1725        guard_async_result("code_action_resolve", async move {
1726            self.handle_code_action_resolve(item).await
1727        })
1728        .await
1729    }
1730}