Skip to main content

php_lsp/db/
refs.rs

1//! `file_refs` / `symbol_refs` salsa queries — Phase D.
2//!
3//! Replaces the imperative `build_reference_index` scan that ran on workspace
4//! startup. References are now computed lazily on first `textDocument/references`
5//! call and memoized thereafter. Body-only edits to a single file invalidate
6//! only that file's `file_refs`; structural edits also invalidate `codebase(ws)`
7//! which cascades into every `file_refs` because StatementsAnalyzer depends on
8//! the finalized codebase.
9
10use std::sync::Arc;
11
12use salsa::{Database, Update};
13
14use crate::db::codebase::codebase;
15use crate::db::input::{SourceFile, Workspace};
16use crate::db::parse::parsed_doc;
17
18/// A single Pass-2 reference observed during StatementsAnalyzer.
19/// `key` mirrors `Codebase::symbol_reference_locations` keys so that consumers
20/// can aggregate by the same scheme `mark_*_referenced_at` would have used.
21#[derive(Debug, Clone)]
22pub struct FileRefRecord {
23    pub key: Arc<str>,
24    pub start: u32,
25    pub end: u32,
26}
27
28#[derive(Clone)]
29pub struct FileRefsArc(pub Arc<Vec<FileRefRecord>>);
30
31impl FileRefsArc {
32    pub fn get(&self) -> &[FileRefRecord] {
33        &self.0
34    }
35}
36
37// SAFETY: same contract as other `*Arc` newtypes — `Arc::ptr_eq` is sufficient
38// because every re-run of the tracked query allocates a fresh `Arc`.
39unsafe impl Update for FileRefsArc {
40    unsafe fn maybe_update(old_pointer: *mut Self, new_value: Self) -> bool {
41        let old_ref = unsafe { &mut *old_pointer };
42        if Arc::ptr_eq(&old_ref.0, &new_value.0) {
43            false
44        } else {
45            *old_ref = new_value;
46            true
47        }
48    }
49}
50
51#[derive(Clone)]
52pub struct SymbolRefsArc(pub Arc<Vec<(Arc<str>, u32, u32)>>);
53
54impl SymbolRefsArc {
55    pub fn get(&self) -> &[(Arc<str>, u32, u32)] {
56        &self.0
57    }
58}
59
60unsafe impl Update for SymbolRefsArc {
61    unsafe fn maybe_update(old_pointer: *mut Self, new_value: Self) -> bool {
62        let old_ref = unsafe { &mut *old_pointer };
63        if Arc::ptr_eq(&old_ref.0, &new_value.0) {
64            false
65        } else {
66            *old_ref = new_value;
67            true
68        }
69    }
70}
71
72/// Run Pass-2 analysis on `file` against the workspace codebase and return
73/// every resolved reference with its codebase key and byte span.
74///
75/// The analyzer internally also calls `mark_*_referenced_at` on the Codebase
76/// Arc from `codebase(ws)` — we deliberately ignore those mutations here and
77/// build our own aggregation via `symbol_refs`. This keeps the data flow
78/// purely functional from salsa's perspective even though the underlying
79/// Codebase uses interior mutability.
80#[salsa::tracked(no_eq)]
81pub fn file_refs(db: &dyn Database, ws: Workspace, file: SourceFile) -> FileRefsArc {
82    let cb = codebase(db, ws);
83    let doc = parsed_doc(db, file);
84    let uri = file.uri(db);
85    let source = file.text(db);
86    let map = php_rs_parser::source_map::SourceMap::new(&source);
87    let mut issue_buffer = mir_issues::IssueBuffer::new();
88    let mut symbols = Vec::new();
89    {
90        let mut analyzer = mir_analyzer::stmt::StatementsAnalyzer::new(
91            cb.get(),
92            uri,
93            &source,
94            &map,
95            &mut issue_buffer,
96            &mut symbols,
97            ws.php_version(db),
98        );
99        let mut ctx = mir_analyzer::context::Context::new();
100        analyzer.analyze_stmts(&doc.get().program().stmts, &mut ctx);
101    }
102
103    let records: Vec<FileRefRecord> = symbols
104        .into_iter()
105        .filter_map(|s| {
106            let key = s.codebase_key()?;
107            Some(FileRefRecord {
108                key: Arc::from(key),
109                start: s.span.start,
110                end: s.span.end,
111            })
112        })
113        .collect();
114    FileRefsArc(Arc::new(records))
115}
116
117/// Aggregate every file's `file_refs` filtered by `key` into a flat
118/// `(uri, start, end)` list — drop-in replacement for
119/// `Codebase::get_reference_locations`.
120#[salsa::tracked(no_eq)]
121pub fn symbol_refs(db: &dyn Database, ws: Workspace, key: String) -> SymbolRefsArc {
122    let files = ws.files(db);
123    let mut out: Vec<(Arc<str>, u32, u32)> = Vec::new();
124    for sf in files.iter() {
125        let refs = file_refs(db, ws, *sf);
126        let uri = sf.uri(db);
127        for r in refs.get() {
128            if r.key.as_ref() == key.as_str() {
129                out.push((uri.clone(), r.start, r.end));
130            }
131        }
132    }
133    SymbolRefsArc(Arc::new(out))
134}
135
136#[cfg(test)]
137mod tests {
138    use std::sync::Arc;
139
140    use super::*;
141    use crate::db::analysis::AnalysisHost;
142    use crate::db::input::{FileId, SourceFile};
143
144    #[test]
145    fn symbol_refs_finds_function_call_across_files() {
146        let host = AnalysisHost::new();
147        let f1 = SourceFile::new(
148            host.db(),
149            FileId(0),
150            Arc::<str>::from("file:///a.php"),
151            Arc::<str>::from("<?php\nfunction greet(): void {}"),
152            None,
153        );
154        let f2 = SourceFile::new(
155            host.db(),
156            FileId(1),
157            Arc::<str>::from("file:///b.php"),
158            Arc::<str>::from("<?php\ngreet();"),
159            None,
160        );
161        let ws = Workspace::new(
162            host.db(),
163            Arc::from([f1, f2]),
164            mir_analyzer::PhpVersion::LATEST,
165        );
166
167        let locs = symbol_refs(host.db(), ws, "greet".to_string());
168        let found: Vec<&str> = locs.get().iter().map(|(u, _, _)| u.as_ref()).collect();
169        assert!(
170            found.iter().any(|u| *u == "file:///b.php"),
171            "expected a reference from b.php, got {:?}",
172            found
173        );
174    }
175
176    #[test]
177    fn symbol_refs_memoizes_per_key() {
178        let host = AnalysisHost::new();
179        let f1 = SourceFile::new(
180            host.db(),
181            FileId(0),
182            Arc::<str>::from("file:///a.php"),
183            Arc::<str>::from("<?php\nfunction hi(): void {}\nhi();"),
184            None,
185        );
186        let ws = Workspace::new(host.db(), Arc::from([f1]), mir_analyzer::PhpVersion::LATEST);
187
188        let a = symbol_refs(host.db(), ws, "hi".to_string());
189        let b = symbol_refs(host.db(), ws, "hi".to_string());
190        assert!(Arc::ptr_eq(&a.0, &b.0), "second call should be memoized");
191    }
192}