gdscript_db/lib.rs
1//! `gdscript-db` — the input layer for the analyzer.
2//!
3//! Holds the virtual file system (`FileId` → text, always injected — never `std::fs`), the
4//! project model, and (from Phase 3) the **salsa** query graph: `#[salsa::input]`s set via
5//! `apply_change`, `#[salsa::tracked]` derived queries, durability tiers. The Phase-0/1/2
6//! plain VFS map + reparse-on-change is being replaced here, localized behind the unchanged
7//! `gdscript-ide` public API (Playbook §3.M0).
8//!
9//! Crate boundary: `gdscript-db` is the *base* of the salsa stack — it owns the [`Db`] trait,
10//! the inputs, and the [`parse`] query (it may depend on `gdscript-syntax`, never on
11//! `gdscript-hir`). The higher queries (`item_tree`, `analyze_file`) live in `gdscript-hir`,
12//! which depends on this crate for `&dyn Db`. This one-way layering is what avoids a
13//! `db ↔ hir` dependency cycle.
14//!
15//! `FileId` is deliberately **not** a salsa input. The `FileId → FileText` mapping is a side
16//! table ([`Files`]) the database owns, mirroring rust-analyzer's `base-db`: `FileId`s are
17//! assigned by the client/loader and stay opaque ids, while the salsa input is the *text*.
18//!
19//! Must build for `wasm32` (single-threaded; salsa with `default-features = false`).
20#![cfg_attr(docsrs, feature(doc_cfg))]
21
22use std::sync::Arc;
23
24use dashmap::DashMap;
25use dashmap::mapref::entry::Entry;
26use gdscript_api::EngineApi;
27use gdscript_base::FileId;
28use gdscript_syntax::Parse;
29use rustc_hash::FxBuildHasher;
30use salsa::{Durability, Setter};
31
32/// The database trait `gdscript-hir` / `gdscript-ide` depend on. `#[salsa::db]` on the *trait*
33/// makes it a salsa supertrait, so any `&dyn Db` upcasts to `&dyn salsa::Database` and every
34/// `#[salsa::tracked]` free function downstream can take `db: &dyn Db`.
35#[salsa::db]
36pub trait Db: salsa::Database {
37 /// The text input for `file`, or `None` if no text has been set for it.
38 fn file_text(&self, file: FileId) -> Option<FileText>;
39 /// The bundled engine model, or `None` on `wasm32` (no embedded blob — the host wires the
40 /// fetched blob in via `EngineApi::from_bytes` in Phase 5).
41 fn engine(&self) -> Option<&'static EngineApi>;
42 /// The project's file set, or `None` before any file has been applied. Project-wide queries
43 /// (the global `class_name` registry) take this as their salsa-tracked input.
44 fn source_root(&self) -> Option<SourceRoot>;
45 /// The project's `project.godot` config, or `None` in single-file mode. The autoload registry
46 /// (M4) takes this as its salsa-tracked input.
47 fn project_config(&self) -> Option<ProjectConfig>;
48}
49
50/// The VFS leaf: one file's UTF-8 text, as a salsa input, plus its [`FileId`] (so a query
51/// holding only a `FileText` can recover the id for cross-file resolution) and its `res://`
52/// path (so `preload`/`extends "res://…"` resolve to the declaring file — M3).
53///
54/// `res_path` is a **separate salsa input field** from `text`: salsa tracks input fields
55/// individually (per-field `revisions`/`durabilities` — verified against salsa 0.27.1
56/// `input.rs`), so a query reading only `res_path` (the `res_path_registry`) *backdates* across
57/// a `text` keystroke — exactly the firewall that protects `file_class_name`. It is held at
58/// `MEDIUM` durability (set on file add, stable across edits); `text` stays `LOW`.
59#[salsa::input(debug)]
60pub struct FileText {
61 /// The file's full text (interned `Arc<str>`; the getter returns `&Arc<str>`).
62 #[returns(ref)]
63 pub text: Arc<str>,
64 /// The opaque file id this text belongs to.
65 pub file_id: FileId,
66 /// The file's project-relative `res://` path, if the loader supplied one (`None` in
67 /// single-file mode / tests — then `preload`/`extends "res://…"` resolve to the seam).
68 pub res_path: Option<smol_str::SmolStr>,
69}
70
71/// The project's file set — a salsa input so project-wide queries (the global `class_name`
72/// registry, M1) iterate the files incrementally. It changes only when a file is **added or
73/// removed**, never on a body edit, and is held at MEDIUM durability — so a keystroke (a `LOW`
74/// change) never invalidates project-wide derived data.
75#[salsa::input]
76pub struct SourceRoot {
77 /// Every file currently in the project, ordered by `FileId` for determinism.
78 #[returns(ref)]
79 pub files: Vec<FileText>,
80}
81
82/// The project's `project.godot`, injected as raw text — the wasm-clean core never reads the
83/// filesystem, so the loader pushes the bytes exactly like a `.gd` file. The autoload index is a
84/// tracked query that parses this text (M4). Held at `MEDIUM` durability (project structure,
85/// stable across `.gd` keystrokes), so a body edit (LOW) never invalidates the autoload registry.
86#[salsa::input]
87pub struct ProjectConfig {
88 /// The full `project.godot` text.
89 #[returns(ref)]
90 pub project_godot_text: Arc<str>,
91}
92
93/// A generation counter that makes the otherwise-untracked runtime engine model **invalidate**
94/// correctly. The engine model is a leaked `&'static` side handle (not a salsa input), so a query
95/// memoized while it was still absent (`engine() == None`, on `wasm32` before `set_engine_api`)
96/// would otherwise return that stale empty result forever. Every `engine()` read records a
97/// dependency on this input; `set_engine_api` bumps it, recomputing those queries. The *value* is
98/// irrelevant — only that setting it advances the revision. Used on `wasm32` only (native has the
99/// bundled model from the start, so it never changes — no generation tracking, no overhead).
100#[salsa::input]
101pub struct EngineGeneration {
102 /// An opaque counter (only its revision matters).
103 pub generation: u32,
104}
105
106/// The `FileId → FileText` side table. `Arc`-backed so a cheap clone shares the same map —
107/// needed to mutate an input (`&mut dyn Db`) without simultaneously borrowing `self.files`.
108#[derive(Debug, Default, Clone)]
109pub struct Files {
110 inner: Arc<DashMap<FileId, FileText, FxBuildHasher>>,
111}
112
113impl Files {
114 /// The input for `file`, if set.
115 #[must_use]
116 pub fn file_text(&self, file: FileId) -> Option<FileText> {
117 self.inner.get(&file).map(|r| *r)
118 }
119
120 /// Create or update `file`'s text input at `durability`. Creating uses `&db`; updating an
121 /// existing input bumps the revision (`&mut db`), which is what cancels live read handles.
122 pub fn set_file_text(&self, db: &mut dyn Db, file: FileId, text: &str, durability: Durability) {
123 match self.inner.entry(file) {
124 Entry::Occupied(occ) => {
125 occ.get()
126 .set_text(db)
127 .with_durability(durability)
128 .to(Arc::from(text));
129 }
130 Entry::Vacant(vac) => {
131 let ft = FileText::builder(Arc::from(text), file, None)
132 .durability(durability)
133 .new(db);
134 vac.insert(ft);
135 }
136 }
137 }
138
139 /// Set `file`'s `res://` path at `MEDIUM` durability (stable project structure, like the
140 /// source root). No-op if the file is unknown or the path is unchanged: salsa does **not**
141 /// value-backdate an input setter (it bumps the field revision on *every* call, even for an
142 /// identical value — verified against salsa 0.27.1 `input.rs:set_field`), so a redundant set
143 /// would needlessly invalidate the `res_path_registry`. The guard keeps a re-`apply_change`
144 /// of an already-known path free.
145 pub fn set_file_path(&self, db: &mut dyn Db, file: FileId, path: &str) {
146 let Some(ft) = self.inner.get(&file).map(|r| *r) else {
147 return;
148 };
149 if ft.res_path(&*db).as_deref() == Some(path) {
150 return;
151 }
152 ft.set_res_path(db)
153 .with_durability(Durability::MEDIUM)
154 .to(Some(smol_str::SmolStr::new(path)));
155 }
156
157 /// Drop `file` from the side table (its salsa input lingers, unreferenced, until GC).
158 pub fn remove(&self, file: FileId) {
159 self.inner.remove(&file);
160 }
161
162 /// Every file, ordered by `FileId` — the deterministic input to project-wide queries.
163 fn all(&self) -> Vec<FileText> {
164 let mut v: Vec<(FileId, FileText)> =
165 self.inner.iter().map(|r| (*r.key(), *r.value())).collect();
166 v.sort_by_key(|(id, _)| *id);
167 v.into_iter().map(|(_, ft)| ft).collect()
168 }
169}
170
171/// Parse a file to its lossless CST. Memoized; re-parses only when the file text changes.
172#[salsa::tracked]
173pub fn parse(db: &dyn Db, file: FileText) -> Parse {
174 gdscript_syntax::parse(file.text(db))
175}
176
177/// The concrete analyzer database — a salsa `Storage` plus the [`Files`] side table.
178#[salsa::db]
179#[derive(Clone, Default)]
180pub struct RootDatabase {
181 storage: salsa::Storage<Self>,
182 files: Files,
183 /// The project file-set input (lazily created on the first file change). Held outside salsa
184 /// as a handle so `apply_change` can update it.
185 root: Option<SourceRoot>,
186 /// The `project.godot` config input (lazily created on the first config push). Held outside
187 /// salsa as a handle so `apply_change` can update it (M4 autoloads).
188 config: Option<ProjectConfig>,
189 /// A runtime-injected engine model. `None` falls back to the bundled blob on native and to "no
190 /// engine model" on `wasm32` (where nothing is embedded). The wasm binding fetches the blob and
191 /// installs it here via [`RootDatabase::set_engine_api`] (Playbook §4.4). Held outside salsa (a
192 /// process-lifetime `&'static`, leaked once).
193 engine: Option<&'static EngineApi>,
194 /// `wasm32`-only: the [`EngineGeneration`] input that makes a *later* `set_engine_api` invalidate
195 /// queries memoized while the model was still absent (so the order "query, then load the engine"
196 /// is correct, not just "load, then query"). Lazily created on the first structural change.
197 #[cfg(target_arch = "wasm32")]
198 engine_gen: Option<EngineGeneration>,
199}
200
201// `salsa::Storage` is not `Debug`, but the public `AnalysisHost`/`Analysis` that will own a
202// `RootDatabase` must stay `Debug` (frozen API); hand-impl an opaque one.
203impl std::fmt::Debug for RootDatabase {
204 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205 f.debug_struct("RootDatabase").finish_non_exhaustive()
206 }
207}
208
209impl RootDatabase {
210 /// Create/update `file`'s text input (the single input-mutation primitive `apply_change`
211 /// drives). Clones the `Arc`-backed [`Files`] handle first so `self` is free to pass as the
212 /// `&mut dyn Db` the salsa setter needs.
213 pub fn set_file_text(&mut self, file: FileId, text: &str, durability: Durability) {
214 let files = self.files.clone();
215 files.set_file_text(self, file, text, durability);
216 }
217
218 /// Set `file`'s `res://` path (the loader supplies it on add; M3 `preload`/`extends` resolve
219 /// through it). Guarded against no-op re-sets — see [`Files::set_file_path`].
220 pub fn set_file_path(&mut self, file: FileId, path: &str) {
221 let files = self.files.clone();
222 files.set_file_path(self, file, path);
223 }
224
225 /// Remove `file`'s entry from the side table.
226 pub fn remove_file(&mut self, file: FileId) {
227 self.files.remove(file);
228 }
229
230 /// Set the project's `project.godot` text (the loader supplies it on project open / when it
231 /// changes — M4 autoloads). No-op if unchanged: salsa bumps an input field's revision on
232 /// every set even for an identical value, so a redundant push would needlessly invalidate the
233 /// autoload registry. Held at `MEDIUM` durability, so a `.gd` keystroke never touches it.
234 pub fn set_project_config(&mut self, text: &str) {
235 if let Some(cfg) = self.config {
236 if cfg.project_godot_text(self).as_ref() == text {
237 return;
238 }
239 cfg.set_project_godot_text(self)
240 .with_durability(Durability::MEDIUM)
241 .to(Arc::from(text));
242 } else {
243 self.config = Some(
244 ProjectConfig::builder(Arc::from(text))
245 .durability(Durability::MEDIUM)
246 .new(self),
247 );
248 }
249 }
250
251 /// Install a runtime-loaded engine model (the wasm path: a `fetch`ed `extension_api` blob
252 /// decoded via [`EngineApi::from_bytes`]). Leaked to `&'static` (one per session, process
253 /// lifetime). **Load-once before any query** — the engine model is not a salsa input, so a later
254 /// set would not invalidate cached reads; first-wins (a redundant install is ignored, so the
255 /// leak happens at most once). Native builds normally never call this (they fall back to the
256 /// bundled blob); it is the seam the wasm/wasip1 binding uses.
257 pub fn set_engine_api(&mut self, api: EngineApi) {
258 if self.engine.is_none() {
259 self.engine = Some(Box::leak(Box::new(api)));
260 // wasm: advance the generation so any query memoized while the model was absent (the
261 // "query before load" order) recomputes. Native never reaches here through the bindings,
262 // and its bundled model is present from the start, so it needs no generation tracking.
263 #[cfg(target_arch = "wasm32")]
264 self.bump_engine_generation();
265 }
266 }
267
268 /// wasm-only: create-or-advance the [`EngineGeneration`] input (see its docs). Creating it the
269 /// first time is harmless; advancing it invalidates every query that read `engine()`.
270 #[cfg(target_arch = "wasm32")]
271 fn bump_engine_generation(&mut self) {
272 if let Some(eg) = self.engine_gen {
273 let next = eg.generation(self).wrapping_add(1);
274 eg.set_generation(self)
275 .with_durability(Durability::MEDIUM)
276 .to(next);
277 } else {
278 self.engine_gen = Some(
279 EngineGeneration::builder(0)
280 .durability(Durability::MEDIUM)
281 .new(self),
282 );
283 }
284 }
285
286 /// Rebuild the project file-set input from the current side table. Call this from
287 /// `apply_change` **only when a file was added or removed** — never on a body edit — so the
288 /// MEDIUM-durability project input (and everything derived from it) stays stable across
289 /// keystrokes.
290 pub fn sync_source_root(&mut self) {
291 // wasm: ensure the engine generation exists before the first query runs, so every query's
292 // `engine()` read records a dependency on it — otherwise a `set_engine_api` afterwards could
293 // not invalidate a query that ran before the input existed. (The first structural change
294 // always precedes the first query, since the Session early-returns for unknown URIs.)
295 #[cfg(target_arch = "wasm32")]
296 if self.engine_gen.is_none() {
297 self.engine_gen = Some(
298 EngineGeneration::builder(0)
299 .durability(Durability::MEDIUM)
300 .new(self),
301 );
302 }
303 let files = self.files.all();
304 if let Some(root) = self.root {
305 root.set_files(self)
306 .with_durability(Durability::MEDIUM)
307 .to(files);
308 } else {
309 let root = SourceRoot::builder(files)
310 .durability(Durability::MEDIUM)
311 .new(self);
312 self.root = Some(root);
313 }
314 }
315}
316
317#[salsa::db]
318impl salsa::Database for RootDatabase {}
319
320#[salsa::db]
321impl Db for RootDatabase {
322 fn file_text(&self, file: FileId) -> Option<FileText> {
323 self.files.file_text(file)
324 }
325
326 // A runtime-injected model wins; else native falls back to the bundled blob and wasm32 to
327 // `None` (until the binding installs a fetched blob). clippy sees one target per build.
328 #[allow(clippy::unnecessary_wraps)]
329 fn engine(&self) -> Option<&'static EngineApi> {
330 // wasm: record a dependency on the generation so a later `set_engine_api` invalidates this
331 // read. (Native skips this entirely — the bundled model is constant, so zero overhead.)
332 #[cfg(target_arch = "wasm32")]
333 if let Some(eg) = self.engine_gen {
334 let _ = eg.generation(self);
335 }
336 if let Some(api) = self.engine {
337 return Some(api);
338 }
339 #[cfg(not(target_arch = "wasm32"))]
340 {
341 Some(gdscript_api::bundled())
342 }
343 #[cfg(target_arch = "wasm32")]
344 {
345 None
346 }
347 }
348
349 fn source_root(&self) -> Option<SourceRoot> {
350 self.root
351 }
352
353 fn project_config(&self) -> Option<ProjectConfig> {
354 self.config
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
363 fn parse_query_returns_a_cst() {
364 let mut db = RootDatabase::default();
365 db.set_file_text(FileId(0), "func f():\n\tpass\n", Durability::LOW);
366 let ft = db.file_text(FileId(0)).unwrap();
367 let p = parse(&db, ft);
368 assert!(p.errors().is_empty());
369 // Re-querying the same input returns the memoized value (no re-parse).
370 assert_eq!(parse(&db, ft).debug_tree(), p.debug_tree());
371 }
372
373 #[test]
374 fn set_get_remove_round_trips() {
375 let mut db = RootDatabase::default();
376 let id = FileId(7);
377 db.set_file_text(id, "var x = 1\n", Durability::LOW);
378 assert_eq!(db.file_text(id).unwrap().text(&db).as_ref(), "var x = 1\n");
379 // Update in place.
380 db.set_file_text(id, "var y = 2\n", Durability::LOW);
381 assert_eq!(db.file_text(id).unwrap().text(&db).as_ref(), "var y = 2\n");
382 // Remove.
383 db.remove_file(id);
384 assert!(db.file_text(id).is_none());
385 }
386
387 #[test]
388 fn res_path_round_trips_and_guards_no_op_sets() {
389 let mut db = RootDatabase::default();
390 let id = FileId(3);
391 // No path until the loader sets one.
392 db.set_file_text(id, "class_name A\n", Durability::LOW);
393 assert_eq!(db.file_text(id).unwrap().res_path(&db), None);
394 // Set, then read back.
395 db.set_file_path(id, "res://a.gd");
396 assert_eq!(
397 db.file_text(id).unwrap().res_path(&db).as_deref(),
398 Some("res://a.gd")
399 );
400 // A re-set of the SAME path is a guarded no-op (does not panic / regress); a real rename
401 // updates it.
402 db.set_file_path(id, "res://a.gd");
403 db.set_file_path(id, "res://b.gd");
404 assert_eq!(
405 db.file_text(id).unwrap().res_path(&db).as_deref(),
406 Some("res://b.gd")
407 );
408 // Setting a path for an unknown file is a no-op (no panic).
409 db.set_file_path(FileId(999), "res://ghost.gd");
410 assert!(db.file_text(FileId(999)).is_none());
411 }
412}