Skip to main content

php_lsp/backend/
mod.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3
4#[allow(unused_imports)]
5use self::helpers::*;
6
7use arc_swap::ArcSwap;
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::Client;
17use tower_lsp::lsp_types::*;
18
19use crate::ast::ParsedDoc;
20use crate::autoload::Psr4Map;
21use crate::config::LspConfig;
22use crate::document_store::DocumentStore;
23use crate::open_files::OpenFiles;
24use crate::phpstorm_meta::PhpStormMeta;
25use crate::util::fqn_short_name;
26
27use crate::navigation::references::find_constructor_references;
28
29use crate::analysis::diagnostics::merge_file_diagnostics;
30
31pub struct Backend {
32    client: Client,
33    docs: Arc<DocumentStore>,
34    /// Open-file state: text, version token, parse diagnostics.
35    /// Files that are only background-indexed (never opened in the editor)
36    /// do not appear here; they live only in `DocumentStore`'s salsa layer.
37    open_files: OpenFiles,
38    root_paths: Arc<ArcSwap<Vec<PathBuf>>>,
39    psr4: Arc<ArcSwap<Psr4Map>>,
40    meta: Arc<ArcSwap<PhpStormMeta>>,
41    config: Arc<ArcSwap<LspConfig>>,
42}
43
44impl Backend {
45    pub fn new(client: Client) -> Self {
46        // No imperative Codebase field anymore — `self.codebase()` below
47        // delegates to the salsa-memoized `codebase` query, which composes
48        // bundled stubs + every file's StubSlice and returns a fresh
49        // `Arc<Codebase>` (or the memoized one when inputs are unchanged).
50        let docs = Arc::new(DocumentStore::new());
51        let psr4 = docs.psr4_arc();
52        Backend {
53            client,
54            docs,
55            open_files: OpenFiles::new(),
56            root_paths: Arc::new(ArcSwap::from_pointee(Vec::new())),
57            psr4,
58            meta: Arc::new(ArcSwap::from_pointee(PhpStormMeta::default())),
59            config: Arc::new(ArcSwap::from_pointee(LspConfig::default())),
60        }
61    }
62
63    // ── Open-file state convenience wrappers (Phase E4) ──────────────────────
64
65    fn set_open_text(&self, uri: Url, text: String) -> u64 {
66        self.open_files.set_open_text(&self.docs, uri, text)
67    }
68
69    fn close_open_file(&self, uri: &Url) {
70        self.open_files.close(&self.docs, uri);
71    }
72
73    /// Background-index a file from disk, but only if it isn't currently
74    /// open in the editor — the editor's buffer is authoritative while a
75    /// file is open, and we must not overwrite it with disk contents.
76    fn index_if_not_open(&self, uri: Url, text: &str) {
77        if !self.open_files.contains(&uri) {
78            self.docs.index(uri, text);
79        }
80    }
81
82    /// Variant of [`index_if_not_open`] that reuses an already-parsed doc.
83    fn index_from_doc_if_not_open(&self, uri: Url, doc: &ParsedDoc) {
84        if !self.open_files.contains(&uri) {
85            self.docs.index_from_doc(uri, doc);
86        }
87    }
88
89    fn get_open_text(&self, uri: &Url) -> Option<String> {
90        self.open_files.text(uri)
91    }
92
93    fn set_parse_diagnostics(&self, uri: &Url, diagnostics: Vec<Diagnostic>) {
94        self.open_files.set_parse_diagnostics(uri, diagnostics);
95    }
96
97    fn get_parse_diagnostics(&self, uri: &Url) -> Option<Vec<Diagnostic>> {
98        self.open_files.parse_diagnostics(uri)
99    }
100
101    fn all_open_files_with_diagnostics(&self) -> Vec<(Url, Vec<Diagnostic>, Option<i64>)> {
102        self.open_files.all_with_diagnostics()
103    }
104
105    fn open_urls(&self) -> Vec<Url> {
106        self.open_files.urls()
107    }
108
109    fn get_doc(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
110        self.open_files.get_doc(&self.docs, uri)
111    }
112
113    /// Current MirDb snapshot for the workspace, owned by the
114    /// `AnalysisSession`. Cheap clone (Arc-wrapped internals).
115    fn codebase(&self) -> mir_analyzer::db::MirDbStorage {
116        let php_version = self.docs.workspace_php_version();
117        let session = self.docs.analysis_session(php_version);
118        session.snapshot_db()
119    }
120
121    /// `use Foo as Bar;` map for a single file, read directly from the AST.
122    fn file_imports(&self, uri: &Url) -> std::collections::HashMap<String, String> {
123        self.docs
124            .get_doc_salsa(uri)
125            .map(|doc| crate::references::collect_file_imports(&doc))
126            .unwrap_or_default()
127    }
128
129    /// Reference call sites for a class's `__construct`.
130    ///
131    /// The constructor's call sites are `new OwningClass(...)`, not
132    /// `->__construct()`, so name-only matching would return every class's
133    /// constructor declaration. We search for `new` expressions only, scoped to
134    /// the owning class.
135    ///
136    /// `class_name` is the FQN when the constructor is inside a namespace
137    /// (e.g. `"Shop\\Order"`). The AST walker searches for the *short* name
138    /// (`"Order"`) since that's what appears at call sites, while the FQN is
139    /// used only to scope the search and prevent collisions between two classes
140    /// with the same short name in different namespaces.
141    fn construct_references(
142        &self,
143        uri: &Url,
144        source: &str,
145        position: Position,
146        class_name: &str,
147        include_declaration: bool,
148    ) -> Vec<Location> {
149        let all_docs = self.docs.all_docs_for_scan();
150        let short_name = fqn_short_name(class_name).to_owned();
151        let class_fqn = class_name.contains('\\').then_some(class_name);
152        // `find_constructor_references` walks `new` expressions directly —
153        // bypasses the codebase/salsa index whose `ClassReference` key is too
154        // broad (covers type hints, `instanceof`, `extends`, `implements`).
155        let mut locations = find_constructor_references(&short_name, &all_docs, class_fqn);
156        // The cursor is already on the `__construct` name, so derive the span
157        // from the identifier under the cursor rather than re-searching via
158        // str_offset (which finds the first occurrence in the file and would
159        // point at the wrong constructor in files with more than one class).
160        if include_declaration && let Some(range) = crate::util::word_range_at(source, position) {
161            locations.push(Location {
162                uri: uri.clone(),
163                range,
164            });
165        }
166        locations
167    }
168
169    /// Resolve the FQN of the symbol at the cursor so reference lookups can match
170    /// by exact FQN instead of short name (fixes cross-namespace overmatch for
171    /// Function/Class and unrelated-class overmatch for Method via the owning
172    /// FQCN). Returns `None` when the kind doesn't carry an FQN or it can't be
173    /// resolved. For class constants, returns the owning class short name.
174    fn resolve_reference_target_fqn(
175        &self,
176        uri: &Url,
177        doc_opt: Option<&Arc<ParsedDoc>>,
178        word: &str,
179        kind: Option<crate::navigation::references::SymbolKind>,
180        position: Position,
181        constant_owner: Option<String>,
182    ) -> Option<String> {
183        use crate::navigation::references::SymbolKind;
184        let doc = doc_opt?;
185        let imports = self.file_imports(uri);
186        match kind {
187            Some(SymbolKind::Function) | Some(SymbolKind::Class) => {
188                let resolved = crate::navigation::moniker::resolve_fqn(doc, word, &imports);
189                resolved.contains('\\').then_some(resolved)
190            }
191            Some(SymbolKind::Method) => {
192                // Owning FQCN: the class/interface/trait/enum that contains the cursor.
193                let short_owner = crate::type_map::enclosing_class_at(doc.source(), doc, position)?;
194                // `resolve_fqn` walks the doc and applies the namespace prefix if any.
195                Some(crate::navigation::moniker::resolve_fqn(
196                    doc,
197                    &short_owner,
198                    &imports,
199                ))
200            }
201            Some(SymbolKind::Property) => {
202                // Only resolve the owning class when the cursor is on a property
203                // declaration — for access sites (`$obj->prop`) enclosing_class_at
204                // returns the accessing class, not the declaring class, so the session
205                // would be queried with the wrong key. Access sites fall back to the
206                // AST walker which finds all `->prop` occurrences.
207                let stmts = &doc.program().stmts;
208                crate::backend::helpers::cursor_is_on_property_decl(doc.source(), stmts, position)?;
209                let short_owner = crate::type_map::enclosing_class_at(doc.source(), doc, position)?;
210                Some(crate::navigation::moniker::resolve_fqn(
211                    doc,
212                    &short_owner,
213                    &imports,
214                ))
215            }
216            Some(SymbolKind::Constant) => {
217                if constant_owner.is_some() {
218                    // Class constant: the owning class short name as-is.
219                    constant_owner
220                } else {
221                    // Global/namespace constant: compute FQN so cross-namespace
222                    // references like `\Config\DB_HOST` can be found.
223                    let fqn = crate::navigation::moniker::resolve_fqn(doc, word, &imports);
224                    fqn.contains('\\').then_some(fqn)
225                }
226            }
227            _ => None,
228        }
229    }
230
231    /// Type-aware method call sites from the mir session.
232    ///
233    /// Method refs need type-aware filtering: `$mailer->process()` and
234    /// `$queue->process()` share a name, but only the one whose receiver type
235    /// matches the cursor's owning class is a real ref. Mir's `references_to` is
236    /// type-aware; use it as the primary source for Method+`target_fqn`.
237    ///
238    /// Returns `None` when the kind isn't `Method` or no mir symbol can be built;
239    /// otherwise the (possibly empty) call-site set, filtered to files that
240    /// actually mention `owner_short` (drops untyped/Mixed receivers — any file
241    /// where a receiver is legitimately typed as the owner must reference it by
242    /// name somewhere via import, `new`, or type hint).
243    fn session_method_references(
244        &self,
245        word: &str,
246        kind: Option<crate::navigation::references::SymbolKind>,
247        target_fqn: Option<&str>,
248        owner_short: Option<&str>,
249    ) -> Option<Vec<Location>> {
250        if !matches!(
251            kind,
252            Some(crate::navigation::references::SymbolKind::Method)
253        ) {
254            return None;
255        }
256        let sym = build_mir_symbol(word, kind, target_fqn)?;
257        let locs = self
258            .docs
259            .session_references_to(&sym)
260            .into_iter()
261            .filter_map(|tuple| {
262                let loc = crate::references::session_tuple_to_location(tuple)?;
263                if let Some(short) = owner_short {
264                    let mentions = self
265                        .docs
266                        .source_text(&loc.uri)
267                        .as_ref()
268                        .map(|src| src.contains(short))
269                        .unwrap_or(true);
270                    if !mentions {
271                        return None;
272                    }
273                }
274                Some(loc)
275            })
276            .collect();
277        Some(locs)
278    }
279
280    /// Type-aware property access sites from the mir session.
281    ///
282    /// Property refs need type-aware filtering: `$mailer->status` and
283    /// `$order->status` share a name but belong to different classes. Mir keys
284    /// property references on the declaring class (since v0.38.0), so
285    /// `references_to(Name::Property { class: fqcn, name })` returns only
286    /// accesses whose receiver type resolved to the correct owner.
287    ///
288    /// Returns `None` when the kind isn't `Property` or no mir symbol can be
289    /// built (i.e. the cursor is on an access site where the owning class is
290    /// unknown rather than on a declaration).
291    fn session_property_references(
292        &self,
293        word: &str,
294        kind: Option<crate::navigation::references::SymbolKind>,
295        target_fqn: Option<&str>,
296    ) -> Option<Vec<Location>> {
297        if !matches!(
298            kind,
299            Some(crate::navigation::references::SymbolKind::Property)
300        ) {
301            return None;
302        }
303        let sym = build_mir_symbol(word, kind, target_fqn)?;
304        let locs = self
305            .docs
306            .session_references_to(&sym)
307            .into_iter()
308            .filter_map(crate::references::session_tuple_to_location)
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),
343/// - the required FQN piece isn't available.
344fn build_mir_symbol(
345    word: &str,
346    kind: Option<crate::navigation::references::SymbolKind>,
347    target_fqn: Option<&str>,
348) -> Option<mir_analyzer::Name> {
349    use crate::navigation::references::SymbolKind;
350    use std::sync::Arc as StdArc;
351    match kind {
352        Some(SymbolKind::Function) => {
353            target_fqn.map(|fqn| mir_analyzer::Name::Function(StdArc::from(fqn)))
354        }
355        Some(SymbolKind::Class) => {
356            target_fqn.map(|fqn| mir_analyzer::Name::Class(StdArc::from(fqn)))
357        }
358        Some(SymbolKind::Method) => target_fqn.map(|owning| mir_analyzer::Name::Method {
359            class: StdArc::from(owning),
360            // PHP method dispatch is case-insensitive — Symbol::method
361            // normalizes the name. The constructor function does this for us.
362            name: StdArc::from(word.to_ascii_lowercase()),
363        }),
364        Some(SymbolKind::Property) => target_fqn.map(|owning| mir_analyzer::Name::Property {
365            class: StdArc::from(owning),
366            name: StdArc::from(word),
367        }),
368        Some(SymbolKind::Constant) | None => None,
369    }
370}
371
372/// Refine the cursor's `(word, kind)` for a references request using
373/// declaration-aware heuristics, returning the (possibly rewritten) word, its
374/// symbol kind, and — for class constants — the owning class short name.
375///
376/// Checks, in order: promoted constructor property params (so `$name` in
377/// `__construct(public string $name)` resolves to the `->name` property, not
378/// `$name` variable occurrences), then method / property / constant
379/// declarations, falling back to the character-based `symbol_kind_at` heuristic.
380fn resolve_reference_symbol(
381    doc_opt: Option<&Arc<ParsedDoc>>,
382    source: &str,
383    position: Position,
384    word: String,
385) -> (
386    String,
387    Option<crate::navigation::references::SymbolKind>,
388    Option<String>,
389) {
390    use crate::navigation::references::SymbolKind;
391    let mut constant_owner: Option<String> = None;
392    let (word, kind) = if let Some(doc) = doc_opt
393        && let Some(prop_name) =
394            promoted_property_at_cursor(doc.source(), &doc.program().stmts, position)
395    {
396        (prop_name, Some(SymbolKind::Property))
397    } else if let Some(doc) = doc_opt {
398        let stmts = &doc.program().stmts;
399        if cursor_is_on_method_decl(doc.source(), stmts, position) {
400            (word, Some(SymbolKind::Method))
401        } else if let Some(prop_name) = cursor_is_on_property_decl(doc.source(), stmts, position) {
402            (prop_name, Some(SymbolKind::Property))
403        } else if let Some((const_name, owner)) =
404            cursor_is_on_constant_decl(doc.source(), stmts, position)
405        {
406            constant_owner = owner;
407            (const_name, Some(SymbolKind::Constant))
408        } else {
409            let k = symbol_kind_at(source, position, &word);
410            (word, k)
411        }
412    } else {
413        let k = symbol_kind_at(source, position, &word);
414        (word, k)
415    };
416    (word, kind, constant_owner)
417}
418
419/// Off-`self` variant of `Backend::compute_dependent_publishes`. Needed
420/// because did_change's blocking republish runs inside a detached
421/// `tokio::spawn` that captures `Arc<Backend>` indirectly via clones of
422/// `docs` / `open_files` rather than `&self`.
423async fn compute_dependent_publishes_owned(
424    docs: Arc<DocumentStore>,
425    open_files: OpenFiles,
426    changed_uri: Url,
427    diag_cfg: crate::config::DiagnosticsConfig,
428) -> Vec<(Url, Vec<Diagnostic>)> {
429    tokio::task::spawn_blocking(move || {
430        // Ask mir which files actually depend on `changed_uri` and let it
431        // re-run Pass 2 for them in parallel. mir 0.25's dependency graph
432        // covers every reference kind that can produce a cross-file
433        // diagnostic (imports, class hierarchy, type hints, instanceof,
434        // catch, ::class, ::CONST, `new`, static and instance calls) and
435        // tracks symbols-deleted-from-a-file so renames / deletions still
436        // surface the orphaned dependents.
437        let php_version = docs.workspace_php_version();
438        let session = docs.analysis_session(php_version);
439        let analyses = session.reanalyze_dependents(changed_uri.as_str());
440        if analyses.is_empty() {
441            return Vec::new();
442        }
443
444        // We only publish for files the editor has open. Filter the
445        // session-wide dependent set down to open URLs.
446        let open_urls: std::collections::HashSet<Url> = open_files
447            .urls()
448            .into_iter()
449            .filter(|u| u != &changed_uri)
450            .collect();
451        let dependents: Vec<(Url, mir_analyzer::FileAnalysis)> = analyses
452            .into_iter()
453            .filter_map(|(file, analysis)| {
454                let url = Url::parse(file.as_ref()).ok()?;
455                open_urls.contains(&url).then_some((url, analysis))
456            })
457            .collect();
458        if dependents.is_empty() {
459            return Vec::new();
460        }
461
462        // Workspace-level class issues (circular inheritance, override
463        // violations, abstract-method gaps) aren't in `FileAnalysis` —
464        // pull them in one batched call covering every affected file.
465        let dep_files: Vec<Arc<str>> = dependents
466            .iter()
467            .map(|(u, _)| Arc::from(u.as_str()))
468            .collect();
469        let class_issues = session.class_issues(&dep_files);
470        let mut class_issues_by_file: std::collections::HashMap<Arc<str>, Vec<mir_issues::Issue>> =
471            std::collections::HashMap::new();
472        for issue in class_issues {
473            if issue.suppressed {
474                continue;
475            }
476            let file = issue.location.file.clone();
477            class_issues_by_file.entry(file).or_default().push(issue);
478        }
479
480        let mut out: Vec<(Url, Vec<Diagnostic>)> = Vec::with_capacity(dependents.len());
481        for (url, analysis) in dependents {
482            let parse = open_files.parse_diagnostics(&url).unwrap_or_default();
483            let mut issues: Vec<mir_issues::Issue> = analysis
484                .issues
485                .into_iter()
486                .filter(|i| !i.suppressed)
487                .collect();
488            if let Some(extra) = class_issues_by_file.remove(&Arc::<str>::from(url.as_str())) {
489                issues.extend(extra);
490            }
491            let semantic =
492                crate::semantic_diagnostics::issues_to_diagnostics(&issues, &url, &diag_cfg);
493            out.push((url, merge_file_diagnostics(parse, semantic)));
494        }
495        out
496    })
497    .await
498    .unwrap_or_default()
499}
500
501/// Generate a stable result_id for diagnostics. Uses the count and position of diagnostics
502/// to create a stable identifier. Same diagnostics = same result_id.
503fn compute_diagnostic_result_id(diagnostics: &[Diagnostic], uri: &str) -> String {
504    use std::collections::hash_map::DefaultHasher;
505    use std::hash::{Hash, Hasher};
506
507    let mut hasher = DefaultHasher::new();
508    uri.hash(&mut hasher);
509    diagnostics.len().hash(&mut hasher);
510
511    for diag in diagnostics {
512        diag.range.start.line.hash(&mut hasher);
513        diag.range.start.character.hash(&mut hasher);
514        diag.range.end.line.hash(&mut hasher);
515        diag.range.end.character.hash(&mut hasher);
516        diag.message.hash(&mut hasher);
517        let severity_val = match diag.severity {
518            Some(tower_lsp::lsp_types::DiagnosticSeverity::ERROR) => 1,
519            Some(tower_lsp::lsp_types::DiagnosticSeverity::WARNING) => 2,
520            Some(tower_lsp::lsp_types::DiagnosticSeverity::INFORMATION) => 3,
521            Some(tower_lsp::lsp_types::DiagnosticSeverity::HINT) => 4,
522            None => 0,
523            _ => 5, // Unknown variants
524        };
525        severity_val.hash(&mut hasher);
526        if let Some(code) = &diag.code {
527            format!("{:?}", code).hash(&mut hasher);
528        }
529        if let Some(source) = &diag.source {
530            source.hash(&mut hasher);
531        }
532        if let Some(tags) = &diag.tags {
533            for tag in tags {
534                let tag_val = match *tag {
535                    tower_lsp::lsp_types::DiagnosticTag::UNNECESSARY => 1,
536                    tower_lsp::lsp_types::DiagnosticTag::DEPRECATED => 2,
537                    _ => 3,
538                };
539                tag_val.hash(&mut hasher);
540            }
541        }
542    }
543
544    format!("v1:{:x}", hasher.finish())
545}
546
547mod helpers;
548mod server;
549#[cfg(test)]
550mod tests;