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