Skip to main content

ridl_core/
db.rs

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