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, build_mir_symbol, 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 diag_cfg = self.config.load().diagnostics.clone();
310            tokio::spawn(async move {
311                // 100 ms debounce: if another edit arrives before we parse,
312                // the version gate below will discard this result.
313                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
314
315                let (_doc, parse_diags) =
316                    tokio::task::spawn_blocking(move || parse_document(&text))
317                        .await
318                        .unwrap_or_else(|_| (ParsedDoc::default(), vec![]));
319
320                // Only apply if no newer edit arrived while we were parsing.
321                if open_files.current_version(&uri) == Some(version) {
322                    open_files.set_parse_diagnostics(&uri, parse_diags);
323                    publish_with_dependents(client, docs, open_files, uri, diag_cfg).await;
324                }
325            });
326        })
327        .await
328    }
329
330    async fn did_close(&self, params: DidCloseTextDocumentParams) {
331        let uri = params.text_document.uri;
332        self.close_open_file(&uri);
333        // Clear editor diagnostics; the file stays indexed for cross-file features
334        self.client.publish_diagnostics(uri, vec![], None).await;
335    }
336
337    async fn will_save(&self, _params: WillSaveTextDocumentParams) {}
338
339    async fn will_save_wait_until(
340        &self,
341        params: WillSaveTextDocumentParams,
342    ) -> Result<Option<Vec<TextEdit>>> {
343        let source = self
344            .get_open_text(&params.text_document.uri)
345            .unwrap_or_default();
346        Ok(format_document(&source))
347    }
348
349    async fn did_save(&self, params: DidSaveTextDocumentParams) {
350        let uri = params.text_document.uri;
351        // Re-publish diagnostics on save so editors that defer diagnostics
352        // until save (rather than on every keystroke) see up-to-date results.
353        // Must include semantic diagnostics — publishDiagnostics replaces the
354        // prior set entirely, so omitting them would clear errors the editor
355        // showed after the last did_change.
356        let diag_cfg = self.config.load().diagnostics.clone();
357        let all = compute_open_file_diagnostics(&self.docs, &self.open_files, &uri, &diag_cfg);
358        self.client
359            .publish_diagnostics(uri.clone(), all, None)
360            .await;
361
362        // Persist the FileIndex to the disk cache so that a server restart
363        // can skip re-parsing this file even for edits that happened between
364        // workspace scans. Use the same stat-based key as the workspace scan
365        // so the entry is found on the next cold start.
366        //
367        // Both the stat and the content are read from disk inside the blocking
368        // task so they come from the same on-disk snapshot. Using the editor
369        // buffer (open_files text) instead would risk writing an index derived
370        // from unsaved edits typed after the save but before this task runs,
371        // producing a cache entry where the key (disk stat) and the value
372        // (index of newer buffer content) describe different file versions.
373        if let (Some(root), Ok(path)) =
374            (self.root_paths.load().first().cloned(), uri.to_file_path())
375        {
376            tokio::task::spawn_blocking(move || {
377                let Some(cache) = crate::index::cache::WorkspaceCache::new(&root) else {
378                    return;
379                };
380                // Stat before read to match workspace_scan ordering and
381                // minimise the TOCTOU window.
382                let Ok(meta) = std::fs::metadata(&path) else {
383                    return;
384                };
385                let Ok(text) = std::fs::read_to_string(&path) else {
386                    return;
387                };
388                let mtime_secs = meta
389                    .modified()
390                    .ok()
391                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
392                    .map(|d| d.as_secs())
393                    .unwrap_or(0);
394                let key = crate::index::cache::WorkspaceCache::key_for_stat(
395                    uri.as_str(),
396                    mtime_secs,
397                    meta.len(),
398                );
399                let doc = parse_document_no_diags(&text);
400                let index = crate::index::file_index::FileIndex::extract(&doc);
401                let _ = cache.write(&key, &index);
402            });
403        }
404    }
405
406    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
407        for change in params.changes {
408            match change.typ {
409                FileChangeType::CREATED | FileChangeType::CHANGED => {
410                    if let Ok(path) = change.uri.to_file_path()
411                        && let Ok(text) = tokio::fs::read_to_string(&path).await
412                    {
413                        // Salsa path: ingest_from_doc mirrors the new text into
414                        // the SourceFile input. On the next codebase() call,
415                        // salsa re-runs file_definitions for this file and the
416                        // aggregator re-folds — no manual remove/collect/finalize.
417                        let doc = parse_document_no_diags(&text);
418                        self.ingest_from_doc_if_not_open(change.uri.clone(), &doc);
419                    }
420                }
421                FileChangeType::DELETED => {
422                    self.docs.remove(&change.uri);
423                }
424                _ => {}
425            }
426        }
427        // File changes may affect cross-file features — refresh all live editors.
428        send_refresh_requests(&self.client).await;
429    }
430
431    #[tracing::instrument(skip_all)]
432    async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
433        guard_async_result("completion", async move {
434            let uri = &params.text_document_position.text_document.uri;
435            let position = params.text_document_position.position;
436            let source = self.get_open_text(uri).unwrap_or_default();
437            let doc = match self.get_doc(uri) {
438                Some(d) => d,
439                None => return Ok(Some(CompletionResponse::Array(vec![]))),
440            };
441            let other_docs: Vec<Arc<ParsedDoc>> = self
442                .docs
443                .other_docs(uri, &self.open_urls())
444                .into_iter()
445                .map(|(_, d)| d)
446                .collect();
447            let trigger = params
448                .context
449                .as_ref()
450                .and_then(|c| c.trigger_character.as_deref());
451            let meta_loaded = self.meta.load();
452            let meta_opt = if meta_loaded.is_empty() {
453                None
454            } else {
455                Some(&**meta_loaded)
456            };
457            let imports = self.file_imports(uri);
458            let wi = self.workspace_index_async().await;
459            let docs_for_lookup = Arc::clone(&self.docs);
460            let find_class_doc_fn = move |name: &str| -> Option<Arc<ParsedDoc>> {
461                let cr = *wi.classes_by_name.get(name)?.first()?;
462                let (uri, _) = wi.at(cr)?;
463                docs_for_lookup.get_doc_salsa(uri)
464            };
465            let analysis = self.cached_analysis_async(uri).await;
466            // Cross-request TypeMap cache: rebuilt only when the document text
467            // (or PHPStorm meta) changes, instead of one full AST walk per
468            // completion request.
469            let docs_for_tm = Arc::clone(&self.docs);
470            let doc_for_tm = Arc::clone(&doc);
471            let uri_for_tm = uri.clone();
472            let get_type_map =
473                move || docs_for_tm.cached_type_map(&uri_for_tm, &doc_for_tm, meta_opt);
474            let session = self
475                .docs
476                .analysis_session(self.docs.workspace_php_version());
477            let ctx = CompletionCtx {
478                source: Some(&source),
479                position: Some(position),
480                meta: meta_opt,
481                doc_uri: Some(uri),
482                file_imports: Some(&imports),
483                find_class_doc: Some(&find_class_doc_fn),
484                analysis: analysis.as_deref(),
485                type_map: Some(&get_type_map),
486                session: Some(session),
487            };
488            Ok(Some(CompletionResponse::Array(filtered_completions_at(
489                &doc,
490                &other_docs,
491                trigger,
492                &ctx,
493            ))))
494        })
495        .await
496    }
497
498    async fn completion_resolve(&self, mut item: CompletionItem) -> Result<CompletionItem> {
499        if item.documentation.is_some() && item.detail.is_some() {
500            return Ok(item);
501        }
502        // Strip trailing ':' from named-argument labels (e.g. "param:") before lookup.
503        let name = item.label.trim_end_matches(':');
504        let all_indexes = self.docs.all_indexes();
505        if item.detail.is_none()
506            && let Some(sig) = signature_for_symbol_from_index(name, &all_indexes)
507        {
508            item.detail = Some(sig);
509        }
510        if item.documentation.is_none()
511            && let Some(md) = docs_for_symbol_from_index(name, &all_indexes)
512        {
513            item.documentation = Some(Documentation::MarkupContent(MarkupContent {
514                kind: MarkupKind::Markdown,
515                value: md,
516            }));
517        }
518        Ok(item)
519    }
520
521    async fn goto_definition(
522        &self,
523        params: GotoDefinitionParams,
524    ) -> Result<Option<GotoDefinitionResponse>> {
525        self.handle_goto_definition(params).await
526    }
527
528    async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
529        self.handle_references(params).await
530    }
531
532    async fn prepare_rename(
533        &self,
534        params: TextDocumentPositionParams,
535    ) -> Result<Option<PrepareRenameResponse>> {
536        let uri = &params.text_document.uri;
537        let source = self.get_open_text(uri).unwrap_or_default();
538        Ok(prepare_rename(&source, params.position).map(PrepareRenameResponse::Range))
539    }
540
541    async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
542        let uri = &params.text_document_position.text_document.uri;
543        let position = params.text_document_position.position;
544        let source = self.get_open_text(uri).unwrap_or_default();
545        let word = match word_at_position(&source, position) {
546            Some(w) => w,
547            None => return Ok(None),
548        };
549        if word.starts_with('$') {
550            let doc = match self.get_doc(uri) {
551                Some(d) => d,
552                None => return Ok(None),
553            };
554            // Cursor on a property declaration (`public int $x`) or a promoted
555            // constructor parameter (`private string $x`) — both act as property
556            // declarations and must use the cross-file property rename path.
557            let prop_name = cursor_is_on_property_decl(&source, &doc.program().stmts, position)
558                .or_else(|| promoted_property_at_cursor(&source, &doc.program().stmts, position));
559            if let Some(prop_name) = prop_name {
560                let all_docs = self.docs.all_docs_for_scan();
561                return Ok(Some(rename_property(
562                    &prop_name,
563                    &params.new_name,
564                    &all_docs,
565                )));
566            }
567            Ok(Some(rename_variable(
568                &word,
569                &params.new_name,
570                uri,
571                &doc,
572                position,
573            )))
574        } else if is_after_arrow(&source, position) {
575            let all_docs = self.docs.all_docs_for_scan();
576            Ok(Some(rename_property(&word, &params.new_name, &all_docs)))
577        } else {
578            let all_docs = self.docs.all_docs_for_scan();
579            let doc_opt = self.get_doc(uri);
580            let target_fqn: Option<String> = doc_opt.as_ref().map(|doc| {
581                let imports = self.file_imports(uri);
582                crate::navigation::moniker::resolve_fqn(doc, &word, &imports)
583            });
584            Ok(Some(rename(
585                &word,
586                &params.new_name,
587                &all_docs,
588                target_fqn.as_deref(),
589            )))
590        }
591    }
592
593    async fn signature_help(&self, params: SignatureHelpParams) -> Result<Option<SignatureHelp>> {
594        let uri = &params.text_document_position_params.text_document.uri;
595        let position = params.text_document_position_params.position;
596        let source = self.get_open_text(uri).unwrap_or_default();
597        let doc = match self.get_doc(uri) {
598            Some(d) => d,
599            None => return Ok(None),
600        };
601        let all_indexes = self.docs.all_indexes();
602        Ok(signature_help(&source, &doc, position, &all_indexes))
603    }
604
605    #[tracing::instrument(skip_all)]
606    async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
607        guard_async_result("hover", async move {
608            let uri = &params.text_document_position_params.text_document.uri;
609            let position = params.text_document_position_params.position;
610            let source = self.get_open_text(uri).unwrap_or_default();
611            let doc = match self.get_doc(uri) {
612                Some(d) => d,
613                None => return Ok(None),
614            };
615            let other_docs = self.docs.other_docs(uri, &self.open_urls());
616            let other_maps = self.docs.other_symbol_maps(uri, &self.open_urls());
617            let analysis = self.cached_analysis_async(uri).await;
618            let hover_session = self
619                .docs
620                .analysis_session(self.docs.workspace_php_version());
621            let result = hover_info_with_maps(
622                &source,
623                &doc,
624                analysis.as_deref(),
625                position,
626                &other_docs,
627                &other_maps,
628                Some(&hover_session),
629            );
630            if result.is_some() {
631                return Ok(result);
632            }
633            // Fallback: look up the word in the workspace index so class names in
634            // extends clauses and parameter types resolve even when their defining
635            // file is never opened.  Also try the alias-resolved name so that
636            // `use Foo as Bar` works even when Foo is only in the index.
637            if let Some(word) = crate::text::word_at_position(&source, position) {
638                let wi = self.workspace_index_async().await;
639                // Try the literal word first.
640                if let Some(h) = class_hover_from_index(&word, &wi.files) {
641                    return Ok(Some(h));
642                }
643                // Try alias resolution.
644                if let Some(resolved) = crate::hover::resolve_use_alias(&doc.program().stmts, &word)
645                    && let Some(h) = class_hover_from_index(&resolved, &wi.files)
646                {
647                    return Ok(Some(h));
648                }
649                // Try static method hover: `ClassName::method(…)`.
650                if let Some(line_text) = source.lines().nth(position.line as usize)
651                    && let Some(class_token) =
652                        extract_static_class_before_cursor(line_text, position.character as usize)
653                {
654                    if let Some(h) = method_hover_from_index(&class_token, &word, &wi.files) {
655                        return Ok(Some(h));
656                    }
657                    if let Some(resolved_class) =
658                        crate::hover::resolve_use_alias(&doc.program().stmts, &class_token)
659                        && let Some(h) = method_hover_from_index(&resolved_class, &word, &wi.files)
660                    {
661                        return Ok(Some(h));
662                    }
663                }
664            }
665            Ok(None)
666        })
667        .await
668    }
669
670    async fn document_symbol(
671        &self,
672        params: DocumentSymbolParams,
673    ) -> Result<Option<DocumentSymbolResponse>> {
674        let uri = &params.text_document.uri;
675        let doc = match self.get_doc(uri) {
676            Some(d) => d,
677            None => return Ok(None),
678        };
679        Ok(Some(DocumentSymbolResponse::Nested(document_symbols(
680            doc.source(),
681            &doc,
682        ))))
683    }
684
685    async fn folding_range(&self, params: FoldingRangeParams) -> Result<Option<Vec<FoldingRange>>> {
686        let uri = &params.text_document.uri;
687        let doc = match self.get_doc(uri) {
688            Some(d) => d,
689            None => return Ok(None),
690        };
691        let ranges = folding_ranges(doc.source(), &doc);
692        Ok(if ranges.is_empty() {
693            None
694        } else {
695            Some(ranges)
696        })
697    }
698
699    async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
700        let uri = &params.text_document.uri;
701        let doc = match self.get_doc(uri) {
702            Some(d) => d,
703            None => return Ok(None),
704        };
705        let analysis = self.cached_analysis_async(uri).await;
706        let wi = self.workspace_index_async().await;
707        Ok(Some(inlay_hints(
708            doc.source(),
709            &doc,
710            analysis.as_deref(),
711            params.range,
712            &wi.files,
713        )))
714    }
715
716    async fn inlay_hint_resolve(&self, mut item: InlayHint) -> Result<InlayHint> {
717        if item.tooltip.is_some() {
718            return Ok(item);
719        }
720        let func_name = item
721            .data
722            .as_ref()
723            .and_then(|d| d.get("php_lsp_fn"))
724            .and_then(|v| v.as_str())
725            .map(str::to_string);
726        if let Some(name) = func_name {
727            let all_indexes = self.docs.all_indexes();
728            if let Some(md) = docs_for_symbol_from_index(&name, &all_indexes) {
729                item.tooltip = Some(InlayHintTooltip::MarkupContent(MarkupContent {
730                    kind: MarkupKind::Markdown,
731                    value: md,
732                }));
733            }
734        }
735        Ok(item)
736    }
737
738    async fn symbol(
739        &self,
740        params: WorkspaceSymbolParams,
741    ) -> Result<Option<Vec<SymbolInformation>>> {
742        // Phase J: read through the salsa-memoized aggregate so repeated
743        // workspace-symbol queries (every keystroke in the picker) share the
744        // same `Arc` until a file changes.
745        let wi = self.workspace_index_async().await;
746        let results = workspace_symbols_from_workspace(&params.query, &wi);
747        Ok(Some(results))
748    }
749
750    async fn symbol_resolve(&self, params: WorkspaceSymbol) -> Result<WorkspaceSymbol> {
751        // For resolve, we need the full range from the ParsedDoc of open files.
752        let docs = self.docs.docs_for(&self.open_urls());
753        Ok(resolve_workspace_symbol(params, &docs))
754    }
755
756    #[tracing::instrument(skip_all)]
757    async fn semantic_tokens_full(
758        &self,
759        params: SemanticTokensParams,
760    ) -> Result<Option<SemanticTokensResult>> {
761        guard_async_result("semantic_tokens_full", async move {
762            let uri = &params.text_document.uri;
763            let doc = match self.get_doc(uri) {
764                Some(d) => d,
765                None => {
766                    return Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
767                        result_id: None,
768                        data: vec![],
769                    })));
770                }
771            };
772            let tokens = semantic_tokens(doc.source(), &doc);
773            let result_id = token_hash(&tokens);
774            let tokens_arc = Arc::new(tokens);
775            self.docs
776                .store_token_cache(uri, result_id.clone(), Arc::clone(&tokens_arc));
777            let data = Arc::try_unwrap(tokens_arc).unwrap_or_else(|arc| (*arc).clone());
778            Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
779                result_id: Some(result_id),
780                data,
781            })))
782        })
783        .await
784    }
785
786    async fn semantic_tokens_range(
787        &self,
788        params: SemanticTokensRangeParams,
789    ) -> Result<Option<SemanticTokensRangeResult>> {
790        let uri = &params.text_document.uri;
791        let doc = match self.get_doc(uri) {
792            Some(d) => d,
793            None => {
794                return Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
795                    result_id: None,
796                    data: vec![],
797                })));
798            }
799        };
800        let tokens = semantic_tokens_range(doc.source(), &doc, params.range);
801        Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
802            result_id: None,
803            data: tokens,
804        })))
805    }
806
807    async fn semantic_tokens_full_delta(
808        &self,
809        params: SemanticTokensDeltaParams,
810    ) -> Result<Option<SemanticTokensFullDeltaResult>> {
811        let uri = &params.text_document.uri;
812        let doc = match self.get_doc(uri) {
813            Some(d) => d,
814            None => return Ok(None),
815        };
816
817        let new_tokens = Arc::new(semantic_tokens(doc.source(), &doc));
818        let new_result_id = token_hash(&new_tokens);
819        let prev_id = &params.previous_result_id;
820
821        let result = match self.docs.get_token_cache(uri, prev_id) {
822            Some(old_tokens) => {
823                let edits = compute_token_delta(&old_tokens, &new_tokens);
824                SemanticTokensFullDeltaResult::TokensDelta(SemanticTokensDelta {
825                    result_id: Some(new_result_id.clone()),
826                    edits,
827                })
828            }
829            // Unknown previous result — fall back to full tokens
830            None => SemanticTokensFullDeltaResult::Tokens(SemanticTokens {
831                result_id: Some(new_result_id.clone()),
832                data: (*new_tokens).clone(),
833            }),
834        };
835
836        self.docs.store_token_cache(uri, new_result_id, new_tokens);
837        Ok(Some(result))
838    }
839
840    async fn selection_range(
841        &self,
842        params: SelectionRangeParams,
843    ) -> Result<Option<Vec<SelectionRange>>> {
844        let uri = &params.text_document.uri;
845        let doc = match self.get_doc(uri) {
846            Some(d) => d,
847            None => return Ok(None),
848        };
849        let ranges = selection_ranges(&doc, &params.positions);
850        Ok(if ranges.is_empty() {
851            None
852        } else {
853            Some(ranges)
854        })
855    }
856
857    async fn prepare_call_hierarchy(
858        &self,
859        params: CallHierarchyPrepareParams,
860    ) -> Result<Option<Vec<CallHierarchyItem>>> {
861        let uri = &params.text_document_position_params.text_document.uri;
862        let position = params.text_document_position_params.position;
863        let source = self.get_open_text(uri).unwrap_or_default();
864        let word = match word_at_position(&source, position) {
865            Some(w) => w,
866            None => return Ok(None),
867        };
868        // O(matches) lookup via the aggregate's `decls_by_name` map instead
869        // of scanning every workspace doc.
870        let wi = self.workspace_index_async().await;
871        let docs = Arc::clone(&self.docs);
872        let get_doc = move |u: &Url| docs.get_doc_salsa(u);
873        Ok(prepare_call_hierarchy_indexed(&word, &wi, &get_doc).map(|item| vec![item]))
874    }
875
876    async fn incoming_calls(
877        &self,
878        params: CallHierarchyIncomingCallsParams,
879    ) -> Result<Option<Vec<CallHierarchyIncomingCall>>> {
880        // Genuinely needs every doc (call sites are body-level, not indexed);
881        // run the workspace scan on the blocking pool.
882        let docs = Arc::clone(&self.docs);
883        let item = params.item;
884        let calls = tokio::task::spawn_blocking(move || {
885            let all_docs = docs.all_docs_for_scan();
886            incoming_calls(&item, &all_docs)
887        })
888        .await
889        .unwrap_or_default();
890        Ok(if calls.is_empty() { None } else { Some(calls) })
891    }
892
893    async fn outgoing_calls(
894        &self,
895        params: CallHierarchyOutgoingCallsParams,
896    ) -> Result<Option<Vec<CallHierarchyOutgoingCall>>> {
897        // Per-callee declaration lookups go through `decls_by_name` — the old
898        // path re-scanned the whole workspace once per distinct callee.
899        let wi = self.workspace_index_async().await;
900        let docs = Arc::clone(&self.docs);
901        let item = params.item;
902        let calls = tokio::task::spawn_blocking(move || {
903            let get_doc = |u: &Url| docs.get_doc_salsa(u);
904            outgoing_calls_indexed(&item, &wi, &get_doc)
905        })
906        .await
907        .unwrap_or_default();
908        Ok(if calls.is_empty() { None } else { Some(calls) })
909    }
910
911    async fn document_highlight(
912        &self,
913        params: DocumentHighlightParams,
914    ) -> Result<Option<Vec<DocumentHighlight>>> {
915        let uri = &params.text_document_position_params.text_document.uri;
916        let position = params.text_document_position_params.position;
917        let source = self.get_open_text(uri).unwrap_or_default();
918        let doc = match self.get_doc(uri) {
919            Some(d) => d,
920            None => return Ok(None),
921        };
922        let highlights = document_highlights(&source, &doc, position);
923        Ok(if highlights.is_empty() {
924            None
925        } else {
926            Some(highlights)
927        })
928    }
929
930    async fn linked_editing_range(
931        &self,
932        params: LinkedEditingRangeParams,
933    ) -> Result<Option<LinkedEditingRanges>> {
934        self.handle_linked_editing_range(params).await
935    }
936
937    async fn goto_implementation(
938        &self,
939        params: tower_lsp::lsp_types::request::GotoImplementationParams,
940    ) -> Result<Option<tower_lsp::lsp_types::request::GotoImplementationResponse>> {
941        let uri = &params.text_document_position_params.text_document.uri;
942        let position = params.text_document_position_params.position;
943        let source = self.get_open_text(uri).unwrap_or_default();
944        let imports = self.file_imports(uri);
945        let raw_word = crate::text::word_at_position(&source, position).unwrap_or_default();
946        // `word_at_position` includes `\` as a word character, so the cursor on
947        // a use-statement import (`use A\B\Foo`) returns the full qualified name.
948        // Split to recover the short name and treat the rest as the FQN so the
949        // workspace index lookup (keyed by short name) still finds subtypes.
950        let (word, fqn_owned): (String, Option<String>) = if raw_word.contains('\\') {
951            let short = raw_word
952                .rsplit('\\')
953                .next()
954                .unwrap_or(&raw_word)
955                .to_string();
956            let full = raw_word.trim_start_matches('\\').to_string();
957            (short, Some(full))
958        } else {
959            let fqn = imports.get(&raw_word).cloned();
960            (raw_word, fqn)
961        };
962        let fqn = fqn_owned.as_deref();
963        // First pass: open-file ParsedDocs give accurate character positions.
964        let open_docs = self.docs.docs_for(&self.open_urls());
965        let mut locs = find_implementations(&word, fqn, &open_docs);
966        // Second pass: workspace aggregate's `subtypes_of` reverse map.
967        let wi = self.workspace_index_async().await;
968        if locs.is_empty() {
969            locs = find_implementations_from_workspace(&word, fqn, &wi);
970        }
971        // Third pass: treat word as a method name inside its declaring
972        // class/interface — returns concrete overrides in every subtype.
973        // Handles cursor on an interface method like `Factory::guard()`.
974        if locs.is_empty()
975            && let Some(doc) = self.get_doc(uri)
976            && let Some(enclosing) =
977                crate::types::type_map::enclosing_class_at(&source, &doc, position)
978        {
979            locs = find_method_implementations_from_workspace(&word, &enclosing, &wi);
980        }
981        if locs.is_empty() {
982            Ok(None)
983        } else {
984            Ok(Some(GotoDefinitionResponse::Array(locs)))
985        }
986    }
987
988    async fn goto_declaration(
989        &self,
990        params: tower_lsp::lsp_types::request::GotoDeclarationParams,
991    ) -> Result<Option<tower_lsp::lsp_types::request::GotoDeclarationResponse>> {
992        let uri = &params.text_document_position_params.text_document.uri;
993        let position = params.text_document_position_params.position;
994        let source = self.get_open_text(uri).unwrap_or_default();
995        // First pass: open-file ParsedDocs give accurate character positions.
996        let open_docs = self.docs.docs_for(&self.open_urls());
997        if let Some(loc) = goto_declaration(&source, &open_docs, position) {
998            return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
999        }
1000        // Second pass: background files via FileIndex (line-only positions).
1001        let all_indexes = self.docs.all_indexes();
1002        Ok(goto_declaration_from_index(&source, &all_indexes, position)
1003            .map(GotoDefinitionResponse::Scalar))
1004    }
1005
1006    async fn goto_type_definition(
1007        &self,
1008        params: tower_lsp::lsp_types::request::GotoTypeDefinitionParams,
1009    ) -> Result<Option<tower_lsp::lsp_types::request::GotoTypeDefinitionResponse>> {
1010        let uri = &params.text_document_position_params.text_document.uri;
1011        let position = params.text_document_position_params.position;
1012        let source = self.get_open_text(uri).unwrap_or_default();
1013        let doc = match self.get_doc(uri) {
1014            Some(d) => d,
1015            None => return Ok(None),
1016        };
1017        let analysis = self.cached_analysis_async(uri).await;
1018        // First pass: open-file ParsedDocs give accurate character positions.
1019        let open_docs = self.docs.docs_for(&self.open_urls());
1020        let mut results =
1021            goto_type_definition(&source, &doc, analysis.as_deref(), &open_docs, position);
1022
1023        // If no results from first pass, try background files via FileIndex (line-only positions).
1024        if results.is_empty() {
1025            let all_indexes = self.docs.all_indexes();
1026            results = goto_type_definition_from_index(
1027                &source,
1028                &doc,
1029                analysis.as_deref(),
1030                &all_indexes,
1031                position,
1032            );
1033        }
1034
1035        // Format response: scalar for single result, array for multiple, none for empty
1036        let response = match results.len() {
1037            0 => None,
1038            1 => Some(GotoDefinitionResponse::Scalar(
1039                results.into_iter().next().unwrap(),
1040            )),
1041            _ => Some(GotoDefinitionResponse::Array(results)),
1042        };
1043        Ok(response)
1044    }
1045
1046    async fn prepare_type_hierarchy(
1047        &self,
1048        params: TypeHierarchyPrepareParams,
1049    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1050        let uri = &params.text_document_position_params.text_document.uri;
1051        let position = params.text_document_position_params.position;
1052        let source = self.get_open_text(uri).unwrap_or_default();
1053        // Phase J: use the salsa-memoized aggregate's `classes_by_name` map.
1054        let wi = self.workspace_index_async().await;
1055        Ok(prepare_type_hierarchy_from_workspace(&source, &wi, position).map(|item| vec![item]))
1056    }
1057
1058    async fn supertypes(
1059        &self,
1060        params: TypeHierarchySupertypesParams,
1061    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1062        // Phase J: resolve parents via the aggregate's `classes_by_name` map.
1063        // Pre-load any direct vendor supertypes via PSR-4 so they appear in the
1064        // workspace index before the lookup runs.
1065        let wi = self.workspace_index_async().await;
1066        let loaded_new = self
1067            .ensure_direct_supertypes_loaded(&params.item.name, &wi)
1068            .await;
1069        let wi = if loaded_new {
1070            self.workspace_index_async().await
1071        } else {
1072            wi
1073        };
1074        let result = supertypes_of_from_workspace(&params.item, &wi);
1075        Ok(if result.is_empty() {
1076            None
1077        } else {
1078            Some(result)
1079        })
1080    }
1081
1082    async fn subtypes(
1083        &self,
1084        params: TypeHierarchySubtypesParams,
1085    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1086        // Phase J: O(matches) lookup via the aggregate's `subtypes_of` map.
1087        let wi = self.workspace_index_async().await;
1088        let result = subtypes_of_from_workspace(&params.item, &wi);
1089        Ok(if result.is_empty() {
1090            None
1091        } else {
1092            Some(result)
1093        })
1094    }
1095
1096    async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
1097        let uri = &params.text_document.uri;
1098        let doc = match self.get_doc(uri) {
1099            Some(d) => d,
1100            None => return Ok(None),
1101        };
1102        // Reference-count lenses scan every doc per declaration; run the
1103        // whole computation on the blocking pool.
1104        let docs = Arc::clone(&self.docs);
1105        let uri_owned = uri.clone();
1106        let lenses = tokio::task::spawn_blocking(move || {
1107            let all_docs = docs.all_docs_for_scan();
1108            code_lenses(&uri_owned, &doc, &all_docs)
1109        })
1110        .await
1111        .unwrap_or_default();
1112        Ok(if lenses.is_empty() {
1113            None
1114        } else {
1115            Some(lenses)
1116        })
1117    }
1118
1119    async fn code_lens_resolve(&self, params: CodeLens) -> Result<CodeLens> {
1120        // Lenses are fully populated by code_lens; nothing to add.
1121        Ok(params)
1122    }
1123
1124    async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
1125        let uri = &params.text_document.uri;
1126        let doc = match self.get_doc(uri) {
1127            Some(d) => d,
1128            None => return Ok(None),
1129        };
1130        let links = document_links(uri, &doc, doc.source());
1131        Ok(if links.is_empty() { None } else { Some(links) })
1132    }
1133
1134    async fn document_link_resolve(&self, params: DocumentLink) -> Result<DocumentLink> {
1135        // Links already carry their target URI; nothing to add.
1136        Ok(params)
1137    }
1138
1139    async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
1140        let uri = &params.text_document.uri;
1141        let source = self.get_open_text(uri).unwrap_or_default();
1142        Ok(format_document(&source))
1143    }
1144
1145    async fn range_formatting(
1146        &self,
1147        params: DocumentRangeFormattingParams,
1148    ) -> 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_range(&source, params.range))
1152    }
1153
1154    async fn on_type_formatting(
1155        &self,
1156        params: DocumentOnTypeFormattingParams,
1157    ) -> Result<Option<Vec<TextEdit>>> {
1158        let uri = &params.text_document_position.text_document.uri;
1159        let source = self.get_open_text(uri).unwrap_or_default();
1160        let edits = on_type_format(
1161            &source,
1162            params.text_document_position.position,
1163            &params.ch,
1164            &params.options,
1165        );
1166        Ok(if edits.is_empty() { None } else { Some(edits) })
1167    }
1168
1169    async fn execute_command(
1170        &self,
1171        params: ExecuteCommandParams,
1172    ) -> Result<Option<serde_json::Value>> {
1173        match params.command.as_str() {
1174            "php-lsp.runTest" => {
1175                // Arguments: [uri_string, "ClassName::methodName"]
1176                let file_uri = params
1177                    .arguments
1178                    .first()
1179                    .and_then(|v| v.as_str())
1180                    .and_then(|s| Url::parse(s).ok());
1181                let filter = params
1182                    .arguments
1183                    .get(1)
1184                    .and_then(|v| v.as_str())
1185                    .unwrap_or("")
1186                    .to_string();
1187
1188                let root = self.root_paths.load().first().cloned();
1189                let client = self.client.clone();
1190
1191                tokio::spawn(async move {
1192                    run_phpunit(&client, &filter, root.as_deref(), file_uri.as_ref()).await;
1193                });
1194
1195                Ok(None)
1196            }
1197            _ => Ok(None),
1198        }
1199    }
1200
1201    async fn will_rename_files(&self, params: RenameFilesParams) -> Result<Option<WorkspaceEdit>> {
1202        self.handle_will_rename_files(params).await
1203    }
1204
1205    async fn did_rename_files(&self, params: RenameFilesParams) {
1206        self.handle_did_rename_files(params).await
1207    }
1208
1209    async fn will_create_files(&self, params: CreateFilesParams) -> Result<Option<WorkspaceEdit>> {
1210        self.handle_will_create_files(params).await
1211    }
1212
1213    async fn did_create_files(&self, params: CreateFilesParams) {
1214        self.handle_did_create_files(params).await
1215    }
1216
1217    /// Before a file is deleted, return workspace edits that remove every
1218    /// `use` import referencing its PSR-4 class name.
1219    async fn will_delete_files(&self, params: DeleteFilesParams) -> Result<Option<WorkspaceEdit>> {
1220        self.handle_will_delete_files(params).await
1221    }
1222
1223    async fn did_delete_files(&self, params: DeleteFilesParams) {
1224        self.handle_did_delete_files(params).await
1225    }
1226
1227    async fn moniker(&self, params: MonikerParams) -> Result<Option<Vec<Moniker>>> {
1228        let uri = &params.text_document_position_params.text_document.uri;
1229        let position = params.text_document_position_params.position;
1230        let source = self.get_open_text(uri).unwrap_or_default();
1231        let doc = match self.get_doc(uri) {
1232            Some(d) => d,
1233            None => return Ok(None),
1234        };
1235        let imports = self.file_imports(uri);
1236        Ok(moniker_at(&source, &doc, position, &imports).map(|m| vec![m]))
1237    }
1238
1239    async fn inline_value(&self, params: InlineValueParams) -> Result<Option<Vec<InlineValue>>> {
1240        let uri = &params.text_document.uri;
1241        let source = self.get_open_text(uri).unwrap_or_default();
1242        let values = inline_values_in_range(&source, params.range);
1243        Ok(if values.is_empty() {
1244            None
1245        } else {
1246            Some(values)
1247        })
1248    }
1249
1250    async fn diagnostic(
1251        &self,
1252        params: DocumentDiagnosticParams,
1253    ) -> Result<DocumentDiagnosticReportResult> {
1254        self.handle_diagnostic(params).await
1255    }
1256
1257    async fn workspace_diagnostic(
1258        &self,
1259        params: WorkspaceDiagnosticParams,
1260    ) -> Result<WorkspaceDiagnosticReportResult> {
1261        self.handle_workspace_diagnostic(params).await
1262    }
1263
1264    async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
1265        self.handle_code_action(params).await
1266    }
1267
1268    async fn code_action_resolve(&self, item: CodeAction) -> Result<CodeAction> {
1269        self.handle_code_action_resolve(item).await
1270    }
1271}