Skip to main content

php_lsp/db/
parse.rs

1//! The `parsed_doc` salsa query: parses a `SourceFile` into an `Arc<ParsedDoc>`
2//! under salsa memoization. Downstream queries (file_index, method_returns,
3//! semantic diagnostics) depend on this one, so each file is parsed at most
4//! once per revision.
5//!
6//! `ParsedDoc` owns a self-referential bumpalo arena and cannot safely
7//! implement the structural `Update` trait — instead we wrap in a `ParsedArc`
8//! newtype whose `Update` impl uses `Arc::ptr_eq`. Every reparse produces a
9//! new `Arc`, so pointer equality is a correct (if conservative) "changed"
10//! signal: salsa never falsely backdates, and downstream queries re-run after
11//! every input text change.
12
13use std::sync::Arc;
14
15use salsa::{Database, Update};
16
17use crate::ast::ParsedDoc;
18use crate::db::input::SourceFile;
19use crate::diagnostics::parse_document;
20
21/// Opaque handle to a parsed document. Cheap to clone (refcount bump); never
22/// compared structurally. See module docs for the `Update` contract.
23///
24/// No `Debug` impl because `ParsedDoc` isn't `Debug` (it owns raw pointers
25/// into a bumpalo arena). Salsa doesn't require `Debug` on tracked returns
26/// when `no_eq` is used.
27#[derive(Clone)]
28pub struct ParsedArc(pub Arc<ParsedDoc>);
29
30impl ParsedArc {
31    pub fn get(&self) -> &ParsedDoc {
32        &self.0
33    }
34}
35
36// SAFETY: `maybe_update` writes `new` through `old_pointer` exactly when it
37// returns `true`. The `ptr_eq` short-circuit returns `false` without writing,
38// matching salsa's "no observable change" contract. `ParsedDoc` is already
39// `Send + Sync` (see `ast.rs:98`).
40unsafe impl Update for ParsedArc {
41    unsafe fn maybe_update(old_pointer: *mut Self, new_value: Self) -> bool {
42        let old_ref = unsafe { &mut *old_pointer };
43        if Arc::ptr_eq(&old_ref.0, &new_value.0) {
44            false
45        } else {
46            *old_ref = new_value;
47            true
48        }
49    }
50}
51
52/// Parse the file's source text. `no_eq` because `ParsedArc` has no
53/// structural equality — invalidation is driven entirely by input changes,
54/// not by comparing the new value against the old one.
55///
56/// Phase F: `lru = 2048` bounds the number of cached ASTs. Parsed docs own
57/// bumpalo arenas and are the largest memoized values in the db; dropping
58/// older entries caps resident memory at roughly 2048 × avg_ast_size.
59/// Re-reads after eviction reparse from the live `SourceFile::text` input
60/// (cheap `Arc<str>` clone). This replaces the hand-written
61/// `DocumentStore::indexed_order` LRU that used to bound `Document` entries.
62#[salsa::tracked(no_eq, lru = 2048)]
63pub fn parsed_doc(db: &dyn Database, file: SourceFile) -> ParsedArc {
64    let text = file.text(db);
65    let (doc, _diags) = parse_document(&text);
66    ParsedArc(Arc::new(doc))
67}
68
69/// Parse-error count, derived from `parsed_doc`. Kept as a separate query so
70/// callers that only need the diagnostic count don't clone the parsed AST.
71#[salsa::tracked]
72pub fn parse_error_count(db: &dyn Database, file: SourceFile) -> usize {
73    let text = file.text(db);
74    let (_doc, diags) = parse_document(&text);
75    diags.len()
76}
77
78#[cfg(test)]
79mod tests {
80    use std::sync::Arc;
81    use std::sync::atomic::{AtomicUsize, Ordering};
82
83    use super::*;
84    use crate::db::analysis::AnalysisHost;
85    use crate::db::input::{FileId, SourceFile};
86    use salsa::Setter;
87
88    static CALLS: AtomicUsize = AtomicUsize::new(0);
89
90    #[salsa::tracked]
91    fn counted_parse(db: &dyn Database, file: SourceFile) -> usize {
92        CALLS.fetch_add(1, Ordering::SeqCst);
93        parsed_doc(db, file).get().errors.len()
94    }
95
96    #[test]
97    fn parsed_doc_returns_ast() {
98        let host = AnalysisHost::new();
99        let file = SourceFile::new(
100            host.db(),
101            FileId(0),
102            Arc::<str>::from("file:///t.php"),
103            Arc::<str>::from("<?php\nfunction greet() {}"),
104            None,
105        );
106        let arc = parsed_doc(host.db(), file);
107        assert!(arc.get().errors.is_empty());
108        assert!(!arc.get().program().stmts.is_empty());
109    }
110
111    #[test]
112    fn parsed_doc_memoizes_and_invalidates() {
113        CALLS.store(0, Ordering::SeqCst);
114        let mut host = AnalysisHost::new();
115        let file = SourceFile::new(
116            host.db(),
117            FileId(1),
118            Arc::<str>::from("file:///t.php"),
119            Arc::<str>::from("<?php\nfunction a() {}"),
120            None,
121        );
122
123        let _ = counted_parse(host.db(), file);
124        let _ = counted_parse(host.db(), file);
125        assert_eq!(
126            CALLS.load(Ordering::SeqCst),
127            1,
128            "salsa should memoize the second call with unchanged input"
129        );
130
131        file.set_text(host.db_mut())
132            .to(Arc::<str>::from("<?php\nclass {"));
133        let _ = counted_parse(host.db(), file);
134        assert_eq!(
135            CALLS.load(Ordering::SeqCst),
136            2,
137            "downstream query should re-run after input text changes"
138        );
139    }
140
141    #[test]
142    fn parse_error_count_reflects_diagnostics() {
143        let host = AnalysisHost::new();
144        let file = SourceFile::new(
145            host.db(),
146            FileId(2),
147            Arc::<str>::from("file:///t.php"),
148            Arc::<str>::from("<?php\nclass {"),
149            None,
150        );
151        assert!(parse_error_count(host.db(), file) > 0);
152    }
153}