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