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