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