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