Skip to main content

php_lsp/db/
index.rs

1//! `file_index` salsa query — derives a compact `FileIndex` from a parsed
2//! document. Depends on `parsed_doc`, so editing a file reparses once and the
3//! index re-extracts from the new AST.
4
5use std::sync::Arc;
6
7use salsa::Database;
8
9use crate::db::input::SourceFile;
10use crate::db::parse::parsed_doc;
11use crate::file_index::FileIndex;
12
13/// Arc wrapper for `FileIndex`. Uses structural equality on the inner
14/// `FileIndex` so salsa can short-circuit downstream queries (e.g.
15/// `workspace_index`) when a body-only edit produces an identical index.
16#[derive(Clone, PartialEq, Debug)]
17pub struct IndexArc(pub Arc<FileIndex>);
18
19impl IndexArc {
20    pub fn get(&self) -> &FileIndex {
21        &self.0
22    }
23}
24
25// SAFETY: writes through `old_pointer` only when returning `true`. Uses
26// structural equality on `FileIndex` so that body-only edits (no declaration
27// change) return `false` and don't cascade to `workspace_index`.
28unsafe impl salsa::Update for IndexArc {
29    unsafe fn maybe_update(old_pointer: *mut Self, new_value: Self) -> bool {
30        let old_ref = unsafe { &mut *old_pointer };
31        if *old_ref.0 == *new_value.0 {
32            false
33        } else {
34            *old_ref = new_value;
35            true
36        }
37    }
38}
39
40/// Build the compact symbol index for a file.  Salsa compares the returned
41/// `IndexArc` structurally via `FileIndex::PartialEq`; if declarations are
42/// unchanged (body-only edit) the comparison returns equal and `workspace_index`
43/// is not re-run.
44///
45/// Fast path: if the workspace scan seeded a `cached_index` (loaded from the
46/// on-disk cache), return it directly — no parse, no extract.
47#[salsa::tracked]
48pub fn file_index(db: &dyn Database, file: SourceFile) -> IndexArc {
49    if let Some(cached) = file.cached_index(db) {
50        return IndexArc(cached);
51    }
52    let doc = parsed_doc(db, file);
53    IndexArc(Arc::new(FileIndex::extract(doc.get())))
54}
55
56#[cfg(test)]
57mod tests {
58    use std::sync::Arc;
59    use std::sync::atomic::{AtomicUsize, Ordering};
60
61    use super::*;
62    use crate::db::analysis::AnalysisHost;
63    use crate::db::input::{FileId, SourceFile};
64    use crate::db::parse::parsed_doc;
65    use salsa::Setter;
66
67    static CALLS: AtomicUsize = AtomicUsize::new(0);
68
69    /// Wrap `file_index` with a counter to verify salsa shares the `parsed_doc`
70    /// memoization between `file_index` and other downstream queries.
71    #[salsa::tracked]
72    fn counted_index_len(db: &dyn Database, file: SourceFile) -> usize {
73        CALLS.fetch_add(1, Ordering::SeqCst);
74        file_index(db, file).get().classes.len()
75    }
76
77    #[test]
78    fn file_index_extracts_class() {
79        let host = AnalysisHost::new();
80        let file = SourceFile::new(
81            host.db(),
82            FileId(0),
83            Arc::<str>::from("file:///t.php"),
84            Arc::<str>::from("<?php\nclass Foo { public function bar() {} }"),
85            None,
86        );
87        let idx = file_index(host.db(), file);
88        assert_eq!(idx.get().classes.len(), 1);
89        assert_eq!(idx.get().classes[0].name, "Foo".into());
90    }
91
92    #[test]
93    fn file_index_memoizes_and_shares_parse_with_downstream() {
94        CALLS.store(0, Ordering::SeqCst);
95        let mut host = AnalysisHost::new();
96        let file = SourceFile::new(
97            host.db(),
98            FileId(1),
99            Arc::<str>::from("file:///t.php"),
100            Arc::<str>::from("<?php\nclass A {} class B {}"),
101            None,
102        );
103
104        // Fetch the parsed doc, then the index — salsa should parse once.
105        let _ = parsed_doc(host.db(), file);
106        let _ = counted_index_len(host.db(), file);
107        let _ = counted_index_len(host.db(), file);
108        assert_eq!(
109            CALLS.load(Ordering::SeqCst),
110            1,
111            "index query should memoize within a revision"
112        );
113
114        // Edit the file — both the parse and the index should re-run.
115        file.set_text(host.db_mut())
116            .to(Arc::<str>::from("<?php\nclass A {}"));
117        let _ = counted_index_len(host.db(), file);
118        assert_eq!(CALLS.load(Ordering::SeqCst), 2);
119
120        let idx = file_index(host.db(), file);
121        assert_eq!(idx.get().classes.len(), 1);
122    }
123
124    #[test]
125    fn body_only_edit_produces_equal_index_arc() {
126        // Changing only the method body must not alter the FileIndex —
127        // verifying this directly is cleaner than counting salsa re-runs and
128        // avoids interference with the shared CALLS counter above.
129        let mut host = AnalysisHost::new();
130        let file = SourceFile::new(
131            host.db(),
132            FileId(2),
133            Arc::<str>::from("file:///t.php"),
134            Arc::<str>::from("<?php\nclass Foo { public function bar(): int { return 1; } }"),
135            None,
136        );
137
138        let before = file_index(host.db(), file);
139
140        // Change the method body only — no declaration-level change.
141        file.set_text(host.db_mut()).to(Arc::<str>::from(
142            "<?php\nclass Foo { public function bar(): int { return 2; } }",
143        ));
144        let after = file_index(host.db(), file);
145
146        assert_eq!(
147            before, after,
148            "body-only edit must produce an equal IndexArc so salsa can short-circuit workspace_index"
149        );
150    }
151}