Skip to main content

php_lsp/db/
workspace_index.rs

1//! `workspace_index` salsa query — aggregates every file's `FileIndex` into a
2//! single structure with pre-built reverse maps.
3//!
4//! Before Phase J, cross-file queries (`workspace_symbols`,
5//! `prepare_type_hierarchy`, `supertypes_of`, `subtypes_of`,
6//! `find_implementations`) called `DocumentStore::all_indexes()` on every
7//! request. `all_indexes()` takes the host mutex once per file via
8//! `get_index_salsa` → `snapshot_query`, so a workspace with 1600 files
9//! paid 1600 lock acquisitions per lookup.
10//!
11//! This query runs once per workspace revision and returns:
12//!
13//! - `files`: the flat `(Url, Arc<FileIndex>)` list every handler used to
14//!   rebuild by hand,
15//! - `classes_by_name`: `name → [ClassRef]` for constant-time prepare /
16//!   supertype resolution,
17//! - `subtypes_of`: `name → [ClassRef]` for constant-time subtype /
18//!   implementation lookups.
19//!
20//! All lookups on the aggregate run in memory against an already-materialised
21//! `Arc`; edits invalidate the aggregate through `file_index` dependency
22//! tracking as usual.
23
24use std::collections::HashMap;
25use std::sync::Arc;
26
27use salsa::Database;
28use tower_lsp::lsp_types::Url;
29
30use crate::db::index::file_index;
31use crate::db::input::Workspace;
32use crate::file_index::FileIndex;
33
34/// Back-pointer into `WorkspaceIndexData.files`: `(file_idx, class_idx)` where
35/// `class_idx` indexes into `files[file_idx].1.classes`.
36#[derive(Debug, Clone, Copy)]
37pub struct ClassRef {
38    pub file: u32,
39    pub class: u32,
40}
41
42/// Aggregated workspace-level index. Constructed once per salsa revision by
43/// `workspace_index` and held behind an `Arc` for cheap cross-request sharing.
44pub struct WorkspaceIndexData {
45    pub files: Vec<(Url, Arc<FileIndex>)>,
46    pub classes_by_name: HashMap<String, Vec<ClassRef>>,
47    /// `parent_or_interface_or_trait_name → [subtype ClassRef]`.
48    /// A class that extends `X` AND implements `Y` contributes separate entries
49    /// under both keys. Keyed by `Arc<str>` so insertions from `ClassDef`'s
50    /// already-interned fields are pointer copies rather than heap allocations.
51    pub subtypes_of: HashMap<Arc<str>, Vec<ClassRef>>,
52}
53
54impl WorkspaceIndexData {
55    /// Resolve a `ClassRef` back to its `(uri, class_def)` pair.
56    pub fn at(&self, r: ClassRef) -> Option<(&Url, &crate::file_index::ClassDef)> {
57        let (uri, idx) = self.files.get(r.file as usize)?;
58        let cls = idx.classes.get(r.class as usize)?;
59        Some((uri, cls))
60    }
61
62    /// Test-only constructor that builds `classes_by_name` + `subtypes_of`
63    /// from an already-materialised `(Url, Arc<FileIndex>)` slice. Exposed
64    /// so callers that don't want to spin up a full `AnalysisHost` (unit
65    /// tests of `find_implementations_from_workspace` etc.) can exercise
66    /// the aggregate-shaped helpers directly.
67    #[cfg(test)]
68    pub fn from_files(files: Vec<(Url, Arc<FileIndex>)>) -> Self {
69        let mut classes_by_name: HashMap<String, Vec<ClassRef>> = HashMap::new();
70        let mut subtypes_of: HashMap<Arc<str>, Vec<ClassRef>> = HashMap::new();
71        for (file_idx, (_, idx)) in files.iter().enumerate() {
72            let file_idx = file_idx as u32;
73            for (cls_idx, cls) in idx.classes.iter().enumerate() {
74                let cr = ClassRef {
75                    file: file_idx,
76                    class: cls_idx as u32,
77                };
78                classes_by_name
79                    .entry(cls.name.as_ref().to_string())
80                    .or_default()
81                    .push(cr);
82                if let Some(parent) = &cls.parent {
83                    subtypes_of.entry(Arc::clone(parent)).or_default().push(cr);
84                }
85                for iface in &cls.implements {
86                    subtypes_of.entry(Arc::clone(iface)).or_default().push(cr);
87                }
88                for trt in &cls.traits {
89                    subtypes_of.entry(Arc::clone(trt)).or_default().push(cr);
90                }
91            }
92        }
93        Self {
94            files,
95            classes_by_name,
96            subtypes_of,
97        }
98    }
99}
100
101/// Arc wrapper with the same `Arc::ptr_eq`-based `Update` impl used throughout
102/// `src/db/`. The inner `WorkspaceIndexData` never compares structurally.
103#[derive(Clone)]
104pub struct WorkspaceIndexArc(pub Arc<WorkspaceIndexData>);
105
106impl WorkspaceIndexArc {
107    #[cfg(test)]
108    pub fn get(&self) -> &WorkspaceIndexData {
109        &self.0
110    }
111}
112
113// SAFETY: same contract as other `*Arc` newtypes — ptr_eq is sufficient because
114// every rebuild allocates a fresh `Arc`.
115crate::impl_arc_update!(WorkspaceIndexArc);
116
117/// Build the aggregate workspace index.
118///
119/// Depends on `Workspace::files` and every per-file `file_index` query;
120/// editing any file invalidates this via normal salsa dependency tracking.
121/// The `Arc<FileIndex>` values are the same ones served by `file_index` —
122/// callers that already held one are guaranteed pointer-equality here.
123#[salsa::tracked(no_eq)]
124pub fn workspace_index(db: &dyn Database, ws: Workspace) -> WorkspaceIndexArc {
125    let files_input = ws.files(db);
126
127    let mut files: Vec<(Url, Arc<FileIndex>)> = Vec::with_capacity(files_input.len());
128    for sf in files_input.iter() {
129        let uri_arc = sf.uri(db);
130        // Fall back to inserting any well-formed entry — salsa inputs carry
131        // whatever string the caller mirrored; if parsing ever fails (test
132        // harness with a non-URL string) we simply skip that file for
133        // cross-workspace queries rather than panic.
134        let Ok(url) = Url::parse(&uri_arc) else {
135            continue;
136        };
137        let idx = file_index(db, *sf).0.clone();
138        files.push((url, idx));
139    }
140
141    let mut classes_by_name: HashMap<String, Vec<ClassRef>> = HashMap::new();
142    let mut subtypes_of: HashMap<Arc<str>, Vec<ClassRef>> = HashMap::new();
143
144    for (file_idx, (_, idx)) in files.iter().enumerate() {
145        let file_idx = file_idx as u32;
146        for (cls_idx, cls) in idx.classes.iter().enumerate() {
147            let cr = ClassRef {
148                file: file_idx,
149                class: cls_idx as u32,
150            };
151            classes_by_name
152                .entry(cls.name.as_ref().to_string())
153                .or_default()
154                .push(cr);
155            if let Some(parent) = &cls.parent {
156                subtypes_of.entry(Arc::clone(parent)).or_default().push(cr);
157            }
158            for iface in &cls.implements {
159                subtypes_of.entry(Arc::clone(iface)).or_default().push(cr);
160            }
161            for trt in &cls.traits {
162                subtypes_of.entry(Arc::clone(trt)).or_default().push(cr);
163            }
164        }
165    }
166
167    WorkspaceIndexArc(Arc::new(WorkspaceIndexData {
168        files,
169        classes_by_name,
170        subtypes_of,
171    }))
172}
173
174#[cfg(test)]
175mod tests {
176    use std::sync::Arc;
177
178    use super::*;
179    use crate::db::analysis::AnalysisHost;
180    use crate::db::input::{FileId, SourceFile};
181    use salsa::Setter;
182
183    fn new_file(host: &AnalysisHost, id: u32, uri: &str, src: &str) -> SourceFile {
184        SourceFile::new(
185            host.db(),
186            FileId(id),
187            Arc::<str>::from(uri),
188            Arc::<str>::from(src),
189            None,
190        )
191    }
192
193    #[test]
194    fn workspace_index_builds_name_and_subtype_maps() {
195        let host = AnalysisHost::new();
196        let f1 = new_file(&host, 0, "file:///a.php", "<?php\nclass Animal {}");
197        let f2 = new_file(
198            &host,
199            1,
200            "file:///b.php",
201            "<?php\nclass Dog extends Animal {}\nclass Cat extends Animal {}",
202        );
203        let ws = Workspace::new(
204            host.db(),
205            Arc::from([f1, f2]),
206            mir_analyzer::PhpVersion::LATEST,
207        );
208
209        let wi = workspace_index(host.db(), ws);
210        let data = wi.get();
211
212        assert!(data.classes_by_name.contains_key("Animal"));
213        assert!(data.classes_by_name.contains_key("Dog"));
214
215        let subs = data
216            .subtypes_of
217            .get("Animal")
218            .expect("Animal must have subtype entries");
219        assert_eq!(subs.len(), 2, "Dog + Cat extend Animal");
220
221        let names: Vec<_> = subs
222            .iter()
223            .filter_map(|r| data.at(*r).map(|(_, c)| c.name.clone()))
224            .collect();
225        assert!(names.iter().any(|n| n.as_ref() == "Dog"));
226        assert!(names.iter().any(|n| n.as_ref() == "Cat"));
227    }
228
229    #[test]
230    fn workspace_index_memoizes_and_invalidates() {
231        let mut host = AnalysisHost::new();
232        let f1 = new_file(&host, 0, "file:///a.php", "<?php\nclass A {}");
233        let ws = Workspace::new(host.db(), Arc::from([f1]), mir_analyzer::PhpVersion::LATEST);
234
235        let a = workspace_index(host.db(), ws);
236        let b = workspace_index(host.db(), ws);
237        assert!(
238            Arc::ptr_eq(&a.0, &b.0),
239            "unchanged inputs must return the memoized Arc"
240        );
241
242        f1.set_text(host.db_mut())
243            .to(Arc::<str>::from("<?php\nclass B {}"));
244        let c = workspace_index(host.db(), ws);
245        assert!(!Arc::ptr_eq(&a.0, &c.0), "an edit must produce a fresh Arc");
246        assert!(c.get().classes_by_name.contains_key("B"));
247        assert!(!c.get().classes_by_name.contains_key("A"));
248    }
249
250    #[test]
251    fn workspace_index_collects_interface_and_trait_subtypes() {
252        let host = AnalysisHost::new();
253        let src = concat!(
254            "<?php\n",
255            "interface Greeter {}\n",
256            "trait Shouting {}\n",
257            "class Hi implements Greeter { use Shouting; }\n",
258        );
259        let f = new_file(&host, 0, "file:///m.php", src);
260        let ws = Workspace::new(host.db(), Arc::from([f]), mir_analyzer::PhpVersion::LATEST);
261        let wi = workspace_index(host.db(), ws);
262        let data = wi.get();
263
264        let greeter_subs = data.subtypes_of.get("Greeter").expect("Greeter subs");
265        assert_eq!(greeter_subs.len(), 1);
266        let shouting_subs = data.subtypes_of.get("Shouting").expect("Shouting subs");
267        assert_eq!(shouting_subs.len(), 1);
268    }
269}