Skip to main content

ridl_core/
db.rs

1//! The salsa incremental database for the RIDL family (docs/ROADMAP.md epic
2//! E0.4, ADR-0004 §3).
3//!
4//! This crate is where "the compiler is a library first" becomes concrete: an
5//! [`InputFile`] input, a memoized [`parse_file`] query over
6//! [`ridl_syntax::parse`], and the concrete [`RidlDatabase`] that `ridlc` and
7//! the language server run on. Salsa memoizes each parse and backdates on the
8//! green-tree equality of [`ridl_syntax::Parse`], so editing one file's text
9//! re-runs the parse for that file alone.
10
11use std::sync::{Arc, Mutex, OnceLock};
12
13use ridl_syntax::{Parse, Profile, parse};
14
15use crate::package::Package;
16
17/// A source file the compiler tracks: its path and its text.
18///
19/// Editing the text through the generated `set_text` setter starts a new salsa
20/// revision, which is what drives incremental reparsing.
21#[salsa::input(debug)]
22pub struct InputFile {
23    pub path: String,
24    #[returns(ref)]
25    pub text: String,
26}
27
28/// The [`Profile`] a source path selects (E2 task 2): a `.ridl` file parses
29/// under [`Profile::Ridl`], a `.rsdl` file under [`Profile::Rsdl`]; everything
30/// else — `.typl` first of all — parses under [`Profile::Typl`].
31pub fn profile_of_path(path: &str) -> Profile {
32    if path.ends_with(".ridl") {
33        Profile::Ridl
34    } else if path.ends_with(".rsdl") {
35        Profile::Rsdl
36    } else {
37        Profile::Typl
38    }
39}
40
41/// Parses one [`InputFile`] into a lossless [`Parse`], under the profile its
42/// path selects ([`profile_of_path`]).
43///
44/// Salsa memoizes the result and backdates on [`Parse`] equality (green-tree
45/// identity), so the body re-runs only when the file's `text` changes.
46#[salsa::tracked(returns(clone))]
47pub fn parse_file(db: &dyn salsa::Database, file: InputFile) -> Parse {
48    parse(file.text(db), profile_of_path(file.path(db)))
49}
50
51/// The concrete salsa database `ridlc` and the language server run on.
52///
53/// A salsa event callback records the `database_key` of every tracked-query
54/// execution, so a test can assert not only how many queries re-ran after an
55/// edit but exactly which ones (issue #102). The log is read and reset with
56/// [`RidlDatabase::take_executed_queries`]. The database deliberately does not
57/// implement `Clone`: a clone sharing the execution log would make the
58/// observability counters ambiguous (issue #102).
59#[salsa::db]
60pub struct RidlDatabase {
61    storage: salsa::Storage<Self>,
62    executed: Arc<Mutex<Vec<salsa::DatabaseKeyIndex>>>,
63    /// The memoized built-in `ridl.std` package
64    /// ([`std_package`](crate::std_lib::std_package)), created at most once
65    /// per database.
66    pub(crate) std_package_cache: OnceLock<Package>,
67}
68
69impl RidlDatabase {
70    /// Returns the `database_key` of every tracked-query execution since the
71    /// previous call, in execution order, and clears the log.
72    pub fn take_executed_queries(&self) -> Vec<salsa::DatabaseKeyIndex> {
73        std::mem::take(
74            &mut *self
75                .executed
76                .lock()
77                .expect("the execution log mutex is never poisoned"),
78        )
79    }
80}
81
82impl Default for RidlDatabase {
83    fn default() -> Self {
84        // A manual `Default` (rather than a derive) is required to install the
85        // event callback at `Storage` construction; the callback records the
86        // executed query's key on every query execution.
87        let executed: Arc<Mutex<Vec<salsa::DatabaseKeyIndex>>> = Arc::default();
88        let storage = salsa::Storage::new(Some(Box::new({
89            let executed = executed.clone();
90            move |event| {
91                if let salsa::EventKind::WillExecute { database_key } = event.kind {
92                    executed
93                        .lock()
94                        .expect("the execution log mutex is never poisoned")
95                        .push(database_key);
96                }
97            }
98        })));
99        Self {
100            storage,
101            executed,
102            std_package_cache: OnceLock::new(),
103        }
104    }
105}
106
107#[salsa::db]
108impl salsa::Database for RidlDatabase {}
109
110#[cfg(test)]
111mod tests {
112    use super::{InputFile, RidlDatabase, parse_file, profile_of_path};
113    use ridl_syntax::Profile;
114    use salsa::Setter;
115    use salsa::plumbing::AsId;
116
117    #[test]
118    fn profile_of_path_selects_ridl_by_extension() {
119        assert_eq!(profile_of_path("a/b.ridl"), Profile::Ridl);
120        assert_eq!(profile_of_path("a/b.typl"), Profile::Typl);
121        assert_eq!(profile_of_path("no_extension"), Profile::Typl);
122    }
123
124    #[test]
125    fn profile_of_path_selects_rsdl_by_extension() {
126        assert_eq!(profile_of_path("veh/topology/system.rsdl"), Profile::Rsdl);
127        assert_eq!(profile_of_path("a/b.rsdl.typl"), Profile::Typl);
128    }
129
130    /// The registry change of rsdl v0.2 (rsdl reference §2, typl §1.4), seen
131    /// through file paths: a word the registry gained is a reserved word in a
132    /// `.typl` and a `.ridl` file (FORM-105 as a declared name), and a word it
133    /// lost is an ordinary name in a `.typl` file.
134    #[test]
135    fn the_rsdl_registry_change_reaches_typl_and_ridl_files() {
136        let db = RidlDatabase::default();
137        let codes = |path: &str, text: String| -> Vec<&'static str> {
138            let file = InputFile::new(&db, path.to_string(), text);
139            parse_file(&db, file)
140                .errors()
141                .iter()
142                .map(|e| e.code)
143                .collect()
144        };
145        for word in ["offers", "distribution", "machine"] {
146            let text = format!("package p\ntype {word}: m\n");
147            assert_eq!(
148                codes("x.typl", text.clone()),
149                vec!["FORM-105"],
150                "`{word}` in .typl"
151            );
152            assert_eq!(codes("x.ridl", text), vec!["FORM-105"], "`{word}` in .ridl");
153        }
154        for word in [
155            "provides",
156            "instance",
157            "assurance",
158            "target",
159            "place",
160            "on",
161            "transport",
162            "bundle",
163            "time",
164            "base",
165            "redundant",
166            "supervise",
167            "degraded",
168        ] {
169            let text = format!("package p\ntype {word}: m\n");
170            assert_eq!(
171                codes("x.typl", text),
172                Vec::<&str>::new(),
173                "`{word}` in .typl"
174            );
175        }
176    }
177
178    /// The profile boundary rides on the path: the same text draws TYPL-302
179    /// under a `.typl` path and the generic FORM-102 under a `.ridl` path.
180    #[test]
181    fn parse_file_derives_the_profile_from_the_path() {
182        let db = RidlDatabase::default();
183        let text = "package p\n@\n";
184        let typl = InputFile::new(&db, "x.typl".to_string(), text.to_string());
185        let ridl = InputFile::new(&db, "x.ridl".to_string(), text.to_string());
186
187        let typl_codes: Vec<_> = parse_file(&db, typl)
188            .errors()
189            .iter()
190            .map(|e| e.code)
191            .collect();
192        assert_eq!(typl_codes, vec!["TYPL-302"]);
193
194        let ridl_codes: Vec<_> = parse_file(&db, ridl)
195            .errors()
196            .iter()
197            .map(|e| e.code)
198            .collect();
199        assert_eq!(ridl_codes, vec!["FORM-102"]);
200    }
201
202    /// Renders a `database_key` the way salsa's ingredient does with the
203    /// database attached, e.g. `parse_file(Id(0))`.
204    fn rendered(db: &RidlDatabase, key: salsa::DatabaseKeyIndex) -> String {
205        salsa::attach(db, || format!("{key:?}"))
206    }
207
208    /// The salsa spike's proof: editing one file's text re-parses only that
209    /// file, and the other file's parse stays memoized (docs/ROADMAP.md epic
210    /// E0.4). The event callback records each executed query's `database_key`,
211    /// so the test asserts *which* query re-ran, not just how many (issue
212    /// #102).
213    #[test]
214    fn edit_reparses_only_the_edited_file() {
215        let mut db = RidlDatabase::default();
216
217        let a = InputFile::new(&db, "a.typl".to_string(), "type A: m".to_string());
218        let b = InputFile::new(&db, "b.typl".to_string(), "type B: s".to_string());
219
220        // Initial parse of both inputs runs the query twice.
221        let parse_a = parse_file(&db, a);
222        let parse_b = parse_file(&db, b);
223        let executed = db.take_executed_queries();
224        assert_eq!(
225            executed.len(),
226            2,
227            "the first parse of A and B must run the query exactly twice",
228        );
229        assert_eq!(parse_a.syntax().text().to_string(), "type A: m");
230        assert_eq!(parse_b.syntax().text().to_string(), "type B: s");
231
232        // Re-querying unchanged inputs is a pure memo hit: no executions.
233        let _ = parse_file(&db, a);
234        let _ = parse_file(&db, b);
235        assert_eq!(
236            db.take_executed_queries(),
237            Vec::new(),
238            "re-querying unchanged inputs must run no executions",
239        );
240
241        // Edit A's text only.
242        a.set_text(&mut db).to("type A: kg".to_string());
243
244        let parse_a2 = parse_file(&db, a);
245        let parse_b2 = parse_file(&db, b);
246        let executed = db.take_executed_queries();
247        assert_eq!(
248            executed.len(),
249            1,
250            "editing A must re-parse exactly one file",
251        );
252        assert_eq!(
253            rendered(&db, executed[0]),
254            format!("parse_file({:?})", a.as_id()),
255            "the re-executed query must be the parse of A, keyed by A's input",
256        );
257        assert_eq!(
258            parse_a2.syntax().text().to_string(),
259            "type A: kg",
260            "A's memoized parse must reflect the edited text",
261        );
262        assert_ne!(
263            parse_a2, parse_a,
264            "A's parse value must change after the edit"
265        );
266        assert_eq!(
267            parse_b2, parse_b,
268            "B's parse value must stay memoized and unchanged",
269        );
270    }
271}