Skip to main content

gdscript_db/
lib.rs

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