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 `files`: the flat
12//! `(Uri, Arc<FileIndex>)` list every handler used to rebuild by hand, plus
13//! `classes_by_lowercase_name` for completion's prefix search.
14//!
15//! All lookups on the aggregate run in memory against an already-materialised
16//! `Arc`; edits invalidate the aggregate through `file_index` dependency
17//! tracking as usual.
18//!
19//! Vocabulary note: `ClassRef` is an internal back-pointer/handle into the
20//! index — *not* an LSP reference. A symbol usage in code is a "reference"
21//! spelled out (see `navigation/references.rs`). See the crate-root glossary
22//! in `lib.rs`.
23//!
24//! Short-name class lookup and disambiguation (`DocumentStore::
25//! resolve_class_ref`/`class_candidates`) and arbitrary-name declaration
26//! lookup (`DocumentStore::declaration_candidate_files`/`find_declaration`)
27//! no longer have eagerly rebuilt-per-revision maps here. Both narrow
28//! candidates via mir's persistent per-file mention cache instead: an edit
29//! invalidates only that file's cached mention set, not the whole
30//! workspace's. Only `classes_by_lowercase_name` remains eager — prefix
31//! search genuinely needs to enumerate every distinct name, which a
32//! point-query mention cache can't answer.
33
34use std::collections::HashMap;
35use std::sync::Arc;
36
37use tower_lsp_server::ls_types::Uri;
38
39use crate::index::file_index::FileIndex;
40
41/// Back-pointer into `WorkspaceIndexData.files`: `(file_idx, class_idx)` where
42/// `class_idx` indexes into `files[file_idx].1.classes`.
43#[derive(Debug, Clone, Copy)]
44pub struct ClassRef {
45    pub file: u32,
46    pub class: u32,
47}
48
49/// Back-pointer into `WorkspaceIndexData.files`: `(file_idx, function_idx)`
50/// where `function_idx` indexes into `files[file_idx].1.functions`.
51#[derive(Debug, Clone, Copy)]
52pub struct FunctionRef {
53    pub file: u32,
54    pub function: u32,
55}
56
57/// Aggregated workspace-level index. Constructed once per salsa revision by
58/// `workspace_index` and held behind an `Arc` for cheap cross-request sharing.
59pub struct WorkspaceIndexData {
60    pub files: Vec<(Uri, Arc<FileIndex>)>,
61    /// `files`' URIs as mir path strings, precomputed once per revision so
62    /// `DocumentStore::declaration_candidate_files`/`class_candidates` don't
63    /// re-allocate one `Arc<str>` per file on every call.
64    pub(crate) file_paths: Vec<Arc<str>>,
65    /// `file_paths[i] → i`, for O(1) resolution of a mention-index candidate
66    /// path back to its `files`/`file_paths` index (no per-candidate linear
67    /// scan or `Uri` round-trip).
68    pub(crate) path_to_file_idx: HashMap<Arc<str>, u32>,
69    /// One `(lowercase_short_name, ClassRef)` pair per distinct class name,
70    /// sorted by the lowercase key. Built once per revision so completion's
71    /// prefix search can binary-search a range instead of scanning every
72    /// class name (and re-lowercasing it) on every keystroke.
73    pub classes_by_lowercase_name: Vec<(Box<str>, ClassRef)>,
74}
75
76/// One entry per distinct class name (first-encountered `ClassRef` wins —
77/// matches the old `classes_by_name`-derived construction), for
78/// [`WorkspaceIndexData::classes_by_lowercase_name`]'s sorted table. No
79/// longer builds a full name→candidates map or a subtype reverse map: every
80/// other caller now resolves those via mir's mention index
81/// (`DocumentStore::class_candidates`/`resolve_class_ref`/
82/// `declaration_candidate_files`) instead of an eagerly rebuilt structure.
83pub(crate) fn build_maps(files: &[(Uri, Arc<FileIndex>)]) -> Vec<(Box<str>, ClassRef)> {
84    let mut first_by_name: HashMap<Box<str>, ClassRef> = HashMap::new();
85    for (file_idx, (_, idx)) in files.iter().enumerate() {
86        let file_idx = file_idx as u32;
87        for (cls_idx, cls) in idx.classes.iter().enumerate() {
88            first_by_name
89                .entry(cls.name.as_ref().into())
90                .or_insert(ClassRef {
91                    file: file_idx,
92                    class: cls_idx as u32,
93                });
94        }
95    }
96    let mut classes_by_lowercase_name: Vec<(Box<str>, ClassRef)> = first_by_name
97        .into_iter()
98        .map(|(name, cr)| (name.to_lowercase().into(), cr))
99        .collect();
100    classes_by_lowercase_name.sort_unstable_by(|a, b| a.0.cmp(&b.0));
101    classes_by_lowercase_name
102}
103
104impl WorkspaceIndexData {
105    /// Resolve a `ClassRef` back to its `(uri, class_def)` pair.
106    pub fn at(&self, r: ClassRef) -> Option<(&Uri, &crate::index::file_index::ClassDef)> {
107        let (uri, idx) = self.files.get(r.file as usize)?;
108        let cls = idx.classes.get(r.class as usize)?;
109        Some((uri, cls))
110    }
111
112    /// Resolve a `FunctionRef` back to its `(uri, function_def)` pair.
113    pub fn function_at(
114        &self,
115        r: FunctionRef,
116    ) -> Option<(&Uri, &crate::index::file_index::FunctionDef)> {
117        let (uri, idx) = self.files.get(r.file as usize)?;
118        let function = idx.functions.get(r.function as usize)?;
119        Some((uri, function))
120    }
121
122    /// Return the canonical FQN when exactly one workspace class has `name`
123    /// as its short name. A duplicate FQN is harmless; distinct FQNs make a
124    /// short-name lookup ambiguous.
125    ///
126    /// This is deliberately a scan rather than another eager reverse map:
127    /// short-name disambiguation is only needed by infrequent interactive
128    /// operations, whereas completion needs its dedicated sorted index.
129    pub fn unique_class_fqn_by_short_name(&self, name: &str) -> Option<&str> {
130        let mut candidate = None;
131        for (_, index) in &self.files {
132            for class in &index.classes {
133                if !class.name.eq_ignore_ascii_case(name) {
134                    continue;
135                }
136                match candidate {
137                    None => candidate = Some(class.fqn.as_ref()),
138                    Some(fqn) if fqn.eq_ignore_ascii_case(&class.fqn) => {}
139                    Some(_) => return None,
140                }
141            }
142        }
143        candidate
144    }
145
146    /// Visit every class-like declaration stored in the aggregate.
147    pub fn for_each_class(&self, mut f: impl FnMut(&Uri, &crate::index::file_index::ClassDef)) {
148        for (uri, idx) in &self.files {
149            for cls in &idx.classes {
150                f(uri, cls);
151            }
152        }
153    }
154
155    /// Visit every class-like declaration in the files named by `uris`.
156    /// Missing paths are skipped; callers typically source `uris` from mir's
157    /// mention or subtype indexes, which are candidate sets rather than proof.
158    pub fn for_each_class_in_uris(
159        &self,
160        uris: &[Uri],
161        mut f: impl FnMut(&Uri, &crate::index::file_index::ClassDef),
162    ) {
163        for uri in uris {
164            let Some(&file_idx) = self.path_to_file_idx.get(uri.as_str()) else {
165                continue;
166            };
167            let Some((stored_uri, idx)) = self.files.get(file_idx as usize) else {
168                continue;
169            };
170            for cls in &idx.classes {
171                f(stored_uri, cls);
172            }
173        }
174    }
175
176    /// Constructor that builds the reverse maps from an already-materialised
177    /// `(Uri, Arc<FileIndex>)` slice. Exposed so callers that don't want to
178    /// spin up a full `AnalysisHost` (unit tests of
179    /// `find_implementations_from_workspace`, benchmark crates) can exercise
180    /// the aggregate-shaped helpers directly. Production code goes through
181    /// the `workspace_index` salsa query instead.
182    pub fn from_files(files: Vec<(Uri, Arc<FileIndex>)>) -> Self {
183        let classes_by_lowercase_name = build_maps(&files);
184        let file_paths: Vec<Arc<str>> = files.iter().map(|(u, _)| Arc::from(u.as_str())).collect();
185        let path_to_file_idx = file_paths
186            .iter()
187            .enumerate()
188            .map(|(i, p)| (Arc::clone(p), i as u32))
189            .collect();
190        Self {
191            files,
192            file_paths,
193            path_to_file_idx,
194            classes_by_lowercase_name,
195        }
196    }
197}
198
199/// Arc wrapper for `workspace_index`, which is `#[salsa::tracked(no_eq)]` —
200/// every rebuild allocates a fresh `Arc` and salsa never attempts to compare
201/// `WorkspaceIndexData` structurally.
202#[derive(Clone)]
203pub struct WorkspaceIndexArc(pub Arc<WorkspaceIndexData>);
204
205impl WorkspaceIndexArc {
206    #[cfg(test)]
207    pub fn get(&self) -> &WorkspaceIndexData {
208        &self.0
209    }
210}