Skip to main content

php_lsp/backend/
server.rs

1#![allow(unused_imports)]
2
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use arc_swap::ArcSwap;
7
8use tower_lsp::jsonrpc::Result;
9use tower_lsp::lsp_types::notification::Progress as ProgressNotification;
10use tower_lsp::lsp_types::request::WorkDoneProgressCreate;
11use tower_lsp::lsp_types::*;
12use tower_lsp::{Client, LanguageServer, async_trait};
13
14use php_ast::{
15    ClassMember, ClassMemberKind, EnumMember, EnumMemberKind, ExprKind, NamespaceBody, Stmt,
16    StmtKind,
17};
18
19use super::panic_guard::{guard_async, guard_async_result};
20use crate::completion::{CompletionCtx, filtered_completions_at};
21use crate::document::ast::{ParsedDoc, str_offset};
22use crate::document::document_store::DocumentStore;
23use crate::document::open_files::{OpenFiles, compute_open_file_diagnostics};
24use crate::editing::file_rename::{use_edits_for_delete, use_edits_for_rename};
25use crate::editing::use_import::{build_use_import_edit, find_fqn_for_class};
26use crate::hover::{
27    class_hover_from_index, docs_for_symbol_from_index, extract_static_class_before_cursor,
28    hover_info_with_maps, method_hover_from_index, signature_for_symbol_from_index,
29};
30use crate::index::workspace_scan::{scan_workspace, send_refresh_requests};
31use crate::lang::autoload::Psr4Map;
32use crate::lang::config::LspConfig;
33use crate::lang::phpstorm_meta::PhpStormMeta;
34use crate::navigation::symbols::{
35    document_symbols, resolve_workspace_symbol, workspace_symbols_from_workspace,
36};
37use crate::text::{fqn_short_name, word_at_position};
38
39use crate::actions::extract_action::extract_variable_actions;
40use crate::actions::extract_constant_action::extract_constant_actions;
41use crate::actions::extract_method_action::extract_method_actions;
42use crate::actions::generate_action::{
43    generate_constructor_actions, generate_getters_setters_actions,
44};
45use crate::actions::implement_action::implement_missing_actions;
46use crate::actions::inline_action::inline_variable_actions;
47use crate::actions::phpdoc_action::phpdoc_actions;
48use crate::actions::promote_action::promote_constructor_actions;
49use crate::actions::type_action::add_return_type_actions;
50
51use crate::navigation::call_hierarchy::{
52    incoming_calls, outgoing_calls_indexed, prepare_call_hierarchy_indexed,
53};
54use crate::navigation::declaration::{goto_declaration, goto_declaration_from_index};
55use crate::navigation::definition::{
56    find_declaration_range, find_method_in_class_hierarchy, find_method_range_in_class,
57    goto_definition,
58};
59use crate::navigation::implementation::{
60    find_implementations, find_implementations_from_workspace,
61    find_method_implementations_from_workspace,
62};
63use crate::navigation::moniker::moniker_at;
64use crate::navigation::references::{
65    SymbolKind, find_constructor_references, find_references, find_references_with_target,
66};
67use crate::navigation::type_definition::{goto_type_definition, goto_type_definition_from_index};
68use crate::navigation::type_hierarchy::{
69    prepare_type_hierarchy_from_workspace, subtypes_of_from_workspace, supertypes_of_from_workspace,
70};
71
72use crate::analysis::code_lens::code_lenses;
73use crate::analysis::diagnostics::{
74    diagnostics_from_doc, merge_file_diagnostics, parse_document, parse_document_no_diags,
75};
76use crate::analysis::document_highlight::document_highlights;
77use crate::analysis::inlay_hints::inlay_hints;
78use crate::analysis::inline_value::inline_values_in_range;
79use crate::analysis::semantic_tokens::{
80    compute_token_delta, legend, semantic_tokens, semantic_tokens_range, token_hash,
81};
82
83use crate::editing::document_link::document_links;
84use crate::editing::folding::folding_ranges;
85use crate::editing::formatting::{format_document, format_range};
86use crate::editing::on_type_format::on_type_format;
87use crate::editing::organize_imports::organize_imports_action;
88use crate::editing::rename::{prepare_rename, rename, rename_property, rename_variable};
89use crate::editing::selection_range::selection_ranges;
90use crate::editing::signature_help::signature_help;
91
92use super::helpers::{
93    DEFERRED_ACTION_TAGS, class_name_at_construct_decl, cursor_is_on_constant_decl,
94    cursor_is_on_method_decl, cursor_is_on_property_decl, defer_actions, is_after_arrow,
95    php_file_op, promoted_property_at_cursor, range_within, run_phpunit, symbol_kind_at,
96};
97use super::{
98    Backend, IndexReadyNotification, compute_dependent_publishes_owned,
99    compute_diagnostic_result_id, publish_with_dependents, resolve_reference_symbol,
100};
101
102#[async_trait]
103impl LanguageServer for Backend {
104    async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
105        self.handle_initialize(params).await
106    }
107
108    async fn initialized(&self, _params: InitializedParams) {
109        self.handle_initialized(_params).await
110    }
111
112    async fn did_change_configuration(&self, _params: DidChangeConfigurationParams) {
113        // Pull the current configuration from the client rather than parsing the
114        // (often-null) params.settings, which not all clients populate.
115        let items = vec![ConfigurationItem {
116            scope_uri: None,
117            section: Some("php-lsp".to_string()),
118        }];
119        if let Ok(values) = self.client.configuration(items).await
120            && let Some(value) = values.into_iter().next()
121        {
122            let roots = self.root_paths.load_full();
123
124            // Re-read .php-lsp.json so a user who edits the file and then
125            // triggers a configuration reload picks up the latest values.
126            let file_cfg = crate::lang::autoload::load_project_config_json(&roots);
127
128            if let Some(ver) = value.get("phpVersion").and_then(|v| v.as_str())
129                && !crate::lang::autoload::is_valid_php_version(ver)
130            {
131                self.client
132                    .log_message(
133                        tower_lsp::lsp_types::MessageType::WARNING,
134                        format!(
135                            "php-lsp: unsupported phpVersion {ver:?} — valid values: {}",
136                            crate::lang::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
137                        ),
138                    )
139                    .await;
140            }
141
142            let file_obj = file_cfg.as_ref().filter(|v| v.is_object());
143            let merged = LspConfig::merge_project_configs(file_obj, Some(&value));
144            let mut cfg = LspConfig::from_value(&merged);
145
146            // Resolve the PHP version and log what was chosen and why.
147            let (ver, source) = self.resolve_php_version(cfg.php_version.as_deref());
148            self.client
149                .log_message(
150                    tower_lsp::lsp_types::MessageType::INFO,
151                    format!("php-lsp: using PHP {ver} ({source})"),
152                )
153                .await;
154            // Clamp unsupported versions to the nearest supported one and warn.
155            let ver = if source != "set by editor"
156                && !crate::lang::autoload::is_valid_php_version(&ver)
157            {
158                let clamped = crate::lang::autoload::clamp_php_version(&ver);
159                self.client
160                    .show_message(
161                        tower_lsp::lsp_types::MessageType::WARNING,
162                        format!(
163                            "php-lsp: detected PHP {ver} is outside the supported range ({}); \
164                             using PHP {clamped} for analysis",
165                            crate::lang::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
166                        ),
167                    )
168                    .await;
169                clamped.to_string()
170            } else {
171                ver
172            };
173            cfg.php_version = Some(ver.clone());
174            if let Ok(pv) = ver.parse::<mir_analyzer::PhpVersion>() {
175                self.docs.set_php_version(pv);
176            }
177            self.config.store(Arc::new(cfg));
178            send_refresh_requests(&self.client).await;
179        }
180    }
181
182    async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
183        // Remove folders from our tracked roots.
184        {
185            let mut roots = (**self.root_paths.load()).clone();
186            for removed in &params.event.removed {
187                if let Ok(path) = removed.uri.to_file_path() {
188                    roots.retain(|r| r != &path);
189                }
190            }
191            self.root_paths.store(Arc::new(roots));
192        }
193
194        // Add new folders and kick off background scans for each.
195        let (exclude_paths, include_paths, max_indexed_files) = {
196            let cfg = self.config.load();
197            (
198                cfg.exclude_paths.clone(),
199                cfg.include_paths.clone(),
200                cfg.max_indexed_files,
201            )
202        };
203        for added in &params.event.added {
204            if let Ok(path) = added.uri.to_file_path() {
205                let is_new = {
206                    let mut roots = (**self.root_paths.load()).clone();
207                    if !roots.contains(&path) {
208                        roots.push(path.clone());
209                        self.root_paths.store(Arc::new(roots));
210                        true
211                    } else {
212                        false
213                    }
214                };
215                if is_new {
216                    let docs = Arc::clone(&self.docs);
217                    let open_files = self.open_files.clone();
218                    let ex = exclude_paths.clone();
219                    let ip = include_paths.clone();
220                    let path_clone = path.clone();
221                    let client = self.client.clone();
222                    tokio::spawn(async move {
223                        let cache = crate::index::cache::WorkspaceCache::new(&path_clone);
224                        scan_workspace(
225                            path_clone,
226                            docs,
227                            open_files,
228                            cache,
229                            &ex,
230                            &ip,
231                            max_indexed_files,
232                        )
233                        .await;
234                        send_refresh_requests(&client).await;
235                    });
236                }
237            }
238        }
239    }
240
241    async fn shutdown(&self) -> Result<()> {
242        Ok(())
243    }
244
245    #[tracing::instrument(skip_all)]
246    async fn did_open(&self, params: DidOpenTextDocumentParams) {
247        guard_async("did_open", async move {
248            let uri = params.text_document.uri;
249            let text = params.text_document.text;
250
251            // Store text immediately so other features work while parsing.
252            // This also mirrors the new text into salsa, so the codebase query
253            // sees it when semantic_diagnostics runs below.
254            self.set_open_text(uri.clone(), text);
255
256            // Seed parse diagnostics from the salsa-cached doc. On the fast
257            // path this is a lock-free DashMap lookup — no re-parse.
258            let parse_diags = self
259                .docs
260                .get_doc_salsa(&uri)
261                .map(|doc| diagnostics_from_doc(&doc))
262                .unwrap_or_default();
263            self.set_parse_diagnostics(&uri, parse_diags);
264
265            publish_with_dependents(
266                self.client.clone(),
267                Arc::clone(&self.docs),
268                self.open_files.clone(),
269                uri,
270                self.config.load().diagnostics.clone(),
271            )
272            .await;
273        })
274        .await
275    }
276
277    #[tracing::instrument(skip_all)]
278    async fn did_change(&self, params: DidChangeTextDocumentParams) {
279        guard_async("did_change", async move {
280            let uri = params.text_document.uri;
281            // Incremental sync: apply changes in order to the live buffer.
282            // Each ranged change refers to the document state produced by the
283            // previous one; a change without a range is a full-document
284            // replacement (clients may still send those under INCREMENTAL).
285            let mut updated: Option<String> = None;
286            for change in params.content_changes {
287                match change.range {
288                    None => updated = Some(change.text),
289                    Some(range) => {
290                        let mut cur = match updated.take() {
291                            Some(t) => t,
292                            None => self.get_open_text(&uri).unwrap_or_default(),
293                        };
294                        crate::text::apply_content_change(&mut cur, range, &change.text);
295                        updated = Some(cur);
296                    }
297                }
298            }
299            let Some(text) = updated else { return };
300
301            // Store text immediately and capture the version token.
302            // Features (completion, hover, …) see the new text instantly while
303            // the parse runs in the background.
304            let version = self.set_open_text(uri.clone(), text.clone());
305
306            let docs = Arc::clone(&self.docs);
307            let open_files = self.open_files.clone();
308            let client = self.client.clone();
309            let cfg = self.config.load();
310            let diag_cfg = cfg.diagnostics.clone();
311            let debounce_ms = cfg.debounce_ms;
312            tokio::spawn(async move {
313                // Debounce: if another edit arrives before we parse, the version
314                // gate below will discard this result.
315                tokio::time::sleep(std::time::Duration::from_millis(debounce_ms)).await;
316
317                // Skip the expensive parse+analyze if a newer edit already
318                // superseded this one. Collapses N rapid keystrokes into 1
319                // spawn_blocking call instead of N.
320                if open_files.current_version(&uri) != Some(version) {
321                    return;
322                }
323
324                let (_doc, parse_diags) =
325                    tokio::task::spawn_blocking(move || parse_document(&text))
326                        .await
327                        .unwrap_or_else(|_| (ParsedDoc::default(), vec![]));
328
329                // Only apply if no newer edit arrived while we were parsing.
330                if open_files.current_version(&uri) == Some(version) {
331                    open_files.set_parse_diagnostics(&uri, parse_diags);
332                    publish_with_dependents(client, docs, open_files, uri, diag_cfg).await;
333                }
334            });
335        })
336        .await
337    }
338
339    async fn did_close(&self, params: DidCloseTextDocumentParams) {
340        let uri = params.text_document.uri;
341        self.close_open_file(&uri);
342        // Clear editor diagnostics; the file stays indexed for cross-file features
343        self.client.publish_diagnostics(uri, vec![], None).await;
344    }
345
346    async fn will_save(&self, _params: WillSaveTextDocumentParams) {}
347
348    async fn will_save_wait_until(
349        &self,
350        params: WillSaveTextDocumentParams,
351    ) -> Result<Option<Vec<TextEdit>>> {
352        let source = self
353            .get_open_text(&params.text_document.uri)
354            .unwrap_or_default();
355        Ok(format_document(&source))
356    }
357
358    async fn did_save(&self, params: DidSaveTextDocumentParams) {
359        let uri = params.text_document.uri;
360        // Re-publish diagnostics on save so editors that defer diagnostics
361        // until save (rather than on every keystroke) see up-to-date results.
362        // Must include semantic diagnostics — publishDiagnostics replaces the
363        // prior set entirely, so omitting them would clear errors the editor
364        // showed after the last did_change.
365        let diag_cfg = self.config.load().diagnostics.clone();
366        let all = compute_open_file_diagnostics(&self.docs, &self.open_files, &uri, &diag_cfg);
367        self.client
368            .publish_diagnostics(uri.clone(), all, None)
369            .await;
370
371        // Persist the FileIndex to the disk cache so that a server restart
372        // can skip re-parsing this file even for edits that happened between
373        // workspace scans. Use the same stat-based key as the workspace scan
374        // so the entry is found on the next cold start.
375        //
376        // Both the stat and the content are read from disk inside the blocking
377        // task so they come from the same on-disk snapshot. Using the editor
378        // buffer (open_files text) instead would risk writing an index derived
379        // from unsaved edits typed after the save but before this task runs,
380        // producing a cache entry where the key (disk stat) and the value
381        // (index of newer buffer content) describe different file versions.
382        if let (Some(root), Ok(path)) =
383            (self.root_paths.load().first().cloned(), uri.to_file_path())
384        {
385            tokio::task::spawn_blocking(move || {
386                let Some(cache) = crate::index::cache::WorkspaceCache::new(&root) else {
387                    return;
388                };
389                // Stat before read to match workspace_scan ordering and
390                // minimise the TOCTOU window.
391                let Ok(meta) = std::fs::metadata(&path) else {
392                    return;
393                };
394                let Ok(text) = std::fs::read_to_string(&path) else {
395                    return;
396                };
397                let mtime_secs = meta
398                    .modified()
399                    .ok()
400                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
401                    .map(|d| d.as_secs())
402                    .unwrap_or(0);
403                let key = crate::index::cache::WorkspaceCache::key_for_stat(
404                    uri.as_str(),
405                    mtime_secs,
406                    meta.len(),
407                );
408                let doc = parse_document_no_diags(&text);
409                let index = crate::index::file_index::FileIndex::extract(&doc);
410                let _ = cache.write(&key, &index);
411            });
412        }
413    }
414
415    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
416        for change in params.changes {
417            match change.typ {
418                FileChangeType::CREATED | FileChangeType::CHANGED => {
419                    if let Ok(path) = change.uri.to_file_path()
420                        && let Ok(text) = tokio::fs::read_to_string(&path).await
421                    {
422                        // Salsa path: ingest_from_doc mirrors the new text into
423                        // the SourceFile input. On the next codebase() call,
424                        // salsa re-runs file_definitions for this file and the
425                        // aggregator re-folds — no manual remove/collect/finalize.
426                        let doc = parse_document_no_diags(&text);
427                        self.ingest_from_doc_if_not_open(change.uri.clone(), &doc);
428                    }
429                }
430                FileChangeType::DELETED => {
431                    self.docs.remove(&change.uri);
432                }
433                _ => {}
434            }
435        }
436        // File changes may affect cross-file features — refresh all live editors.
437        send_refresh_requests(&self.client).await;
438    }
439
440    #[tracing::instrument(skip_all)]
441    async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
442        guard_async_result("completion", async move {
443            let uri = &params.text_document_position.text_document.uri;
444            let position = params.text_document_position.position;
445            let source = self.get_open_text(uri).unwrap_or_default();
446            let doc = match self.get_doc(uri) {
447                Some(d) => d,
448                None => return Ok(Some(CompletionResponse::Array(vec![]))),
449            };
450            let other_docs: Vec<Arc<ParsedDoc>> = self
451                .docs
452                .other_docs(uri, &self.open_urls())
453                .into_iter()
454                .map(|(_, d)| d)
455                .collect();
456            let trigger = params
457                .context
458                .as_ref()
459                .and_then(|c| c.trigger_character.as_deref());
460            let meta_loaded = self.meta.load();
461            let meta_opt = if meta_loaded.is_empty() {
462                None
463            } else {
464                Some(&**meta_loaded)
465            };
466            let imports = self.file_imports(uri);
467            let wi = self.workspace_index_async().await;
468            let docs_for_lookup = Arc::clone(&self.docs);
469            let find_class_doc_fn = move |name: &str| -> Option<Arc<ParsedDoc>> {
470                let cr = *wi.classes_by_name.get(name)?.first()?;
471                let (uri, _) = wi.at(cr)?;
472                docs_for_lookup.get_doc_salsa(uri)
473            };
474            let analysis = self.cached_analysis_async(uri).await;
475            // Cross-request TypeMap cache: rebuilt only when the document text
476            // (or PHPStorm meta) changes, instead of one full AST walk per
477            // completion request.
478            let docs_for_tm = Arc::clone(&self.docs);
479            let doc_for_tm = Arc::clone(&doc);
480            let uri_for_tm = uri.clone();
481            let get_type_map =
482                move || docs_for_tm.cached_type_map(&uri_for_tm, &doc_for_tm, meta_opt);
483            let session = self
484                .docs
485                .analysis_session(self.docs.workspace_php_version());
486            let ctx = CompletionCtx {
487                source: Some(&source),
488                position: Some(position),
489                meta: meta_opt,
490                doc_uri: Some(uri),
491                file_imports: Some(&imports),
492                find_class_doc: Some(&find_class_doc_fn),
493                analysis: analysis.as_deref(),
494                type_map: Some(&get_type_map),
495                session: Some(session),
496            };
497            Ok(Some(CompletionResponse::Array(filtered_completions_at(
498                &doc,
499                &other_docs,
500                trigger,
501                &ctx,
502            ))))
503        })
504        .await
505    }
506
507    async fn completion_resolve(&self, mut item: CompletionItem) -> Result<CompletionItem> {
508        if item.documentation.is_some() && item.detail.is_some() {
509            return Ok(item);
510        }
511        // Strip trailing ':' from named-argument labels (e.g. "param:") before lookup.
512        let name = item.label.trim_end_matches(':');
513        let all_indexes = self.docs.all_indexes();
514        if item.detail.is_none()
515            && let Some(sig) = signature_for_symbol_from_index(name, &all_indexes)
516        {
517            item.detail = Some(sig);
518        }
519        if item.documentation.is_none()
520            && let Some(md) = docs_for_symbol_from_index(name, &all_indexes)
521        {
522            item.documentation = Some(Documentation::MarkupContent(MarkupContent {
523                kind: MarkupKind::Markdown,
524                value: md,
525            }));
526        }
527        Ok(item)
528    }
529
530    async fn goto_definition(
531        &self,
532        params: GotoDefinitionParams,
533    ) -> Result<Option<GotoDefinitionResponse>> {
534        self.handle_goto_definition(params).await
535    }
536
537    async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
538        self.handle_references(params).await
539    }
540
541    async fn prepare_rename(
542        &self,
543        params: TextDocumentPositionParams,
544    ) -> Result<Option<PrepareRenameResponse>> {
545        let uri = &params.text_document.uri;
546        let source = self.get_open_text(uri).unwrap_or_default();
547        Ok(prepare_rename(&source, params.position).map(PrepareRenameResponse::Range))
548    }
549
550    async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
551        let uri = &params.text_document_position.text_document.uri;
552        let position = params.text_document_position.position;
553        let source = self.get_open_text(uri).unwrap_or_default();
554        let word = match word_at_position(&source, position) {
555            Some(w) => w,
556            None => return Ok(None),
557        };
558        if word.starts_with('$') {
559            let doc = match self.get_doc(uri) {
560                Some(d) => d,
561                None => return Ok(None),
562            };
563            // Cursor on a property declaration (`public int $x`) or a promoted
564            // constructor parameter (`private string $x`) — both act as property
565            // declarations and must use the cross-file property rename path.
566            let prop_name = cursor_is_on_property_decl(&source, &doc.program().stmts, position)
567                .or_else(|| promoted_property_at_cursor(&source, &doc.program().stmts, position));
568            if let Some(prop_name) = prop_name {
569                let all_docs = self.docs.all_docs_for_scan();
570                return Ok(Some(rename_property(
571                    &prop_name,
572                    &params.new_name,
573                    &all_docs,
574                )));
575            }
576            Ok(Some(rename_variable(
577                &word,
578                &params.new_name,
579                uri,
580                &doc,
581                position,
582            )))
583        } else if is_after_arrow(&source, position) {
584            let all_docs = self.docs.all_docs_for_scan();
585            Ok(Some(rename_property(&word, &params.new_name, &all_docs)))
586        } else {
587            let all_docs = self.docs.all_docs_for_scan();
588            let doc_opt = self.get_doc(uri);
589            let target_fqn: Option<String> = doc_opt.as_ref().map(|doc| {
590                let imports = self.file_imports(uri);
591                crate::navigation::moniker::resolve_fqn(doc, &word, &imports)
592            });
593            Ok(Some(rename(
594                &word,
595                &params.new_name,
596                &all_docs,
597                target_fqn.as_deref(),
598            )))
599        }
600    }
601
602    async fn signature_help(&self, params: SignatureHelpParams) -> Result<Option<SignatureHelp>> {
603        let uri = &params.text_document_position_params.text_document.uri;
604        let position = params.text_document_position_params.position;
605        let source = self.get_open_text(uri).unwrap_or_default();
606        let doc = match self.get_doc(uri) {
607            Some(d) => d,
608            None => return Ok(None),
609        };
610        let all_indexes = self.docs.all_indexes();
611        Ok(signature_help(&source, &doc, position, &all_indexes))
612    }
613
614    #[tracing::instrument(skip_all)]
615    async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
616        guard_async_result("hover", async move {
617            let uri = &params.text_document_position_params.text_document.uri;
618            let position = params.text_document_position_params.position;
619            let source = self.get_open_text(uri).unwrap_or_default();
620            let doc = match self.get_doc(uri) {
621                Some(d) => d,
622                None => return Ok(None),
623            };
624            let other_docs = self.docs.other_docs(uri, &self.open_urls());
625            let other_maps = self.docs.other_symbol_maps(uri, &self.open_urls());
626            let analysis = self.cached_analysis_async(uri).await;
627            let hover_session = self
628                .docs
629                .analysis_session(self.docs.workspace_php_version());
630            let result = hover_info_with_maps(
631                &source,
632                &doc,
633                analysis.as_deref(),
634                position,
635                &other_docs,
636                &other_maps,
637                Some(&hover_session),
638            );
639            if result.is_some() {
640                return Ok(result);
641            }
642            // Fallback: look up the word in the workspace index so class names in
643            // extends clauses and parameter types resolve even when their defining
644            // file is never opened.  Also try the alias-resolved name so that
645            // `use Foo as Bar` works even when Foo is only in the index.
646            if let Some(word) = crate::text::word_at_position(&source, position) {
647                let wi = self.workspace_index_async().await;
648                // Try the literal word first.
649                if let Some(h) = class_hover_from_index(&word, &wi.files) {
650                    return Ok(Some(h));
651                }
652                // Try alias resolution.
653                if let Some(resolved) = crate::hover::resolve_use_alias(&doc.program().stmts, &word)
654                    && let Some(h) = class_hover_from_index(&resolved, &wi.files)
655                {
656                    return Ok(Some(h));
657                }
658                // Try static method hover: `ClassName::method(…)`.
659                if let Some(line_text) = source.lines().nth(position.line as usize)
660                    && let Some(class_token) =
661                        extract_static_class_before_cursor(line_text, position.character as usize)
662                {
663                    if let Some(h) = method_hover_from_index(&class_token, &word, &wi.files) {
664                        return Ok(Some(h));
665                    }
666                    if let Some(resolved_class) =
667                        crate::hover::resolve_use_alias(&doc.program().stmts, &class_token)
668                        && let Some(h) = method_hover_from_index(&resolved_class, &word, &wi.files)
669                    {
670                        return Ok(Some(h));
671                    }
672                }
673            }
674            Ok(None)
675        })
676        .await
677    }
678
679    async fn document_symbol(
680        &self,
681        params: DocumentSymbolParams,
682    ) -> Result<Option<DocumentSymbolResponse>> {
683        let uri = &params.text_document.uri;
684        let doc = match self.get_doc(uri) {
685            Some(d) => d,
686            None => return Ok(None),
687        };
688        Ok(Some(DocumentSymbolResponse::Nested(document_symbols(
689            doc.source(),
690            &doc,
691        ))))
692    }
693
694    async fn folding_range(&self, params: FoldingRangeParams) -> Result<Option<Vec<FoldingRange>>> {
695        let uri = &params.text_document.uri;
696        let doc = match self.get_doc(uri) {
697            Some(d) => d,
698            None => return Ok(None),
699        };
700        let ranges = folding_ranges(doc.source(), &doc);
701        Ok(if ranges.is_empty() {
702            None
703        } else {
704            Some(ranges)
705        })
706    }
707
708    async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
709        let uri = &params.text_document.uri;
710        let doc = match self.get_doc(uri) {
711            Some(d) => d,
712            None => return Ok(None),
713        };
714        let analysis = self.cached_analysis_async(uri).await;
715        let wi = self.workspace_index_async().await;
716        Ok(Some(inlay_hints(
717            doc.source(),
718            &doc,
719            analysis.as_deref(),
720            params.range,
721            &wi.files,
722        )))
723    }
724
725    async fn inlay_hint_resolve(&self, mut item: InlayHint) -> Result<InlayHint> {
726        if item.tooltip.is_some() {
727            return Ok(item);
728        }
729        let func_name = item
730            .data
731            .as_ref()
732            .and_then(|d| d.get("php_lsp_fn"))
733            .and_then(|v| v.as_str())
734            .map(str::to_string);
735        if let Some(name) = func_name {
736            let all_indexes = self.docs.all_indexes();
737            if let Some(md) = docs_for_symbol_from_index(&name, &all_indexes) {
738                item.tooltip = Some(InlayHintTooltip::MarkupContent(MarkupContent {
739                    kind: MarkupKind::Markdown,
740                    value: md,
741                }));
742            }
743        }
744        Ok(item)
745    }
746
747    async fn symbol(
748        &self,
749        params: WorkspaceSymbolParams,
750    ) -> Result<Option<Vec<SymbolInformation>>> {
751        // Phase J: read through the salsa-memoized aggregate so repeated
752        // workspace-symbol queries (every keystroke in the picker) share the
753        // same `Arc` until a file changes.
754        let wi = self.workspace_index_async().await;
755        let results = workspace_symbols_from_workspace(&params.query, &wi);
756        Ok(Some(results))
757    }
758
759    async fn symbol_resolve(&self, params: WorkspaceSymbol) -> Result<WorkspaceSymbol> {
760        // For resolve, we need the full range from the ParsedDoc of open files.
761        let docs = self.docs.docs_for(&self.open_urls());
762        Ok(resolve_workspace_symbol(params, &docs))
763    }
764
765    #[tracing::instrument(skip_all)]
766    async fn semantic_tokens_full(
767        &self,
768        params: SemanticTokensParams,
769    ) -> Result<Option<SemanticTokensResult>> {
770        guard_async_result("semantic_tokens_full", async move {
771            let uri = &params.text_document.uri;
772            let doc = match self.get_doc(uri) {
773                Some(d) => d,
774                None => {
775                    return Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
776                        result_id: None,
777                        data: vec![],
778                    })));
779                }
780            };
781            let tokens = semantic_tokens(doc.source(), &doc);
782            let result_id = token_hash(&tokens);
783            let tokens_arc = Arc::new(tokens);
784            self.docs
785                .store_token_cache(uri, result_id.clone(), Arc::clone(&tokens_arc));
786            let data = Arc::try_unwrap(tokens_arc).unwrap_or_else(|arc| (*arc).clone());
787            Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
788                result_id: Some(result_id),
789                data,
790            })))
791        })
792        .await
793    }
794
795    async fn semantic_tokens_range(
796        &self,
797        params: SemanticTokensRangeParams,
798    ) -> Result<Option<SemanticTokensRangeResult>> {
799        let uri = &params.text_document.uri;
800        let doc = match self.get_doc(uri) {
801            Some(d) => d,
802            None => {
803                return Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
804                    result_id: None,
805                    data: vec![],
806                })));
807            }
808        };
809        let tokens = semantic_tokens_range(doc.source(), &doc, params.range);
810        Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
811            result_id: None,
812            data: tokens,
813        })))
814    }
815
816    async fn semantic_tokens_full_delta(
817        &self,
818        params: SemanticTokensDeltaParams,
819    ) -> Result<Option<SemanticTokensFullDeltaResult>> {
820        let uri = &params.text_document.uri;
821        let doc = match self.get_doc(uri) {
822            Some(d) => d,
823            None => return Ok(None),
824        };
825
826        let new_tokens = Arc::new(semantic_tokens(doc.source(), &doc));
827        let new_result_id = token_hash(&new_tokens);
828        let prev_id = &params.previous_result_id;
829
830        let result = match self.docs.get_token_cache(uri, prev_id) {
831            Some(old_tokens) => {
832                let edits = compute_token_delta(&old_tokens, &new_tokens);
833                SemanticTokensFullDeltaResult::TokensDelta(SemanticTokensDelta {
834                    result_id: Some(new_result_id.clone()),
835                    edits,
836                })
837            }
838            // Unknown previous result — fall back to full tokens
839            None => SemanticTokensFullDeltaResult::Tokens(SemanticTokens {
840                result_id: Some(new_result_id.clone()),
841                data: (*new_tokens).clone(),
842            }),
843        };
844
845        self.docs.store_token_cache(uri, new_result_id, new_tokens);
846        Ok(Some(result))
847    }
848
849    async fn selection_range(
850        &self,
851        params: SelectionRangeParams,
852    ) -> Result<Option<Vec<SelectionRange>>> {
853        let uri = &params.text_document.uri;
854        let doc = match self.get_doc(uri) {
855            Some(d) => d,
856            None => return Ok(None),
857        };
858        let ranges = selection_ranges(&doc, &params.positions);
859        Ok(if ranges.is_empty() {
860            None
861        } else {
862            Some(ranges)
863        })
864    }
865
866    async fn prepare_call_hierarchy(
867        &self,
868        params: CallHierarchyPrepareParams,
869    ) -> Result<Option<Vec<CallHierarchyItem>>> {
870        let uri = &params.text_document_position_params.text_document.uri;
871        let position = params.text_document_position_params.position;
872        let source = self.get_open_text(uri).unwrap_or_default();
873        let word = match word_at_position(&source, position) {
874            Some(w) => w,
875            None => return Ok(None),
876        };
877        // O(matches) lookup via the aggregate's `decls_by_name` map instead
878        // of scanning every workspace doc.
879        let wi = self.workspace_index_async().await;
880        let docs = Arc::clone(&self.docs);
881        let get_doc = move |u: &Url| docs.get_doc_salsa(u);
882        Ok(prepare_call_hierarchy_indexed(&word, &wi, &get_doc).map(|item| vec![item]))
883    }
884
885    async fn incoming_calls(
886        &self,
887        params: CallHierarchyIncomingCallsParams,
888    ) -> Result<Option<Vec<CallHierarchyIncomingCall>>> {
889        // Genuinely needs every doc (call sites are body-level, not indexed);
890        // run the workspace scan on the blocking pool.
891        let docs = Arc::clone(&self.docs);
892        let item = params.item;
893        let calls = tokio::task::spawn_blocking(move || {
894            let all_docs = docs.all_docs_for_scan();
895            incoming_calls(&item, &all_docs)
896        })
897        .await
898        .unwrap_or_default();
899        Ok(if calls.is_empty() { None } else { Some(calls) })
900    }
901
902    async fn outgoing_calls(
903        &self,
904        params: CallHierarchyOutgoingCallsParams,
905    ) -> Result<Option<Vec<CallHierarchyOutgoingCall>>> {
906        // Per-callee declaration lookups go through `decls_by_name` — the old
907        // path re-scanned the whole workspace once per distinct callee.
908        let wi = self.workspace_index_async().await;
909        let docs = Arc::clone(&self.docs);
910        let item = params.item;
911        let calls = tokio::task::spawn_blocking(move || {
912            let get_doc = |u: &Url| docs.get_doc_salsa(u);
913            outgoing_calls_indexed(&item, &wi, &get_doc)
914        })
915        .await
916        .unwrap_or_default();
917        Ok(if calls.is_empty() { None } else { Some(calls) })
918    }
919
920    async fn document_highlight(
921        &self,
922        params: DocumentHighlightParams,
923    ) -> Result<Option<Vec<DocumentHighlight>>> {
924        let uri = &params.text_document_position_params.text_document.uri;
925        let position = params.text_document_position_params.position;
926        let source = self.get_open_text(uri).unwrap_or_default();
927        let doc = match self.get_doc(uri) {
928            Some(d) => d,
929            None => return Ok(None),
930        };
931        let highlights = document_highlights(&source, &doc, position);
932        Ok(if highlights.is_empty() {
933            None
934        } else {
935            Some(highlights)
936        })
937    }
938
939    async fn linked_editing_range(
940        &self,
941        params: LinkedEditingRangeParams,
942    ) -> Result<Option<LinkedEditingRanges>> {
943        self.handle_linked_editing_range(params).await
944    }
945
946    async fn goto_implementation(
947        &self,
948        params: tower_lsp::lsp_types::request::GotoImplementationParams,
949    ) -> Result<Option<tower_lsp::lsp_types::request::GotoImplementationResponse>> {
950        let uri = &params.text_document_position_params.text_document.uri;
951        let position = params.text_document_position_params.position;
952        let source = self.get_open_text(uri).unwrap_or_default();
953        let imports = self.file_imports(uri);
954        let raw_word = crate::text::word_at_position(&source, position).unwrap_or_default();
955        // `word_at_position` includes `\` as a word character, so the cursor on
956        // a use-statement import (`use A\B\Foo`) returns the full qualified name.
957        // Split to recover the short name and treat the rest as the FQN so the
958        // workspace index lookup (keyed by short name) still finds subtypes.
959        let (word, fqn_owned): (String, Option<String>) = if raw_word.contains('\\') {
960            let short = raw_word
961                .rsplit('\\')
962                .next()
963                .unwrap_or(&raw_word)
964                .to_string();
965            let full = raw_word.trim_start_matches('\\').to_string();
966            (short, Some(full))
967        } else {
968            let fqn = imports.get(&raw_word).cloned();
969            (raw_word, fqn)
970        };
971        let fqn = fqn_owned.as_deref();
972        // First pass: open-file ParsedDocs give accurate character positions.
973        let open_docs = self.docs.docs_for(&self.open_urls());
974        let mut locs = find_implementations(&word, fqn, &open_docs);
975        // Second pass: workspace aggregate's `subtypes_of` reverse map.
976        let wi = self.workspace_index_async().await;
977        if locs.is_empty() {
978            locs = find_implementations_from_workspace(&word, fqn, &wi);
979        }
980        // Third pass: treat word as a method name inside its declaring
981        // class/interface — returns concrete overrides in every subtype.
982        // Handles cursor on an interface method like `Factory::guard()`.
983        if locs.is_empty()
984            && let Some(doc) = self.get_doc(uri)
985            && let Some(enclosing) =
986                crate::types::type_map::enclosing_class_at(&source, &doc, position)
987        {
988            locs = find_method_implementations_from_workspace(&word, &enclosing, &wi);
989        }
990        if locs.is_empty() {
991            Ok(None)
992        } else {
993            Ok(Some(GotoDefinitionResponse::Array(locs)))
994        }
995    }
996
997    async fn goto_declaration(
998        &self,
999        params: tower_lsp::lsp_types::request::GotoDeclarationParams,
1000    ) -> Result<Option<tower_lsp::lsp_types::request::GotoDeclarationResponse>> {
1001        let uri = &params.text_document_position_params.text_document.uri;
1002        let position = params.text_document_position_params.position;
1003        let source = self.get_open_text(uri).unwrap_or_default();
1004        // First pass: open-file ParsedDocs give accurate character positions.
1005        let open_docs = self.docs.docs_for(&self.open_urls());
1006        if let Some(loc) = goto_declaration(&source, &open_docs, position) {
1007            return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1008        }
1009        // Second pass: background files via FileIndex (line-only positions).
1010        let all_indexes = self.docs.all_indexes();
1011        Ok(goto_declaration_from_index(&source, &all_indexes, position)
1012            .map(GotoDefinitionResponse::Scalar))
1013    }
1014
1015    async fn goto_type_definition(
1016        &self,
1017        params: tower_lsp::lsp_types::request::GotoTypeDefinitionParams,
1018    ) -> Result<Option<tower_lsp::lsp_types::request::GotoTypeDefinitionResponse>> {
1019        let uri = &params.text_document_position_params.text_document.uri;
1020        let position = params.text_document_position_params.position;
1021        let source = self.get_open_text(uri).unwrap_or_default();
1022        let doc = match self.get_doc(uri) {
1023            Some(d) => d,
1024            None => return Ok(None),
1025        };
1026        let analysis = self.cached_analysis_async(uri).await;
1027        // First pass: open-file ParsedDocs give accurate character positions.
1028        let open_docs = self.docs.docs_for(&self.open_urls());
1029        let mut results =
1030            goto_type_definition(&source, &doc, analysis.as_deref(), &open_docs, position);
1031
1032        // If no results from first pass, try background files via FileIndex (line-only positions).
1033        if results.is_empty() {
1034            let all_indexes = self.docs.all_indexes();
1035            results = goto_type_definition_from_index(
1036                &source,
1037                &doc,
1038                analysis.as_deref(),
1039                &all_indexes,
1040                position,
1041            );
1042        }
1043
1044        // Format response: scalar for single result, array for multiple, none for empty
1045        let response = match results.len() {
1046            0 => None,
1047            1 => Some(GotoDefinitionResponse::Scalar(
1048                results.into_iter().next().unwrap(),
1049            )),
1050            _ => Some(GotoDefinitionResponse::Array(results)),
1051        };
1052        Ok(response)
1053    }
1054
1055    async fn prepare_type_hierarchy(
1056        &self,
1057        params: TypeHierarchyPrepareParams,
1058    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1059        let uri = &params.text_document_position_params.text_document.uri;
1060        let position = params.text_document_position_params.position;
1061        let source = self.get_open_text(uri).unwrap_or_default();
1062        // Phase J: use the salsa-memoized aggregate's `classes_by_name` map.
1063        let wi = self.workspace_index_async().await;
1064        Ok(prepare_type_hierarchy_from_workspace(&source, &wi, position).map(|item| vec![item]))
1065    }
1066
1067    async fn supertypes(
1068        &self,
1069        params: TypeHierarchySupertypesParams,
1070    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1071        // Phase J: resolve parents via the aggregate's `classes_by_name` map.
1072        // Pre-load any direct vendor supertypes via PSR-4 so they appear in the
1073        // workspace index before the lookup runs.
1074        let wi = self.workspace_index_async().await;
1075        let loaded_new = self
1076            .ensure_direct_supertypes_loaded(&params.item.name, &wi)
1077            .await;
1078        let wi = if loaded_new {
1079            self.workspace_index_async().await
1080        } else {
1081            wi
1082        };
1083        let result = supertypes_of_from_workspace(&params.item, &wi);
1084        Ok(if result.is_empty() {
1085            None
1086        } else {
1087            Some(result)
1088        })
1089    }
1090
1091    async fn subtypes(
1092        &self,
1093        params: TypeHierarchySubtypesParams,
1094    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1095        // Phase J: O(matches) lookup via the aggregate's `subtypes_of` map.
1096        let wi = self.workspace_index_async().await;
1097        let result = subtypes_of_from_workspace(&params.item, &wi);
1098        Ok(if result.is_empty() {
1099            None
1100        } else {
1101            Some(result)
1102        })
1103    }
1104
1105    async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
1106        let uri = &params.text_document.uri;
1107        let doc = match self.get_doc(uri) {
1108            Some(d) => d,
1109            None => return Ok(None),
1110        };
1111        // Reference-count lenses scan every doc per declaration; run the
1112        // whole computation on the blocking pool.
1113        let docs = Arc::clone(&self.docs);
1114        let uri_owned = uri.clone();
1115        let lenses = tokio::task::spawn_blocking(move || {
1116            let all_docs = docs.all_docs_for_scan();
1117            code_lenses(&uri_owned, &doc, &all_docs)
1118        })
1119        .await
1120        .unwrap_or_default();
1121        Ok(if lenses.is_empty() {
1122            None
1123        } else {
1124            Some(lenses)
1125        })
1126    }
1127
1128    async fn code_lens_resolve(&self, params: CodeLens) -> Result<CodeLens> {
1129        // Lenses are fully populated by code_lens; nothing to add.
1130        Ok(params)
1131    }
1132
1133    async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
1134        let uri = &params.text_document.uri;
1135        let doc = match self.get_doc(uri) {
1136            Some(d) => d,
1137            None => return Ok(None),
1138        };
1139        let links = document_links(uri, &doc, doc.source());
1140        Ok(if links.is_empty() { None } else { Some(links) })
1141    }
1142
1143    async fn document_link_resolve(&self, params: DocumentLink) -> Result<DocumentLink> {
1144        // Links already carry their target URI; nothing to add.
1145        Ok(params)
1146    }
1147
1148    async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
1149        let uri = &params.text_document.uri;
1150        let source = self.get_open_text(uri).unwrap_or_default();
1151        Ok(format_document(&source))
1152    }
1153
1154    async fn range_formatting(
1155        &self,
1156        params: DocumentRangeFormattingParams,
1157    ) -> Result<Option<Vec<TextEdit>>> {
1158        let uri = &params.text_document.uri;
1159        let source = self.get_open_text(uri).unwrap_or_default();
1160        Ok(format_range(&source, params.range))
1161    }
1162
1163    async fn on_type_formatting(
1164        &self,
1165        params: DocumentOnTypeFormattingParams,
1166    ) -> Result<Option<Vec<TextEdit>>> {
1167        let uri = &params.text_document_position.text_document.uri;
1168        let source = self.get_open_text(uri).unwrap_or_default();
1169        let edits = on_type_format(
1170            &source,
1171            params.text_document_position.position,
1172            &params.ch,
1173            &params.options,
1174        );
1175        Ok(if edits.is_empty() { None } else { Some(edits) })
1176    }
1177
1178    async fn execute_command(
1179        &self,
1180        params: ExecuteCommandParams,
1181    ) -> Result<Option<serde_json::Value>> {
1182        match params.command.as_str() {
1183            "php-lsp.runTest" => {
1184                // Arguments: [uri_string, "ClassName::methodName"]
1185                let file_uri = params
1186                    .arguments
1187                    .first()
1188                    .and_then(|v| v.as_str())
1189                    .and_then(|s| Url::parse(s).ok());
1190                let filter = params
1191                    .arguments
1192                    .get(1)
1193                    .and_then(|v| v.as_str())
1194                    .unwrap_or("")
1195                    .to_string();
1196
1197                let root = self.root_paths.load().first().cloned();
1198                let client = self.client.clone();
1199
1200                tokio::spawn(async move {
1201                    run_phpunit(&client, &filter, root.as_deref(), file_uri.as_ref()).await;
1202                });
1203
1204                Ok(None)
1205            }
1206            _ => Ok(None),
1207        }
1208    }
1209
1210    async fn will_rename_files(&self, params: RenameFilesParams) -> Result<Option<WorkspaceEdit>> {
1211        self.handle_will_rename_files(params).await
1212    }
1213
1214    async fn did_rename_files(&self, params: RenameFilesParams) {
1215        self.handle_did_rename_files(params).await
1216    }
1217
1218    async fn will_create_files(&self, params: CreateFilesParams) -> Result<Option<WorkspaceEdit>> {
1219        self.handle_will_create_files(params).await
1220    }
1221
1222    async fn did_create_files(&self, params: CreateFilesParams) {
1223        self.handle_did_create_files(params).await
1224    }
1225
1226    /// Before a file is deleted, return workspace edits that remove every
1227    /// `use` import referencing its PSR-4 class name.
1228    async fn will_delete_files(&self, params: DeleteFilesParams) -> Result<Option<WorkspaceEdit>> {
1229        self.handle_will_delete_files(params).await
1230    }
1231
1232    async fn did_delete_files(&self, params: DeleteFilesParams) {
1233        self.handle_did_delete_files(params).await
1234    }
1235
1236    async fn moniker(&self, params: MonikerParams) -> Result<Option<Vec<Moniker>>> {
1237        let uri = &params.text_document_position_params.text_document.uri;
1238        let position = params.text_document_position_params.position;
1239        let source = self.get_open_text(uri).unwrap_or_default();
1240        let doc = match self.get_doc(uri) {
1241            Some(d) => d,
1242            None => return Ok(None),
1243        };
1244        let imports = self.file_imports(uri);
1245        Ok(moniker_at(&source, &doc, position, &imports).map(|m| vec![m]))
1246    }
1247
1248    async fn inline_value(&self, params: InlineValueParams) -> Result<Option<Vec<InlineValue>>> {
1249        let uri = &params.text_document.uri;
1250        let source = self.get_open_text(uri).unwrap_or_default();
1251        let values = inline_values_in_range(&source, params.range);
1252        Ok(if values.is_empty() {
1253            None
1254        } else {
1255            Some(values)
1256        })
1257    }
1258
1259    async fn diagnostic(
1260        &self,
1261        params: DocumentDiagnosticParams,
1262    ) -> Result<DocumentDiagnosticReportResult> {
1263        self.handle_diagnostic(params).await
1264    }
1265
1266    async fn workspace_diagnostic(
1267        &self,
1268        params: WorkspaceDiagnosticParams,
1269    ) -> Result<WorkspaceDiagnosticReportResult> {
1270        self.handle_workspace_diagnostic(params).await
1271    }
1272
1273    async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
1274        self.handle_code_action(params).await
1275    }
1276
1277    async fn code_action_resolve(&self, item: CodeAction) -> Result<CodeAction> {
1278        self.handle_code_action_resolve(item).await
1279    }
1280}