Skip to main content

gdscript_api/
lib.rs

1//! `gdscript-api` — the Godot engine model, generated from `extension_api.json`.
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//! The model (engine classes + inheritance chain, methods, properties, signals, enums,
7//! constants, singletons, utility functions, builtin Variant types) plus the hand-authored
8//! GDScript layer the dump omits (pseudo-constants + builtin functions). See
9//! `plans/PHASE-2-IMPLEMENTATION-PLAYBOOK.md` §4.
10//!
11//! ## Shape
12//! [`model::ApiData`] is the serializable root that `xtask codegen-api` `rkyv`-encodes into a
13//! binary blob; [`EngineApi`] deserializes it once, rebuilds the name indices, merges the
14//! hand-authored layer, and exposes the lookup API (`lookup.rs`). The model is `Arc`-shared
15//! and excluded from per-file timing, so the one-time deserialize is amortized.
16//!
17//! ## Targets
18//! Native builds embed the blob via `include_bytes!` ([`bundled`], behind the default
19//! `bundled-api` feature). The crate never touches `std::fs`/clocks/threads, so it builds for
20//! `wasm32`; there the blob is **not** embedded (Playbook §4.5) — the host fetches it and calls
21//! [`EngineApi::from_bytes`].
22#![cfg_attr(docsrs, feature(doc_cfg))]
23
24pub mod gdscript_layer;
25/// Generated engine-API metadata (version + counts). Produced by `cargo xtask codegen-api`.
26pub mod generated;
27pub mod lookup;
28pub mod model;
29
30use rustc_hash::FxHashMap;
31
32pub use lookup::MemberRef;
33pub use model::{
34    ApiData, ApiType, ApiVersion, BuiltinData, BuiltinId, BuiltinMember, ClassData, ClassId,
35    ConstInfo, DocId, DocStore, ElemRef, EnumInfo, EnumValue, MethodSig, OperatorSig, Param,
36    PropertyInfo, SignalSig, TyRef, UtilityFn,
37};
38
39/// The Godot version string the bundled engine-API artifact was generated from.
40#[must_use]
41pub fn godot_version() -> &'static str {
42    generated::GODOT_VERSION
43}
44
45/// An error loading the engine-API blob.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum LoadError {
48    /// The `rkyv` blob failed to validate or decode.
49    Decode(String),
50}
51
52impl std::fmt::Display for LoadError {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            Self::Decode(msg) => write!(f, "failed to decode engine-API blob: {msg}"),
56        }
57    }
58}
59
60impl std::error::Error for LoadError {}
61
62/// The loaded, indexed Godot engine model.
63///
64/// Holds the deserialized [`ApiData`] plus the name → id indices rebuilt at load (kept out of
65/// the blob so the archived form stays portable — Playbook §4.5) and the hand-authored
66/// GDScript layer (pseudo-constants + builtin functions).
67#[derive(Debug)]
68pub struct EngineApi {
69    pub(crate) data: ApiData,
70    pub(crate) class_by_name: FxHashMap<String, ClassId>,
71    pub(crate) builtin_by_name: FxHashMap<String, BuiltinId>,
72    pub(crate) singleton_by_name: FxHashMap<String, ClassId>,
73    pub(crate) utility_by_name: FxHashMap<String, u32>,
74    pub(crate) global_enum_by_name: FxHashMap<String, u32>,
75    /// Hand-authored `@GlobalScope`/`@GDScript` pseudo-constants (`PI`/`TAU`/`INF`/`NAN`).
76    pub(crate) global_consts: Vec<gdscript_layer::GlobalConst>,
77    /// Hand-authored GDScript builtin functions (`preload`/`range`/`len`/…).
78    pub(crate) gdscript_builtins: Vec<gdscript_layer::BuiltinFn>,
79    /// Cached id of the `int` builtin (used to type engine-class integer constants).
80    pub(crate) int_builtin: Option<BuiltinId>,
81    /// Per-symbol Markdown documentation, when loaded (native `bundled-docs` embed, or a host
82    /// `set_docs` call). `None` on `wasm32`/when docs aren't bundled — hover then shows the
83    /// signature only, never an error.
84    pub(crate) docs: Option<DocStore>,
85}
86
87impl EngineApi {
88    /// Build the indexed model from a freshly decoded [`ApiData`], rebuilding the name indices
89    /// and merging the hand-authored GDScript layer.
90    #[must_use]
91    pub fn from_data(data: ApiData) -> Self {
92        let mut class_by_name = FxHashMap::default();
93        for (i, c) in data.classes.iter().enumerate() {
94            class_by_name.insert(
95                c.name.clone(),
96                ClassId(u32::try_from(i).unwrap_or(u32::MAX)),
97            );
98        }
99        let mut builtin_by_name = FxHashMap::default();
100        for (i, b) in data.builtins.iter().enumerate() {
101            builtin_by_name.insert(
102                b.name.clone(),
103                BuiltinId(u32::try_from(i).unwrap_or(u32::MAX)),
104            );
105        }
106        let singleton_by_name = data
107            .singletons
108            .iter()
109            .map(|(name, id)| (name.clone(), *id))
110            .collect();
111        let mut utility_by_name = FxHashMap::default();
112        for (i, u) in data.utilities.iter().enumerate() {
113            utility_by_name.insert(u.name.clone(), u32::try_from(i).unwrap_or(u32::MAX));
114        }
115        let mut global_enum_by_name = FxHashMap::default();
116        for (i, e) in data.global_enums.iter().enumerate() {
117            global_enum_by_name.insert(e.name.clone(), u32::try_from(i).unwrap_or(u32::MAX));
118        }
119        let int_builtin = builtin_by_name.get("int").copied();
120
121        Self {
122            data,
123            class_by_name,
124            builtin_by_name,
125            singleton_by_name,
126            utility_by_name,
127            global_enum_by_name,
128            global_consts: gdscript_layer::global_consts(),
129            gdscript_builtins: gdscript_layer::builtin_fns(),
130            int_builtin,
131            docs: None,
132        }
133    }
134
135    /// Install a documentation store (the native `bundled-docs` blob, or a host-supplied one).
136    pub fn set_docs(&mut self, docs: DocStore) {
137        self.docs = Some(docs);
138    }
139
140    /// The Markdown documentation for a [`DocId`], if a doc store is loaded and the handle is in
141    /// range. Returns `None` when docs aren't available (e.g. `wasm32` without a host `set_docs`),
142    /// so every caller degrades to a signature-only hover.
143    #[must_use]
144    pub fn doc(&self, id: DocId) -> Option<&str> {
145        self.docs.as_ref()?.get(id)
146    }
147
148    /// Decode and index an engine-API blob produced by `xtask codegen-api`.
149    ///
150    /// The bytes are copied into a 16-byte-aligned buffer before validation so a misaligned
151    /// source (e.g. `include_bytes!` or a `fetch()`ed `ArrayBuffer`) decodes correctly.
152    ///
153    /// # Errors
154    /// Returns [`LoadError::Decode`] if the blob fails `rkyv` validation.
155    pub fn from_bytes(bytes: &[u8]) -> Result<Self, LoadError> {
156        let mut aligned = rkyv::util::AlignedVec::<16>::new();
157        aligned.extend_from_slice(bytes);
158        let data = rkyv::from_bytes::<ApiData, rkyv::rancor::Error>(aligned.as_slice())
159            .map_err(|e| LoadError::Decode(e.to_string()))?;
160        Ok(Self::from_data(data))
161    }
162
163    /// The Godot version this model was generated from.
164    #[must_use]
165    pub fn version(&self) -> &ApiVersion {
166        &self.data.version
167    }
168}
169
170impl DocStore {
171    /// Decode an `engine_docs.bin` blob produced by `xtask codegen-api`.
172    ///
173    /// The bytes are copied into a 16-byte-aligned buffer before validation, mirroring
174    /// [`EngineApi::from_bytes`], so a misaligned source decodes correctly.
175    ///
176    /// # Errors
177    /// Returns [`LoadError::Decode`] if the blob fails `rkyv` validation.
178    pub fn from_bytes(bytes: &[u8]) -> Result<Self, LoadError> {
179        let mut aligned = rkyv::util::AlignedVec::<16>::new();
180        aligned.extend_from_slice(bytes);
181        rkyv::from_bytes::<Self, rkyv::rancor::Error>(aligned.as_slice())
182            .map_err(|e| LoadError::Decode(e.to_string()))
183    }
184}
185
186/// The bundled engine-API model, decoded once on first use.
187///
188/// Native-only and gated on the default `bundled-api` feature: the blob is embedded via
189/// `include_bytes!`. On `wasm32` the blob is not embedded — fetch it and use
190/// [`EngineApi::from_bytes`] instead (Playbook §4.5).
191///
192/// # Panics
193/// Panics if the embedded blob fails to decode, which can only happen if `engine_api.bin` was
194/// hand-edited or truncated — `cargo xtask codegen-api` always emits a valid, self-validated
195/// artifact.
196#[cfg(all(feature = "bundled-api", not(target_arch = "wasm32")))]
197#[must_use]
198pub fn bundled() -> &'static EngineApi {
199    use std::sync::OnceLock;
200    static BUNDLED: OnceLock<EngineApi> = OnceLock::new();
201    static BYTES: &[u8] = include_bytes!("engine_api.bin");
202    BUNDLED.get_or_init(|| {
203        let mut api =
204            EngineApi::from_bytes(BYTES).expect("the bundled engine-API blob must be valid");
205        // The doc store is a separate, native-only embed (`bundled-docs`): it never enters the
206        // wasm-fetched `engine_api.bin`, so the playground download stays lean. A decode failure
207        // is non-fatal — hover simply falls back to signature-only.
208        #[cfg(feature = "bundled-docs")]
209        {
210            static DOC_BYTES: &[u8] = include_bytes!("engine_docs.bin");
211            if let Ok(docs) = DocStore::from_bytes(DOC_BYTES) {
212                api.set_docs(docs);
213            }
214        }
215        api
216    })
217}
218
219#[cfg(test)]
220mod tests {
221    #[test]
222    fn generated_metadata_is_present() {
223        // Regenerated by `cargo xtask codegen-api`; the version string is always populated.
224        assert!(!crate::generated::GODOT_VERSION.is_empty());
225    }
226
227    // The bundled blob is native-only behind the default feature (see `bundled`).
228    #[cfg(all(feature = "bundled-api", not(target_arch = "wasm32")))]
229    #[test]
230    fn bundled_blob_loads_and_resolves_golden_symbols() {
231        let api = crate::bundled();
232
233        // Version came through the blob, not just `generated.rs`.
234        assert_eq!(api.version().major, 4);
235        assert_eq!(api.version().minor, 5);
236
237        // Direct + inherited member resolution and the inheritance walk.
238        let node = api.class_by_name("Node").expect("Node class present");
239        let node2d = api.class_by_name("Node2D").expect("Node2D class present");
240        assert!(api.lookup_member(node, "add_child").is_some());
241        assert!(api.is_subclass(node2d, node), "Node2D is a Node");
242        assert!(
243            api.lookup_member(node2d, "add_child").is_some(),
244            "add_child is inherited onto Node2D"
245        );
246
247        // The `recv.<TAB>` candidate set includes inherited members, deduped.
248        let members = api.members_of(node2d);
249        assert!(members.iter().any(|m| m.name() == "add_child"));
250        assert!(members.iter().any(|m| m.name() == "position"));
251
252        // Singletons, builtins + operators, the enum-property getter cross-ref.
253        assert!(api.singleton("Input").is_some());
254        let v2 = api
255            .builtin_by_name("Vector2")
256            .expect("Vector2 builtin present");
257        assert!(api.builtin_member(v2, "x").is_some());
258        assert!(api.builtin_operators(v2).iter().any(|o| o.op == "+"));
259        let process_mode = api
260            .class(node)
261            .properties
262            .iter()
263            .find(|p| p.name == "process_mode")
264            .expect("Node.process_mode present");
265        assert!(
266            process_mode.enum_of.is_some(),
267            "process_mode is recovered as enum-typed from its getter"
268        );
269
270        // The hand-authored GDScript layer merged at load.
271        assert!(api.global_const("PI").is_some());
272        assert!(api.gdscript_builtin("preload").is_some());
273    }
274
275    // Hover docs are a separate native-only embed (`bundled-docs`).
276    #[cfg(all(feature = "bundled-docs", not(target_arch = "wasm32")))]
277    #[test]
278    fn bundled_docs_resolve_and_are_converted() {
279        use crate::MemberRef;
280        let api = crate::bundled();
281        let node = api.class_by_name("Node").expect("Node class");
282
283        // The class itself and a well-known method carry Markdown hover docs.
284        assert!(
285            api.class(node).doc.and_then(|id| api.doc(id)).is_some(),
286            "Node has a class hover doc"
287        );
288        let MemberRef::Method(m) = api.lookup_member(node, "add_child").expect("add_child") else {
289            panic!("add_child is a method");
290        };
291        let doc = m
292            .doc
293            .and_then(|id| api.doc(id))
294            .expect("add_child hover doc");
295        // BBCode was converted to Markdown — no `[/…]` closing tags survive.
296        assert!(!doc.contains("[/"), "BBCode leaked into hover: {doc:?}");
297    }
298}