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 `(Uri, 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//!
24//! Vocabulary note: the `-Ref` types here (`ClassRef`, `DeclRef`) are internal
25//! back-pointers/handles into the index — *not* LSP references. A symbol usage
26//! in code is a "reference" spelled out (see `navigation/references.rs`). See
27//! the crate-root glossary in `lib.rs`.
28
29use std::collections::HashMap;
30use std::sync::{Arc, OnceLock};
31
32use tower_lsp_server::ls_types::Uri;
33
34use crate::index::file_index::FileIndex;
35
36/// Back-pointer into `WorkspaceIndexData.files`: `(file_idx, class_idx)` where
37/// `class_idx` indexes into `files[file_idx].1.classes`.
38#[derive(Debug, Clone, Copy)]
39pub struct ClassRef {
40 pub file: u32,
41 pub class: u32,
42}
43
44/// What kind of declaration a [`DeclRef`] points at. Drives the per-kind
45/// matching rules in [`WorkspaceIndexData::find_declaration`] (e.g. a `$foo`
46/// query matches properties and functions/classes named `foo`, but never
47/// methods or constants).
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum DeclKind {
50 Function,
51 Class,
52 Method,
53 Property,
54 Constant,
55 EnumCase,
56}
57
58/// One named declaration, pre-resolved to its file and (zero-width) line.
59/// Stored in encounter order — file order, then within a file: functions
60/// first, then per class: the class itself, methods, properties, constants,
61/// enum cases — which is exactly the precedence the old linear scan (since
62/// removed in favor of this map) had.
63#[derive(Debug, Clone, Copy)]
64pub struct DeclRef {
65 pub file: u32,
66 pub line: u32,
67 pub kind: DeclKind,
68}
69
70/// Aggregated workspace-level index. Constructed once per salsa revision by
71/// `workspace_index` and held behind an `Arc` for cheap cross-request sharing.
72pub struct WorkspaceIndexData {
73 pub files: Vec<(Uri, Arc<FileIndex>)>,
74 pub classes_by_name: HashMap<String, Vec<ClassRef>>,
75 /// `parent_or_interface_or_trait_name → [subtype ClassRef]`.
76 /// A class that extends `X` AND implements `Y` contributes separate entries
77 /// under both keys. Keyed by `Arc<str>` so insertions from `ClassDef`'s
78 /// already-interned fields are pointer copies rather than heap allocations.
79 pub subtypes_of: HashMap<Arc<str>, Vec<ClassRef>>,
80 /// `declared_name → [DeclRef]` over every function, class, method,
81 /// property (stored without `$`), class constant, and enum case in the
82 /// workspace; enables O(1) go-to-definition lookups.
83 pub decls_by_name: HashMap<String, Vec<DeclRef>>,
84 /// One `(lowercase_short_name, ClassRef)` pair per distinct class name,
85 /// sorted by the lowercase key. Built once per revision so completion's
86 /// prefix search can binary-search a range instead of scanning every
87 /// class name (and re-lowercasing it) on every keystroke.
88 pub classes_by_lowercase_name: Vec<(Box<str>, ClassRef)>,
89 /// Lazily-built `name/ClassName::method → FuncSignature` map covering
90 /// every workspace function and method, for `inlay_hint`'s cross-file
91 /// fallback. Built at most once per revision (first `inlay_hint` request
92 /// after an edit) and shared by every subsequent request against the
93 /// same `WorkspaceIndexData` Arc via [`WorkspaceIndexData::func_signatures`].
94 func_signatures: OnceLock<Arc<HashMap<String, FuncSignature>>>,
95}
96
97/// A function or method's parameter names, variadic-ness, and return type —
98/// enough to drive inlay-hint rendering at a call site without re-parsing
99/// the declaring file.
100#[derive(Clone)]
101pub struct FuncSignature {
102 pub params: Vec<String>,
103 pub variadic_last: bool,
104 pub return_type: Option<String>,
105}
106
107/// Populate `map` with function and method signatures from every workspace
108/// file. Entries already present (from the current file's own AST) are not
109/// overwritten so an in-file definition always wins over a possibly-stale
110/// index entry.
111pub fn build_func_signatures(files: &[(Uri, Arc<FileIndex>)]) -> HashMap<String, FuncSignature> {
112 let mut map = HashMap::new();
113 for (_, idx) in files {
114 for func in &idx.functions {
115 let func_name = func.name.to_string();
116 if map.contains_key(&func_name) {
117 continue;
118 }
119 let params: Vec<String> = func.params.iter().map(|p| p.name.to_string()).collect();
120 let variadic_last = func.params.last().map(|p| p.variadic).unwrap_or(false);
121 map.insert(
122 func_name,
123 FuncSignature {
124 params,
125 variadic_last,
126 return_type: func.return_type.as_ref().map(|r| r.to_string()),
127 },
128 );
129 }
130 for class in &idx.classes {
131 for method in &class.methods {
132 let method_name = method.name.to_string();
133 let params: Vec<String> =
134 method.params.iter().map(|p| p.name.to_string()).collect();
135 let variadic_last = method.params.last().map(|p| p.variadic).unwrap_or(false);
136 let sig = FuncSignature {
137 params: params.clone(),
138 variadic_last,
139 return_type: method.return_type.as_ref().map(|r| r.to_string()),
140 };
141 // Register with qualified key "ClassName::methodName" for unambiguous lookup
142 let cn = class.name.as_ref();
143 let qualified = format!("{}::{}", cn, method_name);
144 map.insert(qualified, sig.clone());
145 // Also register __construct under the class name so `new ClassName(...)` gets hints.
146 if method_name == "__construct" {
147 map.entry(cn.to_string()).or_insert_with(|| FuncSignature {
148 params: params.clone(),
149 variadic_last,
150 return_type: None,
151 });
152 }
153 // Register with short name as fallback for backwards compatibility
154 map.entry(method_name).or_insert(sig);
155 }
156 }
157 }
158 map
159}
160
161pub(crate) type BuildMapsResult = (
162 HashMap<String, Vec<ClassRef>>,
163 HashMap<Arc<str>, Vec<ClassRef>>,
164 HashMap<String, Vec<DeclRef>>,
165 Vec<(Box<str>, ClassRef)>,
166);
167
168pub(crate) fn build_maps(files: &[(Uri, Arc<FileIndex>)]) -> BuildMapsResult {
169 let mut classes_by_name: HashMap<String, Vec<ClassRef>> = HashMap::new();
170 let mut subtypes_of: HashMap<Arc<str>, Vec<ClassRef>> = HashMap::new();
171 let mut decls_by_name: HashMap<String, Vec<DeclRef>> = HashMap::new();
172 let push_decl = |map: &mut HashMap<String, Vec<DeclRef>>,
173 name: &str,
174 file: u32,
175 line: u32,
176 kind: DeclKind| {
177 map.entry(name.to_string())
178 .or_default()
179 .push(DeclRef { file, line, kind });
180 };
181 for (file_idx, (_, idx)) in files.iter().enumerate() {
182 let file_idx = file_idx as u32;
183 for f in &idx.functions {
184 push_decl(
185 &mut decls_by_name,
186 &f.name,
187 file_idx,
188 f.start_line,
189 DeclKind::Function,
190 );
191 }
192 for (cls_idx, cls) in idx.classes.iter().enumerate() {
193 let cr = ClassRef {
194 file: file_idx,
195 class: cls_idx as u32,
196 };
197 classes_by_name
198 .entry(cls.name.as_ref().to_string())
199 .or_default()
200 .push(cr);
201 if let Some(parent) = &cls.parent {
202 subtypes_of.entry(Arc::clone(parent)).or_default().push(cr);
203 }
204 for iface in &cls.implements {
205 subtypes_of.entry(Arc::clone(iface)).or_default().push(cr);
206 // If this implements name is a use-import alias, also index by
207 // the short name of the resolved FQN so cursor-on-interface-name
208 // lookups work regardless of how the implementor named the interface.
209 if let Some((_, fqn)) = idx
210 .use_imports
211 .iter()
212 .find(|(alias, _)| alias.as_ref() == iface.as_ref())
213 {
214 let short = crate::text::fqn_short_name(fqn);
215 if short != iface.as_ref() {
216 subtypes_of.entry(Arc::from(short)).or_default().push(cr);
217 }
218 }
219 }
220 for trt in &cls.traits {
221 subtypes_of.entry(Arc::clone(trt)).or_default().push(cr);
222 }
223 push_decl(
224 &mut decls_by_name,
225 &cls.name,
226 file_idx,
227 cls.start_line,
228 DeclKind::Class,
229 );
230 for m in &cls.methods {
231 push_decl(
232 &mut decls_by_name,
233 &m.name,
234 file_idx,
235 m.start_line,
236 DeclKind::Method,
237 );
238 }
239 for p in &cls.properties {
240 push_decl(
241 &mut decls_by_name,
242 &p.name,
243 file_idx,
244 p.start_line,
245 DeclKind::Property,
246 );
247 }
248 for cc in &cls.constants {
249 push_decl(
250 &mut decls_by_name,
251 cc,
252 file_idx,
253 cls.start_line,
254 DeclKind::Constant,
255 );
256 }
257 for case in &cls.cases {
258 push_decl(
259 &mut decls_by_name,
260 case,
261 file_idx,
262 cls.start_line,
263 DeclKind::EnumCase,
264 );
265 }
266 }
267 }
268 let mut classes_by_lowercase_name: Vec<(Box<str>, ClassRef)> = classes_by_name
269 .iter()
270 .filter_map(|(name, refs)| refs.first().map(|cr| (name.to_lowercase().into(), *cr)))
271 .collect();
272 classes_by_lowercase_name.sort_unstable_by(|a, b| a.0.cmp(&b.0));
273 (
274 classes_by_name,
275 subtypes_of,
276 decls_by_name,
277 classes_by_lowercase_name,
278 )
279}
280
281impl WorkspaceIndexData {
282 /// Resolve a `ClassRef` back to its `(uri, class_def)` pair.
283 pub fn at(&self, r: ClassRef) -> Option<(&Uri, &crate::index::file_index::ClassDef)> {
284 let (uri, idx) = self.files.get(r.file as usize)?;
285 let cls = idx.classes.get(r.class as usize)?;
286 Some((uri, cls))
287 }
288
289 /// Resolve `name` to a single `ClassRef`, disambiguating same-named
290 /// classes in different namespaces. `classes_by_name` is keyed by short
291 /// name only, so two unrelated classes sharing a name (e.g. Laravel's
292 /// `Illuminate\Support\Facades\Auth` vs `Illuminate\Container\Attributes\
293 /// Auth`) collide in one bucket; picking `.first()` unconditionally
294 /// silently returns whichever happens to be indexed first.
295 ///
296 /// `name` may be a bare short name (kept for compatibility — resolves to
297 /// the first match, as before) or a fully-qualified name (`Foo\Bar\Baz`,
298 /// optionally leading with `\`), in which case the candidate whose own
299 /// FQN matches (case-insensitively, PHP class names are
300 /// case-insensitive) is preferred over an arbitrary first match.
301 pub fn resolve_class_ref(&self, name: &str) -> Option<ClassRef> {
302 let trimmed = name.trim_start_matches('\\');
303 let short = trimmed.rsplit('\\').next().unwrap_or(trimmed);
304 let candidates = self.classes_by_name.get(short)?;
305 if trimmed.contains('\\')
306 && let Some(cr) = candidates.iter().find(|cr| {
307 self.at(**cr).is_some_and(|(_, cls)| {
308 cls.fqn
309 .trim_start_matches('\\')
310 .eq_ignore_ascii_case(trimmed)
311 })
312 })
313 {
314 return Some(*cr);
315 }
316 candidates.first().copied()
317 }
318
319 /// O(1) replacement for the old linear per-file scan (since removed):
320 /// find a declaration by name, optionally excluding one file (the current
321 /// document, which the caller has already searched with accurate AST
322 /// ranges). Matching rules mirror the old scan: a sigil query (`$foo`)
323 /// matches functions, classes, and properties named `foo`; a bare query
324 /// matches every declaration kind. Returns a zero-width line `Location`.
325 pub fn find_declaration(
326 &self,
327 name: &str,
328 exclude: Option<&Uri>,
329 ) -> Option<tower_lsp_server::ls_types::Location> {
330 let bare = crate::text::strip_variable_sigil(name);
331 let sigil = bare != name;
332 let refs = self.decls_by_name.get(bare)?;
333 for r in refs {
334 if sigil
335 && !matches!(
336 r.kind,
337 DeclKind::Function | DeclKind::Class | DeclKind::Property
338 )
339 {
340 continue;
341 }
342 let (uri, _) = self.files.get(r.file as usize)?;
343 if exclude.is_some_and(|e| e == uri) {
344 continue;
345 }
346 return Some(tower_lsp_server::ls_types::Location {
347 uri: uri.clone(),
348 range: crate::text::zero_width_range(r.line),
349 });
350 }
351 None
352 }
353
354 /// Constructor that builds the reverse maps from an already-materialised
355 /// `(Uri, Arc<FileIndex>)` slice. Exposed so callers that don't want to
356 /// spin up a full `AnalysisHost` (unit tests of
357 /// `find_implementations_from_workspace`, benchmark crates) can exercise
358 /// the aggregate-shaped helpers directly. Production code goes through
359 /// the `workspace_index` salsa query instead.
360 pub fn from_files(files: Vec<(Uri, Arc<FileIndex>)>) -> Self {
361 let (classes_by_name, subtypes_of, decls_by_name, classes_by_lowercase_name) =
362 build_maps(&files);
363 Self {
364 files,
365 classes_by_name,
366 subtypes_of,
367 decls_by_name,
368 classes_by_lowercase_name,
369 func_signatures: OnceLock::new(),
370 }
371 }
372
373 /// The workspace-wide function/method signature map, built on first use
374 /// per revision and shared (via `Arc` clone) by every later `inlay_hint`
375 /// request against this same `WorkspaceIndexData`.
376 pub fn func_signatures(&self) -> Arc<HashMap<String, FuncSignature>> {
377 Arc::clone(
378 self.func_signatures
379 .get_or_init(|| Arc::new(build_func_signatures(&self.files))),
380 )
381 }
382}
383
384/// Arc wrapper for `workspace_index`, which is `#[salsa::tracked(no_eq)]` —
385/// every rebuild allocates a fresh `Arc` and salsa never attempts to compare
386/// `WorkspaceIndexData` structurally.
387#[derive(Clone)]
388pub struct WorkspaceIndexArc(pub Arc<WorkspaceIndexData>);
389
390impl WorkspaceIndexArc {
391 #[cfg(test)]
392 pub fn get(&self) -> &WorkspaceIndexData {
393 &self.0
394 }
395}