Skip to main content

php_lsp/
backend.rs

1use std::path::PathBuf;
2use std::sync::{Arc, RwLock};
3
4use dashmap::DashMap;
5use tower_lsp::jsonrpc::Result;
6use tower_lsp::lsp_types::notification::Progress as ProgressNotification;
7
8/// Sent to the client once Phase 3 (reference index build) finishes.
9/// Allows tests and tooling to wait for the codebase fast path to be active.
10enum IndexReadyNotification {}
11impl tower_lsp::lsp_types::notification::Notification for IndexReadyNotification {
12    type Params = ();
13    const METHOD: &'static str = "$/php-lsp/indexReady";
14}
15use tower_lsp::lsp_types::request::{
16    CodeLensRefresh, InlayHintRefreshRequest, InlineValueRefreshRequest, SemanticTokensRefresh,
17    WorkDoneProgressCreate, WorkspaceDiagnosticRefresh,
18};
19use tower_lsp::lsp_types::*;
20use tower_lsp::{Client, LanguageServer, async_trait};
21
22use php_ast::{ClassMemberKind, EnumMemberKind, NamespaceBody, Stmt, StmtKind};
23
24use crate::ast::{ParsedDoc, str_offset};
25use crate::autoload::Psr4Map;
26use crate::call_hierarchy::{incoming_calls, outgoing_calls, prepare_call_hierarchy};
27use crate::code_lens::code_lenses;
28use crate::completion::{CompletionCtx, filtered_completions_at};
29use crate::declaration::{goto_declaration, goto_declaration_from_index};
30use crate::definition::{find_declaration_range, find_in_indexes, goto_definition};
31use crate::diagnostics::parse_document;
32use crate::document_highlight::document_highlights;
33use crate::document_link::document_links;
34use crate::document_store::DocumentStore;
35use crate::extract_action::extract_variable_actions;
36use crate::extract_constant_action::extract_constant_actions;
37use crate::extract_method_action::extract_method_actions;
38use crate::file_rename::{use_edits_for_delete, use_edits_for_rename};
39use crate::folding::folding_ranges;
40use crate::formatting::{format_document, format_range};
41use crate::generate_action::{generate_constructor_actions, generate_getters_setters_actions};
42use crate::hover::{
43    class_hover_from_index, docs_for_symbol_from_index, hover_info, signature_for_symbol_from_index,
44};
45use crate::implement_action::implement_missing_actions;
46use crate::implementation::{find_implementations, find_implementations_from_workspace};
47use crate::inlay_hints::inlay_hints;
48use crate::inline_action::inline_variable_actions;
49use crate::inline_value::inline_values_in_range;
50use crate::moniker::moniker_at;
51use crate::on_type_format::on_type_format;
52use crate::organize_imports::organize_imports_action;
53use crate::phpdoc_action::phpdoc_actions;
54use crate::phpstorm_meta::PhpStormMeta;
55use crate::promote_action::promote_constructor_actions;
56use crate::references::{
57    SymbolKind, find_constructor_references, find_references, find_references_codebase_with_target,
58    find_references_with_target,
59};
60use crate::rename::{prepare_rename, rename, rename_property, rename_variable};
61use crate::selection_range::selection_ranges;
62use crate::semantic_diagnostics::duplicate_declaration_diagnostics;
63use crate::semantic_tokens::{
64    compute_token_delta, legend, semantic_tokens, semantic_tokens_range, token_hash,
65};
66use crate::signature_help::signature_help;
67use crate::symbols::{
68    document_symbols, resolve_workspace_symbol, workspace_symbols_from_workspace,
69};
70use crate::type_action::add_return_type_actions;
71use crate::type_definition::{goto_type_definition, goto_type_definition_from_index};
72use crate::type_hierarchy::{
73    prepare_type_hierarchy_from_workspace, subtypes_of_from_workspace, supertypes_of_from_workspace,
74};
75use crate::use_import::{build_use_import_edit, find_fqn_for_class};
76use crate::util::word_at;
77
78/// Per-category diagnostic toggle flags.
79/// The master `enabled` switch defaults to `true`. Individual category flags
80/// also default to `true`, so all diagnostics are on out of the box; set
81/// `initializationOptions.diagnostics.enabled = false` to silence everything,
82/// or turn off specific categories individually.
83#[derive(Debug, Clone)]
84pub struct DiagnosticsConfig {
85    /// Master switch: when `false`, no diagnostics are emitted. Defaults to `true`.
86    pub enabled: bool,
87    /// Undefined variable references.
88    pub undefined_variables: bool,
89    /// Calls to undefined functions.
90    pub undefined_functions: bool,
91    /// References to undefined classes / interfaces / traits.
92    pub undefined_classes: bool,
93    /// Wrong number of arguments passed to a function.
94    pub arity_errors: bool,
95    /// Return-type mismatches.
96    pub type_errors: bool,
97    /// Calls to `@deprecated` members.
98    pub deprecated_calls: bool,
99    /// Duplicate class / function declarations.
100    pub duplicate_declarations: bool,
101}
102
103impl Default for DiagnosticsConfig {
104    fn default() -> Self {
105        DiagnosticsConfig {
106            enabled: true,
107            undefined_variables: true,
108            undefined_functions: true,
109            undefined_classes: true,
110            arity_errors: true,
111            type_errors: true,
112            deprecated_calls: true,
113            duplicate_declarations: true,
114        }
115    }
116}
117
118impl DiagnosticsConfig {
119    /// All categories on. Used in tests and by clients that explicitly enable
120    /// diagnostics without overriding individual flags.
121    #[cfg(test)]
122    pub fn all_enabled() -> Self {
123        DiagnosticsConfig {
124            enabled: true,
125            ..DiagnosticsConfig::default()
126        }
127    }
128
129    fn from_value(v: &serde_json::Value) -> Self {
130        let mut cfg = DiagnosticsConfig::default();
131        let Some(obj) = v.as_object() else { return cfg };
132        let flag = |key: &str| obj.get(key).and_then(|x| x.as_bool()).unwrap_or(true);
133        cfg.enabled = obj.get("enabled").and_then(|x| x.as_bool()).unwrap_or(true);
134        cfg.undefined_variables = flag("undefinedVariables");
135        cfg.undefined_functions = flag("undefinedFunctions");
136        cfg.undefined_classes = flag("undefinedClasses");
137        cfg.arity_errors = flag("arityErrors");
138        cfg.type_errors = flag("typeErrors");
139        cfg.deprecated_calls = flag("deprecatedCalls");
140        cfg.duplicate_declarations = flag("duplicateDeclarations");
141        cfg
142    }
143}
144
145/// Per-feature capability toggles. All default to `true` (enabled).
146/// Set `initializationOptions.features.<name> = false` to suppress a capability.
147#[derive(Debug, Clone)]
148pub struct FeaturesConfig {
149    pub completion: bool,
150    pub hover: bool,
151    pub definition: bool,
152    pub declaration: bool,
153    pub references: bool,
154    pub document_symbols: bool,
155    pub workspace_symbols: bool,
156    pub rename: bool,
157    pub signature_help: bool,
158    pub inlay_hints: bool,
159    pub semantic_tokens: bool,
160    pub selection_range: bool,
161    pub call_hierarchy: bool,
162    pub document_highlight: bool,
163    pub implementation: bool,
164    pub code_action: bool,
165    pub type_definition: bool,
166    pub code_lens: bool,
167    pub formatting: bool,
168    pub range_formatting: bool,
169    pub on_type_formatting: bool,
170    pub document_link: bool,
171    pub linked_editing_range: bool,
172    pub inline_values: bool,
173}
174
175impl Default for FeaturesConfig {
176    fn default() -> Self {
177        FeaturesConfig {
178            completion: true,
179            hover: true,
180            definition: true,
181            declaration: true,
182            references: true,
183            document_symbols: true,
184            workspace_symbols: true,
185            rename: true,
186            signature_help: true,
187            inlay_hints: true,
188            semantic_tokens: true,
189            selection_range: true,
190            call_hierarchy: true,
191            document_highlight: true,
192            implementation: true,
193            code_action: true,
194            type_definition: true,
195            code_lens: true,
196            formatting: true,
197            range_formatting: true,
198            on_type_formatting: true,
199            document_link: true,
200            linked_editing_range: true,
201            inline_values: true,
202        }
203    }
204}
205
206impl FeaturesConfig {
207    fn from_value(v: &serde_json::Value) -> Self {
208        let mut cfg = FeaturesConfig::default();
209        let Some(obj) = v.as_object() else { return cfg };
210        let flag = |key: &str| obj.get(key).and_then(|x| x.as_bool()).unwrap_or(true);
211        cfg.completion = flag("completion");
212        cfg.hover = flag("hover");
213        cfg.definition = flag("definition");
214        cfg.declaration = flag("declaration");
215        cfg.references = flag("references");
216        cfg.document_symbols = flag("documentSymbols");
217        cfg.workspace_symbols = flag("workspaceSymbols");
218        cfg.rename = flag("rename");
219        cfg.signature_help = flag("signatureHelp");
220        cfg.inlay_hints = flag("inlayHints");
221        cfg.semantic_tokens = flag("semanticTokens");
222        cfg.selection_range = flag("selectionRange");
223        cfg.call_hierarchy = flag("callHierarchy");
224        cfg.document_highlight = flag("documentHighlight");
225        cfg.implementation = flag("implementation");
226        cfg.code_action = flag("codeAction");
227        cfg.type_definition = flag("typeDefinition");
228        cfg.code_lens = flag("codeLens");
229        cfg.formatting = flag("formatting");
230        cfg.range_formatting = flag("rangeFormatting");
231        cfg.on_type_formatting = flag("onTypeFormatting");
232        cfg.document_link = flag("documentLink");
233        cfg.linked_editing_range = flag("linkedEditingRange");
234        cfg.inline_values = flag("inlineValues");
235        cfg
236    }
237}
238
239/// Configuration received from the client via `initializationOptions`.
240#[derive(Debug, Clone)]
241pub struct LspConfig {
242    /// PHP version string, e.g. `"8.1"`.  Set explicitly via `initializationOptions`
243    /// or auto-detected from `composer.json` / the `php` binary at startup.
244    pub php_version: Option<String>,
245    /// Glob patterns for paths to exclude from workspace indexing.
246    pub exclude_paths: Vec<String>,
247    /// Per-category diagnostic toggles.
248    pub diagnostics: DiagnosticsConfig,
249    /// Per-feature capability toggles.
250    pub features: FeaturesConfig,
251    /// Hard cap on the number of PHP files indexed during a workspace scan.
252    /// Defaults to [`MAX_INDEXED_FILES`]. Set lower via `initializationOptions`
253    /// to reduce memory on projects with very large vendor trees.
254    pub max_indexed_files: usize,
255}
256
257impl Default for LspConfig {
258    fn default() -> Self {
259        LspConfig {
260            php_version: None,
261            exclude_paths: Vec::new(),
262            diagnostics: DiagnosticsConfig::default(),
263            features: FeaturesConfig::default(),
264            max_indexed_files: MAX_INDEXED_FILES,
265        }
266    }
267}
268
269impl LspConfig {
270    /// Merge a `.php-lsp.json` value with editor `initializationOptions` /
271    /// `workspace/configuration`. Editor settings win per-key; `excludePaths`
272    /// arrays are **concatenated** (file entries first, editor entries appended)
273    /// rather than replaced, since exclusion patterns are additive.
274    ///
275    /// Hot-reload of `.php-lsp.json` on file change is not supported; the file
276    /// is only read during `initialize` and `did_change_configuration`.
277    pub fn merge_project_configs(
278        file: Option<&serde_json::Value>,
279        editor: Option<&serde_json::Value>,
280    ) -> serde_json::Value {
281        let mut merged = file
282            .cloned()
283            .unwrap_or(serde_json::Value::Object(Default::default()));
284        let Some(editor_obj) = editor.and_then(|e| e.as_object()) else {
285            return merged;
286        };
287        let merged_obj = merged
288            .as_object_mut()
289            .expect("merged base is always an object");
290        for (key, val) in editor_obj {
291            if key == "excludePaths" {
292                let file_arr = merged_obj
293                    .get("excludePaths")
294                    .and_then(|v| v.as_array())
295                    .cloned()
296                    .unwrap_or_default();
297                let editor_arr = val.as_array().cloned().unwrap_or_default();
298                merged_obj.insert(
299                    key.clone(),
300                    serde_json::Value::Array([file_arr, editor_arr].concat()),
301                );
302            } else {
303                merged_obj.insert(key.clone(), val.clone());
304            }
305        }
306        merged
307    }
308
309    fn from_value(v: &serde_json::Value) -> Self {
310        let mut cfg = LspConfig::default();
311        if let Some(ver) = v.get("phpVersion").and_then(|x| x.as_str())
312            && crate::autoload::is_valid_php_version(ver)
313        {
314            cfg.php_version = Some(ver.to_string());
315        }
316        if let Some(arr) = v.get("excludePaths").and_then(|x| x.as_array()) {
317            cfg.exclude_paths = arr
318                .iter()
319                .filter_map(|x| x.as_str().map(str::to_string))
320                .collect();
321        }
322        if let Some(diag_val) = v.get("diagnostics") {
323            cfg.diagnostics = DiagnosticsConfig::from_value(diag_val);
324        }
325        if let Some(feat_val) = v.get("features") {
326            cfg.features = FeaturesConfig::from_value(feat_val);
327        }
328        if let Some(n) = v.get("maxIndexedFiles").and_then(|x| x.as_u64()) {
329            cfg.max_indexed_files = n as usize;
330        }
331        cfg
332    }
333}
334
335/// Per-open-file state owned by `Backend` (Phase E4).
336///
337/// Previously this lived inside `DocumentStore`'s `map: DashMap<Url, Document>`,
338/// but none of these fields are salsa-shaped: `text` is the live editor buffer,
339/// `version` is an async-parse gate, and `parse_diagnostics` is a publish cache.
340/// Keeping them on `Backend` leaves `DocumentStore` as a pure salsa-input wrapper.
341#[derive(Default, Clone)]
342struct OpenFile {
343    /// Live editor text.
344    text: String,
345    /// Monotonic counter bumped on every `set_open_text` / `close_open_file`;
346    /// used to discard stale async parse results.
347    version: u64,
348    /// Parse-level diagnostics most recently cached for publication.
349    parse_diagnostics: Vec<Diagnostic>,
350}
351
352/// Shared handle to open-file state. Cheaply cloneable — wraps an `Arc<DashMap>`
353/// so it can be captured by async closures alongside `Arc<DocumentStore>`.
354#[derive(Clone, Default)]
355pub struct OpenFiles(Arc<DashMap<Url, OpenFile>>);
356
357impl OpenFiles {
358    fn new() -> Self {
359        Self::default()
360    }
361
362    fn set_open_text(&self, docs: &DocumentStore, uri: Url, text: String) -> u64 {
363        docs.mirror_text(&uri, &text);
364        let mut entry = self.0.entry(uri).or_default();
365        entry.version += 1;
366        entry.text = text;
367        entry.version
368    }
369
370    fn close(&self, docs: &DocumentStore, uri: &Url) {
371        self.0.remove(uri);
372        docs.evict_token_cache(uri);
373    }
374
375    fn current_version(&self, uri: &Url) -> Option<u64> {
376        self.0.get(uri).map(|e| e.version)
377    }
378
379    fn text(&self, uri: &Url) -> Option<String> {
380        self.0.get(uri).map(|e| e.text.clone())
381    }
382
383    fn set_parse_diagnostics(&self, uri: &Url, diagnostics: Vec<Diagnostic>) {
384        if let Some(mut entry) = self.0.get_mut(uri) {
385            entry.parse_diagnostics = diagnostics;
386        }
387    }
388
389    fn parse_diagnostics(&self, uri: &Url) -> Option<Vec<Diagnostic>> {
390        self.0.get(uri).map(|e| e.parse_diagnostics.clone())
391    }
392
393    fn all_with_diagnostics(&self) -> Vec<(Url, Vec<Diagnostic>, Option<i64>)> {
394        self.0
395            .iter()
396            .map(|e| {
397                (
398                    e.key().clone(),
399                    e.value().parse_diagnostics.clone(),
400                    Some(e.value().version as i64),
401                )
402            })
403            .collect()
404    }
405
406    fn urls(&self) -> Vec<Url> {
407        self.0.iter().map(|e| e.key().clone()).collect()
408    }
409
410    fn contains(&self, uri: &Url) -> bool {
411        self.0.contains_key(uri)
412    }
413
414    /// Open-gated parsed doc: returns `Some` only when `uri` is currently open.
415    fn get_doc(&self, docs: &DocumentStore, uri: &Url) -> Option<Arc<ParsedDoc>> {
416        if !self.contains(uri) {
417            return None;
418        }
419        docs.get_doc_salsa(uri)
420    }
421}
422
423/// Build the full diagnostic bundle for an already-open file.
424///
425/// Reuses cached parse diagnostics from `OpenFiles` (set by the file's own
426/// debounced parse) and recomputes the rest:
427/// - `duplicate_declaration_diagnostics` is intra-file (AST walk over the
428///   doc's own statements), so a dependency change does NOT change its
429///   result — but it's cheap and keeps this helper a single source of
430///   truth for "the diagnostic bundle for `uri`".
431/// - `semantic_issues` is salsa-cached; for files unaffected by the
432///   triggering change it's a cache hit.
433///
434/// Used both for the originating file (during `did_open`/`did_change`) and
435/// when proactively republishing diagnostics to other open files after a
436/// dependency edit. Salsa-blocking — call from a `spawn_blocking` if invoked
437/// off the originating file's debounce path.
438fn compute_open_file_diagnostics(
439    docs: &DocumentStore,
440    open_files: &OpenFiles,
441    uri: &Url,
442    diag_cfg: &DiagnosticsConfig,
443) -> Vec<Diagnostic> {
444    let mut out = open_files.parse_diagnostics(uri).unwrap_or_default();
445    let source = open_files.text(uri).unwrap_or_default();
446    if let Some(d) = open_files.get_doc(docs, uri) {
447        out.extend(duplicate_declaration_diagnostics(&source, &d, diag_cfg));
448    }
449    if let Some(issues) = docs.get_semantic_issues_salsa(uri) {
450        out.extend(crate::semantic_diagnostics::issues_to_diagnostics(
451            &issues, uri, diag_cfg,
452        ));
453    }
454    out
455}
456
457pub struct Backend {
458    client: Client,
459    docs: Arc<DocumentStore>,
460    /// Open-file state: text, version token, parse diagnostics.
461    /// Files that are only background-indexed (never opened in the editor)
462    /// do not appear here; they live only in `DocumentStore`'s salsa layer.
463    open_files: OpenFiles,
464    root_paths: Arc<RwLock<Vec<PathBuf>>>,
465    psr4: Arc<RwLock<Psr4Map>>,
466    meta: Arc<RwLock<PhpStormMeta>>,
467    config: Arc<RwLock<LspConfig>>,
468}
469
470impl Backend {
471    pub fn new(client: Client) -> Self {
472        // No imperative Codebase field anymore — `self.codebase()` below
473        // delegates to the salsa-memoized `codebase` query, which composes
474        // bundled stubs + every file's StubSlice and returns a fresh
475        // `Arc<Codebase>` (or the memoized one when inputs are unchanged).
476        let docs = Arc::new(DocumentStore::new());
477        let psr4 = docs.psr4_arc();
478        Backend {
479            client,
480            docs,
481            open_files: OpenFiles::new(),
482            root_paths: Arc::new(RwLock::new(Vec::new())),
483            psr4,
484            meta: Arc::new(RwLock::new(PhpStormMeta::default())),
485            config: Arc::new(RwLock::new(LspConfig::default())),
486        }
487    }
488
489    // ── Open-file state convenience wrappers (Phase E4) ──────────────────────
490
491    fn set_open_text(&self, uri: Url, text: String) -> u64 {
492        self.open_files.set_open_text(&self.docs, uri, text)
493    }
494
495    fn close_open_file(&self, uri: &Url) {
496        self.open_files.close(&self.docs, uri);
497    }
498
499    /// Background-index a file from disk, but only if it isn't currently
500    /// open in the editor — the editor's buffer is authoritative while a
501    /// file is open, and we must not overwrite it with disk contents.
502    fn index_if_not_open(&self, uri: Url, text: &str) {
503        if !self.open_files.contains(&uri) {
504            self.docs.index(uri, text);
505        }
506    }
507
508    /// Variant of [`index_if_not_open`] that reuses an already-parsed doc.
509    fn index_from_doc_if_not_open(&self, uri: Url, doc: &ParsedDoc, diags: Vec<Diagnostic>) {
510        if !self.open_files.contains(&uri) {
511            self.docs.index_from_doc(uri, doc, diags);
512        }
513    }
514
515    fn get_open_text(&self, uri: &Url) -> Option<String> {
516        self.open_files.text(uri)
517    }
518
519    fn set_parse_diagnostics(&self, uri: &Url, diagnostics: Vec<Diagnostic>) {
520        self.open_files.set_parse_diagnostics(uri, diagnostics);
521    }
522
523    fn get_parse_diagnostics(&self, uri: &Url) -> Option<Vec<Diagnostic>> {
524        self.open_files.parse_diagnostics(uri)
525    }
526
527    fn all_open_files_with_diagnostics(&self) -> Vec<(Url, Vec<Diagnostic>, Option<i64>)> {
528        self.open_files.all_with_diagnostics()
529    }
530
531    fn open_urls(&self) -> Vec<Url> {
532        self.open_files.urls()
533    }
534
535    fn get_doc(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
536        self.open_files.get_doc(&self.docs, uri)
537    }
538
539    /// Current finalized codebase — stubs + all known files, memoized by salsa.
540    /// Cheap Arc clone on the happy path; on edits the query re-runs under the
541    /// DocumentStore host lock. Hold the returned Arc for the duration of a
542    /// request to get a consistent snapshot.
543    fn codebase(&self) -> Arc<mir_codebase::Codebase> {
544        self.docs.get_codebase_salsa()
545    }
546
547    /// Look up the import map for a file from the persistent codebase.
548    fn file_imports(&self, uri: &Url) -> std::collections::HashMap<String, String> {
549        self.codebase()
550            .file_imports
551            .get(uri.as_str())
552            .map(|r| r.clone())
553            .unwrap_or_default()
554    }
555
556    /// Resolve the PHP version to use. See `autoload::resolve_php_version_from_roots`
557    /// for the full priority order.
558    fn resolve_php_version(&self, explicit: Option<&str>) -> (String, &'static str) {
559        let roots = self.root_paths.read().unwrap().clone();
560        crate::autoload::resolve_php_version_from_roots(&roots, explicit)
561    }
562}
563
564#[async_trait]
565impl LanguageServer for Backend {
566    async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
567        // Collect all workspace roots. Prefer workspace_folders (multi-root) over
568        // the deprecated root_uri (single root).
569        {
570            let mut roots: Vec<PathBuf> = params
571                .workspace_folders
572                .as_deref()
573                .unwrap_or(&[])
574                .iter()
575                .filter_map(|f| f.uri.to_file_path().ok())
576                .collect();
577            if roots.is_empty()
578                && let Some(path) = params.root_uri.as_ref().and_then(|u| u.to_file_path().ok())
579            {
580                roots.push(path);
581            }
582            *self.root_paths.write().unwrap() = roots;
583        }
584
585        // Pre-load PSR-4 map synchronously during initialize so it is available
586        // before the first didOpen arrives. The initialized handler reloads it too
587        // (after workspace folders may be updated), but doing it here eliminates
588        // the race where didOpen runs before the initialized handler finishes its
589        // register_capability round-trip.
590        {
591            let roots = self.root_paths.read().unwrap().clone();
592            if !roots.is_empty() {
593                let mut merged = Psr4Map::empty();
594                for root in &roots {
595                    merged.extend(Psr4Map::load(root));
596                }
597                *self.psr4.write().unwrap() = merged;
598            }
599        }
600
601        // Parse initializationOptions merged with .php-lsp.json (editor wins per-key).
602        {
603            let opts = params.initialization_options.as_ref();
604            let roots = self.root_paths.read().unwrap().clone();
605
606            // Load .php-lsp.json from the workspace root (first root wins).
607            let file_cfg = crate::autoload::load_project_config_json(&roots);
608
609            // Warn if the file exists but is not valid JSON (Null sentinel).
610            if matches!(file_cfg, Some(serde_json::Value::Null)) {
611                self.client
612                    .log_message(
613                        tower_lsp::lsp_types::MessageType::WARNING,
614                        "php-lsp: .php-lsp.json contains invalid JSON — ignoring",
615                    )
616                    .await;
617            }
618
619            // Warn if .php-lsp.json contains an unrecognised phpVersion.
620            if let Some(serde_json::Value::Object(ref obj)) = file_cfg
621                && let Some(ver) = obj.get("phpVersion").and_then(|v| v.as_str())
622                && !crate::autoload::is_valid_php_version(ver)
623            {
624                self.client
625                    .log_message(
626                        tower_lsp::lsp_types::MessageType::WARNING,
627                        format!(
628                            "php-lsp: .php-lsp.json unsupported phpVersion {ver:?} — valid values: {}",
629                            crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
630                        ),
631                    )
632                    .await;
633            }
634
635            // Warn if the client supplied an unrecognised phpVersion.
636            if let Some(ver) = opts
637                .and_then(|o| o.get("phpVersion"))
638                .and_then(|v| v.as_str())
639                && !crate::autoload::is_valid_php_version(ver)
640            {
641                self.client
642                    .log_message(
643                        tower_lsp::lsp_types::MessageType::WARNING,
644                        format!(
645                            "php-lsp: unsupported phpVersion {ver:?} — valid values: {}",
646                            crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
647                        ),
648                    )
649                    .await;
650            }
651
652            // Merge: file config is the base; editor initializationOptions override per-key.
653            // excludePaths arrays are concatenated rather than replaced.
654            let file_obj = file_cfg.as_ref().filter(|v| v.is_object());
655            let merged = LspConfig::merge_project_configs(file_obj, opts);
656            let mut cfg = LspConfig::from_value(&merged);
657
658            // Resolve the PHP version and log what was chosen and why.
659            // phpVersion from initializationOptions is already in cfg.php_version (editor wins).
660            // If neither editor nor .php-lsp.json set it, resolve_php_version falls through
661            // to composer.json / php binary / default.
662            let (ver, source) = self.resolve_php_version(cfg.php_version.as_deref());
663            self.client
664                .log_message(
665                    tower_lsp::lsp_types::MessageType::INFO,
666                    format!("php-lsp: using PHP {ver} ({source})"),
667                )
668                .await;
669            // Show a visible warning when auto-detection yields a version outside
670            // our supported range (e.g. a legacy project with ">=5.6" in composer.json).
671            // TODO: instead of storing and using the unsupported version, consider clamping
672            // it to the nearest supported version so analysis stays meaningful.
673            if source != "set by editor" && !crate::autoload::is_valid_php_version(&ver) {
674                self.client
675                    .show_message(
676                        tower_lsp::lsp_types::MessageType::WARNING,
677                        format!(
678                            "php-lsp: detected PHP {ver} is outside the supported range ({}); \
679                             analysis may be inaccurate",
680                            crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
681                        ),
682                    )
683                    .await;
684            }
685            cfg.php_version = Some(ver.clone());
686            if let Ok(pv) = ver.parse::<mir_analyzer::PhpVersion>() {
687                self.docs.set_php_version(pv);
688            }
689            *self.config.write().unwrap() = cfg;
690        }
691
692        let feat = self.config.read().unwrap().features.clone();
693        Ok(InitializeResult {
694            capabilities: ServerCapabilities {
695                text_document_sync: Some(TextDocumentSyncCapability::Options(
696                    TextDocumentSyncOptions {
697                        open_close: Some(true),
698                        change: Some(TextDocumentSyncKind::FULL),
699                        will_save: Some(true),
700                        will_save_wait_until: Some(true),
701                        save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
702                            include_text: Some(false),
703                        })),
704                    },
705                )),
706                completion_provider: feat.completion.then(|| CompletionOptions {
707                    trigger_characters: Some(vec![
708                        "$".to_string(),
709                        ">".to_string(),
710                        ":".to_string(),
711                        "(".to_string(),
712                        "[".to_string(),
713                    ]),
714                    resolve_provider: Some(true),
715                    ..Default::default()
716                }),
717                hover_provider: feat.hover.then_some(HoverProviderCapability::Simple(true)),
718                definition_provider: feat.definition.then_some(OneOf::Left(true)),
719                references_provider: feat.references.then_some(OneOf::Left(true)),
720                document_symbol_provider: feat.document_symbols.then_some(OneOf::Left(true)),
721                workspace_symbol_provider: feat.workspace_symbols.then(|| {
722                    OneOf::Right(WorkspaceSymbolOptions {
723                        resolve_provider: Some(true),
724                        work_done_progress_options: Default::default(),
725                    })
726                }),
727                rename_provider: feat.rename.then(|| {
728                    OneOf::Right(RenameOptions {
729                        prepare_provider: Some(true),
730                        work_done_progress_options: Default::default(),
731                    })
732                }),
733                signature_help_provider: feat.signature_help.then(|| SignatureHelpOptions {
734                    trigger_characters: Some(vec!["(".to_string(), ",".to_string()]),
735                    retrigger_characters: None,
736                    work_done_progress_options: Default::default(),
737                }),
738                inlay_hint_provider: feat.inlay_hints.then(|| {
739                    OneOf::Right(InlayHintServerCapabilities::Options(InlayHintOptions {
740                        resolve_provider: Some(true),
741                        work_done_progress_options: Default::default(),
742                    }))
743                }),
744                folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
745                semantic_tokens_provider: feat.semantic_tokens.then(|| {
746                    SemanticTokensServerCapabilities::SemanticTokensOptions(SemanticTokensOptions {
747                        legend: legend(),
748                        full: Some(SemanticTokensFullOptions::Delta { delta: Some(true) }),
749                        range: Some(true),
750                        ..Default::default()
751                    })
752                }),
753                selection_range_provider: feat
754                    .selection_range
755                    .then_some(SelectionRangeProviderCapability::Simple(true)),
756                call_hierarchy_provider: feat
757                    .call_hierarchy
758                    .then_some(CallHierarchyServerCapability::Simple(true)),
759                document_highlight_provider: feat.document_highlight.then_some(OneOf::Left(true)),
760                implementation_provider: feat
761                    .implementation
762                    .then_some(ImplementationProviderCapability::Simple(true)),
763                code_action_provider: feat.code_action.then(|| {
764                    CodeActionProviderCapability::Options(CodeActionOptions {
765                        resolve_provider: Some(true),
766                        ..Default::default()
767                    })
768                }),
769                declaration_provider: feat
770                    .declaration
771                    .then_some(DeclarationCapability::Simple(true)),
772                type_definition_provider: feat
773                    .type_definition
774                    .then_some(TypeDefinitionProviderCapability::Simple(true)),
775                code_lens_provider: feat.code_lens.then_some(CodeLensOptions {
776                    resolve_provider: Some(true),
777                }),
778                document_formatting_provider: feat.formatting.then_some(OneOf::Left(true)),
779                document_range_formatting_provider: feat
780                    .range_formatting
781                    .then_some(OneOf::Left(true)),
782                document_on_type_formatting_provider: feat.on_type_formatting.then(|| {
783                    DocumentOnTypeFormattingOptions {
784                        first_trigger_character: "}".to_string(),
785                        more_trigger_character: Some(vec!["\n".to_string()]),
786                    }
787                }),
788                document_link_provider: feat.document_link.then(|| DocumentLinkOptions {
789                    resolve_provider: Some(true),
790                    work_done_progress_options: Default::default(),
791                }),
792                execute_command_provider: Some(ExecuteCommandOptions {
793                    commands: vec!["php-lsp.runTest".to_string()],
794                    work_done_progress_options: Default::default(),
795                }),
796                diagnostic_provider: Some(DiagnosticServerCapabilities::Options(
797                    DiagnosticOptions {
798                        identifier: None,
799                        inter_file_dependencies: true,
800                        workspace_diagnostics: true,
801                        work_done_progress_options: Default::default(),
802                    },
803                )),
804                workspace: Some(WorkspaceServerCapabilities {
805                    workspace_folders: Some(WorkspaceFoldersServerCapabilities {
806                        supported: Some(true),
807                        change_notifications: Some(OneOf::Left(true)),
808                    }),
809                    file_operations: Some(WorkspaceFileOperationsServerCapabilities {
810                        will_rename: Some(php_file_op()),
811                        did_rename: Some(php_file_op()),
812                        did_create: Some(php_file_op()),
813                        will_delete: Some(php_file_op()),
814                        did_delete: Some(php_file_op()),
815                        ..Default::default()
816                    }),
817                }),
818                linked_editing_range_provider: feat
819                    .linked_editing_range
820                    .then_some(LinkedEditingRangeServerCapabilities::Simple(true)),
821                moniker_provider: Some(OneOf::Left(true)),
822                inline_value_provider: feat.inline_values.then(|| {
823                    OneOf::Right(InlineValueServerCapabilities::Options(InlineValueOptions {
824                        work_done_progress_options: Default::default(),
825                    }))
826                }),
827                ..Default::default()
828            },
829            ..Default::default()
830        })
831    }
832
833    async fn initialized(&self, _params: InitializedParams) {
834        // Register dynamic capabilities: file watcher + type hierarchy
835        let php_selector = serde_json::json!([{"language": "php"}]);
836        let registrations = vec![
837            Registration {
838                id: "php-lsp-file-watcher".to_string(),
839                method: "workspace/didChangeWatchedFiles".to_string(),
840                register_options: Some(serde_json::json!({
841                    "watchers": [{"globPattern": "**/*.php"}]
842                })),
843            },
844            // Type hierarchy has no static ServerCapabilities field in lsp-types 0.94,
845            // so register it dynamically here.
846            Registration {
847                id: "php-lsp-type-hierarchy".to_string(),
848                method: "textDocument/prepareTypeHierarchy".to_string(),
849                register_options: Some(serde_json::json!({"documentSelector": php_selector})),
850            },
851            // Watch for configuration changes so we can pull the latest settings.
852            Registration {
853                id: "php-lsp-config-change".to_string(),
854                method: "workspace/didChangeConfiguration".to_string(),
855                register_options: Some(serde_json::json!({"section": "php-lsp"})),
856            },
857        ];
858        self.client.register_capability(registrations).await.ok();
859
860        // Load PSR-4 autoload map and kick off background workspace scan.
861        // Extract roots first so RwLockReadGuard is dropped before any .await.
862        let roots = self.root_paths.read().unwrap().clone();
863        if !roots.is_empty() {
864            // Build PSR-4 map from all roots (entries from all roots are merged).
865            {
866                let mut merged = Psr4Map::empty();
867                for root in &roots {
868                    merged.extend(Psr4Map::load(root));
869                }
870                *self.psr4.write().unwrap() = merged;
871            }
872            // Load PHPStorm metadata from the first root, if present.
873            *self.meta.write().unwrap() = PhpStormMeta::load(&roots[0]);
874
875            // Create a client-side progress indicator for the workspace scan.
876            let token = NumberOrString::String("php-lsp/indexing".to_string());
877            self.client
878                .send_request::<WorkDoneProgressCreate>(WorkDoneProgressCreateParams {
879                    token: token.clone(),
880                })
881                .await
882                .ok();
883
884            let docs = Arc::clone(&self.docs);
885            let open_files = self.open_files.clone();
886            let client = self.client.clone();
887            let (exclude_paths, max_indexed_files) = {
888                let cfg = self.config.read().unwrap();
889                (cfg.exclude_paths.clone(), cfg.max_indexed_files)
890            };
891            tokio::spawn(async move {
892                client
893                    .send_notification::<ProgressNotification>(ProgressParams {
894                        token: token.clone(),
895                        value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(
896                            WorkDoneProgressBegin {
897                                title: "php-lsp: indexing workspace".to_string(),
898                                cancellable: Some(false),
899                                message: None,
900                                percentage: None,
901                            },
902                        )),
903                    })
904                    .await;
905
906                let mut total = 0usize;
907                for root in roots {
908                    // Phase K2b: open the on-disk cache for this root. If the
909                    // system has no usable cache dir (weird XDG env, sandboxed
910                    // runner, read-only home), `new` returns None and every
911                    // per-file `cache.as_ref()` guard below no-ops — scan still
912                    // runs, just without persistence.
913                    let cache = crate::cache::WorkspaceCache::new(&root);
914                    total += scan_workspace(
915                        root,
916                        Arc::clone(&docs),
917                        open_files.clone(),
918                        cache,
919                        &exclude_paths,
920                        max_indexed_files,
921                    )
922                    .await;
923                }
924
925                client
926                    .send_notification::<ProgressNotification>(ProgressParams {
927                        token,
928                        value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(
929                            WorkDoneProgressEnd {
930                                message: Some(format!("Indexed {total} files")),
931                            },
932                        )),
933                    })
934                    .await;
935
936                client
937                    .log_message(
938                        MessageType::INFO,
939                        format!("php-lsp: indexed {total} workspace files"),
940                    )
941                    .await;
942
943                // Ask clients to re-request tokens/lenses/hints/diagnostics now
944                // that the index is populated. Without this, editors that opened
945                // files before indexing finished would show stale information.
946                send_refresh_requests(&client).await;
947
948                // Phase D: reference index is lazy. `textDocument/references`
949                // drives `symbol_refs(ws, key)` on demand; salsa memoizes the
950                // per-file `file_refs` across requests. Invalidation is
951                // automatic on edits.
952                //
953                // Phase L: warm the memo in the background so the first real
954                // reference lookup doesn't pay the full-workspace walk.
955                // `symbol_refs(ws, <any key>)` iterates every file's
956                // `file_refs` to build its result — even with a sentinel key
957                // that matches nothing, the per-file walk runs and populates
958                // salsa's memo. Fire-and-forget: a reference request that
959                // arrives mid-warmup just retries through
960                // `snapshot_query`'s `salsa::Cancelled` handling.
961                let warm_docs = Arc::clone(&docs);
962                tokio::task::spawn_blocking(move || {
963                    warm_docs.warm_reference_index();
964                });
965                drop(docs);
966                client.send_notification::<IndexReadyNotification>(()).await;
967            });
968        }
969
970        self.client
971            .log_message(MessageType::INFO, "php-lsp ready")
972            .await;
973    }
974
975    async fn did_change_configuration(&self, _params: DidChangeConfigurationParams) {
976        // Pull the current configuration from the client rather than parsing the
977        // (often-null) params.settings, which not all clients populate.
978        let items = vec![ConfigurationItem {
979            scope_uri: None,
980            section: Some("php-lsp".to_string()),
981        }];
982        if let Ok(values) = self.client.configuration(items).await
983            && let Some(value) = values.into_iter().next()
984        {
985            let roots = self.root_paths.read().unwrap().clone();
986
987            // Re-read .php-lsp.json so a user who edits the file and then
988            // triggers a configuration reload picks up the latest values.
989            let file_cfg = crate::autoload::load_project_config_json(&roots);
990
991            if let Some(ver) = value.get("phpVersion").and_then(|v| v.as_str())
992                && !crate::autoload::is_valid_php_version(ver)
993            {
994                self.client
995                    .log_message(
996                        tower_lsp::lsp_types::MessageType::WARNING,
997                        format!(
998                            "php-lsp: unsupported phpVersion {ver:?} — valid values: {}",
999                            crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
1000                        ),
1001                    )
1002                    .await;
1003            }
1004
1005            let file_obj = file_cfg.as_ref().filter(|v| v.is_object());
1006            let merged = LspConfig::merge_project_configs(file_obj, Some(&value));
1007            let mut cfg = LspConfig::from_value(&merged);
1008
1009            // Resolve the PHP version and log what was chosen and why.
1010            let (ver, source) = self.resolve_php_version(cfg.php_version.as_deref());
1011            self.client
1012                .log_message(
1013                    tower_lsp::lsp_types::MessageType::INFO,
1014                    format!("php-lsp: using PHP {ver} ({source})"),
1015                )
1016                .await;
1017            // TODO: instead of storing and using the unsupported version, consider clamping
1018            // it to the nearest supported version so analysis stays meaningful.
1019            if source != "set by editor" && !crate::autoload::is_valid_php_version(&ver) {
1020                self.client
1021                    .show_message(
1022                        tower_lsp::lsp_types::MessageType::WARNING,
1023                        format!(
1024                            "php-lsp: detected PHP {ver} is outside the supported range ({}); \
1025                             analysis may be inaccurate",
1026                            crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
1027                        ),
1028                    )
1029                    .await;
1030            }
1031            cfg.php_version = Some(ver.clone());
1032            if let Ok(pv) = ver.parse::<mir_analyzer::PhpVersion>() {
1033                self.docs.set_php_version(pv);
1034            }
1035            *self.config.write().unwrap() = cfg;
1036            send_refresh_requests(&self.client).await;
1037        }
1038    }
1039
1040    async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
1041        // Remove folders from our tracked roots.
1042        {
1043            let mut roots = self.root_paths.write().unwrap();
1044            for removed in &params.event.removed {
1045                if let Ok(path) = removed.uri.to_file_path() {
1046                    roots.retain(|r| r != &path);
1047                }
1048            }
1049        }
1050
1051        // Add new folders and kick off background scans for each.
1052        let (exclude_paths, max_indexed_files) = {
1053            let cfg = self.config.read().unwrap();
1054            (cfg.exclude_paths.clone(), cfg.max_indexed_files)
1055        };
1056        for added in &params.event.added {
1057            if let Ok(path) = added.uri.to_file_path() {
1058                let is_new = {
1059                    let mut roots = self.root_paths.write().unwrap();
1060                    if !roots.contains(&path) {
1061                        roots.push(path.clone());
1062                        true
1063                    } else {
1064                        false
1065                    }
1066                };
1067                if is_new {
1068                    let docs = Arc::clone(&self.docs);
1069                    let open_files = self.open_files.clone();
1070                    let ex = exclude_paths.clone();
1071                    let path_clone = path.clone();
1072                    let client = self.client.clone();
1073                    tokio::spawn(async move {
1074                        let cache = crate::cache::WorkspaceCache::new(&path_clone);
1075                        scan_workspace(path_clone, docs, open_files, cache, &ex, max_indexed_files)
1076                            .await;
1077                        send_refresh_requests(&client).await;
1078                    });
1079                }
1080            }
1081        }
1082    }
1083
1084    async fn shutdown(&self) -> Result<()> {
1085        Ok(())
1086    }
1087
1088    async fn did_open(&self, params: DidOpenTextDocumentParams) {
1089        let uri = params.text_document.uri;
1090        let text = params.text_document.text;
1091
1092        // Store text immediately so other features work while parsing.
1093        // This also mirrors the new text into salsa, so the codebase query
1094        // sees it when semantic_diagnostics runs below.
1095        self.set_open_text(uri.clone(), text.clone());
1096
1097        let docs_for_spawn = Arc::clone(&self.docs);
1098        let diag_cfg = self.config.read().unwrap().diagnostics.clone();
1099
1100        // Phase I: parse + semantic analysis both run on the blocking pool.
1101        // The semantic pass is memoized by salsa, but the *first* call per
1102        // file walks `StatementsAnalyzer` over the AST (hundreds of ms on
1103        // cold files) — we must not block the async executor on it.
1104        let uri_sem = uri.clone();
1105        let (parse_diags, sem_issues) = tokio::task::spawn_blocking(move || {
1106            let (_doc, parse_diags) = parse_document(&text);
1107            let sem_issues = docs_for_spawn.get_semantic_issues_salsa(&uri_sem);
1108            (parse_diags, sem_issues)
1109        })
1110        .await
1111        .unwrap_or_else(|_| (vec![], None));
1112
1113        self.set_parse_diagnostics(&uri, parse_diags.clone());
1114        let stored_source = self.get_open_text(&uri).unwrap_or_default();
1115        let doc2 = self.get_doc(&uri);
1116        let mut all_diags = parse_diags;
1117        if let Some(ref d) = doc2 {
1118            let dup_diags = duplicate_declaration_diagnostics(&stored_source, d, &diag_cfg);
1119            all_diags.extend(dup_diags);
1120        }
1121        if let Some(issues) = sem_issues {
1122            all_diags.extend(crate::semantic_diagnostics::issues_to_diagnostics(
1123                &issues, &uri, &diag_cfg,
1124            ));
1125        }
1126        // Publish for the opened file FIRST — see did_change for why ordering matters.
1127        self.client
1128            .publish_diagnostics(uri.clone(), all_diags, None)
1129            .await;
1130
1131        // Cross-file republish: opening a file that defines new symbols can
1132        // clear `UndefinedClass`/`UndefinedFunction` errors in already-open
1133        // dependents. Symmetric to the loop in did_change.
1134        let docs_dep = Arc::clone(&self.docs);
1135        let open_files_dep = self.open_files.clone();
1136        let diag_cfg_dep = diag_cfg.clone();
1137        let opened_uri = uri.clone();
1138        let dependents = tokio::task::spawn_blocking(move || {
1139            let mut out: Vec<(Url, Vec<Diagnostic>)> = Vec::new();
1140            for other in open_files_dep.urls() {
1141                if other == opened_uri {
1142                    continue;
1143                }
1144                let diags = compute_open_file_diagnostics(
1145                    &docs_dep,
1146                    &open_files_dep,
1147                    &other,
1148                    &diag_cfg_dep,
1149                );
1150                out.push((other, diags));
1151            }
1152            out
1153        })
1154        .await
1155        .unwrap_or_default();
1156        for (dep_uri, dep_diags) in dependents {
1157            self.client
1158                .publish_diagnostics(dep_uri, dep_diags, None)
1159                .await;
1160        }
1161    }
1162
1163    async fn did_change(&self, params: DidChangeTextDocumentParams) {
1164        let uri = params.text_document.uri;
1165        let text = match params.content_changes.into_iter().last() {
1166            Some(c) => c.text,
1167            None => return,
1168        };
1169
1170        // Store text immediately and capture the version token.
1171        // Features (completion, hover, …) see the new text instantly while
1172        // the parse runs in the background.
1173        let version = self.set_open_text(uri.clone(), text.clone());
1174
1175        let docs = Arc::clone(&self.docs);
1176        let open_files = self.open_files.clone();
1177        let client = self.client.clone();
1178        let diag_cfg = self.config.read().unwrap().diagnostics.clone();
1179        tokio::spawn(async move {
1180            // 100 ms debounce: if another edit arrives before we parse,
1181            // the version gate in Backend below will discard this result.
1182            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1183
1184            let (_doc, diagnostics) = tokio::task::spawn_blocking(move || parse_document(&text))
1185                .await
1186                .unwrap_or_else(|_| (ParsedDoc::default(), vec![]));
1187
1188            // Only apply if no newer edit arrived while we were parsing.
1189            // Backend-level gate replaces the old `apply_parse` version check.
1190            if open_files.current_version(&uri) == Some(version) {
1191                open_files.set_parse_diagnostics(&uri, diagnostics.clone());
1192
1193                // Phase I: the salsa `semantic_issues` walk is synchronous
1194                // and CPU-bound on a cold file — run it on the blocking
1195                // pool so the async runtime stays responsive. Returns the
1196                // full diagnostic bundle (semantic + dup-decl + deprecated
1197                // calls), all computed off-thread.
1198                let docs_sem = Arc::clone(&docs);
1199                let open_files_sem = open_files.clone();
1200                let uri_sem = uri.clone();
1201                let diag_cfg_sem = diag_cfg.clone();
1202                let extra = tokio::task::spawn_blocking(move || {
1203                    let Some(d) = open_files_sem.get_doc(&docs_sem, &uri_sem) else {
1204                        return Vec::<Diagnostic>::new();
1205                    };
1206                    let source = open_files_sem.text(&uri_sem).unwrap_or_default();
1207                    let mut out = Vec::new();
1208                    if let Some(issues) = docs_sem.get_semantic_issues_salsa(&uri_sem) {
1209                        out.extend(crate::semantic_diagnostics::issues_to_diagnostics(
1210                            &issues,
1211                            &uri_sem,
1212                            &diag_cfg_sem,
1213                        ));
1214                    }
1215                    out.extend(duplicate_declaration_diagnostics(
1216                        &source,
1217                        &d,
1218                        &diag_cfg_sem,
1219                    ));
1220                    out
1221                })
1222                .await
1223                .unwrap_or_default();
1224
1225                let mut all_diags = diagnostics;
1226                all_diags.extend(extra);
1227                // Publish for the changed file FIRST. Test harnesses (and
1228                // some clients) consume publishDiagnostics for unrelated
1229                // URIs while waiting for one specific URI; reversing this
1230                // order would silently swallow the changed file's publish.
1231                client
1232                    .publish_diagnostics(uri.clone(), all_diags, None)
1233                    .await;
1234
1235                // Cross-file republish: a dependency change may invalidate
1236                // diagnostics in other open files. We re-query each open
1237                // file's diagnostics (salsa-cached for unaffected files,
1238                // recomputed for affected ones) and publish the result.
1239                //
1240                // Race window: if `other` is being edited concurrently, its
1241                // own debounced did_change will still fire a republish, so
1242                // any briefly-stale publish here self-corrects within ~100ms.
1243                let docs_dep = Arc::clone(&docs);
1244                let open_files_dep = open_files.clone();
1245                let diag_cfg_dep = diag_cfg.clone();
1246                let changed_uri = uri.clone();
1247                let dependents = tokio::task::spawn_blocking(move || {
1248                    let mut out: Vec<(Url, Vec<Diagnostic>)> = Vec::new();
1249                    for other in open_files_dep.urls() {
1250                        if other == changed_uri {
1251                            continue;
1252                        }
1253                        let diags = compute_open_file_diagnostics(
1254                            &docs_dep,
1255                            &open_files_dep,
1256                            &other,
1257                            &diag_cfg_dep,
1258                        );
1259                        out.push((other, diags));
1260                    }
1261                    out
1262                })
1263                .await
1264                .unwrap_or_default();
1265                for (dep_uri, dep_diags) in dependents {
1266                    client.publish_diagnostics(dep_uri, dep_diags, None).await;
1267                }
1268            }
1269        });
1270    }
1271
1272    async fn did_close(&self, params: DidCloseTextDocumentParams) {
1273        let uri = params.text_document.uri;
1274        self.close_open_file(&uri);
1275        // Clear editor diagnostics; the file stays indexed for cross-file features
1276        self.client.publish_diagnostics(uri, vec![], None).await;
1277    }
1278
1279    async fn will_save(&self, _params: WillSaveTextDocumentParams) {}
1280
1281    async fn will_save_wait_until(
1282        &self,
1283        params: WillSaveTextDocumentParams,
1284    ) -> Result<Option<Vec<TextEdit>>> {
1285        let source = self
1286            .get_open_text(&params.text_document.uri)
1287            .unwrap_or_default();
1288        Ok(format_document(&source))
1289    }
1290
1291    async fn did_save(&self, params: DidSaveTextDocumentParams) {
1292        let uri = params.text_document.uri;
1293        // Re-publish diagnostics on save so editors that defer diagnostics
1294        // until save (rather than on every keystroke) see up-to-date results.
1295        // Must include semantic diagnostics — publishDiagnostics replaces the
1296        // prior set entirely, so omitting them would clear errors the editor
1297        // showed after the last did_change.
1298        let diag_cfg = self.config.read().unwrap().diagnostics.clone();
1299        let all = compute_open_file_diagnostics(&self.docs, &self.open_files, &uri, &diag_cfg);
1300        self.client.publish_diagnostics(uri, all, None).await;
1301    }
1302
1303    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
1304        for change in params.changes {
1305            match change.typ {
1306                FileChangeType::CREATED | FileChangeType::CHANGED => {
1307                    if let Ok(path) = change.uri.to_file_path()
1308                        && let Ok(text) = tokio::fs::read_to_string(&path).await
1309                    {
1310                        // Salsa path: index_from_doc mirrors the new text into
1311                        // the SourceFile input. On the next codebase() call,
1312                        // salsa re-runs file_definitions for this file and the
1313                        // aggregator re-folds — no manual remove/collect/finalize.
1314                        let (doc, diags) = parse_document(&text);
1315                        self.index_from_doc_if_not_open(change.uri.clone(), &doc, diags);
1316                    }
1317                }
1318                FileChangeType::DELETED => {
1319                    self.docs.remove(&change.uri);
1320                }
1321                _ => {}
1322            }
1323        }
1324        // File changes may affect cross-file features — refresh all live editors.
1325        send_refresh_requests(&self.client).await;
1326    }
1327
1328    async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
1329        let uri = &params.text_document_position.text_document.uri;
1330        let position = params.text_document_position.position;
1331        let source = self.get_open_text(uri).unwrap_or_default();
1332        // B4c: first production caller migrated to salsa-backed read.
1333        let doc = match self.get_doc(uri) {
1334            Some(d) => d,
1335            None => return Ok(Some(CompletionResponse::Array(vec![]))),
1336        };
1337        let other_with_returns = self.docs.other_docs_with_returns(uri, &self.open_urls());
1338        let other_docs: Vec<Arc<ParsedDoc>> = other_with_returns
1339            .iter()
1340            .map(|(_, d, _)| d.clone())
1341            .collect();
1342        let other_returns: Vec<Arc<crate::ast::MethodReturnsMap>> = other_with_returns
1343            .iter()
1344            .map(|(_, _, r)| r.clone())
1345            .collect();
1346        let doc_returns = self.docs.get_method_returns_salsa(uri);
1347        let trigger = params
1348            .context
1349            .as_ref()
1350            .and_then(|c| c.trigger_character.as_deref());
1351        let meta_guard = self.meta.read().unwrap();
1352        let meta_opt = if meta_guard.is_empty() {
1353            None
1354        } else {
1355            Some(&*meta_guard)
1356        };
1357        let imports = self.file_imports(uri);
1358        let ctx = CompletionCtx {
1359            source: Some(&source),
1360            position: Some(position),
1361            meta: meta_opt,
1362            doc_uri: Some(uri),
1363            file_imports: Some(&imports),
1364            doc_returns: doc_returns.as_deref(),
1365            other_returns: Some(&other_returns),
1366        };
1367        Ok(Some(CompletionResponse::Array(filtered_completions_at(
1368            &doc,
1369            &other_docs,
1370            trigger,
1371            &ctx,
1372        ))))
1373    }
1374
1375    async fn completion_resolve(&self, mut item: CompletionItem) -> Result<CompletionItem> {
1376        if item.documentation.is_some() && item.detail.is_some() {
1377            return Ok(item);
1378        }
1379        // Strip trailing ':' from named-argument labels (e.g. "param:") before lookup.
1380        let name = item.label.trim_end_matches(':');
1381        let all_indexes = self.docs.all_indexes();
1382        if item.detail.is_none()
1383            && let Some(sig) = signature_for_symbol_from_index(name, &all_indexes)
1384        {
1385            item.detail = Some(sig);
1386        }
1387        if item.documentation.is_none()
1388            && let Some(md) = docs_for_symbol_from_index(name, &all_indexes)
1389        {
1390            item.documentation = Some(Documentation::MarkupContent(MarkupContent {
1391                kind: MarkupKind::Markdown,
1392                value: md,
1393            }));
1394        }
1395        Ok(item)
1396    }
1397
1398    async fn goto_definition(
1399        &self,
1400        params: GotoDefinitionParams,
1401    ) -> Result<Option<GotoDefinitionResponse>> {
1402        let uri = &params.text_document_position_params.text_document.uri;
1403        let position = params.text_document_position_params.position;
1404        let source = self.get_open_text(uri).unwrap_or_default();
1405        let doc = match self.get_doc(uri) {
1406            Some(d) => d,
1407            None => return Ok(None),
1408        };
1409        // Search current file's ParsedDoc first (fast), then fall back to index search.
1410        let empty_other_docs: Vec<(Url, Arc<ParsedDoc>)> = vec![];
1411        if let Some(loc) = goto_definition(uri, &source, &doc, &empty_other_docs, position) {
1412            return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1413        }
1414        // Cross-file: use FileIndex (no disk I/O for background files).
1415        let other_indexes = self.docs.other_indexes(uri);
1416        if let Some(word) = crate::util::word_at(&source, position)
1417            && let Some(loc) = find_in_indexes(&word, &other_indexes)
1418        {
1419            return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1420        }
1421
1422        // PSR-4 fallback: only useful for fully-qualified names (contain `\`)
1423        if let Some(word) = word_at(&source, position)
1424            && word.contains('\\')
1425            && let Some(loc) = self.psr4_goto(&word).await
1426        {
1427            return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1428        }
1429
1430        Ok(None)
1431    }
1432
1433    async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
1434        let uri = &params.text_document_position.text_document.uri;
1435        let position = params.text_document_position.position;
1436        let source = self.get_open_text(uri).unwrap_or_default();
1437        let word = match word_at(&source, position) {
1438            Some(w) => w,
1439            None => return Ok(None),
1440        };
1441        // Special case: cursor on a class's `__construct` method declaration.
1442        // The constructor's call sites are `new OwningClass(...)`, not
1443        // `->__construct()`, so name-only matching would return every class's
1444        // constructor declaration (what issue reports describe as "references
1445        // to __construct shows every class"). Redirect to Class-kind refs on
1446        // the owning class and tack on the ctor's own decl span.
1447        if word == "__construct"
1448            && let Some(doc) = self.get_doc(uri)
1449            && let Some(class_name) =
1450                class_name_at_construct_decl(doc.source(), &doc.program().stmts, position)
1451        {
1452            let all_docs = self.docs.all_docs_for_scan();
1453            let include_declaration = params.context.include_declaration;
1454            // `class_name` is the FQN when the constructor is inside a namespace
1455            // (e.g. `"Shop\\Order"`). The AST walker must search for the *short*
1456            // name (`"Order"`) since that's what appears in source at call sites,
1457            // while the FQN is used only to scope the search and prevent collisions
1458            // between two classes with the same short name in different namespaces.
1459            let short_name = class_name
1460                .rsplit('\\')
1461                .next()
1462                .unwrap_or(class_name.as_str())
1463                .to_owned();
1464            let class_fqn = if class_name.contains('\\') {
1465                Some(class_name.as_str())
1466            } else {
1467                None
1468            };
1469            // Use `new_refs_in_stmts` directly — bypasses the codebase/salsa
1470            // index whose `ClassReference` key is too broad (covers type hints,
1471            // `instanceof`, `extends`, `implements` in addition to `new` calls).
1472            let mut locations = find_constructor_references(&short_name, &all_docs, class_fqn);
1473            if include_declaration {
1474                // The cursor is already on the `__construct` name (verified by
1475                // `class_name_at_construct_decl`), so use the cursor position directly as
1476                // the span rather than re-searching via str_offset (which finds the first
1477                // occurrence in the file and would point at the wrong constructor in files
1478                // with more than one class).
1479                let end = Position {
1480                    line: position.line,
1481                    character: position.character + "__construct".len() as u32,
1482                };
1483                locations.push(Location {
1484                    uri: uri.clone(),
1485                    range: Range {
1486                        start: position,
1487                        end,
1488                    },
1489                });
1490            }
1491            return Ok(if locations.is_empty() {
1492                None
1493            } else {
1494                Some(locations)
1495            });
1496        }
1497
1498        let doc_opt = self.get_doc(uri);
1499        // Check for promoted constructor property params before the character-based
1500        // heuristic: `$name` in `public function __construct(public string $name)`
1501        // should find `->name` property accesses, not `$name` variable occurrences.
1502        let (word, kind) = if let Some(doc) = &doc_opt
1503            && let Some(prop_name) =
1504                promoted_property_at_cursor(doc.source(), &doc.program().stmts, position)
1505        {
1506            (prop_name, Some(SymbolKind::Property))
1507        } else if let Some(doc) = &doc_opt {
1508            let stmts = &doc.program().stmts;
1509            if cursor_is_on_method_decl(doc.source(), stmts, position) {
1510                (word, Some(SymbolKind::Method))
1511            } else if let Some(prop_name) =
1512                cursor_is_on_property_decl(doc.source(), stmts, position)
1513            {
1514                (prop_name, Some(SymbolKind::Property))
1515            } else {
1516                let k = symbol_kind_at(&source, position, &word);
1517                (word, k)
1518            }
1519        } else {
1520            let k = symbol_kind_at(&source, position, &word);
1521            (word, k)
1522        };
1523        let all_docs = self.docs.all_docs_for_scan();
1524        let include_declaration = params.context.include_declaration;
1525
1526        // Resolve the FQN at the cursor so `find_references_codebase_with_target`
1527        // can match by exact FQN instead of short name. This fixes the
1528        // cross-namespace overmatch for Function/Class and the unrelated-class
1529        // overmatch for Method (via the owning FQCN).
1530        let target_fqn: Option<String> = doc_opt.as_ref().and_then(|doc| {
1531            let imports = self.file_imports(uri);
1532            match kind {
1533                Some(SymbolKind::Function) | Some(SymbolKind::Class) => {
1534                    let resolved = crate::moniker::resolve_fqn(doc, &word, &imports);
1535                    if resolved.contains('\\') {
1536                        Some(resolved)
1537                    } else {
1538                        None
1539                    }
1540                }
1541                Some(SymbolKind::Method) => {
1542                    // Owning FQCN: the class/interface/trait/enum that contains the cursor.
1543                    let short_owner =
1544                        crate::type_map::enclosing_class_at(doc.source(), doc, position)?;
1545                    // `resolve_fqn` walks the doc and applies namespace prefix if any.
1546                    Some(crate::moniker::resolve_fqn(doc, &short_owner, &imports))
1547                }
1548                _ => None,
1549            }
1550        });
1551
1552        // Fast path: look up references via the salsa `symbol_refs` query.
1553        // First call per key runs `file_refs` across the workspace; subsequent
1554        // calls hit salsa's memo. Falls back to the full AST scan for Method /
1555        // None kinds, and whenever the symbol is not found in the codebase.
1556        let locations = {
1557            let cb = self.codebase();
1558            let docs = Arc::clone(&self.docs);
1559            let lookup = move |key: &str| docs.get_symbol_refs_salsa(key);
1560            find_references_codebase_with_target(
1561                &word,
1562                &all_docs,
1563                include_declaration,
1564                kind,
1565                target_fqn.as_deref(),
1566                &cb,
1567                &lookup,
1568            )
1569            .unwrap_or_else(|| match target_fqn.as_deref() {
1570                Some(t) => {
1571                    find_references_with_target(&word, &all_docs, include_declaration, kind, t)
1572                }
1573                None => find_references(&word, &all_docs, include_declaration, kind),
1574            })
1575        };
1576
1577        Ok(if locations.is_empty() {
1578            None
1579        } else {
1580            Some(locations)
1581        })
1582    }
1583
1584    async fn prepare_rename(
1585        &self,
1586        params: TextDocumentPositionParams,
1587    ) -> Result<Option<PrepareRenameResponse>> {
1588        let uri = &params.text_document.uri;
1589        let source = self.get_open_text(uri).unwrap_or_default();
1590        Ok(prepare_rename(&source, params.position).map(PrepareRenameResponse::Range))
1591    }
1592
1593    async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
1594        let uri = &params.text_document_position.text_document.uri;
1595        let position = params.text_document_position.position;
1596        let source = self.get_open_text(uri).unwrap_or_default();
1597        let word = match word_at(&source, position) {
1598            Some(w) => w,
1599            None => return Ok(None),
1600        };
1601        if word.starts_with('$') {
1602            let doc = match self.get_doc(uri) {
1603                Some(d) => d,
1604                None => return Ok(None),
1605            };
1606            Ok(Some(rename_variable(
1607                &word,
1608                &params.new_name,
1609                uri,
1610                &doc,
1611                position,
1612            )))
1613        } else if is_after_arrow(&source, position) {
1614            let all_docs = self.docs.all_docs_for_scan();
1615            Ok(Some(rename_property(&word, &params.new_name, &all_docs)))
1616        } else {
1617            let all_docs = self.docs.all_docs_for_scan();
1618            Ok(Some(rename(&word, &params.new_name, &all_docs)))
1619        }
1620    }
1621
1622    async fn signature_help(&self, params: SignatureHelpParams) -> Result<Option<SignatureHelp>> {
1623        let uri = &params.text_document_position_params.text_document.uri;
1624        let position = params.text_document_position_params.position;
1625        let source = self.get_open_text(uri).unwrap_or_default();
1626        let doc = match self.get_doc(uri) {
1627            Some(d) => d,
1628            None => return Ok(None),
1629        };
1630        Ok(signature_help(&source, &doc, position))
1631    }
1632
1633    async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
1634        let uri = &params.text_document_position_params.text_document.uri;
1635        let position = params.text_document_position_params.position;
1636        let source = self.get_open_text(uri).unwrap_or_default();
1637        let doc = match self.get_doc(uri) {
1638            Some(d) => d,
1639            None => return Ok(None),
1640        };
1641        let doc_returns = self
1642            .docs
1643            .get_method_returns_salsa(uri)
1644            .unwrap_or_else(|| std::sync::Arc::new(Default::default()));
1645        let other_docs = self.docs.other_docs_with_returns(uri, &self.open_urls());
1646        let result = hover_info(&source, &doc, &doc_returns, position, &other_docs);
1647        if result.is_some() {
1648            return Ok(result);
1649        }
1650        // Fallback: look up the word in the workspace index so class names in
1651        // extends clauses and parameter types resolve even when their defining
1652        // file is never opened.  Also try the alias-resolved name so that
1653        // `use Foo as Bar` works even when Foo is only in the index.
1654        let all_indexes = self.docs.all_indexes();
1655        if let Some(word) = crate::util::word_at(&source, position) {
1656            // Try the literal word first.
1657            if let Some(h) = class_hover_from_index(&word, &all_indexes) {
1658                return Ok(Some(h));
1659            }
1660            // Try alias resolution.
1661            if let Some(resolved) = crate::hover::resolve_use_alias(&doc.program().stmts, &word)
1662                && let Some(h) = class_hover_from_index(&resolved, &all_indexes)
1663            {
1664                return Ok(Some(h));
1665            }
1666        }
1667        Ok(None)
1668    }
1669
1670    async fn document_symbol(
1671        &self,
1672        params: DocumentSymbolParams,
1673    ) -> Result<Option<DocumentSymbolResponse>> {
1674        let uri = &params.text_document.uri;
1675        let doc = match self.get_doc(uri) {
1676            Some(d) => d,
1677            None => return Ok(None),
1678        };
1679        Ok(Some(DocumentSymbolResponse::Nested(document_symbols(
1680            doc.source(),
1681            &doc,
1682        ))))
1683    }
1684
1685    async fn folding_range(&self, params: FoldingRangeParams) -> Result<Option<Vec<FoldingRange>>> {
1686        let uri = &params.text_document.uri;
1687        let doc = match self.get_doc(uri) {
1688            Some(d) => d,
1689            None => return Ok(None),
1690        };
1691        let ranges = folding_ranges(doc.source(), &doc);
1692        Ok(if ranges.is_empty() {
1693            None
1694        } else {
1695            Some(ranges)
1696        })
1697    }
1698
1699    async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
1700        let uri = &params.text_document.uri;
1701        let doc = match self.get_doc(uri) {
1702            Some(d) => d,
1703            None => return Ok(None),
1704        };
1705        let doc_returns = self.docs.get_method_returns_salsa(uri);
1706        let wi = self.docs.get_workspace_index_salsa();
1707        Ok(Some(inlay_hints(
1708            doc.source(),
1709            &doc,
1710            doc_returns.as_deref(),
1711            params.range,
1712            &wi.files,
1713        )))
1714    }
1715
1716    async fn inlay_hint_resolve(&self, mut item: InlayHint) -> Result<InlayHint> {
1717        if item.tooltip.is_some() {
1718            return Ok(item);
1719        }
1720        let func_name = item
1721            .data
1722            .as_ref()
1723            .and_then(|d| d.get("php_lsp_fn"))
1724            .and_then(|v| v.as_str())
1725            .map(str::to_string);
1726        if let Some(name) = func_name {
1727            let all_indexes = self.docs.all_indexes();
1728            if let Some(md) = docs_for_symbol_from_index(&name, &all_indexes) {
1729                item.tooltip = Some(InlayHintTooltip::MarkupContent(MarkupContent {
1730                    kind: MarkupKind::Markdown,
1731                    value: md,
1732                }));
1733            }
1734        }
1735        Ok(item)
1736    }
1737
1738    async fn symbol(
1739        &self,
1740        params: WorkspaceSymbolParams,
1741    ) -> Result<Option<Vec<SymbolInformation>>> {
1742        // Phase J: read through the salsa-memoized aggregate so repeated
1743        // workspace-symbol queries (every keystroke in the picker) share the
1744        // same `Arc` until a file changes.
1745        let wi = self.docs.get_workspace_index_salsa();
1746        let results = workspace_symbols_from_workspace(&params.query, &wi);
1747        Ok(if results.is_empty() {
1748            None
1749        } else {
1750            Some(results)
1751        })
1752    }
1753
1754    async fn symbol_resolve(&self, params: WorkspaceSymbol) -> Result<WorkspaceSymbol> {
1755        // For resolve, we need the full range from the ParsedDoc of open files.
1756        let docs = self.docs.docs_for(&self.open_urls());
1757        Ok(resolve_workspace_symbol(params, &docs))
1758    }
1759
1760    async fn semantic_tokens_full(
1761        &self,
1762        params: SemanticTokensParams,
1763    ) -> Result<Option<SemanticTokensResult>> {
1764        let uri = &params.text_document.uri;
1765        let doc = match self.get_doc(uri) {
1766            Some(d) => d,
1767            None => {
1768                return Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
1769                    result_id: None,
1770                    data: vec![],
1771                })));
1772            }
1773        };
1774        let tokens = semantic_tokens(doc.source(), &doc);
1775        let result_id = token_hash(&tokens);
1776        self.docs
1777            .store_token_cache(uri, result_id.clone(), tokens.clone());
1778        Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
1779            result_id: Some(result_id),
1780            data: tokens,
1781        })))
1782    }
1783
1784    async fn semantic_tokens_range(
1785        &self,
1786        params: SemanticTokensRangeParams,
1787    ) -> Result<Option<SemanticTokensRangeResult>> {
1788        let uri = &params.text_document.uri;
1789        let doc = match self.get_doc(uri) {
1790            Some(d) => d,
1791            None => {
1792                return Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
1793                    result_id: None,
1794                    data: vec![],
1795                })));
1796            }
1797        };
1798        let tokens = semantic_tokens_range(doc.source(), &doc, params.range);
1799        Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
1800            result_id: None,
1801            data: tokens,
1802        })))
1803    }
1804
1805    async fn semantic_tokens_full_delta(
1806        &self,
1807        params: SemanticTokensDeltaParams,
1808    ) -> Result<Option<SemanticTokensFullDeltaResult>> {
1809        let uri = &params.text_document.uri;
1810        let doc = match self.get_doc(uri) {
1811            Some(d) => d,
1812            None => return Ok(None),
1813        };
1814
1815        let new_tokens = semantic_tokens(doc.source(), &doc);
1816        let new_result_id = token_hash(&new_tokens);
1817        let prev_id = &params.previous_result_id;
1818
1819        let result = match self.docs.get_token_cache(uri, prev_id) {
1820            Some(old_tokens) => {
1821                let edits = compute_token_delta(&old_tokens, &new_tokens);
1822                SemanticTokensFullDeltaResult::TokensDelta(SemanticTokensDelta {
1823                    result_id: Some(new_result_id.clone()),
1824                    edits,
1825                })
1826            }
1827            // Unknown previous result — fall back to full tokens
1828            None => SemanticTokensFullDeltaResult::Tokens(SemanticTokens {
1829                result_id: Some(new_result_id.clone()),
1830                data: new_tokens.clone(),
1831            }),
1832        };
1833
1834        self.docs.store_token_cache(uri, new_result_id, new_tokens);
1835        Ok(Some(result))
1836    }
1837
1838    async fn selection_range(
1839        &self,
1840        params: SelectionRangeParams,
1841    ) -> Result<Option<Vec<SelectionRange>>> {
1842        let uri = &params.text_document.uri;
1843        let doc = match self.get_doc(uri) {
1844            Some(d) => d,
1845            None => return Ok(None),
1846        };
1847        let ranges = selection_ranges(doc.source(), &doc, &params.positions);
1848        Ok(if ranges.is_empty() {
1849            None
1850        } else {
1851            Some(ranges)
1852        })
1853    }
1854
1855    async fn prepare_call_hierarchy(
1856        &self,
1857        params: CallHierarchyPrepareParams,
1858    ) -> Result<Option<Vec<CallHierarchyItem>>> {
1859        let uri = &params.text_document_position_params.text_document.uri;
1860        let position = params.text_document_position_params.position;
1861        let source = self.get_open_text(uri).unwrap_or_default();
1862        let word = match word_at(&source, position) {
1863            Some(w) => w,
1864            None => return Ok(None),
1865        };
1866        let all_docs = self.docs.all_docs_for_scan();
1867        Ok(prepare_call_hierarchy(&word, &all_docs).map(|item| vec![item]))
1868    }
1869
1870    async fn incoming_calls(
1871        &self,
1872        params: CallHierarchyIncomingCallsParams,
1873    ) -> Result<Option<Vec<CallHierarchyIncomingCall>>> {
1874        let all_docs = self.docs.all_docs_for_scan();
1875        let calls = incoming_calls(&params.item, &all_docs);
1876        Ok(if calls.is_empty() { None } else { Some(calls) })
1877    }
1878
1879    async fn outgoing_calls(
1880        &self,
1881        params: CallHierarchyOutgoingCallsParams,
1882    ) -> Result<Option<Vec<CallHierarchyOutgoingCall>>> {
1883        let all_docs = self.docs.all_docs_for_scan();
1884        let calls = outgoing_calls(&params.item, &all_docs);
1885        Ok(if calls.is_empty() { None } else { Some(calls) })
1886    }
1887
1888    async fn document_highlight(
1889        &self,
1890        params: DocumentHighlightParams,
1891    ) -> Result<Option<Vec<DocumentHighlight>>> {
1892        let uri = &params.text_document_position_params.text_document.uri;
1893        let position = params.text_document_position_params.position;
1894        let source = self.get_open_text(uri).unwrap_or_default();
1895        let doc = match self.get_doc(uri) {
1896            Some(d) => d,
1897            None => return Ok(None),
1898        };
1899        let highlights = document_highlights(&source, &doc, position);
1900        Ok(if highlights.is_empty() {
1901            None
1902        } else {
1903            Some(highlights)
1904        })
1905    }
1906
1907    async fn linked_editing_range(
1908        &self,
1909        params: LinkedEditingRangeParams,
1910    ) -> Result<Option<LinkedEditingRanges>> {
1911        let uri = &params.text_document_position_params.text_document.uri;
1912        let position = params.text_document_position_params.position;
1913        let source = self.get_open_text(uri).unwrap_or_default();
1914        let doc = match self.get_doc(uri) {
1915            Some(d) => d,
1916            None => return Ok(None),
1917        };
1918        // Reuse document_highlights: every occurrence of the symbol is a linked range.
1919        let highlights = document_highlights(&source, &doc, position);
1920        if highlights.is_empty() {
1921            return Ok(None);
1922        }
1923        let ranges: Vec<Range> = highlights.into_iter().map(|h| h.range).collect();
1924        Ok(Some(LinkedEditingRanges {
1925            ranges,
1926            // PHP identifiers: letters, digits, underscore; variables also allow leading $
1927            word_pattern: Some(r"[$a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*".to_string()),
1928        }))
1929    }
1930
1931    async fn goto_implementation(
1932        &self,
1933        params: tower_lsp::lsp_types::request::GotoImplementationParams,
1934    ) -> Result<Option<tower_lsp::lsp_types::request::GotoImplementationResponse>> {
1935        let uri = &params.text_document_position_params.text_document.uri;
1936        let position = params.text_document_position_params.position;
1937        let source = self.get_open_text(uri).unwrap_or_default();
1938        let imports = self.file_imports(uri);
1939        let word = crate::util::word_at(&source, position).unwrap_or_default();
1940        let fqn = imports.get(&word).map(|s| s.as_str());
1941        // First pass: open-file ParsedDocs give accurate character positions.
1942        let open_docs = self.docs.docs_for(&self.open_urls());
1943        let mut locs = find_implementations(&word, fqn, &open_docs);
1944        if locs.is_empty() {
1945            // Second pass: background files via the salsa-memoized workspace
1946            // aggregate's `subtypes_of` reverse map (line-only positions).
1947            let wi = self.docs.get_workspace_index_salsa();
1948            locs = find_implementations_from_workspace(&word, fqn, &wi);
1949        }
1950        if locs.is_empty() {
1951            Ok(None)
1952        } else {
1953            Ok(Some(GotoDefinitionResponse::Array(locs)))
1954        }
1955    }
1956
1957    async fn goto_declaration(
1958        &self,
1959        params: tower_lsp::lsp_types::request::GotoDeclarationParams,
1960    ) -> Result<Option<tower_lsp::lsp_types::request::GotoDeclarationResponse>> {
1961        let uri = &params.text_document_position_params.text_document.uri;
1962        let position = params.text_document_position_params.position;
1963        let source = self.get_open_text(uri).unwrap_or_default();
1964        // First pass: open-file ParsedDocs give accurate character positions.
1965        let open_docs = self.docs.docs_for(&self.open_urls());
1966        if let Some(loc) = goto_declaration(&source, &open_docs, position) {
1967            return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1968        }
1969        // Second pass: background files via FileIndex (line-only positions).
1970        let all_indexes = self.docs.all_indexes();
1971        Ok(goto_declaration_from_index(&source, &all_indexes, position)
1972            .map(GotoDefinitionResponse::Scalar))
1973    }
1974
1975    async fn goto_type_definition(
1976        &self,
1977        params: tower_lsp::lsp_types::request::GotoTypeDefinitionParams,
1978    ) -> Result<Option<tower_lsp::lsp_types::request::GotoTypeDefinitionResponse>> {
1979        let uri = &params.text_document_position_params.text_document.uri;
1980        let position = params.text_document_position_params.position;
1981        let source = self.get_open_text(uri).unwrap_or_default();
1982        let doc = match self.get_doc(uri) {
1983            Some(d) => d,
1984            None => return Ok(None),
1985        };
1986        let doc_returns = self.docs.get_method_returns_salsa(uri);
1987        // First pass: open-file ParsedDocs give accurate character positions.
1988        let open_docs = self.docs.docs_for(&self.open_urls());
1989        if let Some(loc) =
1990            goto_type_definition(&source, &doc, doc_returns.as_deref(), &open_docs, position)
1991        {
1992            return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1993        }
1994        // Second pass: background files via FileIndex (line-only positions).
1995        let all_indexes = self.docs.all_indexes();
1996        Ok(goto_type_definition_from_index(
1997            &source,
1998            &doc,
1999            doc_returns.as_deref(),
2000            &all_indexes,
2001            position,
2002        )
2003        .map(GotoDefinitionResponse::Scalar))
2004    }
2005
2006    async fn prepare_type_hierarchy(
2007        &self,
2008        params: TypeHierarchyPrepareParams,
2009    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
2010        let uri = &params.text_document_position_params.text_document.uri;
2011        let position = params.text_document_position_params.position;
2012        let source = self.get_open_text(uri).unwrap_or_default();
2013        // Phase J: use the salsa-memoized aggregate's `classes_by_name` map.
2014        let wi = self.docs.get_workspace_index_salsa();
2015        Ok(prepare_type_hierarchy_from_workspace(&source, &wi, position).map(|item| vec![item]))
2016    }
2017
2018    async fn supertypes(
2019        &self,
2020        params: TypeHierarchySupertypesParams,
2021    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
2022        // Phase J: resolve parents via the aggregate's `classes_by_name` map.
2023        let wi = self.docs.get_workspace_index_salsa();
2024        let result = supertypes_of_from_workspace(&params.item, &wi);
2025        Ok(if result.is_empty() {
2026            None
2027        } else {
2028            Some(result)
2029        })
2030    }
2031
2032    async fn subtypes(
2033        &self,
2034        params: TypeHierarchySubtypesParams,
2035    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
2036        // Phase J: O(matches) lookup via the aggregate's `subtypes_of` map.
2037        let wi = self.docs.get_workspace_index_salsa();
2038        let result = subtypes_of_from_workspace(&params.item, &wi);
2039        Ok(if result.is_empty() {
2040            None
2041        } else {
2042            Some(result)
2043        })
2044    }
2045
2046    async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
2047        let uri = &params.text_document.uri;
2048        let doc = match self.get_doc(uri) {
2049            Some(d) => d,
2050            None => return Ok(None),
2051        };
2052        let all_docs = self.docs.all_docs_for_scan();
2053        let lenses = code_lenses(uri, &doc, &all_docs);
2054        Ok(if lenses.is_empty() {
2055            None
2056        } else {
2057            Some(lenses)
2058        })
2059    }
2060
2061    async fn code_lens_resolve(&self, params: CodeLens) -> Result<CodeLens> {
2062        // Lenses are fully populated by code_lens; nothing to add.
2063        Ok(params)
2064    }
2065
2066    async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
2067        let uri = &params.text_document.uri;
2068        let doc = match self.get_doc(uri) {
2069            Some(d) => d,
2070            None => return Ok(None),
2071        };
2072        let links = document_links(uri, &doc, doc.source());
2073        Ok(if links.is_empty() { None } else { Some(links) })
2074    }
2075
2076    async fn document_link_resolve(&self, params: DocumentLink) -> Result<DocumentLink> {
2077        // Links already carry their target URI; nothing to add.
2078        Ok(params)
2079    }
2080
2081    async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
2082        let uri = &params.text_document.uri;
2083        let source = self.get_open_text(uri).unwrap_or_default();
2084        Ok(format_document(&source))
2085    }
2086
2087    async fn range_formatting(
2088        &self,
2089        params: DocumentRangeFormattingParams,
2090    ) -> Result<Option<Vec<TextEdit>>> {
2091        let uri = &params.text_document.uri;
2092        let source = self.get_open_text(uri).unwrap_or_default();
2093        Ok(format_range(&source, params.range))
2094    }
2095
2096    async fn on_type_formatting(
2097        &self,
2098        params: DocumentOnTypeFormattingParams,
2099    ) -> Result<Option<Vec<TextEdit>>> {
2100        let uri = &params.text_document_position.text_document.uri;
2101        let source = self.get_open_text(uri).unwrap_or_default();
2102        let edits = on_type_format(
2103            &source,
2104            params.text_document_position.position,
2105            &params.ch,
2106            &params.options,
2107        );
2108        Ok(if edits.is_empty() { None } else { Some(edits) })
2109    }
2110
2111    async fn execute_command(
2112        &self,
2113        params: ExecuteCommandParams,
2114    ) -> Result<Option<serde_json::Value>> {
2115        match params.command.as_str() {
2116            "php-lsp.runTest" => {
2117                // Arguments: [uri_string, "ClassName::methodName"]
2118                let file_uri = params
2119                    .arguments
2120                    .first()
2121                    .and_then(|v| v.as_str())
2122                    .and_then(|s| Url::parse(s).ok());
2123                let filter = params
2124                    .arguments
2125                    .get(1)
2126                    .and_then(|v| v.as_str())
2127                    .unwrap_or("")
2128                    .to_string();
2129
2130                let root = self.root_paths.read().unwrap().first().cloned();
2131                let client = self.client.clone();
2132
2133                tokio::spawn(async move {
2134                    run_phpunit(&client, &filter, root.as_deref(), file_uri.as_ref()).await;
2135                });
2136
2137                Ok(None)
2138            }
2139            _ => Ok(None),
2140        }
2141    }
2142
2143    async fn will_rename_files(&self, params: RenameFilesParams) -> Result<Option<WorkspaceEdit>> {
2144        let psr4 = self.psr4.read().unwrap();
2145        let all_docs = self.docs.all_docs_for_scan();
2146        let mut merged_changes: std::collections::HashMap<
2147            tower_lsp::lsp_types::Url,
2148            Vec<tower_lsp::lsp_types::TextEdit>,
2149        > = std::collections::HashMap::new();
2150
2151        for file_rename in &params.files {
2152            let old_path = tower_lsp::lsp_types::Url::parse(&file_rename.old_uri)
2153                .ok()
2154                .and_then(|u| u.to_file_path().ok());
2155            let new_path = tower_lsp::lsp_types::Url::parse(&file_rename.new_uri)
2156                .ok()
2157                .and_then(|u| u.to_file_path().ok());
2158
2159            let (Some(old_path), Some(new_path)) = (old_path, new_path) else {
2160                continue;
2161            };
2162
2163            let old_fqn = psr4.file_to_fqn(&old_path);
2164            let new_fqn = psr4.file_to_fqn(&new_path);
2165
2166            let (Some(old_fqn), Some(new_fqn)) = (old_fqn, new_fqn) else {
2167                continue;
2168            };
2169
2170            let edit = use_edits_for_rename(&old_fqn, &new_fqn, &all_docs);
2171            if let Some(changes) = edit.changes {
2172                for (uri, edits) in changes {
2173                    merged_changes.entry(uri).or_default().extend(edits);
2174                }
2175            }
2176        }
2177
2178        Ok(if merged_changes.is_empty() {
2179            None
2180        } else {
2181            Some(WorkspaceEdit {
2182                changes: Some(merged_changes),
2183                ..Default::default()
2184            })
2185        })
2186    }
2187
2188    async fn did_rename_files(&self, params: RenameFilesParams) {
2189        for file_rename in &params.files {
2190            // Drop the old URI from the index
2191            if let Ok(old_uri) = tower_lsp::lsp_types::Url::parse(&file_rename.old_uri) {
2192                self.docs.remove(&old_uri);
2193            }
2194            // Index the file at its new location
2195            if let Ok(new_uri) = tower_lsp::lsp_types::Url::parse(&file_rename.new_uri)
2196                && let Ok(path) = new_uri.to_file_path()
2197                && let Ok(text) = tokio::fs::read_to_string(&path).await
2198            {
2199                self.index_if_not_open(new_uri, &text);
2200            }
2201        }
2202    }
2203
2204    // ── File-create notifications ────────────────────────────────────────────
2205
2206    async fn will_create_files(&self, params: CreateFilesParams) -> Result<Option<WorkspaceEdit>> {
2207        let psr4 = self.psr4.read().unwrap();
2208        let mut changes: std::collections::HashMap<Url, Vec<TextEdit>> =
2209            std::collections::HashMap::new();
2210
2211        for file in &params.files {
2212            let Ok(uri) = Url::parse(&file.uri) else {
2213                continue;
2214            };
2215            // Check the extension from the URI path so this works on Windows
2216            // where to_file_path() fails for drive-less URIs (e.g. file:///foo.php).
2217            if !uri.path().ends_with(".php") {
2218                continue;
2219            }
2220
2221            let stub = if let Ok(path) = uri.to_file_path()
2222                && let Some(fqn) = psr4.file_to_fqn(&path)
2223            {
2224                let (ns, class_name) = match fqn.rfind('\\') {
2225                    Some(pos) => (&fqn[..pos], &fqn[pos + 1..]),
2226                    None => ("", fqn.as_str()),
2227                };
2228                if ns.is_empty() {
2229                    format!("<?php\n\ndeclare(strict_types=1);\n\nclass {class_name}\n{{\n}}\n")
2230                } else {
2231                    format!(
2232                        "<?php\n\ndeclare(strict_types=1);\n\nnamespace {ns};\n\nclass {class_name}\n{{\n}}\n"
2233                    )
2234                }
2235            } else {
2236                "<?php\n\n".to_string()
2237            };
2238
2239            changes.insert(
2240                uri,
2241                vec![TextEdit {
2242                    range: Range {
2243                        start: Position {
2244                            line: 0,
2245                            character: 0,
2246                        },
2247                        end: Position {
2248                            line: 0,
2249                            character: 0,
2250                        },
2251                    },
2252                    new_text: stub,
2253                }],
2254            );
2255        }
2256
2257        Ok(if changes.is_empty() {
2258            None
2259        } else {
2260            Some(WorkspaceEdit {
2261                changes: Some(changes),
2262                ..Default::default()
2263            })
2264        })
2265    }
2266
2267    async fn did_create_files(&self, params: CreateFilesParams) {
2268        for file in &params.files {
2269            if let Ok(uri) = Url::parse(&file.uri)
2270                && let Ok(path) = uri.to_file_path()
2271                && let Ok(text) = tokio::fs::read_to_string(&path).await
2272            {
2273                self.index_if_not_open(uri, &text);
2274            }
2275        }
2276        send_refresh_requests(&self.client).await;
2277    }
2278
2279    // ── File-delete notifications ────────────────────────────────────────────
2280
2281    /// Before a file is deleted, return workspace edits that remove every
2282    /// `use` import referencing its PSR-4 class name.
2283    async fn will_delete_files(&self, params: DeleteFilesParams) -> Result<Option<WorkspaceEdit>> {
2284        let psr4 = self.psr4.read().unwrap();
2285        let all_docs = self.docs.all_docs_for_scan();
2286        let mut merged_changes: std::collections::HashMap<Url, Vec<TextEdit>> =
2287            std::collections::HashMap::new();
2288
2289        for file in &params.files {
2290            let path = Url::parse(&file.uri)
2291                .ok()
2292                .and_then(|u| u.to_file_path().ok());
2293            let Some(path) = path else { continue };
2294            let Some(fqn) = psr4.file_to_fqn(&path) else {
2295                continue;
2296            };
2297
2298            let edit = use_edits_for_delete(&fqn, &all_docs);
2299            if let Some(changes) = edit.changes {
2300                for (uri, edits) in changes {
2301                    merged_changes.entry(uri).or_default().extend(edits);
2302                }
2303            }
2304        }
2305
2306        Ok(if merged_changes.is_empty() {
2307            None
2308        } else {
2309            Some(WorkspaceEdit {
2310                changes: Some(merged_changes),
2311                ..Default::default()
2312            })
2313        })
2314    }
2315
2316    async fn did_delete_files(&self, params: DeleteFilesParams) {
2317        for file in &params.files {
2318            if let Ok(uri) = Url::parse(&file.uri) {
2319                self.docs.remove(&uri);
2320                // Clear diagnostics for the now-deleted file.
2321                self.client.publish_diagnostics(uri, vec![], None).await;
2322            }
2323        }
2324        send_refresh_requests(&self.client).await;
2325    }
2326
2327    // ── Moniker ──────────────────────────────────────────────────────────────
2328
2329    async fn moniker(&self, params: MonikerParams) -> Result<Option<Vec<Moniker>>> {
2330        let uri = &params.text_document_position_params.text_document.uri;
2331        let position = params.text_document_position_params.position;
2332        let source = self.get_open_text(uri).unwrap_or_default();
2333        let doc = match self.get_doc(uri) {
2334            Some(d) => d,
2335            None => return Ok(None),
2336        };
2337        let imports = self.file_imports(uri);
2338        Ok(moniker_at(&source, &doc, position, &imports).map(|m| vec![m]))
2339    }
2340
2341    // ── Inline values ────────────────────────────────────────────────────────
2342
2343    async fn inline_value(&self, params: InlineValueParams) -> Result<Option<Vec<InlineValue>>> {
2344        let uri = &params.text_document.uri;
2345        let source = self.get_open_text(uri).unwrap_or_default();
2346        let values = inline_values_in_range(&source, params.range);
2347        Ok(if values.is_empty() {
2348            None
2349        } else {
2350            Some(values)
2351        })
2352    }
2353
2354    async fn diagnostic(
2355        &self,
2356        params: DocumentDiagnosticParams,
2357    ) -> Result<DocumentDiagnosticReportResult> {
2358        let uri = &params.text_document.uri;
2359        let source = self.get_open_text(uri).unwrap_or_default();
2360
2361        let parse_diags = self.get_parse_diagnostics(uri).unwrap_or_default();
2362        let doc = match self.get_doc(uri) {
2363            Some(d) => d,
2364            None => {
2365                return Ok(DocumentDiagnosticReportResult::Report(
2366                    DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
2367                        related_documents: None,
2368                        full_document_diagnostic_report: FullDocumentDiagnosticReport {
2369                            result_id: None,
2370                            items: parse_diags,
2371                        },
2372                    }),
2373                ));
2374            }
2375        };
2376        let (diag_cfg, php_version) = {
2377            let cfg = self.config.read().unwrap();
2378            (cfg.diagnostics.clone(), cfg.php_version.clone())
2379        };
2380        let _ = php_version.as_deref();
2381        // Phase I: salsa Pass-2 is CPU-bound; run off the async executor.
2382        let docs = Arc::clone(&self.docs);
2383        let uri_owned = uri.clone();
2384        let diag_cfg_sem = diag_cfg.clone();
2385        let sem_diags = tokio::task::spawn_blocking(move || {
2386            docs.get_semantic_issues_salsa(&uri_owned)
2387                .map(|issues| {
2388                    crate::semantic_diagnostics::issues_to_diagnostics(
2389                        &issues,
2390                        &uri_owned,
2391                        &diag_cfg_sem,
2392                    )
2393                })
2394                .unwrap_or_default()
2395        })
2396        .await
2397        .unwrap_or_default();
2398        let dup_diags = duplicate_declaration_diagnostics(&source, &doc, &diag_cfg);
2399
2400        let mut items = parse_diags;
2401        items.extend(sem_diags);
2402        items.extend(dup_diags);
2403
2404        Ok(DocumentDiagnosticReportResult::Report(
2405            DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
2406                related_documents: None,
2407                full_document_diagnostic_report: FullDocumentDiagnosticReport {
2408                    result_id: None,
2409                    items,
2410                },
2411            }),
2412        ))
2413    }
2414
2415    async fn workspace_diagnostic(
2416        &self,
2417        _params: WorkspaceDiagnosticParams,
2418    ) -> Result<WorkspaceDiagnosticReportResult> {
2419        let all_parse_diags = self.all_open_files_with_diagnostics();
2420        let (diag_cfg, php_version) = {
2421            let cfg = self.config.read().unwrap();
2422            (cfg.diagnostics.clone(), cfg.php_version.clone())
2423        };
2424
2425        // Phase I: each file's semantic issues flow through the salsa
2426        // `semantic_issues` query. The memo is shared with `did_open` /
2427        // `did_change` / `document_diagnostic` / `code_action`, so repeated
2428        // workspace-diagnostic pulls reuse prior analysis. The first pull on
2429        // a cold workspace still walks every file's `StatementsAnalyzer` —
2430        // run the whole sweep on the blocking pool so the async runtime
2431        // stays responsive.
2432        let _ = php_version.as_deref();
2433        let docs = Arc::clone(&self.docs);
2434        let diag_cfg_sweep = diag_cfg.clone();
2435        let items = tokio::task::spawn_blocking(move || {
2436            all_parse_diags
2437                .into_iter()
2438                .filter_map(|(uri, parse_diags, version)| {
2439                    let doc = docs.get_doc_salsa(&uri)?;
2440
2441                    let source = doc.source().to_string();
2442                    let sem_diags = docs
2443                        .get_semantic_issues_salsa(&uri)
2444                        .map(|issues| {
2445                            crate::semantic_diagnostics::issues_to_diagnostics(
2446                                &issues,
2447                                &uri,
2448                                &diag_cfg_sweep,
2449                            )
2450                        })
2451                        .unwrap_or_default();
2452                    let dup_diags =
2453                        duplicate_declaration_diagnostics(&source, &doc, &diag_cfg_sweep);
2454
2455                    let mut all_diags = parse_diags;
2456                    all_diags.extend(sem_diags);
2457                    all_diags.extend(dup_diags);
2458
2459                    Some(WorkspaceDocumentDiagnosticReport::Full(
2460                        WorkspaceFullDocumentDiagnosticReport {
2461                            uri,
2462                            version,
2463                            full_document_diagnostic_report: FullDocumentDiagnosticReport {
2464                                result_id: None,
2465                                items: all_diags,
2466                            },
2467                        },
2468                    ))
2469                })
2470                .collect::<Vec<_>>()
2471        })
2472        .await
2473        .unwrap_or_default();
2474
2475        Ok(WorkspaceDiagnosticReportResult::Report(
2476            WorkspaceDiagnosticReport { items },
2477        ))
2478    }
2479
2480    async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
2481        let uri = &params.text_document.uri;
2482        let source = self.get_open_text(uri).unwrap_or_default();
2483        let doc = match self.get_doc(uri) {
2484            Some(d) => d,
2485            None => return Ok(None),
2486        };
2487        let other_docs = self.docs.other_docs(uri, &self.open_urls());
2488
2489        // Phase I: read semantic issues through the salsa query. The result
2490        // is memoized across did_open/did_change/document_diagnostic, so
2491        // code_action usually hits the memo instead of rerunning analysis.
2492        // On a memo miss (e.g. code-action fires before did_open finishes),
2493        // the analyzer runs — park that on the blocking pool so the async
2494        // runtime doesn't stall.
2495        let diag_cfg = self.config.read().unwrap().diagnostics.clone();
2496        let docs_sem = Arc::clone(&self.docs);
2497        let uri_sem = uri.clone();
2498        let diag_cfg_sem = diag_cfg.clone();
2499        let sem_diags = tokio::task::spawn_blocking(move || {
2500            docs_sem
2501                .get_semantic_issues_salsa(&uri_sem)
2502                .map(|issues| {
2503                    crate::semantic_diagnostics::issues_to_diagnostics(
2504                        &issues,
2505                        &uri_sem,
2506                        &diag_cfg_sem,
2507                    )
2508                })
2509                .unwrap_or_default()
2510        })
2511        .await
2512        .unwrap_or_default();
2513
2514        // Build "Add use import" code actions for undefined class names in range
2515        let mut actions: Vec<CodeActionOrCommand> = Vec::new();
2516        for diag in &sem_diags {
2517            if diag.code != Some(NumberOrString::String("UndefinedClass".to_string())) {
2518                continue;
2519            }
2520            // Only act on diagnostics within the requested range
2521            if diag.range.start.line < params.range.start.line
2522                || diag.range.start.line > params.range.end.line
2523            {
2524                continue;
2525            }
2526            // Message format: "Class {name} does not exist"
2527            let class_name = diag
2528                .message
2529                .strip_prefix("Class ")
2530                .and_then(|s| s.strip_suffix(" does not exist"))
2531                .unwrap_or("")
2532                .trim();
2533            if class_name.is_empty() {
2534                continue;
2535            }
2536
2537            // Find a class with this short name in other indexed documents
2538            for (_other_uri, other_doc) in &other_docs {
2539                if let Some(fqn) = find_fqn_for_class(other_doc, class_name) {
2540                    let edit = build_use_import_edit(&source, uri, &fqn);
2541                    let action = CodeAction {
2542                        title: format!("Add use {fqn}"),
2543                        kind: Some(CodeActionKind::QUICKFIX),
2544                        edit: Some(edit),
2545                        diagnostics: Some(vec![diag.clone()]),
2546                        ..Default::default()
2547                    };
2548                    actions.push(CodeActionOrCommand::CodeAction(action));
2549                    break; // one action per undefined symbol
2550                }
2551            }
2552        }
2553
2554        // Defer edit computation to code_action_resolve so the menu renders
2555        // instantly; the client fetches the full edit only for the selected item.
2556        for tag in DEFERRED_ACTION_TAGS {
2557            actions.extend(defer_actions(
2558                self.generate_deferred_actions(tag, &source, &doc, params.range, uri),
2559                tag,
2560                uri,
2561                params.range,
2562            ));
2563        }
2564
2565        // Extract variable: cheap, keep eager.
2566        actions.extend(extract_variable_actions(&source, params.range, uri));
2567        actions.extend(extract_method_actions(&source, &doc, params.range, uri));
2568        actions.extend(extract_constant_actions(&source, params.range, uri));
2569        // Inline variable: inverse of extract variable.
2570        actions.extend(inline_variable_actions(&source, params.range, uri));
2571        // Organize imports: sort and remove unused use statements.
2572        if let Some(action) = organize_imports_action(&source, uri) {
2573            actions.push(action);
2574        }
2575
2576        Ok(if actions.is_empty() {
2577            None
2578        } else {
2579            Some(actions)
2580        })
2581    }
2582
2583    async fn code_action_resolve(&self, item: CodeAction) -> Result<CodeAction> {
2584        let data = match &item.data {
2585            Some(d) => d.clone(),
2586            None => return Ok(item),
2587        };
2588        let kind_tag = match data.get("php_lsp_resolve").and_then(|v| v.as_str()) {
2589            Some(k) => k.to_string(),
2590            None => return Ok(item),
2591        };
2592        let uri: Url = match data
2593            .get("uri")
2594            .and_then(|v| v.as_str())
2595            .and_then(|s| Url::parse(s).ok())
2596        {
2597            Some(u) => u,
2598            None => return Ok(item),
2599        };
2600        let range: Range = match data
2601            .get("range")
2602            .and_then(|v| serde_json::from_value(v.clone()).ok())
2603        {
2604            Some(r) => r,
2605            None => return Ok(item),
2606        };
2607
2608        let source = self.get_open_text(&uri).unwrap_or_default();
2609        let doc = match self.get_doc(&uri) {
2610            Some(d) => d,
2611            None => return Ok(item),
2612        };
2613
2614        let candidates = self.generate_deferred_actions(&kind_tag, &source, &doc, range, &uri);
2615
2616        // Find the action whose title matches and return it fully resolved.
2617        for candidate in candidates {
2618            if let CodeActionOrCommand::CodeAction(ca) = candidate
2619                && ca.title == item.title
2620            {
2621                return Ok(ca);
2622            }
2623        }
2624
2625        Ok(item)
2626    }
2627}
2628
2629/// Shorthand for a `FileOperationRegistrationOptions` that matches `*.php` files.
2630fn php_file_op() -> FileOperationRegistrationOptions {
2631    FileOperationRegistrationOptions {
2632        filters: vec![FileOperationFilter {
2633            scheme: Some("file".to_string()),
2634            pattern: FileOperationPattern {
2635                glob: "**/*.php".to_string(),
2636                matches: Some(FileOperationPatternKind::File),
2637                options: None,
2638            },
2639        }],
2640    }
2641}
2642
2643/// Strip the `edit` from each `CodeAction` and attach a `data` payload so the
2644/// client can request the edit lazily via `codeAction/resolve`.
2645fn defer_actions(
2646    actions: Vec<CodeActionOrCommand>,
2647    kind_tag: &str,
2648    uri: &Url,
2649    range: Range,
2650) -> Vec<CodeActionOrCommand> {
2651    actions
2652        .into_iter()
2653        .map(|a| match a {
2654            CodeActionOrCommand::CodeAction(mut ca) => {
2655                ca.edit = None;
2656                ca.data = Some(serde_json::json!({
2657                    "php_lsp_resolve": kind_tag,
2658                    "uri": uri.to_string(),
2659                    "range": range,
2660                }));
2661                CodeActionOrCommand::CodeAction(ca)
2662            }
2663            other => other,
2664        })
2665        .collect()
2666}
2667
2668/// Returns `true` when the identifier at `position` is immediately preceded by `->`,
2669/// indicating it is a property or method name in an instance access expression.
2670fn is_after_arrow(source: &str, position: Position) -> bool {
2671    let line = match source.lines().nth(position.line as usize) {
2672        Some(l) => l,
2673        None => return false,
2674    };
2675    let chars: Vec<char> = line.chars().collect();
2676    let col = position.character as usize;
2677    // Find the char index of the cursor (UTF-16 → char index).
2678    let mut utf16_col = 0usize;
2679    let mut char_idx = 0usize;
2680    for ch in &chars {
2681        if utf16_col >= col {
2682            break;
2683        }
2684        utf16_col += ch.len_utf16();
2685        char_idx += 1;
2686    }
2687    // Walk left past word chars to the start of the identifier.
2688    let is_word = |c: char| c.is_alphanumeric() || c == '_';
2689    while char_idx > 0 && is_word(chars[char_idx - 1]) {
2690        char_idx -= 1;
2691    }
2692    char_idx >= 2 && chars[char_idx - 1] == '>' && chars[char_idx - 2] == '-'
2693}
2694
2695/// Classify the symbol at `position` so `find_references` can use the right walker.
2696///
2697/// Heuristics (in priority order):
2698/// 1. Preceded by `->` or `?->` → `Method`
2699/// 2. Preceded by `::` → `Method` (static)
2700/// 3. Word starts with `$` → variable (returns `None`; variables are handled separately)
2701/// 4. First character is uppercase AND not preceded by `->` or `::` → `Class`
2702/// 5. Otherwise → `Function`
2703///
2704/// Falls back to `None` when the context cannot be determined.
2705fn symbol_kind_at(source: &str, position: Position, word: &str) -> Option<SymbolKind> {
2706    if word.starts_with('$') {
2707        return None; // variables handled elsewhere
2708    }
2709    let line = source.lines().nth(position.line as usize)?;
2710    let chars: Vec<char> = line.chars().collect();
2711
2712    // Convert UTF-16 column to char index.
2713    let col = position.character as usize;
2714    let mut utf16_col = 0usize;
2715    let mut char_idx = 0usize;
2716    for ch in &chars {
2717        if utf16_col >= col {
2718            break;
2719        }
2720        utf16_col += ch.len_utf16();
2721        char_idx += 1;
2722    }
2723
2724    // Walk left past identifier characters to find the first character before the word.
2725    let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
2726    while char_idx > 0 && is_word_char(chars[char_idx - 1]) {
2727        char_idx -= 1;
2728    }
2729
2730    // Look past the end of the word to distinguish `->method()` from `->prop`.
2731    let word_end = {
2732        let mut i = char_idx;
2733        while i < chars.len() && is_word_char(chars[i]) {
2734            i += 1;
2735        }
2736        // Skip spaces before the next token.
2737        while i < chars.len() && chars[i] == ' ' {
2738            i += 1;
2739        }
2740        i
2741    };
2742    let next_is_call = word_end < chars.len() && chars[word_end] == '(';
2743
2744    // Check for `->` or `?->`
2745    if char_idx >= 2 && chars[char_idx - 1] == '>' && chars[char_idx - 2] == '-' {
2746        return if next_is_call {
2747            Some(SymbolKind::Method)
2748        } else {
2749            Some(SymbolKind::Property)
2750        };
2751    }
2752    if char_idx >= 3
2753        && chars[char_idx - 1] == '>'
2754        && chars[char_idx - 2] == '-'
2755        && chars[char_idx - 3] == '?'
2756    {
2757        return if next_is_call {
2758            Some(SymbolKind::Method)
2759        } else {
2760            Some(SymbolKind::Property)
2761        };
2762    }
2763
2764    // Check for `::`
2765    if char_idx >= 2 && chars[char_idx - 1] == ':' && chars[char_idx - 2] == ':' {
2766        return Some(SymbolKind::Method);
2767    }
2768
2769    // If the word starts with an uppercase letter it is likely a class/interface/enum name.
2770    if word
2771        .chars()
2772        .next()
2773        .map(|c| c.is_uppercase())
2774        .unwrap_or(false)
2775    {
2776        return Some(SymbolKind::Class);
2777    }
2778
2779    // Otherwise treat as a free function.
2780    Some(SymbolKind::Function)
2781}
2782
2783/// Convert an LSP `Position` to a byte offset within `source`.
2784/// Returns `None` if the position is beyond the end of the source.
2785fn position_to_offset(source: &str, position: Position) -> Option<u32> {
2786    let mut byte_offset = 0usize;
2787    for (idx, line) in source.split('\n').enumerate() {
2788        if idx as u32 == position.line {
2789            // Strip trailing \r so CRLF lines don't affect column counting.
2790            let line_content = line.trim_end_matches('\r');
2791            let mut col = 0u32;
2792            for (byte_idx, ch) in line_content.char_indices() {
2793                if col >= position.character {
2794                    return Some((byte_offset + byte_idx) as u32);
2795                }
2796                col += ch.len_utf16() as u32;
2797            }
2798            return Some((byte_offset + line_content.len()) as u32);
2799        }
2800        byte_offset += line.len() + 1; // +1 for the '\n'
2801    }
2802    None
2803}
2804
2805/// Returns `true` if the cursor is positioned on a method name inside a class,
2806/// interface, trait, or enum declaration in the AST.
2807///
2808/// This is a pre-pass used before the character-based `symbol_kind_at` heuristic
2809/// so that method *declarations* (`public function add() {}`) are classified as
2810/// `SymbolKind::Method` rather than falling through to `SymbolKind::Function`.
2811fn cursor_is_on_method_decl(source: &str, stmts: &[Stmt<'_, '_>], position: Position) -> bool {
2812    let Some(cursor) = position_to_offset(source, position) else {
2813        return false;
2814    };
2815
2816    fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32) -> bool {
2817        for stmt in stmts {
2818            match &stmt.kind {
2819                StmtKind::Class(c) => {
2820                    for member in c.members.iter() {
2821                        if let ClassMemberKind::Method(m) = &member.kind {
2822                            let start = str_offset(source, m.name);
2823                            let end = start + m.name.len() as u32;
2824                            if cursor >= start && cursor < end {
2825                                return true;
2826                            }
2827                        }
2828                    }
2829                }
2830                StmtKind::Interface(i) => {
2831                    for member in i.members.iter() {
2832                        if let ClassMemberKind::Method(m) = &member.kind {
2833                            let start = str_offset(source, m.name);
2834                            let end = start + m.name.len() as u32;
2835                            if cursor >= start && cursor < end {
2836                                return true;
2837                            }
2838                        }
2839                    }
2840                }
2841                StmtKind::Trait(t) => {
2842                    for member in t.members.iter() {
2843                        if let ClassMemberKind::Method(m) = &member.kind {
2844                            let start = str_offset(source, m.name);
2845                            let end = start + m.name.len() as u32;
2846                            if cursor >= start && cursor < end {
2847                                return true;
2848                            }
2849                        }
2850                    }
2851                }
2852                StmtKind::Enum(e) => {
2853                    for member in e.members.iter() {
2854                        if let EnumMemberKind::Method(m) = &member.kind {
2855                            let start = str_offset(source, m.name);
2856                            let end = start + m.name.len() as u32;
2857                            if cursor >= start && cursor < end {
2858                                return true;
2859                            }
2860                        }
2861                    }
2862                }
2863                StmtKind::Namespace(ns) => {
2864                    if let NamespaceBody::Braced(inner) = &ns.body
2865                        && check(source, inner, cursor)
2866                    {
2867                        return true;
2868                    }
2869                }
2870                _ => {}
2871            }
2872        }
2873        false
2874    }
2875
2876    check(source, stmts, cursor)
2877}
2878
2879/// If the cursor is on a class or trait property *declaration* name (e.g.
2880/// `public string $status`), return the property name without the leading `$`
2881/// so the caller can search for `status` via `SymbolKind::Property`.  Returns
2882/// `None` when the cursor is elsewhere.
2883fn cursor_is_on_property_decl(
2884    source: &str,
2885    stmts: &[Stmt<'_, '_>],
2886    position: Position,
2887) -> Option<String> {
2888    let cursor = position_to_offset(source, position)?;
2889
2890    fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32) -> Option<String> {
2891        for stmt in stmts {
2892            match &stmt.kind {
2893                StmtKind::Class(c) => {
2894                    for member in c.members.iter() {
2895                        if let ClassMemberKind::Property(p) = &member.kind {
2896                            let start = str_offset(source, p.name);
2897                            let end = start + p.name.len() as u32;
2898                            if cursor >= start && cursor < end {
2899                                return Some(p.name.to_owned());
2900                            }
2901                        }
2902                    }
2903                }
2904                StmtKind::Trait(t) => {
2905                    for member in t.members.iter() {
2906                        if let ClassMemberKind::Property(p) = &member.kind {
2907                            let start = str_offset(source, p.name);
2908                            let end = start + p.name.len() as u32;
2909                            if cursor >= start && cursor < end {
2910                                return Some(p.name.to_owned());
2911                            }
2912                        }
2913                    }
2914                }
2915                StmtKind::Namespace(ns) => {
2916                    if let NamespaceBody::Braced(inner) = &ns.body
2917                        && let Some(name) = check(source, inner, cursor)
2918                    {
2919                        return Some(name);
2920                    }
2921                }
2922                _ => {}
2923            }
2924        }
2925        None
2926    }
2927
2928    check(source, stmts, cursor)
2929}
2930
2931/// When the cursor sits on a `__construct` method name declaration, return
2932/// the owning class FQN (namespace-qualified when inside a namespace). Returns
2933/// `None` otherwise (including when the cursor is on a non-constructor method,
2934/// inside a trait/interface, or inside a namespaced enum — constructors on
2935/// those don't drive class instantiation call sites the way class constructors
2936/// do).
2937fn class_name_at_construct_decl(
2938    source: &str,
2939    stmts: &[Stmt<'_, '_>],
2940    position: Position,
2941) -> Option<String> {
2942    let cursor = position_to_offset(source, position)?;
2943
2944    fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32, ns_prefix: &str) -> Option<String> {
2945        let mut current_ns = ns_prefix.to_owned();
2946        for stmt in stmts {
2947            match &stmt.kind {
2948                StmtKind::Class(c) => {
2949                    for member in c.members.iter() {
2950                        if let ClassMemberKind::Method(m) = &member.kind
2951                            && m.name == "__construct"
2952                        {
2953                            let start = str_offset(source, m.name);
2954                            let end = start + m.name.len() as u32;
2955                            if cursor >= start && cursor < end {
2956                                let short = c.name?;
2957                                return Some(if current_ns.is_empty() {
2958                                    short.to_owned()
2959                                } else {
2960                                    format!("{}\\{}", current_ns, short)
2961                                });
2962                            }
2963                        }
2964                    }
2965                }
2966                StmtKind::Namespace(ns) => {
2967                    let ns_name = ns
2968                        .name
2969                        .as_ref()
2970                        .map(|n| n.to_string_repr().to_string())
2971                        .unwrap_or_default();
2972                    match &ns.body {
2973                        NamespaceBody::Braced(inner) => {
2974                            if let Some(name) = check(source, inner, cursor, &ns_name) {
2975                                return Some(name);
2976                            }
2977                        }
2978                        NamespaceBody::Simple => {
2979                            current_ns = ns_name;
2980                        }
2981                    }
2982                }
2983                _ => {}
2984            }
2985        }
2986        None
2987    }
2988
2989    check(source, stmts, cursor, "")
2990}
2991
2992/// If the cursor sits on a promoted constructor property parameter (one that
2993/// has a visibility modifier like `public`/`protected`/`private`), return the
2994/// property name without the leading `$` so the caller can search for
2995/// `->name` property accesses (`SymbolKind::Property`).
2996///
2997/// Returns `None` for regular (non-promoted) params and for any cursor position
2998/// not on a constructor param name.
2999fn promoted_property_at_cursor(
3000    source: &str,
3001    stmts: &[Stmt<'_, '_>],
3002    position: Position,
3003) -> Option<String> {
3004    let cursor = position_to_offset(source, position)?;
3005
3006    fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32) -> Option<String> {
3007        for stmt in stmts {
3008            match &stmt.kind {
3009                StmtKind::Class(c) => {
3010                    for member in c.members.iter() {
3011                        if let ClassMemberKind::Method(m) = &member.kind
3012                            && m.name == "__construct"
3013                        {
3014                            for param in m.params.iter() {
3015                                if param.visibility.is_none() {
3016                                    continue;
3017                                }
3018                                let name_start = str_offset(source, param.name);
3019                                let name_end = name_start + param.name.len() as u32;
3020                                if cursor >= name_start && cursor < name_end {
3021                                    return Some(param.name.trim_start_matches('$').to_owned());
3022                                }
3023                            }
3024                        }
3025                    }
3026                }
3027                StmtKind::Namespace(ns) => {
3028                    if let NamespaceBody::Braced(inner) = &ns.body
3029                        && let Some(name) = check(source, inner, cursor)
3030                    {
3031                        return Some(name);
3032                    }
3033                }
3034                _ => {}
3035            }
3036        }
3037        None
3038    }
3039
3040    check(source, stmts, cursor)
3041}
3042
3043/// Tags for deferred code actions (resolved lazily via `codeAction/resolve`).
3044/// Iteration order controls the order items appear in the client menu.
3045const DEFERRED_ACTION_TAGS: &[&str] = &[
3046    "phpdoc",
3047    "implement",
3048    "constructor",
3049    "getters_setters",
3050    "return_type",
3051    "promote",
3052];
3053
3054impl Backend {
3055    /// Tag → generator mapping for deferred code actions.
3056    fn generate_deferred_actions(
3057        &self,
3058        tag: &str,
3059        source: &str,
3060        doc: &Arc<ParsedDoc>,
3061        range: Range,
3062        uri: &Url,
3063    ) -> Vec<CodeActionOrCommand> {
3064        match tag {
3065            "phpdoc" => phpdoc_actions(uri, doc, source, range),
3066            "implement" => {
3067                let imports = self.file_imports(uri);
3068                implement_missing_actions(
3069                    source,
3070                    doc,
3071                    &self
3072                        .docs
3073                        .doc_with_others(uri, Arc::clone(doc), &self.open_urls()),
3074                    range,
3075                    uri,
3076                    &imports,
3077                )
3078            }
3079            "constructor" => generate_constructor_actions(source, doc, range, uri),
3080            "getters_setters" => generate_getters_setters_actions(source, doc, range, uri),
3081            "return_type" => add_return_type_actions(source, doc, range, uri),
3082            "promote" => promote_constructor_actions(source, doc, range, uri),
3083            _ => Vec::new(),
3084        }
3085    }
3086
3087    /// Try to resolve a fully-qualified name via the PSR-4 map.
3088    /// Indexes the file on-demand if it is not already in the document store.
3089    async fn psr4_goto(&self, fqn: &str) -> Option<Location> {
3090        let path = {
3091            let psr4 = self.psr4.read().unwrap();
3092            psr4.resolve(fqn)?
3093        };
3094
3095        let file_uri = Url::from_file_path(&path).ok()?;
3096
3097        // Index on-demand if the file was not picked up by the workspace scan.
3098        // Use `get_doc_salsa_any` (ignores open-file gating): after `index()`
3099        // the file is mirrored but background-only, and the call site needs
3100        // the AST regardless of whether the editor has the file open.
3101        if self.docs.get_doc_salsa(&file_uri).is_none() {
3102            let text = tokio::fs::read_to_string(&path).await.ok()?;
3103            self.index_if_not_open(file_uri.clone(), &text);
3104        }
3105
3106        let doc = self.docs.get_doc_salsa(&file_uri)?;
3107
3108        // Classes are declared by their short (unqualified) name, e.g. `class Foo`
3109        // not `class App\Services\Foo`.
3110        let short_name = fqn.split('\\').next_back()?;
3111        let range = find_declaration_range(doc.source(), &doc, short_name)?;
3112
3113        Some(Location {
3114            uri: file_uri,
3115            range,
3116        })
3117    }
3118}
3119
3120/// Run `vendor/bin/phpunit --filter <filter>` and show the result via
3121/// `window/showMessageRequest`.  Offers "Run Again" on both success and
3122/// failure, and additionally "Open File" on failure so the user can jump
3123/// straight to the test source.  Selecting "Run Again" re-executes the test
3124/// in the same task without returning to the client first.
3125async fn run_phpunit(
3126    client: &Client,
3127    filter: &str,
3128    root: Option<&std::path::Path>,
3129    file_uri: Option<&Url>,
3130) {
3131    let output = tokio::process::Command::new("vendor/bin/phpunit")
3132        .arg("--filter")
3133        .arg(filter)
3134        .current_dir(root.unwrap_or(std::path::Path::new(".")))
3135        .output()
3136        .await;
3137
3138    let (success, message) = match output {
3139        Ok(out) => {
3140            let text = String::from_utf8_lossy(&out.stdout).into_owned()
3141                + &String::from_utf8_lossy(&out.stderr);
3142            let last_line = text
3143                .lines()
3144                .rev()
3145                .find(|l| !l.trim().is_empty())
3146                .unwrap_or("(no output)")
3147                .to_string();
3148            let ok = out.status.success();
3149            let msg = if ok {
3150                format!("✓ {filter}: {last_line}")
3151            } else {
3152                format!("✗ {filter}: {last_line}")
3153            };
3154            (ok, msg)
3155        }
3156        Err(e) => (
3157            false,
3158            format!("php-lsp.runTest: failed to spawn phpunit — {e}"),
3159        ),
3160    };
3161
3162    let msg_type = if success {
3163        MessageType::INFO
3164    } else {
3165        MessageType::ERROR
3166    };
3167    let mut actions = vec![MessageActionItem {
3168        title: "Run Again".to_string(),
3169        properties: Default::default(),
3170    }];
3171    if !success && file_uri.is_some() {
3172        actions.push(MessageActionItem {
3173            title: "Open File".to_string(),
3174            properties: Default::default(),
3175        });
3176    }
3177
3178    let chosen = client
3179        .show_message_request(msg_type, message, Some(actions))
3180        .await;
3181
3182    match chosen {
3183        Ok(Some(ref action)) if action.title == "Run Again" => {
3184            // Re-run once; result shown as a plain message to avoid infinite recursion.
3185            let output2 = tokio::process::Command::new("vendor/bin/phpunit")
3186                .arg("--filter")
3187                .arg(filter)
3188                .current_dir(root.unwrap_or(std::path::Path::new(".")))
3189                .output()
3190                .await;
3191            let msg2 = match output2 {
3192                Ok(out) => {
3193                    let text = String::from_utf8_lossy(&out.stdout).into_owned()
3194                        + &String::from_utf8_lossy(&out.stderr);
3195                    let last_line = text
3196                        .lines()
3197                        .rev()
3198                        .find(|l| !l.trim().is_empty())
3199                        .unwrap_or("(no output)")
3200                        .to_string();
3201                    if out.status.success() {
3202                        format!("✓ {filter}: {last_line}")
3203                    } else {
3204                        format!("✗ {filter}: {last_line}")
3205                    }
3206                }
3207                Err(e) => format!("php-lsp.runTest: failed to spawn phpunit — {e}"),
3208            };
3209            client.show_message(MessageType::INFO, msg2).await;
3210        }
3211        Ok(Some(ref action)) if action.title == "Open File" => {
3212            if let Some(uri) = file_uri {
3213                client
3214                    .show_document(ShowDocumentParams {
3215                        uri: uri.clone(),
3216                        external: Some(false),
3217                        take_focus: Some(true),
3218                        selection: None,
3219                    })
3220                    .await
3221                    .ok();
3222            }
3223        }
3224        _ => {}
3225    }
3226}
3227
3228/// Ask all connected clients to re-request semantic tokens, code lenses, inlay hints,
3229/// and diagnostics. Called after bulk index operations so that previously-opened editors
3230/// immediately pick up the newly indexed symbol information.
3231async fn send_refresh_requests(client: &Client) {
3232    client.send_request::<SemanticTokensRefresh>(()).await.ok();
3233    client.send_request::<CodeLensRefresh>(()).await.ok();
3234    client
3235        .send_request::<InlayHintRefreshRequest>(())
3236        .await
3237        .ok();
3238    client
3239        .send_request::<WorkspaceDiagnosticRefresh>(())
3240        .await
3241        .ok();
3242    client
3243        .send_request::<InlineValueRefreshRequest>(())
3244        .await
3245        .ok();
3246}
3247
3248/// Maximum number of PHP files indexed during a workspace scan.
3249/// Prevents excessive memory use on projects with very large vendor trees.
3250const MAX_INDEXED_FILES: usize = 50_000;
3251
3252/// Recursively scan `root` for `*.php` files and add them to the document store.
3253/// Skips hidden directories (names starting with `.`) and any path whose string
3254/// representation contains a segment matching one of the `exclude_paths` patterns.
3255/// Returns the number of files indexed.
3256///
3257/// Phase 1 — directory traversal: async, serial (I/O-bound; tokio handles it well).
3258/// Phase 2 — file reading + parsing: concurrent, bounded by available CPU cores.
3259///
3260/// Post-salsa: we only populate the DocumentStore here. The codebase is built
3261/// on demand by the salsa `codebase` query the first time a feature asks for
3262/// it — stubs + every indexed file's StubSlice, memoized thereafter.
3263#[tracing::instrument(
3264    skip(docs, open_files, cache, exclude_paths),
3265    fields(root = %root.display())
3266)]
3267async fn scan_workspace(
3268    root: PathBuf,
3269    docs: Arc<DocumentStore>,
3270    open_files: OpenFiles,
3271    cache: Option<crate::cache::WorkspaceCache>,
3272    exclude_paths: &[String],
3273    max_files: usize,
3274) -> usize {
3275    // Phase 1: collect PHP file paths via async directory walk.
3276    let mut php_files: Vec<PathBuf> = Vec::new();
3277    let mut stack = vec![root];
3278
3279    'walk: while let Some(dir) = stack.pop() {
3280        let mut entries = match tokio::fs::read_dir(&dir).await {
3281            Ok(e) => e,
3282            Err(_) => continue,
3283        };
3284        while let Ok(Some(entry)) = entries.next_entry().await {
3285            let path = entry.path();
3286            // Normalize to forward slashes so patterns like "src/Service/*"
3287            // match on Windows where paths use backslashes.
3288            let path_str = path.to_string_lossy().replace('\\', "/");
3289            // Check user-configured exclude patterns (simple substring/prefix match).
3290            if exclude_paths.iter().any(|pat| {
3291                let p = pat.trim_end_matches('*').trim_end_matches('/');
3292                path_str.contains(p)
3293            }) {
3294                continue;
3295            }
3296            let file_type = match entry.file_type().await {
3297                Ok(ft) => ft,
3298                Err(_) => continue,
3299            };
3300            if file_type.is_dir() {
3301                let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
3302                // Skip hidden directories; vendor is indexed unless excluded above.
3303                if !name.starts_with('.') {
3304                    stack.push(path);
3305                }
3306            } else if file_type.is_file() && path.extension().is_some_and(|e| e == "php") {
3307                php_files.push(path);
3308                if php_files.len() >= max_files {
3309                    break 'walk;
3310                }
3311            }
3312        }
3313    }
3314
3315    // Phase 2: read and parse files concurrently, bounded by available CPU cores.
3316    let parallelism = std::thread::available_parallelism()
3317        .map(|n| n.get())
3318        .unwrap_or(4);
3319    let sem = Arc::new(tokio::sync::Semaphore::new(parallelism));
3320    let count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3321    let mut set: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
3322
3323    for path in php_files {
3324        let permit = Arc::clone(&sem).acquire_owned().await.unwrap();
3325        let docs = Arc::clone(&docs);
3326        let open_files = open_files.clone();
3327        let cache = cache.clone();
3328        let count = Arc::clone(&count);
3329        set.spawn(async move {
3330            let _permit = permit;
3331            let Ok(text) = tokio::fs::read_to_string(&path).await else {
3332                return;
3333            };
3334            let Ok(uri) = Url::from_file_path(&path) else {
3335                return;
3336            };
3337            tokio::task::spawn_blocking(move || {
3338                // Skip files the editor has already opened — their buffer
3339                // is authoritative; scan must not overwrite their salsa
3340                // input with disk contents.
3341                if open_files.contains(&uri) {
3342                    return;
3343                }
3344
3345                // Phase K2b read path: if the on-disk cache has a StubSlice
3346                // for this (uri, content) key, mirror the text and seed
3347                // the cached slice — `file_definitions` will return it
3348                // directly on the first query, skipping parse and
3349                // `DefinitionCollector` entirely. An edit later clears
3350                // the seeded slice via `mirror_text` (K2a).
3351                let cache_key = cache
3352                    .as_ref()
3353                    .map(|_| crate::cache::WorkspaceCache::key_for(uri.as_str(), &text));
3354                if let (Some(cache), Some(key)) = (cache.as_ref(), cache_key.as_ref())
3355                    && let Some(slice) = cache.read::<mir_codebase::storage::StubSlice>(key)
3356                {
3357                    docs.mirror_text(&uri, &text);
3358                    docs.seed_cached_slice(&uri, Arc::new(slice));
3359                    count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3360                    return;
3361                }
3362
3363                // Cache miss: normal parse + mirror.
3364                let (doc, diags) = parse_document(&text);
3365                docs.index_from_doc(uri.clone(), &doc, diags);
3366                count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3367
3368                // K2b write path: force `file_definitions` and persist
3369                // the fresh slice so a subsequent startup hits the cache.
3370                // The work is unavoidable anyway — `get_codebase_salsa`
3371                // would call `file_definitions` lazily on first use — so
3372                // materializing it here trades a small up-front cost for
3373                // a large warm-start win next time. Best-effort: a write
3374                // error is logged via `.ok()` and doesn't fail the scan.
3375                if let (Some(cache), Some(key)) = (cache.as_ref(), cache_key.as_ref())
3376                    && let Some(slice) = docs.slice_for(&uri)
3377                {
3378                    let _ = cache.write(key, &*slice);
3379                }
3380            })
3381            .await
3382            .ok();
3383        });
3384    }
3385
3386    while set.join_next().await.is_some() {}
3387
3388    count.load(std::sync::atomic::Ordering::Relaxed)
3389}
3390
3391#[cfg(test)]
3392mod tests {
3393    use super::*;
3394    use crate::use_import::find_use_insert_line;
3395    use tower_lsp::lsp_types::{Position, Range, Url};
3396
3397    // DiagnosticsConfig::from_value tests
3398    #[test]
3399    fn diagnostics_config_default_is_enabled() {
3400        let cfg = DiagnosticsConfig::default();
3401        assert!(cfg.enabled);
3402        assert!(cfg.undefined_variables);
3403        assert!(cfg.undefined_functions);
3404        assert!(cfg.undefined_classes);
3405        assert!(cfg.arity_errors);
3406        assert!(cfg.type_errors);
3407        assert!(cfg.deprecated_calls);
3408        assert!(cfg.duplicate_declarations);
3409    }
3410
3411    #[test]
3412    fn diagnostics_config_from_empty_object_is_enabled() {
3413        let cfg = DiagnosticsConfig::from_value(&serde_json::json!({}));
3414        assert!(cfg.enabled);
3415        assert!(cfg.undefined_variables);
3416    }
3417
3418    #[test]
3419    fn diagnostics_config_from_non_object_uses_defaults() {
3420        let cfg = DiagnosticsConfig::from_value(&serde_json::json!(null));
3421        assert!(cfg.enabled);
3422    }
3423
3424    #[test]
3425    fn diagnostics_config_can_disable_individual_flags() {
3426        let cfg = DiagnosticsConfig::from_value(&serde_json::json!({
3427            "enabled": true,
3428            "undefinedVariables": false,
3429            "undefinedFunctions": false,
3430            "undefinedClasses": true,
3431            "arityErrors": false,
3432            "typeErrors": true,
3433            "deprecatedCalls": false,
3434            "duplicateDeclarations": true,
3435        }));
3436        assert!(cfg.enabled);
3437        assert!(!cfg.undefined_variables);
3438        assert!(!cfg.undefined_functions);
3439        assert!(cfg.undefined_classes);
3440        assert!(!cfg.arity_errors);
3441        assert!(cfg.type_errors);
3442        assert!(!cfg.deprecated_calls);
3443        assert!(cfg.duplicate_declarations);
3444    }
3445
3446    #[test]
3447    fn diagnostics_config_master_switch_disables_all() {
3448        let cfg = DiagnosticsConfig::from_value(&serde_json::json!({"enabled": false}));
3449        assert!(!cfg.enabled);
3450        // Other flags still have their default values
3451        assert!(cfg.undefined_variables);
3452    }
3453
3454    #[test]
3455    fn diagnostics_config_master_switch_enables_all() {
3456        let cfg = DiagnosticsConfig::from_value(&serde_json::json!({"enabled": true}));
3457        assert!(cfg.enabled);
3458        assert!(cfg.undefined_variables);
3459    }
3460
3461    // LspConfig::from_value tests
3462    #[test]
3463    fn lsp_config_default_is_empty() {
3464        let cfg = LspConfig::default();
3465        assert!(cfg.php_version.is_none());
3466        assert!(cfg.exclude_paths.is_empty());
3467        assert!(cfg.diagnostics.enabled);
3468    }
3469
3470    #[test]
3471    fn lsp_config_parses_php_version() {
3472        let cfg =
3473            LspConfig::from_value(&serde_json::json!({"phpVersion": crate::autoload::PHP_8_2}));
3474        assert_eq!(cfg.php_version.as_deref(), Some(crate::autoload::PHP_8_2));
3475    }
3476
3477    #[test]
3478    fn lsp_config_parses_exclude_paths() {
3479        let cfg = LspConfig::from_value(&serde_json::json!({
3480            "excludePaths": ["cache/*", "generated/*"]
3481        }));
3482        assert_eq!(cfg.exclude_paths, vec!["cache/*", "generated/*"]);
3483    }
3484
3485    #[test]
3486    fn lsp_config_parses_diagnostics_section() {
3487        let cfg = LspConfig::from_value(&serde_json::json!({
3488            "diagnostics": {"enabled": false}
3489        }));
3490        assert!(!cfg.diagnostics.enabled);
3491    }
3492
3493    #[test]
3494    fn lsp_config_ignores_missing_fields() {
3495        let cfg = LspConfig::from_value(&serde_json::json!({}));
3496        assert!(cfg.php_version.is_none());
3497        assert!(cfg.exclude_paths.is_empty());
3498    }
3499
3500    #[test]
3501    fn lsp_config_parses_max_indexed_files() {
3502        let cfg = LspConfig::from_value(&serde_json::json!({"maxIndexedFiles": 5000}));
3503        assert_eq!(cfg.max_indexed_files, 5000);
3504    }
3505
3506    #[test]
3507    fn lsp_config_default_max_indexed_files() {
3508        let cfg = LspConfig::default();
3509        assert_eq!(cfg.max_indexed_files, MAX_INDEXED_FILES);
3510    }
3511
3512    // FeaturesConfig tests
3513    #[test]
3514    fn features_config_default_all_enabled() {
3515        let cfg = FeaturesConfig::default();
3516        assert!(cfg.completion);
3517        assert!(cfg.hover);
3518        assert!(cfg.definition);
3519        assert!(cfg.declaration);
3520        assert!(cfg.references);
3521        assert!(cfg.document_symbols);
3522        assert!(cfg.workspace_symbols);
3523        assert!(cfg.rename);
3524        assert!(cfg.signature_help);
3525        assert!(cfg.inlay_hints);
3526        assert!(cfg.semantic_tokens);
3527        assert!(cfg.selection_range);
3528        assert!(cfg.call_hierarchy);
3529        assert!(cfg.document_highlight);
3530        assert!(cfg.implementation);
3531        assert!(cfg.code_action);
3532        assert!(cfg.type_definition);
3533        assert!(cfg.code_lens);
3534        assert!(cfg.formatting);
3535        assert!(cfg.range_formatting);
3536        assert!(cfg.on_type_formatting);
3537        assert!(cfg.document_link);
3538        assert!(cfg.linked_editing_range);
3539        assert!(cfg.inline_values);
3540    }
3541
3542    #[test]
3543    fn features_config_from_empty_object_all_enabled() {
3544        let cfg = FeaturesConfig::from_value(&serde_json::json!({}));
3545        assert!(cfg.completion);
3546        assert!(cfg.hover);
3547        assert!(cfg.call_hierarchy);
3548        assert!(cfg.inline_values);
3549    }
3550
3551    #[test]
3552    fn features_config_can_disable_individual_flags() {
3553        let cfg = FeaturesConfig::from_value(&serde_json::json!({
3554            "callHierarchy": false,
3555        }));
3556        assert!(!cfg.call_hierarchy);
3557        assert!(cfg.completion);
3558        assert!(cfg.hover);
3559        assert!(cfg.definition);
3560        assert!(cfg.inline_values);
3561    }
3562
3563    #[test]
3564    fn lsp_config_parses_features_section() {
3565        let cfg = LspConfig::from_value(&serde_json::json!({
3566            "features": {"callHierarchy": false}
3567        }));
3568        assert!(!cfg.features.call_hierarchy);
3569        assert!(cfg.features.completion);
3570        assert!(cfg.features.hover);
3571    }
3572
3573    // find_use_insert_line tests
3574    #[test]
3575    fn find_use_insert_line_after_php_open_tag() {
3576        let src = "<?php\nfunction foo() {}";
3577        assert_eq!(find_use_insert_line(src), 1);
3578    }
3579
3580    #[test]
3581    fn find_use_insert_line_after_existing_use() {
3582        let src = "<?php\nuse Foo\\Bar;\nuse Baz\\Qux;\nclass Impl {}";
3583        assert_eq!(find_use_insert_line(src), 3);
3584    }
3585
3586    #[test]
3587    fn find_use_insert_line_after_namespace() {
3588        let src = "<?php\nnamespace App\\Services;\nclass Service {}";
3589        assert_eq!(find_use_insert_line(src), 2);
3590    }
3591
3592    #[test]
3593    fn find_use_insert_line_after_namespace_and_use() {
3594        let src = "<?php\nnamespace App;\nuse Foo\\Bar;\nclass Impl {}";
3595        assert_eq!(find_use_insert_line(src), 3);
3596    }
3597
3598    #[test]
3599    fn find_use_insert_line_empty_file() {
3600        assert_eq!(find_use_insert_line(""), 0);
3601    }
3602
3603    // is_after_arrow tests
3604    #[test]
3605    fn is_after_arrow_with_method_call() {
3606        let src = "<?php\n$obj->method();\n";
3607        // Position after `->m` i.e. on `method` — character 6 (after `$obj->`)
3608        let pos = Position {
3609            line: 1,
3610            character: 6,
3611        };
3612        assert!(is_after_arrow(src, pos));
3613    }
3614
3615    #[test]
3616    fn is_after_arrow_without_arrow() {
3617        let src = "<?php\n$obj->method();\n";
3618        // Position on `$obj` — not after arrow
3619        let pos = Position {
3620            line: 1,
3621            character: 1,
3622        };
3623        assert!(!is_after_arrow(src, pos));
3624    }
3625
3626    #[test]
3627    fn is_after_arrow_on_standalone_identifier() {
3628        let src = "<?php\nfunction greet() {}\n";
3629        let pos = Position {
3630            line: 1,
3631            character: 10,
3632        };
3633        assert!(!is_after_arrow(src, pos));
3634    }
3635
3636    #[test]
3637    fn is_after_arrow_out_of_bounds_line() {
3638        let src = "<?php\n$x = 1;\n";
3639        let pos = Position {
3640            line: 99,
3641            character: 0,
3642        };
3643        assert!(!is_after_arrow(src, pos));
3644    }
3645
3646    #[test]
3647    fn is_after_arrow_at_start_of_property() {
3648        let src = "<?php\n$this->name;\n";
3649        // `name` starts at character 7 (after `$this->`)
3650        let pos = Position {
3651            line: 1,
3652            character: 7,
3653        };
3654        assert!(is_after_arrow(src, pos));
3655    }
3656
3657    // php_file_op tests
3658    #[test]
3659    fn php_file_op_matches_php_files() {
3660        let op = php_file_op();
3661        assert_eq!(op.filters.len(), 1);
3662        let filter = &op.filters[0];
3663        assert_eq!(filter.scheme.as_deref(), Some("file"));
3664        assert_eq!(filter.pattern.glob, "**/*.php");
3665    }
3666
3667    // defer_actions tests
3668    #[test]
3669    fn defer_actions_strips_edit_and_adds_data() {
3670        let uri = Url::parse("file:///test.php").unwrap();
3671        let range = Range {
3672            start: Position {
3673                line: 0,
3674                character: 0,
3675            },
3676            end: Position {
3677                line: 0,
3678                character: 5,
3679            },
3680        };
3681        let actions = vec![CodeActionOrCommand::CodeAction(CodeAction {
3682            title: "My Action".to_string(),
3683            kind: Some(CodeActionKind::REFACTOR),
3684            edit: Some(WorkspaceEdit::default()),
3685            data: None,
3686            ..Default::default()
3687        })];
3688        let deferred = defer_actions(actions, "test_kind", &uri, range);
3689        assert_eq!(deferred.len(), 1);
3690        if let CodeActionOrCommand::CodeAction(ca) = &deferred[0] {
3691            assert!(ca.edit.is_none(), "edit should be stripped");
3692            assert!(ca.data.is_some(), "data payload should be set");
3693            let data = ca.data.as_ref().unwrap();
3694            assert_eq!(data["php_lsp_resolve"], "test_kind");
3695            assert_eq!(data["uri"], uri.to_string());
3696        } else {
3697            panic!("expected CodeAction");
3698        }
3699    }
3700
3701    // build_use_import_edit tests
3702    #[test]
3703    fn build_use_import_edit_inserts_after_php_tag() {
3704        let src = "<?php\nclass Foo {}";
3705        let uri = Url::parse("file:///test.php").unwrap();
3706        let edit = build_use_import_edit(src, &uri, "App\\Services\\Bar");
3707        let changes = edit.changes.unwrap();
3708        let edits = changes.get(&uri).unwrap();
3709        assert_eq!(edits.len(), 1);
3710        assert_eq!(edits[0].new_text, "use App\\Services\\Bar;\n");
3711        assert_eq!(edits[0].range.start.line, 1);
3712    }
3713
3714    #[test]
3715    fn build_use_import_edit_inserts_after_existing_use() {
3716        let src = "<?php\nuse Foo\\Bar;\nclass Impl {}";
3717        let uri = Url::parse("file:///test.php").unwrap();
3718        let edit = build_use_import_edit(src, &uri, "Baz\\Qux");
3719        let changes = edit.changes.unwrap();
3720        let edits = changes.get(&uri).unwrap();
3721        assert_eq!(edits[0].range.start.line, 2);
3722        assert_eq!(edits[0].new_text, "use Baz\\Qux;\n");
3723    }
3724
3725    // Extraction logic for "Add use import" code action — matches IssueKind::UndefinedClass message format
3726    #[test]
3727    fn undefined_class_name_extracted_from_message() {
3728        let msg = "Class MyService does not exist";
3729        let name = msg
3730            .strip_prefix("Class ")
3731            .and_then(|s| s.strip_suffix(" does not exist"))
3732            .unwrap_or("")
3733            .trim();
3734        assert_eq!(name, "MyService");
3735    }
3736
3737    #[test]
3738    fn undefined_function_message_not_matched_by_extraction() {
3739        // UndefinedFunction message format must NOT match the UndefinedClass extraction,
3740        // ensuring code action is not offered for undefined functions.
3741        let msg = "Function myHelper() is not defined";
3742        let name = msg
3743            .strip_prefix("Class ")
3744            .and_then(|s| s.strip_suffix(" does not exist"))
3745            .unwrap_or("")
3746            .trim();
3747        assert!(
3748            name.is_empty(),
3749            "function diagnostic should not extract a class name"
3750        );
3751    }
3752
3753    // ── position_to_offset ───────────────────────────────────────────────────
3754
3755    #[test]
3756    fn position_to_offset_first_line() {
3757        let src = "<?php\nfoo();";
3758        // Character 0 → byte 0.
3759        assert_eq!(
3760            position_to_offset(
3761                src,
3762                Position {
3763                    line: 0,
3764                    character: 0
3765                }
3766            ),
3767            Some(0)
3768        );
3769        // Character 4 → byte 4 (last char 'p' of "<?php").
3770        assert_eq!(
3771            position_to_offset(
3772                src,
3773                Position {
3774                    line: 0,
3775                    character: 4
3776                }
3777            ),
3778            Some(4)
3779        );
3780        // Character 5 is past the end of "<?php" (5 chars) — clamps to line_content.len().
3781        assert_eq!(
3782            position_to_offset(
3783                src,
3784                Position {
3785                    line: 0,
3786                    character: 5
3787                }
3788            ),
3789            Some(5)
3790        );
3791    }
3792
3793    #[test]
3794    fn position_to_offset_second_line() {
3795        let src = "<?php\nfoo();";
3796        // Start of line 1 is byte 6 (after "<?php\n").
3797        assert_eq!(
3798            position_to_offset(
3799                src,
3800                Position {
3801                    line: 1,
3802                    character: 0
3803                }
3804            ),
3805            Some(6)
3806        );
3807        // "foo" ends at character 3 → byte 9.
3808        assert_eq!(
3809            position_to_offset(
3810                src,
3811                Position {
3812                    line: 1,
3813                    character: 3
3814                }
3815            ),
3816            Some(9)
3817        );
3818    }
3819
3820    #[test]
3821    fn position_to_offset_line_boundary_returns_none() {
3822        // A source with exactly one line has only line 0; line 1 must return None.
3823        let src = "<?php";
3824        assert_eq!(
3825            position_to_offset(
3826                src,
3827                Position {
3828                    line: 1,
3829                    character: 0
3830                }
3831            ),
3832            None
3833        );
3834        assert_eq!(
3835            position_to_offset(
3836                src,
3837                Position {
3838                    line: 5,
3839                    character: 0
3840                }
3841            ),
3842            None
3843        );
3844    }
3845
3846    // ── cursor_is_on_method_decl ─────────────────────────────────────────────
3847
3848    #[test]
3849    fn cursor_on_method_decl_name_returns_true() {
3850        // "    public function add() {}" — "add" is cols 20-22 on line 2.
3851        // Use doc.source() so str_offset uses pointer arithmetic (production path).
3852        let doc = ParsedDoc::parse("<?php\nclass C {\n    public function add() {}\n}".to_string());
3853        let source = doc.source();
3854        let stmts = &doc.program().stmts;
3855        // All three characters of "add" must match.
3856        for col in 20u32..=22 {
3857            assert!(
3858                cursor_is_on_method_decl(
3859                    source,
3860                    stmts,
3861                    Position {
3862                        line: 2,
3863                        character: col
3864                    }
3865                ),
3866                "expected true at col {col}"
3867            );
3868        }
3869        // One before and one after must not match.
3870        assert!(!cursor_is_on_method_decl(
3871            source,
3872            stmts,
3873            Position {
3874                line: 2,
3875                character: 19
3876            }
3877        ));
3878        assert!(!cursor_is_on_method_decl(
3879            source,
3880            stmts,
3881            Position {
3882                line: 2,
3883                character: 23
3884            }
3885        ));
3886    }
3887
3888    #[test]
3889    fn cursor_on_free_function_decl_returns_false() {
3890        // "add" at col 9 on line 1 is a free function — not a method.
3891        let doc = ParsedDoc::parse("<?php\nfunction add() {}".to_string());
3892        let source = doc.source();
3893        let stmts = &doc.program().stmts;
3894        assert!(!cursor_is_on_method_decl(
3895            source,
3896            stmts,
3897            Position {
3898                line: 1,
3899                character: 9
3900            }
3901        ));
3902    }
3903
3904    #[test]
3905    fn cursor_on_method_call_site_returns_false() {
3906        // "$c->add()" — "add" at col 4 on line 3 is a call site, not a declaration.
3907        let doc = ParsedDoc::parse(
3908            "<?php\nclass C { public function add() {} }\n$c = new C();\n$c->add();".to_string(),
3909        );
3910        let source = doc.source();
3911        let stmts = &doc.program().stmts;
3912        assert!(!cursor_is_on_method_decl(
3913            source,
3914            stmts,
3915            Position {
3916                line: 3,
3917                character: 4
3918            }
3919        ));
3920    }
3921
3922    #[test]
3923    fn cursor_on_interface_method_decl_returns_true() {
3924        // "    public function add(): void;" — "add" starts at col 20 on line 2.
3925        let doc = ParsedDoc::parse(
3926            "<?php\ninterface I {\n    public function add(): void;\n}".to_string(),
3927        );
3928        let source = doc.source();
3929        let stmts = &doc.program().stmts;
3930        assert!(cursor_is_on_method_decl(
3931            source,
3932            stmts,
3933            Position {
3934                line: 2,
3935                character: 20
3936            }
3937        ));
3938    }
3939
3940    #[test]
3941    fn cursor_on_trait_method_decl_returns_true() {
3942        // "    public function add() {}" — "add" starts at col 20 on line 2.
3943        let doc = ParsedDoc::parse("<?php\ntrait T {\n    public function add() {}\n}".to_string());
3944        let source = doc.source();
3945        let stmts = &doc.program().stmts;
3946        assert!(cursor_is_on_method_decl(
3947            source,
3948            stmts,
3949            Position {
3950                line: 2,
3951                character: 20
3952            }
3953        ));
3954    }
3955
3956    #[test]
3957    fn cursor_on_enum_method_decl_returns_true() {
3958        // "    public function label(): string {}" — "label" starts at col 20 on line 2.
3959        let doc = ParsedDoc::parse(
3960            "<?php\nenum Status {\n    public function label(): string { return 'x'; }\n}"
3961                .to_string(),
3962        );
3963        let source = doc.source();
3964        let stmts = &doc.program().stmts;
3965        assert!(cursor_is_on_method_decl(
3966            source,
3967            stmts,
3968            Position {
3969                line: 2,
3970                character: 20
3971            }
3972        ));
3973    }
3974
3975    #[test]
3976    fn cursor_on_method_decl_in_unbraced_namespace_returns_true() {
3977        // Unbraced (Simple) namespace: the class is a top-level sibling of the
3978        // namespace statement, not nested inside it.
3979        //
3980        // Line 0: <?php
3981        // Line 1: namespace App;
3982        // Line 2: class C {
3983        // Line 3:     public function add() {}   ← "add" starts at col 20
3984        // Line 4: }
3985        let doc = ParsedDoc::parse(
3986            "<?php\nnamespace App;\nclass C {\n    public function add() {}\n}".to_string(),
3987        );
3988        let source = doc.source();
3989        let stmts = &doc.program().stmts;
3990        assert!(
3991            cursor_is_on_method_decl(
3992                source,
3993                stmts,
3994                Position {
3995                    line: 3,
3996                    character: 20
3997                }
3998            ),
3999            "method in unbraced namespace must be detected"
4000        );
4001    }
4002
4003    #[test]
4004    fn cursor_on_method_decl_in_braced_namespace_returns_true() {
4005        // Braced namespace: the class is nested inside NamespaceBody::Braced.
4006        //
4007        // Line 0: <?php
4008        // Line 1: namespace App {
4009        // Line 2:     class C {
4010        // Line 3:         public function add() {}   ← "add" starts at col 24
4011        // Line 4:     }
4012        // Line 5: }
4013        let doc = ParsedDoc::parse(
4014            "<?php\nnamespace App {\n    class C {\n        public function add() {}\n    }\n}"
4015                .to_string(),
4016        );
4017        let source = doc.source();
4018        let stmts = &doc.program().stmts;
4019        assert!(
4020            cursor_is_on_method_decl(
4021                source,
4022                stmts,
4023                Position {
4024                    line: 3,
4025                    character: 24
4026                }
4027            ),
4028            "method in braced namespace must be detected"
4029        );
4030    }
4031
4032    // --- LspConfig::merge_project_configs ---
4033
4034    #[test]
4035    fn merge_file_only_uses_file_values() {
4036        let file = serde_json::json!({
4037            "phpVersion": "8.1",
4038            "excludePaths": ["vendor/*"],
4039            "maxIndexedFiles": 500,
4040        });
4041        let merged = LspConfig::merge_project_configs(Some(&file), None);
4042        let cfg = LspConfig::from_value(&merged);
4043        assert_eq!(cfg.php_version, Some("8.1".to_string()));
4044        assert_eq!(cfg.exclude_paths, vec!["vendor/*"]);
4045        assert_eq!(cfg.max_indexed_files, 500);
4046    }
4047
4048    #[test]
4049    fn merge_editor_wins_per_key_over_file() {
4050        let file = serde_json::json!({"phpVersion": "8.1", "maxIndexedFiles": 100});
4051        let editor = serde_json::json!({"phpVersion": "8.3", "maxIndexedFiles": 200});
4052        let merged = LspConfig::merge_project_configs(Some(&file), Some(&editor));
4053        let cfg = LspConfig::from_value(&merged);
4054        assert_eq!(cfg.php_version, Some("8.3".to_string()));
4055        assert_eq!(cfg.max_indexed_files, 200);
4056    }
4057
4058    #[test]
4059    fn merge_exclude_paths_concat_not_replace() {
4060        let file = serde_json::json!({"excludePaths": ["cache/*"]});
4061        let editor = serde_json::json!({"excludePaths": ["logs/*"]});
4062        let merged = LspConfig::merge_project_configs(Some(&file), Some(&editor));
4063        let cfg = LspConfig::from_value(&merged);
4064        // File entries come first, editor entries appended.
4065        assert_eq!(cfg.exclude_paths, vec!["cache/*", "logs/*"]);
4066    }
4067
4068    #[test]
4069    fn merge_no_file_uses_editor_only() {
4070        let editor = serde_json::json!({"phpVersion": "8.2", "excludePaths": ["tmp/*"]});
4071        let merged = LspConfig::merge_project_configs(None, Some(&editor));
4072        let cfg = LspConfig::from_value(&merged);
4073        assert_eq!(cfg.php_version, Some("8.2".to_string()));
4074        assert_eq!(cfg.exclude_paths, vec!["tmp/*"]);
4075    }
4076
4077    #[test]
4078    fn merge_both_none_returns_defaults() {
4079        let merged = LspConfig::merge_project_configs(None, None);
4080        let cfg = LspConfig::from_value(&merged);
4081        assert!(cfg.php_version.is_none());
4082        assert!(cfg.exclude_paths.is_empty());
4083        assert_eq!(cfg.max_indexed_files, MAX_INDEXED_FILES);
4084    }
4085
4086    #[test]
4087    fn merge_file_editor_both_have_exclude_paths_all_present() {
4088        let file = serde_json::json!({"excludePaths": ["a/*", "b/*"]});
4089        let editor = serde_json::json!({"excludePaths": ["c/*"]});
4090        let merged = LspConfig::merge_project_configs(Some(&file), Some(&editor));
4091        let cfg = LspConfig::from_value(&merged);
4092        assert_eq!(cfg.exclude_paths, vec!["a/*", "b/*", "c/*"]);
4093    }
4094}