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