Skip to main content

php_lsp/
backend.rs

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