Skip to main content

php_lsp/db/
definitions.rs

1//! `file_definitions` salsa query — runs `DefinitionCollector::collect_slice`
2//! under salsa memoization, producing a pure `StubSlice` value per file.
3//!
4//! This is Phase C step 1: the per-file Pass-1 definitions become a
5//! tracked query. Phase C step 2 will add a `codebase(Workspace)`
6//! aggregator that folds all slices via `mir_codebase::codebase_from_parts`.
7
8use std::sync::Arc;
9
10use mir_codebase::storage::StubSlice;
11use salsa::{Database, Update};
12
13use crate::db::input::SourceFile;
14use crate::db::parse::parsed_doc;
15
16#[derive(Clone)]
17pub struct SliceArc(pub Arc<StubSlice>);
18
19impl SliceArc {
20    pub fn get(&self) -> &StubSlice {
21        &self.0
22    }
23}
24
25// SAFETY: identical contract to `ParsedArc::maybe_update`.
26unsafe impl Update for SliceArc {
27    unsafe fn maybe_update(old_pointer: *mut Self, new_value: Self) -> bool {
28        let old_ref = unsafe { &mut *old_pointer };
29        if Arc::ptr_eq(&old_ref.0, &new_value.0) {
30            false
31        } else {
32            *old_ref = new_value;
33            true
34        }
35    }
36}
37
38/// Collect Pass-1 definitions (classes, interfaces, traits, enums, functions,
39/// constants, global_vars) from a file. Uses mir-analyzer's collector in its
40/// new pure-slice mode (`collect_slice`), added in mir 0.7.1 for salsa
41/// integration.
42///
43/// Phase K2: when the `cached_slice` input field is `Some`, returns that
44/// slice directly and skips parse + `DefinitionCollector`. The slice is
45/// seeded before any query runs (by the workspace-scan warm-start path)
46/// and cleared on every text edit in `DocumentStore::mirror_text`, so by
47/// construction a cached slice is equivalent to what parse+collect would
48/// produce for the file's current text. Reading `file.text(db)` on the
49/// cached path is still required: it declares a salsa dependency on the
50/// text input so that a future edit invalidates this query's memo and
51/// forces a recompute via the fresh-parse branch.
52#[salsa::tracked(no_eq)]
53pub fn file_definitions(db: &dyn Database, file: SourceFile) -> SliceArc {
54    // Fast path: serve from the on-disk cache if it was seeded for this
55    // file. Still touch `file.text` so salsa invalidates this memo when
56    // the editor edits the file — at which point `mirror_text` also
57    // clears `cached_slice` back to `None`, forcing the slow path below.
58    if let Some(cached) = file.cached_slice(db) {
59        let _ = file.text(db);
60        return SliceArc(cached);
61    }
62
63    let doc = parsed_doc(db, file);
64    let text = file.text(db);
65    let file_path: Arc<str> = file.uri(db);
66    let source_map = php_rs_parser::source_map::SourceMap::new(&text);
67    let collector =
68        mir_analyzer::collector::DefinitionCollector::new_for_slice(file_path, &text, &source_map);
69    let (slice, _issues) = collector.collect_slice(doc.get().program());
70    SliceArc(Arc::new(slice))
71}
72
73#[cfg(test)]
74mod tests {
75    use std::sync::Arc;
76
77    use super::*;
78    use crate::db::analysis::AnalysisHost;
79    use crate::db::input::{FileId, SourceFile};
80    use salsa::Setter;
81
82    #[test]
83    fn file_definitions_extracts_class() {
84        let host = AnalysisHost::new();
85        let file = SourceFile::new(
86            host.db(),
87            FileId(0),
88            Arc::<str>::from("file:///t.php"),
89            Arc::<str>::from("<?php\nnamespace App;\nclass Foo {}"),
90            None,
91        );
92        let slice = file_definitions(host.db(), file);
93        let classes: Vec<&str> = slice
94            .get()
95            .classes
96            .iter()
97            .map(|c| c.fqcn.as_ref())
98            .collect();
99        assert_eq!(classes, vec!["App\\Foo"]);
100    }
101
102    #[test]
103    fn file_definitions_reruns_after_edit() {
104        let mut host = AnalysisHost::new();
105        let file = SourceFile::new(
106            host.db(),
107            FileId(1),
108            Arc::<str>::from("file:///t.php"),
109            Arc::<str>::from("<?php\nclass A {}"),
110            None,
111        );
112        let a1 = file_definitions(host.db(), file);
113        let first_ptr = Arc::as_ptr(&a1.0);
114
115        file.set_text(host.db_mut())
116            .to(Arc::<str>::from("<?php\nclass B {}"));
117        let a2 = file_definitions(host.db(), file);
118        assert_ne!(first_ptr, Arc::as_ptr(&a2.0));
119        let classes: Vec<&str> = a2.get().classes.iter().map(|c| c.fqcn.as_ref()).collect();
120        assert_eq!(classes, vec!["B"]);
121    }
122
123    /// Phase K2: a pre-seeded `cached_slice` short-circuits parse +
124    /// `DefinitionCollector`. The query returns the *cached* slice verbatim
125    /// even when the file's text says something different — the scan path
126    /// is responsible for keeping them in sync, and this test proves the
127    /// fast-path is actually taken (not silently falling through to parse).
128    #[test]
129    fn file_definitions_returns_seeded_slice_without_parsing() {
130        let mut host = AnalysisHost::new();
131        // The text says "class Text" but we'll seed a slice claiming "class Cached".
132        // A correct fast-path returns Cached; a broken fast-path (ignoring
133        // cached_slice and re-parsing) returns Text.
134        let file = SourceFile::new(
135            host.db(),
136            FileId(2),
137            Arc::<str>::from("file:///t.php"),
138            Arc::<str>::from("<?php\nclass Text {}"),
139            None,
140        );
141
142        let seeded = {
143            // Build a plausible class entry using the real collector on
144            // a file that contains "class Cached" so the StubSlice is
145            // well-formed. This is load-bearing: a hand-rolled slice is
146            // more likely to drift with mir-codebase schema changes.
147            let src = "<?php\nclass Cached {}";
148            let source_map = php_rs_parser::source_map::SourceMap::new(src);
149            let (doc, _) = crate::diagnostics::parse_document(src);
150            let collector = mir_analyzer::collector::DefinitionCollector::new_for_slice(
151                Arc::<str>::from("file:///t.php"),
152                src,
153                &source_map,
154            );
155            let (slice, _) = collector.collect_slice(doc.program());
156            Arc::new(slice)
157        };
158        file.set_cached_slice(host.db_mut()).to(Some(seeded));
159
160        let out = file_definitions(host.db(), file);
161        let classes: Vec<&str> = out.get().classes.iter().map(|c| c.fqcn.as_ref()).collect();
162        assert_eq!(
163            classes,
164            vec!["Cached"],
165            "seeded cached_slice must short-circuit parse + collect"
166        );
167    }
168
169    /// Editing the text after seeding must invalidate the cache — the next
170    /// query re-parses from scratch. This is the correctness guarantee that
171    /// makes the fast path safe even in the face of editor edits.
172    #[test]
173    fn edit_invalidates_seeded_slice() {
174        let mut host = AnalysisHost::new();
175        let file = SourceFile::new(
176            host.db(),
177            FileId(3),
178            Arc::<str>::from("file:///t.php"),
179            Arc::<str>::from("<?php\nclass Original {}"),
180            None,
181        );
182
183        // Seed a slice with a misleading fact.
184        let misleading = {
185            let src = "<?php\nclass Misleading {}";
186            let source_map = php_rs_parser::source_map::SourceMap::new(src);
187            let (doc, _) = crate::diagnostics::parse_document(src);
188            let collector = mir_analyzer::collector::DefinitionCollector::new_for_slice(
189                Arc::<str>::from("file:///t.php"),
190                src,
191                &source_map,
192            );
193            let (s, _) = collector.collect_slice(doc.program());
194            Arc::new(s)
195        };
196        file.set_cached_slice(host.db_mut()).to(Some(misleading));
197
198        let out1 = file_definitions(host.db(), file);
199        let names: Vec<&str> = out1.get().classes.iter().map(|c| c.fqcn.as_ref()).collect();
200        assert_eq!(names, vec!["Misleading"]);
201
202        // Editor edit: text changes AND the mirror layer should clear
203        // cached_slice (simulating DocumentStore::mirror_text). We do
204        // both steps explicitly here to model the production sequence.
205        file.set_text(host.db_mut())
206            .to(Arc::<str>::from("<?php\nclass Edited {}"));
207        file.set_cached_slice(host.db_mut()).to(None);
208
209        let out2 = file_definitions(host.db(), file);
210        let names: Vec<&str> = out2.get().classes.iter().map(|c| c.fqcn.as_ref()).collect();
211        assert_eq!(
212            names,
213            vec!["Edited"],
214            "edit must invalidate cached slice — fresh parse of new text"
215        );
216    }
217}