Skip to main content

php_lsp/
backend.rs

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