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
19/// Response for the `$/php-lsp/debugStats` custom request.
20#[derive(Debug, serde::Serialize, serde::Deserialize)]
21pub struct DebugStats {
22    /// Cumulative count of real `ParsedDoc` parses (cache misses).
23    pub parses: u64,
24}
25
26use crate::document::ast::ParsedDoc;
27use crate::document::document_store::DocumentStore;
28use crate::document::open_files::OpenFiles;
29use crate::lang::autoload::Psr4Map;
30use crate::lang::config::LspConfig;
31use crate::lang::phpstorm_meta::PhpStormMeta;
32use crate::text::fqn_short_name;
33
34use crate::navigation::references::find_constructor_references;
35
36use crate::analysis::diagnostics::merge_file_diagnostics;
37use crate::document::open_files::compute_open_file_diagnostics;
38
39pub struct Backend {
40    client: Client,
41    docs: Arc<DocumentStore>,
42    /// Open-file state: text, version token, parse diagnostics.
43    /// Files that are only background-indexed (never opened in the editor)
44    /// do not appear here; they live only in `DocumentStore`'s salsa layer.
45    open_files: OpenFiles,
46    root_paths: Arc<ArcSwap<Vec<PathBuf>>>,
47    psr4: Arc<ArcSwap<Psr4Map>>,
48    meta: Arc<ArcSwap<PhpStormMeta>>,
49    config: Arc<ArcSwap<LspConfig>>,
50}
51
52impl Backend {
53    pub fn new(client: Client) -> Self {
54        // No imperative Codebase field anymore — analysis reads the
55        // salsa-memoized `codebase` query, which composes bundled stubs + every
56        // file's StubSlice and returns a fresh `Arc<Codebase>` (or the memoized
57        // one when inputs are unchanged).
58        let docs = Arc::new(DocumentStore::new());
59        let psr4 = docs.psr4_arc();
60        Backend {
61            client,
62            docs,
63            open_files: OpenFiles::new(),
64            root_paths: Arc::new(ArcSwap::from_pointee(Vec::new())),
65            psr4,
66            meta: Arc::new(ArcSwap::from_pointee(PhpStormMeta::default())),
67            config: Arc::new(ArcSwap::from_pointee(LspConfig::default())),
68        }
69    }
70
71    /// `$/php-lsp/debugStats` — internal observability counters. Currently just
72    /// the cumulative real-parse count, used by the references stress tests to
73    /// assert the read path doesn't parse the whole workspace.
74    pub async fn debug_stats(&self) -> tower_lsp::jsonrpc::Result<DebugStats> {
75        Ok(DebugStats {
76            parses: self.docs.parse_count(),
77        })
78    }
79
80    fn set_open_text(&self, uri: Url, text: String) -> u64 {
81        self.open_files.set_open_text(&self.docs, uri, text)
82    }
83
84    fn close_open_file(&self, uri: &Url) {
85        self.open_files.close(&self.docs, uri);
86    }
87
88    /// Background-index a file from disk, but only if it isn't currently
89    /// open in the editor — the editor's buffer is authoritative while a
90    /// file is open, and we must not overwrite it with disk contents.
91    fn ingest_if_not_open(&self, uri: Url, text: &str) {
92        if !self.open_files.contains(&uri) {
93            self.docs.ingest(uri, text);
94        }
95    }
96
97    /// Variant of [`ingest_if_not_open`] that reuses an already-parsed doc.
98    fn ingest_from_doc_if_not_open(&self, uri: Url, doc: &ParsedDoc) {
99        if !self.open_files.contains(&uri) {
100            self.docs.ingest_from_doc(uri, doc);
101        }
102    }
103
104    fn get_open_text(&self, uri: &Url) -> Option<String> {
105        self.open_files.text(uri)
106    }
107
108    fn set_parse_diagnostics(&self, uri: &Url, diagnostics: Vec<Diagnostic>) {
109        self.open_files.set_parse_diagnostics(uri, diagnostics);
110    }
111
112    fn get_parse_diagnostics(&self, uri: &Url) -> Option<Vec<Diagnostic>> {
113        self.open_files.parse_diagnostics(uri)
114    }
115
116    fn all_open_files_with_diagnostics(&self) -> Vec<(Url, Vec<Diagnostic>, Option<i64>)> {
117        self.open_files.all_with_diagnostics()
118    }
119
120    fn open_urls(&self) -> Vec<Url> {
121        self.open_files.urls()
122    }
123
124    fn get_doc(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
125        self.open_files.get_doc(&self.docs, uri)
126    }
127
128    fn get_doc_stale(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
129        self.open_files.get_doc_stale(&self.docs, uri)
130    }
131
132    /// `use Foo as Bar;` map for a single file, read directly from the AST.
133    fn file_imports(&self, uri: &Url) -> std::collections::HashMap<String, String> {
134        self.docs
135            .get_doc_salsa(uri)
136            .map(|doc| crate::references::collect_file_imports(&doc))
137            .unwrap_or_default()
138    }
139
140    /// Reference call sites for a class's `__construct`.
141    ///
142    /// The constructor's call sites are `new OwningClass(...)`, not
143    /// `->__construct()`, so name-only matching would return every class's
144    /// constructor declaration. We search for `new` expressions only, scoped to
145    /// the owning class.
146    ///
147    /// `class_name` is the FQN when the constructor is inside a namespace
148    /// (e.g. `"Shop\\Order"`). The AST walker searches for the *short* name
149    /// (`"Order"`) since that's what appears at call sites, while the FQN is
150    /// used only to scope the search and prevent collisions between two classes
151    /// with the same short name in different namespaces.
152    fn construct_references(
153        &self,
154        uri: &Url,
155        source: &str,
156        position: Position,
157        class_name: &str,
158        include_declaration: bool,
159    ) -> Vec<Location> {
160        let short_name = fqn_short_name(class_name).to_owned();
161        let class_fqn = class_name.contains('\\').then_some(class_name);
162        // Prefilter to files mentioning the short class name before parsing.
163        let candidate_docs = self.docs.candidate_docs_for(&short_name);
164        // `find_constructor_references` walks `new` expressions directly —
165        // bypasses the codebase/salsa index whose `ClassReference` key is too
166        // broad (covers type hints, `instanceof`, `extends`, `implements`).
167        let mut locations = find_constructor_references(&short_name, &candidate_docs, class_fqn);
168        // The cursor is already on the `__construct` name, so derive the span
169        // from the identifier under the cursor rather than re-searching via
170        // str_offset (which finds the first occurrence in the file and would
171        // point at the wrong constructor in files with more than one class).
172        if include_declaration && let Some(range) = crate::text::word_range_at(source, position) {
173            locations.push(Location {
174                uri: uri.clone(),
175                range,
176            });
177        }
178        locations
179    }
180
181    /// Resolve the FQN of the symbol at the cursor so reference lookups can match
182    /// by exact FQN instead of short name (fixes cross-namespace overmatch for
183    /// Function/Class and unrelated-class overmatch for Method via the owning
184    /// FQCN). Returns `None` when the kind doesn't carry an FQN or it can't be
185    /// resolved. For class constants, returns the owning class short name.
186    fn resolve_reference_target_fqn(
187        &self,
188        uri: &Url,
189        doc_opt: Option<&Arc<ParsedDoc>>,
190        word: &str,
191        kind: Option<crate::navigation::references::SymbolKind>,
192        position: Position,
193        constant_owner: Option<String>,
194    ) -> Option<String> {
195        use crate::navigation::references::SymbolKind;
196        let doc = doc_opt?;
197        let imports = self.file_imports(uri);
198        match kind {
199            Some(SymbolKind::Function) | Some(SymbolKind::Class) => {
200                let resolved = crate::navigation::moniker::resolve_fqn(doc, word, &imports);
201                resolved.contains('\\').then_some(resolved)
202            }
203            Some(SymbolKind::Method) => {
204                // Owning FQCN: the class/interface/trait/enum that contains the cursor.
205                let short_owner =
206                    crate::types::type_map::enclosing_class_at(doc.source(), doc, position)?;
207                // `resolve_fqn` walks the doc and applies the namespace prefix if any.
208                Some(crate::navigation::moniker::resolve_fqn(
209                    doc,
210                    &short_owner,
211                    &imports,
212                ))
213            }
214            Some(SymbolKind::Property) => {
215                // Only resolve the owning class when the cursor is on a property
216                // declaration — for access sites (`$obj->prop`) enclosing_class_at
217                // returns the accessing class, not the declaring class, so the session
218                // would be queried with the wrong key. Access sites fall back to the
219                // AST walker which finds all `->prop` occurrences.
220                let stmts = &doc.program().stmts;
221                crate::backend::helpers::cursor_is_on_property_decl(doc.source(), stmts, position)?;
222                let short_owner =
223                    crate::types::type_map::enclosing_class_at(doc.source(), doc, position)?;
224                Some(crate::navigation::moniker::resolve_fqn(
225                    doc,
226                    &short_owner,
227                    &imports,
228                ))
229            }
230            Some(SymbolKind::Constant) => {
231                if constant_owner.is_some() {
232                    // Class constant: the owning class short name as-is.
233                    constant_owner
234                } else {
235                    // Global/namespace constant: compute FQN so cross-namespace
236                    // references like `\Config\DB_HOST` can be found.
237                    let fqn = crate::navigation::moniker::resolve_fqn(doc, word, &imports);
238                    fqn.contains('\\').then_some(fqn)
239                }
240            }
241            _ => None,
242        }
243    }
244
245    /// Resolve the PHP version to use. See `autoload::resolve_php_version_from_roots`
246    /// for the full priority order.
247    fn resolve_php_version(&self, explicit: Option<&str>) -> (String, &'static str) {
248        let roots = self.root_paths.load();
249        crate::lang::autoload::resolve_php_version_from_roots(&roots, explicit)
250    }
251}
252
253/// Refine the cursor's `(word, kind)` for a references request using
254/// declaration-aware heuristics, returning the (possibly rewritten) word, its
255/// symbol kind, and — for class constants — the owning class short name.
256///
257/// Checks, in order: promoted constructor property params (so `$name` in
258/// `__construct(public string $name)` resolves to the `->name` property, not
259/// `$name` variable occurrences), then method / property / constant
260/// declarations, falling back to the character-based `symbol_kind_at` heuristic.
261fn resolve_reference_symbol(
262    doc_opt: Option<&Arc<ParsedDoc>>,
263    source: &str,
264    position: Position,
265    word: String,
266) -> (
267    String,
268    Option<crate::navigation::references::SymbolKind>,
269    Option<String>,
270) {
271    use crate::navigation::references::SymbolKind;
272    let mut constant_owner: Option<String> = None;
273    let (word, kind) = if let Some(doc) = doc_opt
274        && let Some(prop_name) =
275            promoted_property_at_cursor(doc.source(), &doc.program().stmts, position)
276    {
277        (prop_name, Some(SymbolKind::Property))
278    } else if let Some(doc) = doc_opt {
279        let stmts = &doc.program().stmts;
280        if cursor_is_on_method_decl(doc.source(), stmts, position) {
281            (word, Some(SymbolKind::Method))
282        } else if let Some(prop_name) = cursor_is_on_property_decl(doc.source(), stmts, position) {
283            (prop_name, Some(SymbolKind::Property))
284        } else if let Some((const_name, owner)) =
285            cursor_is_on_constant_decl(doc.source(), stmts, position)
286        {
287            constant_owner = owner;
288            (const_name, Some(SymbolKind::Constant))
289        } else {
290            let k = symbol_kind_at(source, position, &word);
291            // For constant access sites (`ClassName::CONST`, `self::CONST`),
292            // extract the owning class so the constant walker is scoped to
293            // the right class rather than falling back to the global-constant
294            // path (which would look for a top-level constant named `CONST`).
295            if matches!(k, Some(SymbolKind::Constant))
296                && let Some(raw) = class_before_double_colon(source, position)
297            {
298                constant_owner = Some(match raw.as_str() {
299                    "self" | "static" => {
300                        crate::types::type_map::enclosing_class_at(doc.source(), doc, position)
301                            .unwrap_or(raw)
302                    }
303                    _ => raw,
304                });
305            }
306            (word, k)
307        }
308    } else {
309        let k = symbol_kind_at(source, position, &word);
310        (word, k)
311    };
312    (word, kind, constant_owner)
313}
314
315/// Extract the class name (or pseudo-keyword) immediately to the left of `::` at
316/// the cursor position. Returns `None` when the cursor is not on an identifier
317/// preceded by `::`.
318///
319/// Used to populate `constant_owner` for constant access sites so that
320/// `Status::ACTIVE` (cursor on `ACTIVE`) scopes the constant walker to `Status`
321/// rather than treating `ACTIVE` as a global constant.
322fn class_before_double_colon(source: &str, position: Position) -> Option<String> {
323    let line = source.lines().nth(position.line as usize)?;
324    let chars: Vec<char> = line.chars().collect();
325    let col = position.character as usize;
326
327    let mut utf16_col = 0usize;
328    let mut char_idx = 0usize;
329    for ch in &chars {
330        if utf16_col >= col {
331            break;
332        }
333        utf16_col += ch.len_utf16();
334        char_idx += 1;
335    }
336
337    let is_word = |c: char| c.is_alphanumeric() || c == '_';
338    while char_idx > 0 && is_word(chars[char_idx - 1]) {
339        char_idx -= 1;
340    }
341
342    if char_idx < 2 || chars[char_idx - 1] != ':' || chars[char_idx - 2] != ':' {
343        return None;
344    }
345
346    let class_end = char_idx - 2;
347    let mut class_start = class_end;
348    while class_start > 0 && (is_word(chars[class_start - 1]) || chars[class_start - 1] == '\\') {
349        class_start -= 1;
350    }
351
352    let name: String = chars[class_start..class_end].iter().collect();
353    if name.is_empty() { None } else { Some(name) }
354}
355
356/// Off-`self` variant of `Backend::compute_dependent_publishes`. Needed
357/// because did_change's blocking republish runs inside a detached
358/// `tokio::spawn` that captures `Arc<Backend>` indirectly via clones of
359/// `docs` / `open_files` rather than `&self`.
360async fn compute_dependent_publishes_owned(
361    docs: Arc<DocumentStore>,
362    open_files: OpenFiles,
363    changed_uri: Url,
364    diag_cfg: crate::lang::config::DiagnosticsConfig,
365) -> Vec<(Url, Vec<Diagnostic>)> {
366    tokio::task::spawn_blocking(move || {
367        // Ask mir which files actually depend on `changed_uri` and let it
368        // re-run Pass 2 for them in parallel. mir 0.25's dependency graph
369        // covers every reference kind that can produce a cross-file
370        // diagnostic (imports, class hierarchy, type hints, instanceof,
371        // catch, ::class, ::CONST, `new`, static and instance calls) and
372        // tracks symbols-deleted-from-a-file so renames / deletions still
373        // surface the orphaned dependents.
374        let php_version = docs.workspace_php_version();
375        let session = docs.analysis_session(php_version);
376        // Cancel any older in-flight sweep and take a fresh token: when the
377        // next edit starts its own sweep, this one stops at its next file
378        // boundary instead of blocking typing behind a full dependent walk.
379        let cancel = docs.begin_reanalyze();
380        let analyses = session.reanalyze_dependents_cancellable(changed_uri.as_str(), &cancel);
381        // A newer edit that flipped `cancel` mid-sweep is now authoritative and
382        // will republish; drop this sweep's partial results so they can't land
383        // out of order and leave stale diagnostics as the final state.
384        if cancel.is_cancelled() || analyses.is_empty() {
385            return Vec::new();
386        }
387
388        // We only publish for files the editor has open. Filter the
389        // session-wide dependent set down to open URLs.
390        let open_urls: std::collections::HashSet<Url> = open_files
391            .urls()
392            .into_iter()
393            .filter(|u| u != &changed_uri)
394            .collect();
395        let dependents: Vec<(Url, mir_analyzer::FileAnalysis)> = analyses
396            .into_iter()
397            .filter_map(|(file, analysis)| {
398                let url = Url::parse(file.as_ref()).ok()?;
399                open_urls.contains(&url).then_some((url, analysis))
400            })
401            .collect();
402        if dependents.is_empty() {
403            return Vec::new();
404        }
405
406        // Workspace-level class issues (circular inheritance, override
407        // violations, abstract-method gaps) aren't in `FileAnalysis` —
408        // pull them in one batched call covering every affected file.
409        let dep_files: Vec<Arc<str>> = dependents
410            .iter()
411            .map(|(u, _)| Arc::from(u.as_str()))
412            .collect();
413        let class_issues = session.class_issues(&dep_files);
414        let mut class_issues_by_file: std::collections::HashMap<Arc<str>, Vec<mir_issues::Issue>> =
415            std::collections::HashMap::new();
416        for issue in class_issues {
417            if issue.suppressed {
418                continue;
419            }
420            let file = issue.location.file.clone();
421            class_issues_by_file.entry(file).or_default().push(issue);
422        }
423
424        let mut out: Vec<(Url, Vec<Diagnostic>)> = Vec::with_capacity(dependents.len());
425        for (url, analysis) in dependents {
426            let parse = open_files.parse_diagnostics(&url).unwrap_or_default();
427            let mut issues: Vec<mir_issues::Issue> = analysis
428                .issues
429                .into_iter()
430                .filter(|i| !i.suppressed)
431                .collect();
432            if let Some(extra) = class_issues_by_file.remove(&Arc::<str>::from(url.as_str())) {
433                issues.extend(extra);
434            }
435            let semantic =
436                crate::semantic_diagnostics::issues_to_diagnostics(&issues, &url, &diag_cfg);
437            out.push((url, merge_file_diagnostics(parse, semantic)));
438        }
439        out
440    })
441    .await
442    .unwrap_or_default()
443}
444
445/// Compute and publish diagnostics for `uri`, then republish any open files
446/// that depend on it. Requires `open_files.set_parse_diagnostics` to be up to
447/// date for `uri` before this is called.
448pub(super) async fn publish_with_dependents(
449    client: Client,
450    docs: Arc<DocumentStore>,
451    open_files: OpenFiles,
452    uri: Url,
453    diag_cfg: crate::lang::config::DiagnosticsConfig,
454) {
455    let docs_ref = Arc::clone(&docs);
456    let open_files_ref = open_files.clone();
457    let uri_ref = uri.clone();
458    let diag_cfg_ref = diag_cfg.clone();
459    let all_diags = tokio::task::spawn_blocking(move || {
460        compute_open_file_diagnostics(&docs_ref, &open_files_ref, &uri_ref, &diag_cfg_ref)
461    })
462    .await
463    .unwrap_or_default();
464    client
465        .publish_diagnostics(uri.clone(), all_diags, None)
466        .await;
467    let dependents = compute_dependent_publishes_owned(docs, open_files, uri, diag_cfg).await;
468    for (dep_uri, dep_diags) in dependents {
469        client.publish_diagnostics(dep_uri, dep_diags, None).await;
470    }
471}
472
473/// Generate a stable result_id for diagnostics. Uses the count and position of diagnostics
474/// to create a stable identifier. Same diagnostics = same result_id.
475fn compute_diagnostic_result_id(diagnostics: &[Diagnostic], uri: &str) -> String {
476    use std::collections::hash_map::DefaultHasher;
477    use std::hash::{Hash, Hasher};
478
479    let mut hasher = DefaultHasher::new();
480    uri.hash(&mut hasher);
481    diagnostics.len().hash(&mut hasher);
482
483    for diag in diagnostics {
484        diag.range.start.line.hash(&mut hasher);
485        diag.range.start.character.hash(&mut hasher);
486        diag.range.end.line.hash(&mut hasher);
487        diag.range.end.character.hash(&mut hasher);
488        diag.message.hash(&mut hasher);
489        let severity_val = match diag.severity {
490            Some(tower_lsp::lsp_types::DiagnosticSeverity::ERROR) => 1,
491            Some(tower_lsp::lsp_types::DiagnosticSeverity::WARNING) => 2,
492            Some(tower_lsp::lsp_types::DiagnosticSeverity::INFORMATION) => 3,
493            Some(tower_lsp::lsp_types::DiagnosticSeverity::HINT) => 4,
494            None => 0,
495            _ => 5, // Unknown variants
496        };
497        severity_val.hash(&mut hasher);
498        if let Some(code) = &diag.code {
499            format!("{:?}", code).hash(&mut hasher);
500        }
501        if let Some(source) = &diag.source {
502            source.hash(&mut hasher);
503        }
504        if let Some(tags) = &diag.tags {
505            for tag in tags {
506                let tag_val = match *tag {
507                    tower_lsp::lsp_types::DiagnosticTag::UNNECESSARY => 1,
508                    tower_lsp::lsp_types::DiagnosticTag::DEPRECATED => 2,
509                    _ => 3,
510                };
511                tag_val.hash(&mut hasher);
512            }
513        }
514    }
515
516    format!("v1:{:x}", hasher.finish())
517}
518
519mod handlers;
520mod helpers;
521pub mod panic_guard;
522mod server;
523#[cfg(test)]
524mod tests;