Skip to main content

php_lsp/backend/
server.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use tower_lsp_server::LanguageServer;
5use tower_lsp_server::jsonrpc::Result;
6use tower_lsp_server::ls_types::*;
7
8use super::panic_guard::{guard_async, guard_async_result};
9use crate::analysis::callable_info::{callable_info_for_name, format_declared_params};
10use crate::completion::{CompletionCtx, filtered_completions_at};
11use crate::document::ast::ParsedDoc;
12use crate::document::open_files::compute_open_file_diagnostics;
13use crate::hover::{extract_static_class_before_cursor, hover_info_with_maps};
14use crate::index::file_index::ClassKind;
15use crate::index::workspace_scan::{scan_workspace, send_refresh_requests};
16use crate::lang::config::LspConfig;
17use crate::lang::is_unresolvable_bareword_at;
18use crate::navigation::symbols::{
19    document_symbols, resolve_workspace_symbol, workspace_symbols_from_workspace,
20};
21use crate::text::word_at_position;
22
23use crate::navigation::call_hierarchy::{
24    incoming_calls_indexed, outgoing_calls_via_mir, prepare_call_hierarchy_indexed,
25};
26use crate::navigation::declaration::{goto_declaration, goto_declaration_from_index};
27use crate::navigation::moniker::moniker_at;
28use crate::navigation::type_definition::type_class_fqns;
29use crate::navigation::type_hierarchy::{
30    prepare_type_hierarchy_from_fqn, subtypes_from_sites, supertypes_of_from_workspace,
31};
32
33use crate::analysis::code_lens::{code_lenses, subtype_site_to_location};
34use crate::analysis::diagnostics::{diagnostics_from_doc, parse_document, parse_document_no_diags};
35use crate::analysis::document_highlight::document_highlights;
36use crate::analysis::inlay_hints::inlay_hints;
37use crate::analysis::inline_value::inline_values_in_range;
38use crate::analysis::semantic_tokens::{
39    compute_token_delta, semantic_tokens, semantic_tokens_range, token_hash,
40};
41
42use crate::editing::document_link::document_links;
43use crate::editing::folding::folding_ranges;
44use crate::editing::formatting::{format_document, format_range};
45use crate::editing::on_type_format::on_type_format;
46use crate::editing::rename::{prepare_rename, rename_variable};
47use crate::editing::selection_range::selection_ranges;
48use crate::editing::signature_help::signature_help;
49
50use super::helpers::{cursor_is_on_property_decl, promoted_property_at_cursor, run_phpunit};
51use super::{Backend, publish_with_dependents};
52
53/// Idle time after the last edit before the background analysis re-warm runs.
54/// Long enough that a typing burst triggers one sweep, not one per pause;
55/// short enough that references asked "shortly after editing" are warm again.
56const WARM_RESWEEP_IDLE_MS: u64 = 1_000;
57
58fn normalized_php_fqn(name: &str) -> &str {
59    name.trim_start_matches('\\')
60}
61
62fn is_bare_php_name(name: &str) -> bool {
63    crate::text::fqn_short_name(name) == name
64}
65
66fn class_lookup_fqn(doc: &ParsedDoc, name: &str, imports: &HashMap<String, String>) -> String {
67    if is_bare_php_name(name) {
68        crate::navigation::moniker::resolve_fqn(doc, name, imports)
69    } else {
70        normalized_php_fqn(name).to_string()
71    }
72}
73
74fn inlay_hint_symbol_from_data(data: &serde_json::Value) -> Option<mir_analyzer::Name> {
75    let kind = data.get("php_lsp_symbol_kind")?.as_str()?;
76    let name = data.get("php_lsp_fn")?.as_str()?;
77    match kind {
78        "function" => Some(mir_analyzer::Name::function(name)),
79        "method" => {
80            let class = data.get("php_lsp_class")?.as_str()?;
81            Some(mir_analyzer::Name::method(class, name))
82        }
83        "class" => Some(mir_analyzer::Name::class(name)),
84        _ => None,
85    }
86}
87
88fn completion_symbol_from_item(
89    name: &str,
90    kind: Option<CompletionItemKind>,
91    class_hint: Option<&str>,
92) -> Option<mir_analyzer::Name> {
93    if let Some(class_name) = class_hint {
94        return Some(mir_analyzer::Name::method(class_name, name));
95    }
96    match kind {
97        Some(CompletionItemKind::FUNCTION) => Some(mir_analyzer::Name::function(name)),
98        Some(CompletionItemKind::CLASS)
99        | Some(CompletionItemKind::INTERFACE)
100        | Some(CompletionItemKind::ENUM) => Some(mir_analyzer::Name::class(name)),
101        _ => None,
102    }
103}
104
105fn hover_markup(value: String) -> Hover {
106    Hover {
107        contents: HoverContents::Markup(MarkupContent {
108            kind: MarkupKind::Markdown,
109            value,
110        }),
111        range: None,
112    }
113}
114
115fn hover_display_class_name(snapshot: &mir_analyzer::AnalysisSnapshot, name: &str) -> String {
116    let normalized = normalized_php_fqn(name);
117    if !is_bare_php_name(normalized) {
118        return normalized.to_string();
119    }
120    if let Ok(true) = snapshot.is_builtin_class(normalized) {
121        return format!("\\{normalized}");
122    }
123    normalized.to_string()
124}
125
126fn class_hover_for_fqcn(session: &mir_analyzer::AnalysisSnapshot, fqcn: &str) -> Option<Hover> {
127    let class = {
128        let db = session.db();
129        let here = mir_analyzer::db::Fqcn::from_str(db, fqcn);
130        mir_analyzer::db::find_class_like(db, here)?
131    };
132    let sig = match &class {
133        mir_analyzer::db::ClassLike::Class(c) => {
134            let mut sig = if c.is_abstract {
135                format!("abstract class {}", c.short_name)
136            } else if c.is_readonly {
137                format!("readonly class {}", c.short_name)
138            } else if c.is_final {
139                format!("final class {}", c.short_name)
140            } else {
141                format!("class {}", c.short_name)
142            };
143            if let Some(parent) = &c.parent {
144                sig.push_str(&format!(
145                    " extends {}",
146                    hover_display_class_name(session, parent)
147                ));
148            }
149            if !c.interfaces.is_empty() {
150                let list: Vec<String> = c
151                    .interfaces
152                    .iter()
153                    .map(|s| hover_display_class_name(session, s))
154                    .collect();
155                sig.push_str(&format!(" implements {}", list.join(", ")));
156            }
157            sig
158        }
159        mir_analyzer::db::ClassLike::Interface(i) => {
160            let mut sig = format!("interface {}", i.short_name);
161            if !i.extends.is_empty() {
162                let list: Vec<String> = i
163                    .extends
164                    .iter()
165                    .map(|s| hover_display_class_name(session, s))
166                    .collect();
167                sig.push_str(&format!(" extends {}", list.join(", ")));
168            }
169            sig
170        }
171        mir_analyzer::db::ClassLike::Trait(t) => format!("trait {}", t.short_name),
172        mir_analyzer::db::ClassLike::Enum(e) => {
173            let mut sig = if let Some(scalar) = &e.scalar_type {
174                format!("enum {}: {}", e.short_name, scalar)
175            } else {
176                format!("enum {}", e.short_name)
177            };
178            if !e.interfaces.is_empty() {
179                let list: Vec<String> = e
180                    .interfaces
181                    .iter()
182                    .map(|s| hover_display_class_name(session, s))
183                    .collect();
184                sig.push_str(&format!(" implements {}", list.join(", ")));
185            }
186            sig
187        }
188    };
189    Some(hover_markup(crate::hover::formatting::wrap_php(&sig)))
190}
191
192fn method_hover_for_fqcn(
193    session: &mir_analyzer::AnalysisSnapshot,
194    fqcn: &str,
195    method_name: &str,
196) -> Option<Hover> {
197    let db = session.db();
198    let (_, m) = mir_analyzer::db::find_method_in_chain(
199        db,
200        mir_analyzer::db::Fqcn::from_str(db, fqcn),
201        method_name,
202    )?;
203    let ret = m
204        .return_type
205        .as_deref()
206        .or(m.inferred_return_type.as_deref())
207        .map(|r| format!(": {}", r))
208        .unwrap_or_default();
209    let sig = format!(
210        "{}::{}({}){}",
211        crate::text::fqn_short_name(fqcn),
212        m.name,
213        format_declared_params(&m.params),
214        ret
215    );
216    let mut value = crate::hover::formatting::wrap_php(&sig);
217    if let Some(docstring) = m.docstring.as_deref().filter(|s| !s.is_empty()) {
218        value.push_str("\n\n---\n\n");
219        value.push_str(docstring);
220    }
221    Some(hover_markup(value))
222}
223
224fn callable_signature_for_symbol(
225    db: &mir_analyzer::db::MirDbStorage,
226    symbol: &mir_analyzer::Name,
227) -> Option<(String, Option<String>)> {
228    let info = callable_info_for_name(db, symbol)?;
229    match symbol {
230        mir_analyzer::Name::Function(fqn) => {
231            let short = fqn.rsplit('\\').next().unwrap_or(fqn.as_ref());
232            if mir_analyzer::is_builtin_function(short) {
233                return None;
234            }
235            let ret = info
236                .return_type
237                .as_deref()
238                .map(|ty| format!(": {ty}"))
239                .unwrap_or_default();
240            Some((
241                format!("function {}({}){}", short, info.params, ret),
242                info.documentation,
243            ))
244        }
245        mir_analyzer::Name::Method { class: _, name } => {
246            let ret = info
247                .return_type
248                .as_deref()
249                .map(|ty| format!(": {ty}"))
250                .unwrap_or_default();
251            Some((
252                format!("function {}({}){}", name, info.params, ret),
253                info.documentation,
254            ))
255        }
256        mir_analyzer::Name::Class(_) => Some((
257            format!("function __construct({})", info.params),
258            info.documentation,
259        )),
260        _ => None,
261    }
262}
263
264fn inlay_hint_tooltip_for_symbol(
265    db: &mir_analyzer::db::MirDbStorage,
266    symbol: &mir_analyzer::Name,
267) -> Option<String> {
268    let (signature, docstring) = callable_signature_for_symbol(db, symbol)?;
269    let mut value = format!("```php\n{signature}\n```");
270    if let Some(docstring) = docstring.filter(|s| !s.is_empty()) {
271        value.push_str("\n\n---\n\n");
272        value.push_str(&docstring);
273    }
274    Some(value)
275}
276
277fn completion_signature_and_docstring_for_symbol(
278    db: &mir_analyzer::db::MirDbStorage,
279    symbol: &mir_analyzer::Name,
280) -> (Option<String>, Option<String>) {
281    if let Some((signature, docstring)) = callable_signature_for_symbol(db, symbol) {
282        return (Some(signature), docstring.filter(|s| !s.is_empty()));
283    }
284    if let mir_analyzer::Name::Function(fqn) = symbol {
285        let short = fqn.rsplit('\\').next().unwrap_or(fqn.as_ref());
286        if mir_analyzer::is_builtin_function(short) {
287            return (
288                None,
289                Some(format!(
290                    "```php\nfunction {}()\n```\n\n[php.net documentation]({})",
291                    short,
292                    crate::lang::php_names::php_doc_url(short)
293                )),
294            );
295        }
296    }
297    (None, None)
298}
299
300impl LanguageServer for Backend {
301    async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
302        self.handle_initialize(params).await
303    }
304
305    async fn initialized(&self, _params: InitializedParams) {
306        guard_async("initialized", async move {
307            self.handle_initialized(_params).await
308        })
309        .await
310    }
311
312    async fn did_change_configuration(&self, _params: DidChangeConfigurationParams) {
313        guard_async("did_change_configuration", async move {
314            // Pull the current configuration from the client rather than parsing the
315            // (often-null) params.settings, which not all clients populate.
316            let items = vec![ConfigurationItem {
317                scope_uri: None,
318                section: Some("php-lsp".to_string()),
319            }];
320            if let Ok(values) = self.client.configuration(items).await
321                && let Some(value) = values.into_iter().next()
322            {
323                let roots = self.root_paths.load_full();
324
325                // Re-read .php-lsp.json so a user who edits the file and then
326                // triggers a configuration reload picks up the latest values.
327                let file_cfg = crate::lang::autoload::load_project_config_json(&roots);
328
329                if let Some(ver) = value.get("phpVersion").and_then(|v| v.as_str())
330                    && !crate::lang::autoload::is_valid_php_version(ver)
331                {
332                    self.client
333                        .log_message(
334                            tower_lsp_server::ls_types::MessageType::WARNING,
335                            format!(
336                                "php-lsp: unsupported phpVersion {ver:?} — valid values: {}",
337                                crate::lang::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
338                            ),
339                        )
340                        .await;
341                }
342
343                let file_obj = file_cfg.as_ref().filter(|v| v.is_object());
344                let merged = LspConfig::merge_project_configs(file_obj, Some(&value));
345                let mut cfg = LspConfig::from_value(&merged);
346
347                // Resolve the PHP version and log what was chosen and why.
348                let (ver, source) = self.resolve_php_version(cfg.php_version.as_deref());
349                self.client
350                    .log_message(
351                        tower_lsp_server::ls_types::MessageType::INFO,
352                        format!("php-lsp: using PHP {ver} ({source})"),
353                    )
354                    .await;
355                // Clamp unsupported versions to the nearest supported one and warn.
356                let ver = if source != "set by editor"
357                    && !crate::lang::autoload::is_valid_php_version(&ver)
358                {
359                    let clamped = crate::lang::autoload::clamp_php_version(&ver);
360                    self.client
361                        .show_message(
362                            tower_lsp_server::ls_types::MessageType::WARNING,
363                            format!(
364                                "php-lsp: detected PHP {ver} is outside the supported range ({}); \
365                             using PHP {clamped} for analysis",
366                                crate::lang::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
367                            ),
368                        )
369                        .await;
370                    clamped.to_string()
371                } else {
372                    ver
373                };
374                cfg.php_version = Some(ver.clone());
375                let version_changed = ver
376                    .parse::<mir_analyzer::PhpVersion>()
377                    .is_ok_and(|pv| self.docs.set_php_version(pv));
378                self.config.store(Arc::new(cfg));
379                if version_changed {
380                    // set_php_version's drop_session_scoped_state cleared
381                    // lsp_ws_files and nothing else repopulates it — without
382                    // this, workspace_file_paths() (and everything scoped by
383                    // it: references, rename, ...) silently sees an empty
384                    // workspace until files are individually re-touched. The
385                    // rescan below walks disk files but explicitly skips ones
386                    // already open, so open buffers need re-mirroring too.
387                    self.remirror_open_files();
388                    self.rescan_roots_after_version_change(&roots).await;
389                }
390                send_refresh_requests(&self.client).await;
391            }
392        })
393        .await
394    }
395
396    async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
397        guard_async("did_change_workspace_folders", async move {
398            // Remove folders from our tracked roots.
399            {
400                let mut roots = (**self.root_paths.load()).clone();
401                for removed in &params.event.removed {
402                    if let Some(path) = removed.uri.to_file_path() {
403                        roots.retain(|r| r.as_path() != path.as_ref());
404                    }
405                }
406                self.root_paths.store(Arc::new(roots));
407            }
408
409            // Add new folders and kick off background scans for each.
410            let (exclude_paths, include_paths, max_indexed_files, cache_path) = {
411                let cfg = self.config.load();
412                let mut exclude = cfg.exclude_paths.clone();
413                if !cfg.index_vendor && !exclude.iter().any(|p| p == "vendor" || p == "vendor/") {
414                    exclude.push("vendor/".to_string());
415                }
416                (
417                    exclude,
418                    cfg.include_paths.clone(),
419                    cfg.max_indexed_files,
420                    cfg.cache_path.clone(),
421                )
422            };
423            for added in &params.event.added {
424                if let Some(path) = added.uri.to_file_path().map(|c| c.into_owned()) {
425                    let is_new = {
426                        let mut roots = (**self.root_paths.load()).clone();
427                        if !roots.contains(&path) {
428                            roots.push(path.clone());
429                            self.root_paths.store(Arc::new(roots));
430                            true
431                        } else {
432                            false
433                        }
434                    };
435                    if is_new {
436                        let docs = Arc::clone(&self.docs);
437                        let open_files = self.open_files.clone();
438                        let open_urls = open_files.urls();
439                        let ex = exclude_paths.clone();
440                        let ip = include_paths.clone();
441                        let path_clone = path.clone();
442                        let client = self.client.clone();
443                        let cp = cache_path.clone();
444                        let warm_analysis = self.config.load().warm_analysis;
445                        tokio::spawn(async move {
446                            let cache = if let Some(p) = cp {
447                                Some(crate::index::cache::WorkspaceCache::with_dir(p))
448                            } else {
449                                crate::index::cache::WorkspaceCache::new(&path_clone)
450                            };
451                            scan_workspace(
452                                path_clone,
453                                Arc::clone(&docs),
454                                open_files,
455                                cache,
456                                &ex,
457                                &ip,
458                                max_indexed_files,
459                                None,
460                            )
461                            .await;
462                            // Replay disk-cached index postings/subtype edges for
463                            // this folder's files, then run the reference-warm
464                            // phase (Mir's returned priority set, plus the
465                            // ambient sweep when configured) — same as the boot
466                            // path, so a folder added at runtime ends up as warm
467                            // as the startup roots, not just scan-mirrored.
468                            super::offload::run("workspaceFolders.warmStart", move || {
469                                docs.get_workspace_index_salsa();
470                                let untrusted = docs.warm_start_indexes();
471                                docs.warm_references_phase(
472                                    untrusted,
473                                    &open_urls,
474                                    warm_analysis,
475                                    None,
476                                );
477                            })
478                            .await;
479                            send_refresh_requests(&client).await;
480                        });
481                    }
482                }
483            }
484        })
485        .await
486    }
487
488    async fn shutdown(&self) -> Result<()> {
489        // Postings committed since the last warm sweep (on-demand query
490        // freshness passes, edit re-sweeps) reach disk only via a flush.
491        let docs = Arc::clone(&self.docs);
492        self.blocking("shutdown.flush", move || docs.flush_analysis_cache())
493            .await;
494        Ok(())
495    }
496
497    #[tracing::instrument(skip_all)]
498    async fn did_open(&self, params: DidOpenTextDocumentParams) {
499        guard_async("did_open", async move {
500            let uri = params.text_document.uri;
501            let text = params.text_document.text;
502            let version = params.text_document.version;
503
504            // The transport recorded a later close before this future got a
505            // chance to run. Do not resurrect a document the client has
506            // already closed.
507            if !self.open_files.should_apply_open(&uri) {
508                return;
509            }
510
511            // Store text immediately so other features work while parsing.
512            // This also mirrors the new text into salsa, so the codebase query
513            // sees it when semantic_diagnostics runs below.
514            self.set_open_text(uri.clone(), text);
515            // didOpen can make a brand-new declaring file visible to sibling
516            // open files that were analyzed earlier. Mir invalidates its own
517            // resolver caches; sync only php-lsp's retained analysis_cache.
518            self.docs.sync_analysis_cache_declarations(&uri);
519            // Seed parse diagnostics from the salsa-cached doc. On the fast
520            // path this is a lock-free DashMap lookup — no re-parse — but a
521            // cache miss triggers a full parse, so keep it off the async task.
522            let docs = Arc::clone(&self.docs);
523            let uri_for_task = uri.clone();
524            let parse_diags = self
525                .blocking_gated(super::debug_gate::GATE_DID_OPEN_PARSE, move || {
526                    let target = docs.workspace_php_version();
527                    docs.get_doc_salsa(&uri_for_task)
528                        .map(|doc| diagnostics_from_doc(&doc, target))
529                        .unwrap_or_default()
530                })
531                .await
532                .unwrap_or_default();
533            self.set_parse_diagnostics(&uri, parse_diags);
534
535            publish_with_dependents(
536                self.client.clone(),
537                Arc::clone(&self.docs),
538                self.open_files.clone(),
539                uri,
540                Some(version),
541                self.config.load().diagnostics.clone(),
542                self.laravel.load_full().is_laravel,
543            )
544            .await;
545        })
546        .await
547    }
548
549    #[tracing::instrument(skip_all)]
550    async fn did_change(&self, params: DidChangeTextDocumentParams) {
551        guard_async("did_change", async move {
552            let uri = params.text_document.uri;
553            let document_version = params.text_document.version;
554            // Incremental sync: apply changes in order to the live buffer.
555            // Each ranged change refers to the document state produced by the
556            // previous one; a change without a range is a full-document
557            // replacement (clients may still send those under INCREMENTAL).
558            let mut updated: Option<String> = None;
559            for change in params.content_changes {
560                match change.range {
561                    None => updated = Some(change.text),
562                    Some(range) => {
563                        let mut cur = match updated.take() {
564                            Some(t) => t,
565                            None => self.get_open_text(&uri).unwrap_or_default().to_string(),
566                        };
567                        crate::text::apply_content_change(&mut cur, range, &change.text);
568                        updated = Some(cur);
569                    }
570                }
571            }
572            let Some(text) = updated else { return };
573
574            // Store text immediately and capture the version token.
575            // Features (completion, hover, …) see the new text instantly while
576            // the parse runs in the background.
577            let version = self.set_open_text(uri.clone(), text.clone());
578
579            let docs = Arc::clone(&self.docs);
580            let open_files = self.open_files.clone();
581            let client = self.client.clone();
582            let cfg = self.config.load();
583            let diag_cfg = cfg.diagnostics.clone();
584            let debounce_ms = cfg.debounce_ms;
585            let warm_analysis = cfg.warm_analysis;
586            let is_laravel = self.laravel.load_full().is_laravel;
587            tokio::spawn(async move {
588                // Debounce: if another edit arrives before we parse, the version
589                // gate below will discard this result.
590                tokio::time::sleep(std::time::Duration::from_millis(debounce_ms)).await;
591
592                // Skip the expensive parse+analyze if a newer edit already
593                // superseded this one. Collapses N rapid keystrokes into 1
594                // spawn_blocking call instead of N.
595                if open_files.current_version(&uri) != Some(version) {
596                    return;
597                }
598
599                let parse_docs = Arc::clone(&docs);
600                let (_doc, parse_diags) = super::offload::run("did_change.parse", move || {
601                    parse_document(&text, parse_docs.workspace_php_version())
602                })
603                .await
604                .unwrap_or_default();
605
606                // Only apply if no newer edit arrived while we were parsing.
607                if open_files.current_version(&uri) != Some(version) {
608                    return;
609                }
610                open_files.set_parse_diagnostics(&uri, parse_diags);
611                publish_with_dependents(
612                    client,
613                    Arc::clone(&docs),
614                    open_files.clone(),
615                    uri.clone(),
616                    Some(document_version),
617                    diag_cfg,
618                    is_laravel,
619                )
620                .await;
621
622                // Re-warm the analysis memos this edit invalidated once typing
623                // goes idle, so the next references/rename stays a memo hit.
624                // Unaffected files revalidate without re-analysis, so the sweep
625                // cost tracks what the edit actually touched.
626                if warm_analysis && docs.is_index_ready() {
627                    tokio::time::sleep(std::time::Duration::from_millis(WARM_RESWEEP_IDLE_MS))
628                        .await;
629                    if open_files.current_version(&uri) != Some(version) {
630                        return;
631                    }
632                    let open_urls = open_files.urls();
633                    // Deliberately not `offload::run`: spawn_blocking schedules
634                    // synchronously at the call, but `run`'s async body is lazy
635                    // and would never even start the sweep without an `.await`
636                    // this fire-and-forget site intentionally omits.
637                    drop(tokio::task::spawn_blocking(move || {
638                        let cancel = docs.begin_warm_sweep();
639                        docs.warm_analysis_sweep(&open_urls, &cancel);
640                    }));
641                }
642            });
643        })
644        .await
645    }
646
647    async fn did_close(&self, params: DidCloseTextDocumentParams) {
648        guard_async("did_close", async move {
649            let uri = params.text_document.uri;
650            // `didOpen` may have arrived after this close but begun execution
651            // first. The transport ingress state is authoritative, so an old
652            // close must not erase that reopened buffer.
653            if !self.open_files.should_apply_close(&uri) {
654                return;
655            }
656            self.close_open_file(&uri);
657            // The salsa-mirrored text still holds the last edited buffer, which may
658            // include unsaved changes the user just discarded on close. Re-sync from
659            // disk so cross-file features resolve against the on-disk content; if the
660            // file is gone (never saved / deleted), drop it from the index.
661            let disk_text = match uri.to_file_path() {
662                Some(path) => tokio::fs::read_to_string(&path).await.ok(),
663                None => None,
664            };
665            // The disk read awaited above; a reopen may have raced in. Only mutate
666            // the index if the file is still closed, so we never clobber a fresh
667            // buffer.
668            if !self.open_files.contains(&uri) {
669                match disk_text {
670                    Some(text) => self.docs.ingest(uri.clone(), &text),
671                    None => self.docs.remove(&uri),
672                }
673            }
674            // Clear editor diagnostics; the file stays indexed for cross-file features
675            self.client.publish_diagnostics(uri, vec![], None).await;
676        })
677        .await
678    }
679
680    async fn will_save(&self, _params: WillSaveTextDocumentParams) {}
681
682    async fn will_save_wait_until(
683        &self,
684        params: WillSaveTextDocumentParams,
685    ) -> Result<Option<Vec<TextEdit>>> {
686        guard_async_result("will_save_wait_until", async move {
687            let source = self
688                .get_open_text(&params.text_document.uri)
689                .unwrap_or_default();
690            // Spawns and blocks on an external formatter process; keep that
691            // wait off the async runtime worker.
692            Ok(self
693                .blocking("willSaveWaitUntil.format", move || format_document(&source))
694                .await
695                .unwrap_or_default())
696        })
697        .await
698    }
699
700    async fn did_save(&self, params: DidSaveTextDocumentParams) {
701        guard_async("did_save", async move {
702            let uri = params.text_document.uri;
703            // Re-publish diagnostics on save so editors that defer diagnostics
704            // until save (rather than on every keystroke) see up-to-date results.
705            // Must include semantic diagnostics — publishDiagnostics replaces the
706            // prior set entirely, so omitting them would clear errors the editor
707            // showed after the last did_change.
708            let diag_cfg = self.config.load().diagnostics.clone();
709            let is_laravel = self.laravel.load_full().is_laravel;
710            // Includes the semantic (mir) analysis pass, same as
711            // `publish_with_dependents` — keep it off the async runtime worker
712            // here too, not just on the did_open/did_change path.
713            let docs = Arc::clone(&self.docs);
714            let open_files = self.open_files.clone();
715            let uri_for_diags = uri.clone();
716            let all = self
717                .blocking_gated(super::debug_gate::GATE_DID_SAVE_DIAGNOSTICS, move || {
718                    compute_open_file_diagnostics(
719                        &docs,
720                        &open_files,
721                        &uri_for_diags,
722                        &diag_cfg,
723                        is_laravel,
724                    )
725                })
726                .await
727                .unwrap_or_default();
728            self.open_files
729                .note_published(&uri, super::diagnostics_content_hash(&all));
730            self.client
731                .publish_diagnostics(uri.clone(), all, None)
732                .await;
733
734            // Persist the FileIndex to the disk cache so that a server restart
735            // can skip re-parsing this file even for edits that happened between
736            // workspace scans. Content-keyed so the entry matches the scan's key
737            // on restart regardless of mtime granularity.
738            let cache_path = self.config.load().cache_path.clone();
739            if let Some(path) = uri.to_file_path().map(|c| c.into_owned()) {
740                let root = self.root_paths.load().first().cloned();
741                // Deliberately not `offload::run` — see the fire-and-forget
742                // comment on `did_change`'s warm-resweep; this cache write is
743                // never awaited either.
744                tokio::task::spawn_blocking({
745                    let path = path.clone();
746                    let uri = uri.clone();
747                    let root = root.clone();
748                    move || {
749                        let cache = if let Some(p) = cache_path {
750                            crate::index::cache::WorkspaceCache::with_dir(p)
751                        } else if let Some(r) = &root {
752                            let Some(c) = crate::index::cache::WorkspaceCache::new(r) else {
753                                return;
754                            };
755                            c
756                        } else {
757                            return;
758                        };
759                        let Ok(text) = std::fs::read_to_string(&path) else {
760                            return;
761                        };
762                        let key = crate::index::cache::WorkspaceCache::key_for(uri.as_str(), &text);
763                        let doc = parse_document_no_diags(&text);
764                        let index = crate::index::file_index::FileIndex::extract(&doc);
765                        let _ = cache.write(&key, &index);
766                    }
767                });
768
769                // External tools (PHPStan/PHPCS) are opt-in and much slower
770                // than the in-process analyzer, so they run in the background
771                // and republish once done rather than delaying the publish
772                // above. Guarded by the version token so an edit that lands
773                // while the tool is still running discards its result instead
774                // of showing diagnostics against text the buffer no longer
775                // holds.
776                let ext_cfg = self.config.load().external_tools.clone();
777                if (ext_cfg.phpstan.enabled || ext_cfg.phpcs.enabled)
778                    && let Some(version) = self.open_files.current_version(&uri)
779                {
780                    let client = self.client.clone();
781                    let docs = Arc::clone(&self.docs);
782                    let open_files = self.open_files.clone();
783                    let diag_cfg = self.config.load().diagnostics.clone();
784                    let uri = uri.clone();
785                    tokio::spawn(async move {
786                        let diagnostics = crate::analysis::external::run_external_diagnostics(
787                            &ext_cfg,
788                            &path,
789                            root.as_deref(),
790                        )
791                        .await;
792                        if open_files.current_version(&uri) != Some(version) {
793                            return;
794                        }
795                        open_files.set_external_diagnostics(&uri, version, diagnostics);
796                        publish_with_dependents(
797                            client, docs, open_files, uri, None, diag_cfg, is_laravel,
798                        )
799                        .await;
800                    });
801                }
802            }
803        })
804        .await
805    }
806
807    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
808        guard_async("did_change_watched_files", async move {
809            enum Change {
810                Upsert(Uri, String),
811                Delete(Uri),
812            }
813            let mut changes = Vec::with_capacity(params.changes.len());
814            for change in params.changes {
815                match change.typ {
816                    FileChangeType::CREATED | FileChangeType::CHANGED => {
817                        if let Some(path) = change.uri.to_file_path()
818                            && let Ok(text) = tokio::fs::read_to_string(&path).await
819                        {
820                            changes.push(Change::Upsert(change.uri.clone(), text));
821                        }
822                    }
823                    FileChangeType::DELETED => changes.push(Change::Delete(change.uri.clone())),
824                    _ => {}
825                }
826            }
827
828            // Parsing is CPU-bound and a bulk change (git checkout, a
829            // generator run) can cover hundreds of files at once; process
830            // the whole batch in one spawn_blocking call instead of
831            // re-entering the blocking pool per file.
832            let docs = Arc::clone(&self.docs);
833            let open_files = self.open_files.clone();
834            self.blocking_gated(
835                super::debug_gate::GATE_DID_CHANGE_WATCHED_FILES,
836                move || {
837                    for change in changes {
838                        match change {
839                            Change::Upsert(uri, text) => {
840                                // Salsa path: ingest_from_doc mirrors the new text into
841                                // the SourceFile input. On the next codebase() call,
842                                // salsa re-runs file_definitions for this file and the
843                                // aggregator re-folds — no manual remove/collect/finalize.
844                                let doc = parse_document_no_diags(&text);
845                                if !open_files.contains(&uri) {
846                                    docs.ingest_from_doc(uri.clone(), &doc);
847                                }
848                                // Keep php-lsp's retained FileAnalysis cache
849                                // coherent for consumers analyzed before this
850                                // external file change landed. Mir handles its
851                                // own workspace invalidation.
852                                docs.sync_analysis_cache_declarations(&uri);
853                            }
854                            Change::Delete(uri) => docs.remove(&uri),
855                        }
856                    }
857                },
858            )
859            .await;
860            // File changes may affect cross-file features — refresh all live editors.
861            send_refresh_requests(&self.client).await;
862
863            // Bulk external changes (git checkout, generators) invalidate many
864            // analysis memos at once; re-warm so references stay fast after a
865            // branch switch, not just after in-editor edits.
866            if self.config.load().warm_analysis && self.docs.is_index_ready() {
867                let docs = Arc::clone(&self.docs);
868                let open_urls = self.open_files.urls();
869                // Deliberately not `offload::run` — see the matching comment
870                // in `did_change`'s warm-resweep.
871                drop(tokio::task::spawn_blocking(move || {
872                    let cancel = docs.begin_warm_sweep();
873                    docs.warm_analysis_sweep(&open_urls, &cancel);
874                }));
875            }
876        })
877        .await
878    }
879
880    #[tracing::instrument(skip_all)]
881    async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
882        guard_async_result("completion", async move {
883            // Held for the request's lifetime so a concurrent background
884            // warm/reanalysis sweep yields at its next chunk boundary
885            // instead of racing this interactive read for CPU/rayon workers.
886            let _interactive = self.docs.interactive_read_guard();
887            let uri = &params.text_document_position.text_document.uri;
888            let position = params.text_document_position.position;
889            let cancel_rev = self.docs.write_rev();
890            let source = self.get_open_text(uri).unwrap_or_default();
891            let doc = match self.get_doc(uri) {
892                Some(d) => d,
893                None => return Ok(Some(CompletionResponse::Array(vec![]))),
894            };
895            let other_docs: Vec<Arc<ParsedDoc>> = self
896                .docs
897                .other_docs(uri, &self.open_urls())
898                .into_iter()
899                .map(|(_, d)| d)
900                .collect();
901            // Clone to owned so the closure is 'static + Send.
902            let trigger = params
903                .context
904                .as_ref()
905                .and_then(|c| c.trigger_character.clone());
906            let laravel_arc = self.laravel.load_full();
907            let imports = self.file_imports(uri);
908            let wi = self.workspace_index_async().await;
909            let wi_for_class_search = Arc::clone(&wi);
910            let uri_for_class_search = uri.clone();
911            let docs_for_lookup = Arc::clone(&self.docs);
912            let doc_for_lookup = Arc::clone(&doc);
913            let imports_for_lookup = imports.clone();
914            let find_class_doc_fn = move |name: &str| -> Option<Arc<ParsedDoc>> {
915                let fqn = class_lookup_fqn(&doc_for_lookup, name, &imports_for_lookup);
916                let cr = docs_for_lookup
917                    .resolve_class_ref_by_fqn(&wi, &fqn)
918                    .or_else(|| {
919                        if !is_bare_php_name(name) {
920                            return None;
921                        }
922                        let name_lc = name.to_lowercase();
923                        let table = &wi.classes_by_lowercase_name;
924                        let pos =
925                            table.partition_point(|(short, _)| short.as_ref() < name_lc.as_str());
926                        table.get(pos).and_then(|(short, cr)| {
927                            (short.as_ref() == name_lc.as_str()).then_some(*cr)
928                        })
929                    })?;
930                let (uri, _) = wi.at(cr)?;
931                docs_for_lookup.get_doc_salsa(uri)
932            };
933            // Finds classes anywhere in the workspace index — including
934            // vendor/ and other files that are never opened as editor
935            // buffers — whose short name starts with `prefix`. Complements
936            // `other_docs`, which only covers currently open documents.
937            //
938            // `classes_by_lowercase_name` is sorted once per workspace
939            // revision (shared via `Arc` across every completion request
940            // until a file changes), so this does a binary search for the
941            // prefix range instead of scanning + re-lowercasing every class
942            // name on every keystroke.
943            const WORKSPACE_CLASS_SEARCH_LIMIT: usize = 50;
944            let workspace_class_search_fn =
945                move |prefix: &str| -> Vec<(String, CompletionItemKind, String)> {
946                    let prefix_lc = prefix.to_lowercase();
947                    let table = &wi_for_class_search.classes_by_lowercase_name;
948                    let start =
949                        table.partition_point(|(name, _)| name.as_ref() < prefix_lc.as_str());
950                    let mut out = Vec::new();
951                    for (name, cr) in &table[start..] {
952                        if !name.starts_with(prefix_lc.as_str()) {
953                            break;
954                        }
955                        let Some((file_uri, cls)) = wi_for_class_search.at(*cr) else {
956                            continue;
957                        };
958                        if *file_uri == uri_for_class_search {
959                            continue;
960                        }
961                        let kind = match cls.kind {
962                            ClassKind::Class | ClassKind::Trait => CompletionItemKind::CLASS,
963                            ClassKind::Interface => CompletionItemKind::INTERFACE,
964                            ClassKind::Enum => CompletionItemKind::ENUM,
965                        };
966                        out.push((cls.name.to_string(), kind, cls.fqn.to_string()));
967                        if out.len() >= WORKSPACE_CLASS_SEARCH_LIMIT {
968                            break;
969                        }
970                    }
971                    out
972                };
973            let analysis = self
974                .cached_analysis_async_cancellable(uri, cancel_rev)
975                .await;
976            let docs = Arc::clone(&self.docs);
977            let uri_owned = uri.clone();
978            // Offload to spawn_blocking: filtered_completions_at walks the full
979            // AST + workspace index which can take tens of milliseconds on large
980            // files, blocking the async executor from unrelated requests.
981            let items = self
982                .blocking("completion", move || {
983                    let ctx = CompletionCtx {
984                        source: Some(&source),
985                        position: Some(position),
986                        doc_uri: Some(&uri_owned),
987                        file_imports: Some(&imports),
988                        find_class_doc: Some(&find_class_doc_fn),
989                        workspace_class_search: Some(&workspace_class_search_fn),
990                        analysis: analysis.as_deref(),
991                        session: Some(&docs),
992                        laravel: Some(&laravel_arc),
993                    };
994                    filtered_completions_at(&doc, &other_docs, trigger.as_deref(), &ctx)
995                })
996                .await
997                .unwrap_or_default();
998            Ok(Some(CompletionResponse::Array(items)))
999        })
1000        .await
1001    }
1002
1003    async fn completion_resolve(&self, mut item: CompletionItem) -> Result<CompletionItem> {
1004        guard_async_result("completion_resolve", async move {
1005            if item.documentation.is_some() && item.detail.is_some() {
1006                return Ok(item);
1007            }
1008            // Strip trailing ':' from named-argument labels (e.g. "param:") before lookup.
1009            let name = item.label.trim_end_matches(':').to_string();
1010            // Method completion items carry their owning class in `data` (see
1011            // member.rs's all_members) so two unrelated classes declaring a
1012            // same-named method don't resolve to whichever is indexed first.
1013            let class_hint = item
1014                .data
1015                .as_ref()
1016                .and_then(|d| d.get("class"))
1017                .and_then(|v| v.as_str())
1018                .map(str::to_string);
1019            let symbol = completion_symbol_from_item(&name, item.kind, class_hint.as_deref());
1020            let need_detail = item.detail.is_none();
1021            let need_docs = item.documentation.is_none();
1022            let docs = Arc::clone(&self.docs);
1023            let (detail, documentation) = self
1024                .blocking_gated(super::debug_gate::GATE_COMPLETION_RESOLVE, move || {
1025                    let Some(symbol) = symbol.as_ref() else {
1026                        return (None, None);
1027                    };
1028                    let (resolved_detail, resolved_docstring) = docs
1029                        .read_snapshot(|snap| {
1030                            completion_signature_and_docstring_for_symbol(snap.db(), symbol)
1031                        })
1032                        .unwrap_or_default();
1033                    let detail = if need_detail {
1034                        resolved_detail.clone()
1035                    } else {
1036                        None
1037                    };
1038                    let documentation = need_docs
1039                        .then(|| {
1040                            match (resolved_detail.as_deref(), resolved_docstring.as_deref()) {
1041                                (Some(_), Some(doc)) if need_detail => Some(doc.to_string()),
1042                                (Some(sig), Some(doc)) => {
1043                                    Some(format!("```php\n{sig}\n```\n\n---\n\n{doc}"))
1044                                }
1045                                (Some(sig), None) => Some(format!("```php\n{sig}\n```")),
1046                                (None, Some(doc)) => Some(doc.to_string()),
1047                                (None, None) => None,
1048                            }
1049                        })
1050                        .flatten();
1051                    (detail, documentation)
1052                })
1053                .await
1054                .unwrap_or_default();
1055            if let Some(sig) = detail {
1056                item.detail = Some(sig);
1057            }
1058            if let Some(md) = documentation {
1059                item.documentation = Some(Documentation::MarkupContent(MarkupContent {
1060                    kind: MarkupKind::Markdown,
1061                    value: md,
1062                }));
1063            }
1064            Ok(item)
1065        })
1066        .await
1067    }
1068
1069    async fn goto_definition(
1070        &self,
1071        params: GotoDefinitionParams,
1072    ) -> Result<Option<GotoDefinitionResponse>> {
1073        guard_async_result("goto_definition", async move {
1074            self.handle_goto_definition(params).await
1075        })
1076        .await
1077    }
1078
1079    async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
1080        guard_async_result(
1081            "references",
1082            async move { self.handle_references(params).await },
1083        )
1084        .await
1085    }
1086
1087    async fn prepare_rename(
1088        &self,
1089        params: TextDocumentPositionParams,
1090    ) -> Result<Option<PrepareRenameResponse>> {
1091        guard_async_result("prepare_rename", async move {
1092            let uri = &params.text_document.uri;
1093            let doc = match self.get_doc(uri) {
1094                Some(d) => d,
1095                None => return Ok(None),
1096            };
1097            let position = params.position;
1098            // For a keyword-shaped identifier (`match`, `list`, ...),
1099            // `prepare_rename` walks the whole AST to check whether it's used
1100            // as a real member name; keep that off the async runtime worker.
1101            let range = self
1102                .blocking_gated(super::debug_gate::GATE_PREPARE_RENAME, move || {
1103                    prepare_rename(&doc, position)
1104                })
1105                .await
1106                .unwrap_or_default();
1107            Ok(range.map(PrepareRenameResponse::Range))
1108        })
1109        .await
1110    }
1111
1112    async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
1113        guard_async_result("rename", async move {
1114            let uri = &params.text_document_position.text_document.uri;
1115            let position = params.text_document_position.position;
1116            let source = self.get_open_text(uri).unwrap_or_default();
1117            let word = match word_at_position(&source, position) {
1118                Some(w) => w,
1119                None => return Ok(None),
1120            };
1121            if word.starts_with('$') {
1122                let doc = match self.get_doc(uri) {
1123                    Some(d) => d,
1124                    None => return Ok(None),
1125                };
1126                // Cursor on a property declaration (`public int $x`) or a promoted
1127                // constructor parameter (`private string $x`) — both act as property
1128                // declarations and take the cross-file indexed path below. Plain
1129                // variables stay on the single-document scope walker. Both checks
1130                // and the walker itself scan every class member/statement in the
1131                // document, so keep them off the async runtime worker.
1132                let word_task = word.clone();
1133                let new_name = params.new_name.clone();
1134                let uri_task = uri.clone();
1135                let doc_task = Arc::clone(&doc);
1136                let source_task = Arc::clone(&source);
1137                let variable_edit = self
1138                    .blocking_gated(super::debug_gate::GATE_RENAME_VARIABLE, move || {
1139                        let on_property_decl = cursor_is_on_property_decl(
1140                            &source_task,
1141                            &doc_task.program().stmts,
1142                            position,
1143                        )
1144                        .is_some()
1145                            || promoted_property_at_cursor(
1146                                &source_task,
1147                                &doc_task.program().stmts,
1148                                position,
1149                            )
1150                            .is_some();
1151                        if on_property_decl {
1152                            None
1153                        } else {
1154                            Some(rename_variable(
1155                                &word_task, &new_name, &uri_task, &doc_task, position,
1156                            ))
1157                        }
1158                    })
1159                    .await
1160                    .flatten();
1161                if let Some(edit) = variable_edit {
1162                    return Ok(Some(edit));
1163                }
1164            }
1165            // Everything else — classes, functions, methods, properties,
1166            // constants — renames from mir's posting lists (declaration
1167            // token and `use` import lines included).
1168            Ok(self.indexed_rename(uri, position, &params.new_name).await)
1169        })
1170        .await
1171    }
1172
1173    async fn signature_help(&self, params: SignatureHelpParams) -> Result<Option<SignatureHelp>> {
1174        guard_async_result("signature_help", async move {
1175            let uri = &params.text_document_position_params.text_document.uri;
1176            let position = params.text_document_position_params.position;
1177            let source = self.get_open_text(uri).unwrap_or_default();
1178            let doc = match self.get_doc(uri) {
1179                Some(d) => d,
1180                None => return Ok(None),
1181            };
1182            let analysis = self.cached_analysis_async(uri).await;
1183            let docs = Arc::clone(&self.docs);
1184            let doc_clone = Arc::clone(&doc);
1185            let result = self
1186                .blocking("signature_help", move || {
1187                    docs.read_snapshot(|snap| {
1188                        signature_help(
1189                            &source,
1190                            &doc_clone,
1191                            position,
1192                            analysis.as_deref(),
1193                            Some(snap.db()),
1194                        )
1195                    })
1196                    .ok()
1197                })
1198                .await
1199                .flatten()
1200                .flatten();
1201            Ok(result)
1202        })
1203        .await
1204    }
1205
1206    #[tracing::instrument(skip_all)]
1207    async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
1208        guard_async_result("hover", async move {
1209            // Held for the request's lifetime so a concurrent background
1210            // warm/reanalysis sweep yields at its next chunk boundary
1211            // instead of racing this interactive read for CPU/rayon workers.
1212            let _interactive = self.docs.interactive_read_guard();
1213            let uri = &params.text_document_position_params.text_document.uri;
1214            let position = params.text_document_position_params.position;
1215            let mut source = self.get_open_text(uri).unwrap_or_default();
1216            let doc = match self.get_doc(uri) {
1217                Some(d) => d,
1218                None => {
1219                    // tower-lsp-server polls concurrent inbound messages out
1220                    // of order. A hover sent immediately after didClose then
1221                    // didOpen can therefore run while the earlier didOpen is
1222                    // queued but has not yet installed its buffer. Yield one
1223                    // turn only on this closed-document path so that lifecycle
1224                    // notification can publish its state; ordinary hovers keep
1225                    // the zero-yield fast path.
1226                    tokio::task::yield_now().await;
1227                    source = self.get_open_text(uri).unwrap_or_default();
1228                    match self.get_doc(uri) {
1229                        Some(d) => d,
1230                        None => return Ok(None),
1231                    }
1232                }
1233            };
1234            // Laravel string-key calls (`env('KEY')`, `config('a.b')`, ...) —
1235            // resolved before the general hover pipeline below, since a
1236            // string literal is never a `word_at_position` match. Mirrors
1237            // `handle_goto_definition`'s same early check.
1238            let laravel = self.laravel.load();
1239            let laravel_root = self.root_paths.load().first().cloned();
1240            let laravel_hover = crate::laravel::hover_for_string_key(
1241                &doc,
1242                position,
1243                &laravel,
1244                laravel_root.as_deref(),
1245            )
1246            .or_else(|| {
1247                crate::laravel::blade::hover(
1248                    uri,
1249                    &source,
1250                    position,
1251                    &laravel,
1252                    laravel_root.as_deref(),
1253                )
1254            });
1255            drop(laravel);
1256            if let Some(hover) = laravel_hover {
1257                return Ok(Some(hover));
1258            }
1259            let open = self.open_urls();
1260            let other_docs = self.docs.other_docs(uri, &open);
1261            let other_maps = self.docs.other_symbol_maps(uri, &open);
1262            let analysis = self.cached_analysis_async(uri).await;
1263            let docs_for_hover = Arc::clone(&self.docs);
1264            let source_clone = source.clone();
1265            let doc_clone = Arc::clone(&doc);
1266            // Lets mir-member/static-prop hover resolve a class's declaring doc
1267            // via the workspace index even when that file isn't open in the
1268            // editor, instead of only searching `other_docs` (open files).
1269            // Cached across this request: nothing between here and the
1270            // fallback branch below lazily ingests a new vendor file, so the
1271            // same Arc is safe to reuse instead of re-awaiting the index.
1272            let mut wi_cache: Option<Arc<crate::db::workspace_index::WorkspaceIndexData>> = None;
1273            let wi = self.workspace_index_cached(&mut wi_cache).await;
1274            let docs_for_lookup = Arc::clone(&self.docs);
1275            let imports_for_lookup = doc.file_imports();
1276            let doc_for_lookup = Arc::clone(&doc);
1277            let find_class_doc_fn = move |name: &str| -> Option<Arc<ParsedDoc>> {
1278                let fqn = class_lookup_fqn(&doc_for_lookup, name, &imports_for_lookup);
1279                let cr = docs_for_lookup
1280                    .resolve_class_ref_by_fqn(&wi, &fqn)
1281                    .or_else(|| {
1282                        if !is_bare_php_name(name) {
1283                            return None;
1284                        }
1285                        let name_lc = name.to_lowercase();
1286                        let table = &wi.classes_by_lowercase_name;
1287                        let pos =
1288                            table.partition_point(|(short, _)| short.as_ref() < name_lc.as_str());
1289                        table.get(pos).and_then(|(short, cr)| {
1290                            (short.as_ref() == name_lc.as_str()).then_some(*cr)
1291                        })
1292                    })?;
1293                let (uri, _) = wi.at(cr)?;
1294                docs_for_lookup.get_doc_salsa(uri)
1295            };
1296            let result = self
1297                .blocking("hover", move || {
1298                    hover_info_with_maps(
1299                        &source_clone,
1300                        &doc_clone,
1301                        analysis.as_deref(),
1302                        position,
1303                        &other_docs,
1304                        &other_maps,
1305                        Some(&docs_for_hover),
1306                        Some(&find_class_doc_fn),
1307                    )
1308                })
1309                .await
1310                .flatten();
1311            if result.is_some() {
1312                return Ok(result);
1313            }
1314            // Fallback: look up the word in the workspace index so class names in
1315            // extends clauses and parameter types resolve even when their defining
1316            // file is never opened.  Also try the alias-resolved name so that
1317            // `use Foo as Bar` works even when Foo is only in the index.
1318            if let Some(word) = crate::text::word_at_position(&source, position) {
1319                let wi = self.workspace_index_cached(&mut wi_cache).await;
1320                let docs = Arc::clone(&self.docs);
1321                let doc = Arc::clone(&doc);
1322                let source = source.clone();
1323                let uri = uri.clone();
1324                // Off the async worker: every step below takes the session lock.
1325                let fallback = self
1326                    .blocking("hover_fallback", move || {
1327                        let fallback_imports = doc.file_imports();
1328                        let fallback_class_fqcn = |name: &str| {
1329                            let fqn = class_lookup_fqn(&doc, name, &fallback_imports);
1330                            docs.resolve_class_ref_by_fqn(&wi, &fqn).and_then(|cr| {
1331                                wi.at(cr)
1332                                    .map(|(_, cls)| normalized_php_fqn(&cls.fqn).to_string())
1333                            })
1334                        };
1335                        // mir resolves the cursor class through imports, aliases and the
1336                        // namespace, so same-short-named classes can't be confused.
1337                        if let Some(offset) = crate::text::word_range_at(&source, position)
1338                            .map(|r| doc.view().byte_of_position(r.start))
1339                            && let Ok(Some(mir_analyzer::Name::Class(fqcn))) =
1340                                docs.mir_name_at(&uri, offset)
1341                            && let Some(h) = docs
1342                                .read_snapshot(|snap| class_hover_for_fqcn(snap, &fqcn))
1343                                .ok()
1344                                .flatten()
1345                        {
1346                            return Some(h);
1347                        }
1348                        // Try static method hover: `ClassName::method(…)`.
1349                        if let Some(line_text) = source.lines().nth(position.line as usize)
1350                            && let Some(class_token) = extract_static_class_before_cursor(
1351                                line_text,
1352                                position.character as usize,
1353                            )
1354                        {
1355                            if let Some(fqcn) = fallback_class_fqcn(&class_token)
1356                                && let Some(h) = docs
1357                                    .read_snapshot(|snap| method_hover_for_fqcn(snap, &fqcn, &word))
1358                                    .ok()
1359                                    .flatten()
1360                            {
1361                                return Some(h);
1362                            }
1363                            if let Some(resolved_class) =
1364                                crate::hover::resolve_use_alias(&doc.program().stmts, &class_token)
1365                                && let Some(fqcn) = fallback_class_fqcn(&resolved_class)
1366                                && let Some(h) = docs
1367                                    .read_snapshot(|snap| method_hover_for_fqcn(snap, &fqcn, &word))
1368                                    .ok()
1369                                    .flatten()
1370                            {
1371                                return Some(h);
1372                            }
1373                        }
1374                        None
1375                    })
1376                    .await
1377                    .flatten();
1378                return Ok(fallback);
1379            }
1380            Ok(None)
1381        })
1382        .await
1383    }
1384
1385    async fn document_symbol(
1386        &self,
1387        params: DocumentSymbolParams,
1388    ) -> Result<Option<DocumentSymbolResponse>> {
1389        guard_async_result("document_symbol", async move {
1390            let uri = &params.text_document.uri;
1391            let doc = match self.get_doc(uri) {
1392                Some(d) => d,
1393                None => return Ok(None),
1394            };
1395            // The AST walk is CPU-bound; keep it off the async runtime worker.
1396            let symbols = self
1397                .blocking_gated(super::debug_gate::GATE_DOCUMENT_SYMBOL, move || {
1398                    document_symbols(doc.source(), &doc)
1399                })
1400                .await
1401                .unwrap_or_default();
1402            Ok(Some(DocumentSymbolResponse::Nested(symbols)))
1403        })
1404        .await
1405    }
1406
1407    async fn folding_range(&self, params: FoldingRangeParams) -> Result<Option<Vec<FoldingRange>>> {
1408        guard_async_result("folding_range", async move {
1409            let uri = &params.text_document.uri;
1410            let doc = match self.get_doc(uri) {
1411                Some(d) => d,
1412                None => return Ok(None),
1413            };
1414            // The AST walk plus full-text comment/region scans are CPU-bound;
1415            // keep them off the async runtime worker.
1416            let ranges = self
1417                .blocking_gated(super::debug_gate::GATE_FOLDING_RANGE, move || {
1418                    folding_ranges(doc.source(), &doc)
1419                })
1420                .await
1421                .unwrap_or_default();
1422            Ok(if ranges.is_empty() {
1423                None
1424            } else {
1425                Some(ranges)
1426            })
1427        })
1428        .await
1429    }
1430
1431    async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
1432        guard_async_result("inlay_hint", async move {
1433            let uri = &params.text_document.uri;
1434            let doc = match self.get_doc(uri) {
1435                Some(d) => d,
1436                None => return Ok(None),
1437            };
1438            let analysis = self.cached_analysis_async(uri).await;
1439            let docs = Arc::clone(&self.docs);
1440            let hints = self
1441                .blocking("inlay_hint", move || {
1442                    docs.read_snapshot(|snap| {
1443                        inlay_hints(
1444                            doc.source(),
1445                            &doc,
1446                            analysis.as_deref(),
1447                            Some(snap.db()),
1448                            params.range,
1449                        )
1450                    })
1451                    .unwrap_or_default()
1452                })
1453                .await
1454                .unwrap_or_default();
1455            Ok(Some(hints))
1456        })
1457        .await
1458    }
1459
1460    async fn inlay_hint_resolve(&self, mut item: InlayHint) -> Result<InlayHint> {
1461        let fallback = item.clone();
1462        let resolved = guard_async("inlay_hint_resolve", async move {
1463            if item.tooltip.is_some() {
1464                return Some(item);
1465            }
1466            let symbol = item.data.as_ref().and_then(inlay_hint_symbol_from_data);
1467            if let Some(symbol) = symbol {
1468                let docs = Arc::clone(&self.docs);
1469                let tooltip = self
1470                    .blocking_gated(super::debug_gate::GATE_INLAY_HINT_RESOLVE, move || {
1471                        docs.read_snapshot(|snap| inlay_hint_tooltip_for_symbol(snap.db(), &symbol))
1472                            .ok()
1473                    })
1474                    .await
1475                    .flatten()
1476                    .flatten();
1477                if let Some(md) = tooltip {
1478                    item.tooltip = Some(InlayHintTooltip::MarkupContent(MarkupContent {
1479                        kind: MarkupKind::Markdown,
1480                        value: md,
1481                    }));
1482                }
1483            }
1484            Some(item)
1485        })
1486        .await;
1487        Ok(resolved.unwrap_or(fallback))
1488    }
1489
1490    async fn symbol(
1491        &self,
1492        params: WorkspaceSymbolParams,
1493    ) -> Result<Option<WorkspaceSymbolResponse>> {
1494        guard_async_result("symbol", async move {
1495            // Phase J: read through the salsa-memoized aggregate so repeated
1496            // workspace-symbol queries (every keystroke in the picker) share the
1497            // same `Arc` until a file changes.
1498            let wi = self.workspace_index_async().await;
1499            let docs = Arc::clone(&self.docs);
1500            let query = params.query;
1501            let results = self
1502                .blocking("workspace/symbol", move || {
1503                    let get_doc = |uri: &Uri| docs.get_doc_salsa(uri);
1504                    workspace_symbols_from_workspace(&query, &wi, &get_doc)
1505                })
1506                .await
1507                .unwrap_or_default();
1508            Ok(Some(WorkspaceSymbolResponse::Flat(results)))
1509        })
1510        .await
1511    }
1512
1513    async fn symbol_resolve(&self, params: WorkspaceSymbol) -> Result<WorkspaceSymbol> {
1514        // For resolve, we need the full range from the ParsedDoc of open files.
1515        let docs = self.docs.docs_for(&self.open_urls());
1516        let fallback = params.clone();
1517        let resolved = guard_async("symbol_resolve", async move {
1518            Some(resolve_workspace_symbol(params, &docs))
1519        })
1520        .await;
1521        Ok(resolved.unwrap_or(fallback))
1522    }
1523
1524    #[tracing::instrument(skip_all)]
1525    async fn semantic_tokens_full(
1526        &self,
1527        params: SemanticTokensParams,
1528    ) -> Result<Option<SemanticTokensResult>> {
1529        guard_async_result("semantic_tokens_full", async move {
1530            let uri = params.text_document.uri;
1531            let doc = match self.get_doc(&uri) {
1532                Some(d) => d,
1533                None => {
1534                    return Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
1535                        result_id: None,
1536                        data: vec![],
1537                    })));
1538                }
1539            };
1540            let docs = Arc::clone(&self.docs);
1541            let uri_for_task = uri.clone();
1542            // The AST walk is CPU-bound; keep it off the async runtime worker
1543            // (see `document_highlight` for the same pattern).
1544            let (result_id, data) = self
1545                .blocking_gated(super::debug_gate::GATE_SEMANTIC_TOKENS_FULL, move || {
1546                    let tokens = semantic_tokens(doc.source(), &doc);
1547                    let result_id = token_hash(&tokens);
1548                    let tokens_arc = Arc::new(tokens);
1549                    docs.store_token_cache(
1550                        &uri_for_task,
1551                        result_id.clone(),
1552                        Arc::clone(&tokens_arc),
1553                    );
1554                    let data = Arc::try_unwrap(tokens_arc).unwrap_or_else(|arc| (*arc).clone());
1555                    (result_id, data)
1556                })
1557                .await
1558                .unwrap_or_default();
1559            Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
1560                result_id: Some(result_id),
1561                data,
1562            })))
1563        })
1564        .await
1565    }
1566
1567    async fn semantic_tokens_range(
1568        &self,
1569        params: SemanticTokensRangeParams,
1570    ) -> Result<Option<SemanticTokensRangeResult>> {
1571        guard_async_result("semantic_tokens_range", async move {
1572            let uri = params.text_document.uri;
1573            let doc = match self.get_doc(&uri) {
1574                Some(d) => d,
1575                None => {
1576                    return Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
1577                        result_id: None,
1578                        data: vec![],
1579                    })));
1580                }
1581            };
1582            let range = params.range;
1583            let tokens = self
1584                .blocking_gated(super::debug_gate::GATE_SEMANTIC_TOKENS_RANGE, move || {
1585                    semantic_tokens_range(doc.source(), &doc, range)
1586                })
1587                .await
1588                .unwrap_or_default();
1589            Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
1590                result_id: None,
1591                data: tokens,
1592            })))
1593        })
1594        .await
1595    }
1596
1597    async fn semantic_tokens_full_delta(
1598        &self,
1599        params: SemanticTokensDeltaParams,
1600    ) -> Result<Option<SemanticTokensFullDeltaResult>> {
1601        guard_async_result("semantic_tokens_full_delta", async move {
1602            let uri = params.text_document.uri;
1603            let doc = match self.get_doc(&uri) {
1604                Some(d) => d,
1605                None => return Ok(None),
1606            };
1607
1608            let docs = Arc::clone(&self.docs);
1609            let uri_for_task = uri.clone();
1610            let prev_id = params.previous_result_id;
1611            let result = self
1612                .blocking_gated(
1613                    super::debug_gate::GATE_SEMANTIC_TOKENS_FULL_DELTA,
1614                    move || {
1615                        let new_tokens = Arc::new(semantic_tokens(doc.source(), &doc));
1616                        let new_result_id = token_hash(&new_tokens);
1617
1618                        let result = match docs.get_token_cache(&uri_for_task, &prev_id) {
1619                            Some(old_tokens) => {
1620                                let edits = compute_token_delta(&old_tokens, &new_tokens);
1621                                SemanticTokensFullDeltaResult::TokensDelta(SemanticTokensDelta {
1622                                    result_id: Some(new_result_id.clone()),
1623                                    edits,
1624                                })
1625                            }
1626                            // Unknown previous result — fall back to full tokens
1627                            None => SemanticTokensFullDeltaResult::Tokens(SemanticTokens {
1628                                result_id: Some(new_result_id.clone()),
1629                                data: (*new_tokens).clone(),
1630                            }),
1631                        };
1632
1633                        docs.store_token_cache(&uri_for_task, new_result_id, new_tokens);
1634                        result
1635                    },
1636                )
1637                .await;
1638            let Some(result) = result else {
1639                return Ok(None);
1640            };
1641            Ok(Some(result))
1642        })
1643        .await
1644    }
1645
1646    async fn selection_range(
1647        &self,
1648        params: SelectionRangeParams,
1649    ) -> Result<Option<Vec<SelectionRange>>> {
1650        guard_async_result("selection_range", async move {
1651            let uri = &params.text_document.uri;
1652            let doc = match self.get_doc(uri) {
1653                Some(d) => d,
1654                None => return Ok(None),
1655            };
1656            // Walks every top-level statement per requested position; keep
1657            // that off the async runtime worker, same as document_symbol
1658            // and folding_range's whole-document walks.
1659            let ranges = self
1660                .blocking_gated(super::debug_gate::GATE_SELECTION_RANGE, move || {
1661                    selection_ranges(&doc, &params.positions)
1662                })
1663                .await
1664                .unwrap_or_default();
1665            Ok(if ranges.is_empty() {
1666                None
1667            } else {
1668                Some(ranges)
1669            })
1670        })
1671        .await
1672    }
1673
1674    async fn prepare_call_hierarchy(
1675        &self,
1676        params: CallHierarchyPrepareParams,
1677    ) -> Result<Option<Vec<CallHierarchyItem>>> {
1678        guard_async_result("prepare_call_hierarchy", async move {
1679            let uri = &params.text_document_position_params.text_document.uri;
1680            let position = params.text_document_position_params.position;
1681            let source = self.get_open_text(uri).unwrap_or_default();
1682            let word = match word_at_position(&source, position) {
1683                Some(w) => w,
1684                None => return Ok(None),
1685            };
1686            // A bare keyword can never be a callable name; skip it here so it
1687            // doesn't fall through to the trait-alias exhaustive scan in
1688            // `prepare_call_hierarchy_indexed`.
1689            if is_unresolvable_bareword_at(&source, position, &word) {
1690                return Ok(None);
1691            }
1692            // O(matches) lookup via mir's mention index instead of scanning
1693            // every workspace doc, but a miss falls through to a
1694            // workspace-wide trait-alias scan — keep that off the async
1695            // runtime worker, same as incoming_calls/outgoing_calls below.
1696            let wi = self.workspace_index_async().await;
1697            let docs = Arc::clone(&self.docs);
1698            let cursor_uri = uri.clone();
1699            let item = self
1700                .blocking_gated(super::debug_gate::GATE_PREPARE_CALL_HIERARCHY, move || {
1701                    let get_doc = |u: &Uri| docs.get_doc_salsa(u);
1702                    let mention_candidates =
1703                        |name: &str| docs.declaration_candidate_files(&wi, name);
1704                    prepare_call_hierarchy_indexed(
1705                        &word,
1706                        (&cursor_uri, position),
1707                        &wi,
1708                        &get_doc,
1709                        &mention_candidates,
1710                    )
1711                })
1712                .await
1713                .flatten();
1714            Ok(item.map(|item| vec![item]))
1715        })
1716        .await
1717    }
1718
1719    async fn incoming_calls(
1720        &self,
1721        params: CallHierarchyIncomingCallsParams,
1722    ) -> Result<Option<Vec<CallHierarchyIncomingCall>>> {
1723        guard_async_result("incoming_calls", async move {
1724            // Call sites come from mir's posting lists; only the documents
1725            // containing them are parsed to resolve the enclosing caller.
1726            let docs = Arc::clone(&self.docs);
1727            let item = params.item;
1728            let calls = self
1729                .blocking("incoming_calls", move || {
1730                    // Pause the background scan and snapshot a settled revision so
1731                    // only a genuine user edit cancels the lookup.
1732                    let (_interactive, cancel_rev) = docs.settled_write_rev_guard();
1733                    incoming_calls_indexed(&item, &docs, Some(cancel_rev))
1734                })
1735                .await
1736                .unwrap_or_default();
1737            Ok(if calls.is_empty() { None } else { Some(calls) })
1738        })
1739        .await
1740    }
1741
1742    async fn outgoing_calls(
1743        &self,
1744        params: CallHierarchyOutgoingCallsParams,
1745    ) -> Result<Option<Vec<CallHierarchyOutgoingCall>>> {
1746        guard_async_result("outgoing_calls", async move {
1747            let docs = Arc::clone(&self.docs);
1748            let item = params.item;
1749            let calls = self
1750                .blocking("outgoing_calls", move || {
1751                    outgoing_calls_via_mir(&docs, &item).unwrap_or_default()
1752                })
1753                .await
1754                .unwrap_or_default();
1755            Ok(if calls.is_empty() { None } else { Some(calls) })
1756        })
1757        .await
1758    }
1759
1760    async fn document_highlight(
1761        &self,
1762        params: DocumentHighlightParams,
1763    ) -> Result<Option<Vec<DocumentHighlight>>> {
1764        guard_async_result("document_highlight", async move {
1765            let uri = &params.text_document_position_params.text_document.uri;
1766            let position = params.text_document_position_params.position;
1767            let source = self.get_open_text(uri).unwrap_or_default();
1768            // Stale-tolerant: a highlight fired on every cursor move must never
1769            // spin on the salsa `Cancelled` retry loop while the user types.
1770            let doc = match self.get_doc_stale(uri) {
1771                Some(d) => d,
1772                None => return Ok(None),
1773            };
1774            // The AST walk is CPU-bound; keep it off the async runtime worker.
1775            let highlights = self
1776                .blocking("document_highlight", move || {
1777                    document_highlights(&source, &doc, position)
1778                })
1779                .await
1780                .unwrap_or_default();
1781            Ok(if highlights.is_empty() {
1782                None
1783            } else {
1784                Some(highlights)
1785            })
1786        })
1787        .await
1788    }
1789
1790    async fn linked_editing_range(
1791        &self,
1792        params: LinkedEditingRangeParams,
1793    ) -> Result<Option<LinkedEditingRanges>> {
1794        guard_async_result("linked_editing_range", async move {
1795            self.handle_linked_editing_range(params).await
1796        })
1797        .await
1798    }
1799
1800    async fn goto_implementation(
1801        &self,
1802        params: tower_lsp_server::ls_types::request::GotoImplementationParams,
1803    ) -> Result<Option<tower_lsp_server::ls_types::request::GotoImplementationResponse>> {
1804        guard_async_result("goto_implementation", async move {
1805            let uri = &params.text_document_position_params.text_document.uri;
1806            let position = params.text_document_position_params.position;
1807            let source = self.get_open_text(uri).unwrap_or_default();
1808            let imports = self.file_imports(uri);
1809            let raw_word = crate::text::word_at_position(&source, position).unwrap_or_default();
1810            if raw_word.is_empty() || is_unresolvable_bareword_at(&source, position, &raw_word) {
1811                return Ok(None);
1812            }
1813            // `word_at_position` includes `\` as a word character, so the cursor on
1814            // a use-statement import (`use A\B\Foo`) returns the full qualified name.
1815            let (word, fqn): (String, String) = if is_bare_php_name(&raw_word) {
1816                // Resolve via this file's imports + namespace; covers usages,
1817                // aliases, and the type's own declaration (which resolves to
1818                // `<current-namespace>\<word>`).
1819                let resolved = match self.get_doc(uri) {
1820                    Some(doc) => crate::navigation::moniker::resolve_fqn(&doc, &raw_word, &imports),
1821                    None => raw_word.clone(),
1822                };
1823                (raw_word, normalized_php_fqn(&resolved).to_string())
1824            } else {
1825                let short = crate::text::fqn_short_name(&raw_word).to_string();
1826                (short, normalized_php_fqn(&raw_word).to_string())
1827            };
1828
1829            // A method name can collide case-insensitively with a class name
1830            // (`Guard` interface vs `guard()` method in one namespace); mir's
1831            // symbol at the cursor decides which sense wins.
1832            let method_offset = self.get_doc(uri).and_then(|doc| {
1833                crate::text::word_range_at(&source, position)
1834                    .map(|r| doc.view().byte_of_position(r.start))
1835            });
1836            let docs_for_method_check = Arc::clone(&self.docs);
1837            let uri_for_method_check = uri.clone();
1838            let on_method_decl = self
1839                .blocking_gated(super::debug_gate::GATE_GOTO_IMPLEMENTATION, move || {
1840                    method_offset.is_some_and(|offset| {
1841                        matches!(
1842                            docs_for_method_check.mir_name_at(&uri_for_method_check, offset),
1843                            Ok(Some(mir_analyzer::Name::Method { .. }))
1844                        )
1845                    })
1846                })
1847                .await
1848                .unwrap_or(false);
1849
1850            // Subtypes of the type under the cursor, from mir's maintained
1851            // subtype edge index (aliased/FQN extends forms all resolve).
1852            let mut locs: Vec<Location> = if on_method_decl {
1853                Vec::new()
1854            } else {
1855                let docs = Arc::clone(&self.docs);
1856                let fqn_task = fqn.clone();
1857                self.blocking("goto_implementation.subtypes", move || {
1858                    docs.indexed_subtype_classes(&fqn_task, false)
1859                })
1860                .await
1861                .unwrap_or_else(|| Ok(Vec::new()))?
1862                .into_iter()
1863                .filter_map(|site| subtype_site_to_location(&site.file, &site.range))
1864                .collect()
1865            };
1866            if locs.is_empty() && !on_method_decl {
1867                let wi = self.workspace_index_async().await;
1868                let docs = Arc::clone(&self.docs);
1869                let target_fqn = fqn.clone();
1870                let target_short = word.clone();
1871                locs = self
1872                    .blocking("goto_implementation.index_fallback", move || {
1873                        let mention_candidates =
1874                            |name: &str| docs.declaration_candidate_files(&wi, name);
1875                        let get_doc = |uri: &Uri| docs.get_doc_salsa(uri);
1876                        implementation_locations_from_index(
1877                            &wi,
1878                            &target_fqn,
1879                            &target_short,
1880                            &mention_candidates,
1881                            &get_doc,
1882                        )
1883                    })
1884                    .await
1885                    .unwrap_or_default();
1886            }
1887
1888            // Cursor on a method name inside its declaring class/interface:
1889            // concrete overrides in every subtype.
1890            if locs.is_empty()
1891                && let Some(doc) = self.get_doc(uri)
1892                && let Some(enclosing) =
1893                    crate::types::type_map::enclosing_class_at(&source, &doc, position)
1894            {
1895                let resolved_enclosing =
1896                    crate::navigation::moniker::resolve_fqn(&doc, &enclosing, &imports);
1897                let enclosing_fqn = normalized_php_fqn(&resolved_enclosing).to_string();
1898                let docs = Arc::clone(&self.docs);
1899                let method = word.clone();
1900                let impls = self
1901                    .blocking("goto_implementation.method_impls", move || {
1902                        docs.indexed_method_implementations(&enclosing_fqn, &method)
1903                    })
1904                    .await
1905                    .unwrap_or_else(|| Ok(Vec::new()))?;
1906                locs = impls
1907                    .into_iter()
1908                    .filter_map(|(_, file, range)| subtype_site_to_location(&file, &range))
1909                    .collect();
1910            }
1911            if locs.is_empty() {
1912                Ok(None)
1913            } else {
1914                Ok(Some(GotoDefinitionResponse::Array(locs)))
1915            }
1916        })
1917        .await
1918    }
1919
1920    async fn goto_declaration(
1921        &self,
1922        params: tower_lsp_server::ls_types::request::GotoDeclarationParams,
1923    ) -> Result<Option<tower_lsp_server::ls_types::request::GotoDeclarationResponse>> {
1924        guard_async_result("goto_declaration", async move {
1925            let uri = &params.text_document_position_params.text_document.uri;
1926            let position = params.text_document_position_params.position;
1927            let source = self.get_open_text(uri).unwrap_or_default();
1928            if let Some(doc) = self.get_doc(uri)
1929                && let Some(offset) = crate::text::word_range_at(&source, position)
1930                    .map(|range| doc.view().byte_of_position(range.start))
1931            {
1932                let docs = Arc::clone(&self.docs);
1933                let uri_task = uri.clone();
1934                let implemented = self
1935                    .blocking("goto_declaration.implemented", move || {
1936                        crate::navigation::mir_definition::mir_implemented_declaration(
1937                            &docs, &uri_task, offset,
1938                        )
1939                    })
1940                    .await;
1941                if let Some(Ok(Some(loc))) = implemented {
1942                    return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1943                }
1944            }
1945            // First pass: open-file ParsedDocs give accurate character
1946            // positions. Walking every open doc's AST is CPU-bound; keep it
1947            // off the async runtime worker. A separate hop (rather than one
1948            // spawn_blocking around both passes) so a first-pass hit skips
1949            // the second pass's workspace-index fetch entirely, as before.
1950            let open_docs = self.docs.docs_for(&self.open_urls());
1951            let source_for_pass1 = source.clone();
1952            let first_pass = self
1953                .blocking("goto_declaration.open_docs", move || {
1954                    goto_declaration(&source_for_pass1, &open_docs, position)
1955                })
1956                .await
1957                .unwrap_or_default();
1958            if let Some(loc) = first_pass {
1959                return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1960            }
1961            // Second pass: background files via the aggregated workspace index
1962            // (line-only positions for anything not covered by a mention hit).
1963            let wi = self.workspace_index_async().await;
1964            let docs = Arc::clone(&self.docs);
1965            let second_pass = self
1966                .blocking("goto_declaration.index", move || {
1967                    let mention_candidates =
1968                        |name: &str| docs.declaration_candidate_files(&wi, name);
1969                    let get_doc = |uri: &Uri| docs.get_doc_salsa(uri);
1970                    goto_declaration_from_index(
1971                        &source,
1972                        &wi,
1973                        position,
1974                        &get_doc,
1975                        &mention_candidates,
1976                    )
1977                })
1978                .await
1979                .unwrap_or_default();
1980            Ok(second_pass.map(GotoDefinitionResponse::Scalar))
1981        })
1982        .await
1983    }
1984
1985    async fn goto_type_definition(
1986        &self,
1987        params: tower_lsp_server::ls_types::request::GotoTypeDefinitionParams,
1988    ) -> Result<Option<tower_lsp_server::ls_types::request::GotoTypeDefinitionResponse>> {
1989        guard_async_result("goto_type_definition", async move {
1990            let uri = &params.text_document_position_params.text_document.uri;
1991            let position = params.text_document_position_params.position;
1992            let source = self.get_open_text(uri).unwrap_or_default();
1993            let doc = match self.get_doc(uri) {
1994                Some(d) => d,
1995                None => return Ok(None),
1996            };
1997            let analysis = self.cached_analysis_async(uri).await;
1998            let docs = Arc::clone(&self.docs);
1999
2000            let results = self
2001                .blocking_gated(super::debug_gate::GATE_GOTO_TYPE_DEFINITION, move || {
2002                    let fqns = type_class_fqns(&source, &doc, analysis.as_deref(), position);
2003                    crate::navigation::mir_definition::mir_class_locations(&docs, &fqns)
2004                })
2005                .await
2006                .unwrap_or_else(|| Ok(Vec::new()))?;
2007            Ok(match results.len() {
2008                0 => None,
2009                1 => Some(GotoDefinitionResponse::Scalar(
2010                    results.into_iter().next().unwrap(),
2011                )),
2012                _ => Some(GotoDefinitionResponse::Array(results)),
2013            })
2014        })
2015        .await
2016    }
2017
2018    async fn prepare_type_hierarchy(
2019        &self,
2020        params: TypeHierarchyPrepareParams,
2021    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
2022        guard_async_result("prepare_type_hierarchy", async move {
2023            let uri = &params.text_document_position_params.text_document.uri;
2024            let position = params.text_document_position_params.position;
2025            let wi = self.workspace_index_async().await;
2026            let class_ref = self
2027                .get_doc(uri)
2028                .and_then(|doc| {
2029                    word_at_position(doc.source(), position).map(|word| {
2030                        let imports = doc.file_imports();
2031                        crate::navigation::moniker::resolve_fqn(&doc, &word, &imports)
2032                    })
2033                })
2034                .and_then(|fqn| self.docs.class_ref_by_fqn(&wi, &fqn));
2035            let item = self
2036                .blocking_gated(super::debug_gate::GATE_TYPE_HIERARCHY, move || {
2037                    prepare_type_hierarchy_from_fqn(class_ref?, &wi)
2038                })
2039                .await
2040                .flatten();
2041            Ok(item.map(|item| vec![item]))
2042        })
2043        .await
2044    }
2045
2046    async fn supertypes(
2047        &self,
2048        params: TypeHierarchySupertypesParams,
2049    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
2050        guard_async_result("supertypes", async move {
2051            // Phase J: resolve parents via the aggregate, mention-index-narrowed.
2052            // Pre-load any direct vendor supertypes via PSR-4 so they appear in the
2053            // workspace index before the lookup runs.
2054            let wi = self.workspace_index_async().await;
2055            let Some(item_fqn) = crate::navigation::type_hierarchy::item_fqn(&params.item) else {
2056                return Ok(None);
2057            };
2058            let loaded_new = self.ensure_direct_supertypes_loaded(item_fqn, &wi).await;
2059            let wi = if loaded_new {
2060                self.workspace_index_async().await
2061            } else {
2062                wi
2063            };
2064            let docs = Arc::clone(&self.docs);
2065            let item_fqn = item_fqn.to_owned();
2066            let result = self
2067                .blocking_gated(super::debug_gate::GATE_TYPE_HIERARCHY, move || {
2068                    let supertype_fqns = docs.class_direct_supertypes(&item_fqn)?;
2069                    let resolve_class_ref = |fqn: &str| docs.resolve_class_ref_by_fqn(&wi, fqn);
2070                    Ok::<_, crate::document::document_store::ContentModified>(
2071                        supertypes_of_from_workspace(&supertype_fqns, &wi, &resolve_class_ref),
2072                    )
2073                })
2074                .await
2075                .unwrap_or_else(|| Ok(Vec::new()))?;
2076            Ok(if result.is_empty() {
2077                None
2078            } else {
2079                Some(result)
2080            })
2081        })
2082        .await
2083    }
2084
2085    async fn subtypes(
2086        &self,
2087        params: TypeHierarchySubtypesParams,
2088    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
2089        guard_async_result("subtypes", async move {
2090            let docs = Arc::clone(&self.docs);
2091            let item = params.item;
2092            let Some(item_fqn) = crate::navigation::type_hierarchy::item_fqn(&item) else {
2093                return Ok(None);
2094            };
2095            let item_fqn = item_fqn.to_owned();
2096            let result = self
2097                .blocking_gated(super::debug_gate::GATE_TYPE_HIERARCHY, move || {
2098                    let sites = docs.indexed_direct_subtype_classes(&item_fqn, true)?;
2099                    Ok::<_, crate::document::document_store::ContentModified>(subtypes_from_sites(
2100                        &sites,
2101                    ))
2102                })
2103                .await
2104                .unwrap_or_else(|| Ok(Vec::new()))?;
2105            Ok(if result.is_empty() {
2106                None
2107            } else {
2108                Some(result)
2109            })
2110        })
2111        .await
2112    }
2113
2114    async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
2115        guard_async_result("code_lens", async move {
2116            let uri = &params.text_document.uri;
2117            let doc = match self.get_doc(uri) {
2118                Some(d) => d,
2119                None => return Ok(None),
2120            };
2121            // Lens counts come from mir's inverted indexes; still run on the
2122            // blocking pool (index freshness passes may analyze on demand).
2123            // A write during computation (write_rev advances) cancels early —
2124            // stale lens counts are useless and the editor will request fresh
2125            // ones after the edit.
2126            let docs = Arc::clone(&self.docs);
2127            let docs_cancel = Arc::clone(&self.docs);
2128            let uri_owned = uri.clone();
2129            let imports = self.file_imports(uri);
2130            let lenses = self
2131                .blocking("code_lens", move || {
2132                    // Pause the background scan and snapshot a settled revision so
2133                    // only a genuine user edit cancels the sweep.
2134                    let (_interactive, cancel_rev) = docs.settled_write_rev_guard();
2135                    code_lenses(
2136                        &uri_owned,
2137                        &doc,
2138                        &docs,
2139                        &imports,
2140                        Some(cancel_rev),
2141                        move || docs_cancel.write_rev() != cancel_rev,
2142                    )
2143                })
2144                .await
2145                .unwrap_or_default();
2146            Ok(if lenses.is_empty() {
2147                None
2148            } else {
2149                Some(lenses)
2150            })
2151        })
2152        .await
2153    }
2154
2155    async fn code_lens_resolve(&self, params: CodeLens) -> Result<CodeLens> {
2156        let fallback = params.clone();
2157        let resolved = guard_async("code_lens_resolve", async move {
2158            // Lenses are fully populated by code_lens; nothing to add.
2159            Some(params)
2160        })
2161        .await;
2162        Ok(resolved.unwrap_or(fallback))
2163    }
2164
2165    async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
2166        guard_async_result("document_link", async move {
2167            let uri = params.text_document.uri;
2168            let doc = match self.get_doc(&uri) {
2169                Some(d) => d,
2170                None => return Ok(None),
2171            };
2172            let laravel = self.laravel.load_full();
2173            // The AST walk plus full-text `@link`/`@see` scan are CPU-bound;
2174            // keep them off the async runtime worker.
2175            let links = self
2176                .blocking_gated(super::debug_gate::GATE_DOCUMENT_LINK, move || {
2177                    let mut links = document_links(&uri, &doc, doc.source());
2178                    links.extend(crate::laravel::document_links(&doc, &laravel));
2179                    links.extend(crate::laravel::blade::document_links(
2180                        &uri,
2181                        doc.source(),
2182                        &laravel,
2183                    ));
2184                    links
2185                })
2186                .await
2187                .unwrap_or_default();
2188            Ok(if links.is_empty() { None } else { Some(links) })
2189        })
2190        .await
2191    }
2192
2193    async fn document_link_resolve(&self, params: DocumentLink) -> Result<DocumentLink> {
2194        let fallback = params.clone();
2195        let resolved = guard_async("document_link_resolve", async move {
2196            // Links already carry their target URI; nothing to add.
2197            Some(params)
2198        })
2199        .await;
2200        Ok(resolved.unwrap_or(fallback))
2201    }
2202
2203    async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
2204        guard_async_result("formatting", async move {
2205            let uri = &params.text_document.uri;
2206            let source = self.get_open_text(uri).unwrap_or_default();
2207            // Spawns and blocks on an external formatter process; keep that
2208            // wait off the async runtime worker.
2209            Ok(self
2210                .blocking("formatting", move || format_document(&source))
2211                .await
2212                .unwrap_or_default())
2213        })
2214        .await
2215    }
2216
2217    async fn range_formatting(
2218        &self,
2219        params: DocumentRangeFormattingParams,
2220    ) -> Result<Option<Vec<TextEdit>>> {
2221        guard_async_result("range_formatting", async move {
2222            let uri = &params.text_document.uri;
2223            let source = self.get_open_text(uri).unwrap_or_default();
2224            let range = params.range;
2225            Ok(self
2226                .blocking("range_formatting", move || format_range(&source, range))
2227                .await
2228                .unwrap_or_default())
2229        })
2230        .await
2231    }
2232
2233    async fn on_type_formatting(
2234        &self,
2235        params: DocumentOnTypeFormattingParams,
2236    ) -> Result<Option<Vec<TextEdit>>> {
2237        guard_async_result("on_type_formatting", async move {
2238            let uri = &params.text_document_position.text_document.uri;
2239            let source = self.get_open_text(uri).unwrap_or_default();
2240            let position = params.text_document_position.position;
2241            // `}` fires a whole-document char-vec + string-literal-mask scan
2242            // to find the matching brace; every keystroke fires this handler,
2243            // so keep it off the async runtime worker like the other
2244            // whole-document walks.
2245            let edits = self
2246                .blocking_gated(super::debug_gate::GATE_ON_TYPE_FORMATTING, move || {
2247                    on_type_format(&source, position, &params.ch, &params.options)
2248                })
2249                .await
2250                .unwrap_or_default();
2251            Ok(if edits.is_empty() { None } else { Some(edits) })
2252        })
2253        .await
2254    }
2255
2256    async fn execute_command(
2257        &self,
2258        params: ExecuteCommandParams,
2259    ) -> Result<Option<serde_json::Value>> {
2260        guard_async_result("execute_command", async move {
2261            match params.command.as_str() {
2262                "php-lsp.runTest" => {
2263                    // Arguments: [uri_string, "ClassName::methodName"]
2264                    let file_uri = params
2265                        .arguments
2266                        .first()
2267                        .and_then(|v| v.as_str())
2268                        .and_then(|s| (s).parse::<Uri>().ok());
2269                    let filter = params
2270                        .arguments
2271                        .get(1)
2272                        .and_then(|v| v.as_str())
2273                        .unwrap_or("")
2274                        .to_string();
2275
2276                    let root = self.root_paths.load().first().cloned();
2277                    let client = self.client.clone();
2278
2279                    tokio::spawn(async move {
2280                        run_phpunit(&client, &filter, root.as_deref(), file_uri.as_ref()).await;
2281                    });
2282
2283                    Ok(None)
2284                }
2285                _ => Ok(None),
2286            }
2287        })
2288        .await
2289    }
2290
2291    async fn will_rename_files(&self, params: RenameFilesParams) -> Result<Option<WorkspaceEdit>> {
2292        guard_async_result("will_rename_files", async move {
2293            self.handle_will_rename_files(params).await
2294        })
2295        .await
2296    }
2297
2298    async fn did_rename_files(&self, params: RenameFilesParams) {
2299        guard_async("did_rename_files", async move {
2300            self.handle_did_rename_files(params).await
2301        })
2302        .await
2303    }
2304
2305    async fn will_create_files(&self, params: CreateFilesParams) -> Result<Option<WorkspaceEdit>> {
2306        guard_async_result("will_create_files", async move {
2307            self.handle_will_create_files(params).await
2308        })
2309        .await
2310    }
2311
2312    async fn did_create_files(&self, params: CreateFilesParams) {
2313        guard_async("did_create_files", async move {
2314            self.handle_did_create_files(params).await
2315        })
2316        .await
2317    }
2318
2319    /// Before a file is deleted, return workspace edits that remove every
2320    /// `use` import referencing its PSR-4 class name.
2321    async fn will_delete_files(&self, params: DeleteFilesParams) -> Result<Option<WorkspaceEdit>> {
2322        guard_async_result("will_delete_files", async move {
2323            self.handle_will_delete_files(params).await
2324        })
2325        .await
2326    }
2327
2328    async fn did_delete_files(&self, params: DeleteFilesParams) {
2329        guard_async("did_delete_files", async move {
2330            self.handle_did_delete_files(params).await
2331        })
2332        .await
2333    }
2334
2335    async fn moniker(&self, params: MonikerParams) -> Result<Option<Vec<Moniker>>> {
2336        guard_async_result("moniker", async move {
2337            let uri = &params.text_document_position_params.text_document.uri;
2338            let position = params.text_document_position_params.position;
2339            let source = self.get_open_text(uri).unwrap_or_default();
2340            let doc = match self.get_doc(uri) {
2341                Some(d) => d,
2342                None => return Ok(None),
2343            };
2344            let imports = self.file_imports(uri);
2345            let moniker = self
2346                .blocking_gated(super::debug_gate::GATE_MONIKER, move || {
2347                    moniker_at(&source, &doc, position, &imports)
2348                })
2349                .await
2350                .flatten();
2351            Ok(moniker.map(|m| vec![m]))
2352        })
2353        .await
2354    }
2355
2356    async fn inline_value(&self, params: InlineValueParams) -> Result<Option<Vec<InlineValue>>> {
2357        guard_async_result("inline_value", async move {
2358            let uri = &params.text_document.uri;
2359            let source = self.get_open_text(uri).unwrap_or_default();
2360            let range = params.range;
2361            let values = self
2362                .blocking_gated(super::debug_gate::GATE_INLINE_VALUE, move || {
2363                    inline_values_in_range(&source, range)
2364                })
2365                .await
2366                .unwrap_or_default();
2367            Ok(if values.is_empty() {
2368                None
2369            } else {
2370                Some(values)
2371            })
2372        })
2373        .await
2374    }
2375
2376    async fn diagnostic(
2377        &self,
2378        params: DocumentDiagnosticParams,
2379    ) -> Result<DocumentDiagnosticReportResult> {
2380        let report = guard_async("diagnostic", async move {
2381            Some(self.handle_diagnostic(params).await)
2382        })
2383        .await;
2384        match report {
2385            Some(r) => r,
2386            None => Ok(DocumentDiagnosticReportResult::Report(
2387                DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport::default()),
2388            )),
2389        }
2390    }
2391
2392    async fn workspace_diagnostic(
2393        &self,
2394        params: WorkspaceDiagnosticParams,
2395    ) -> Result<WorkspaceDiagnosticReportResult> {
2396        let report = guard_async("workspace_diagnostic", async move {
2397            Some(self.handle_workspace_diagnostic(params).await)
2398        })
2399        .await;
2400        match report {
2401            Some(r) => r,
2402            None => Ok(WorkspaceDiagnosticReportResult::Report(
2403                WorkspaceDiagnosticReport::default(),
2404            )),
2405        }
2406    }
2407
2408    async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
2409        guard_async_result("code_action", async move {
2410            self.handle_code_action(params).await
2411        })
2412        .await
2413    }
2414
2415    async fn code_action_resolve(&self, item: CodeAction) -> Result<CodeAction> {
2416        guard_async_result("code_action_resolve", async move {
2417            self.handle_code_action_resolve(item).await
2418        })
2419        .await
2420    }
2421}
2422
2423/// Fallback for `goto_implementation` when mir has no resolved subtype sites.
2424///
2425/// The primary path above is the authoritative one: mir's graph has already
2426/// resolved aliases, leading slashes, and namespace context. This fallback keeps
2427/// interactive behavior useful while the graph is cold or while a workspace has
2428/// incomplete/invalid PHP by narrowing to files that mention the target short
2429/// name, then verifying each candidate's declaration-level `extends`/
2430/// `implements` clauses against its own namespace/import context. Exact FQN
2431/// matches are preferred; the short-name pass preserves the legacy behavior for
2432/// unresolved symbols without scanning the whole workspace.
2433fn implementation_locations_from_index(
2434    wi: &crate::db::workspace_index::WorkspaceIndexData,
2435    target_fqn: &str,
2436    target_short: &str,
2437    mention_candidates: &dyn Fn(&str) -> Vec<Uri>,
2438    get_doc: &dyn Fn(&Uri) -> Option<Arc<ParsedDoc>>,
2439) -> Vec<Location> {
2440    let mut exact = Vec::new();
2441    let mut short_name = Vec::new();
2442    let candidate_uris = mention_candidates(target_short);
2443    wi.for_each_class_in_uris(&candidate_uris, |uri, cls| {
2444        let Some(doc) = get_doc(uri) else {
2445            return;
2446        };
2447        let imports = doc.file_imports();
2448        let mut saw_short_name_match = false;
2449        let mut relation_matches = |name: &str| {
2450            let resolved = crate::navigation::moniker::resolve_fqn(&doc, name, &imports);
2451            if normalized_php_fqn(&resolved).eq_ignore_ascii_case(target_fqn) {
2452                return true;
2453            }
2454            if crate::text::fqn_short_name(name).eq_ignore_ascii_case(target_short) {
2455                saw_short_name_match = true;
2456            }
2457            false
2458        };
2459        let exact_match = cls.parent.as_deref().is_some_and(&mut relation_matches)
2460            || cls
2461                .implements
2462                .iter()
2463                .any(|iface| relation_matches(iface.as_ref()));
2464
2465        let loc = class_index_location(uri, cls);
2466        if exact_match {
2467            exact.push(loc);
2468        } else if saw_short_name_match {
2469            short_name.push(loc);
2470        }
2471    });
2472
2473    let mut result = if exact.is_empty() { short_name } else { exact };
2474    result.sort_by(|a, b| {
2475        a.uri
2476            .as_str()
2477            .cmp(b.uri.as_str())
2478            .then(a.range.start.line.cmp(&b.range.start.line))
2479            .then(a.range.start.character.cmp(&b.range.start.character))
2480    });
2481    result.dedup_by(|a, b| a.uri == b.uri && a.range == b.range);
2482    result
2483}
2484
2485fn class_index_location(uri: &Uri, cls: &crate::index::file_index::ClassDef) -> Location {
2486    let start = Position {
2487        line: cls.start_line,
2488        character: cls.name_char,
2489    };
2490    Location {
2491        uri: uri.clone(),
2492        range: Range {
2493            start,
2494            end: Position {
2495                line: cls.start_line,
2496                character: cls.name_char + cls.name.len() as u32,
2497            },
2498        },
2499    }
2500}
2501
2502#[cfg(test)]
2503mod tests {
2504    use super::*;
2505
2506    /// Resolving a bare builtin interface loads its stub (a salsa input
2507    /// write) — the hover's own db handle must be gone by then. Mirrored via
2508    /// `upsert_source_file`, like the workspace scan, so no stub is preloaded.
2509    #[test]
2510    fn class_hover_loads_builtin_interface_stub_without_deadlock() {
2511        let (tx, rx) = std::sync::mpsc::channel();
2512        std::thread::spawn(move || {
2513            let mut session = mir_analyzer::AnalysisSession::new(mir_analyzer::PhpVersion::LATEST);
2514            session.upsert_source_file(
2515                Arc::from("file:///foo.php"),
2516                Arc::from("<?php\nclass Foo implements Countable {\n    public function count(): int { return 0; }\n}\n"),
2517                salsa::Durability::LOW,
2518            );
2519            let hover = class_hover_for_fqcn(&session.snapshot(), "Foo");
2520            let _ = tx.send(hover.map(|h| match h.contents {
2521                HoverContents::Markup(m) => m.value,
2522                _ => String::new(),
2523            }));
2524        });
2525        let value = rx
2526            .recv_timeout(std::time::Duration::from_secs(30))
2527            .expect("class hover deadlocked")
2528            .expect("class hover for Foo");
2529        assert!(value.contains("implements \\Countable"), "{value}");
2530    }
2531}