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    /// Every `@GlobalScope` enum VALUE name (`MOUSE_BUTTON_LEFT`, `OK`, `TYPE_STRING`, …) →
76    /// `(enum index, value index)`. These are bare globals in GDScript; first declarer wins on
77    /// the (rare) duplicate name.
78    pub(crate) global_enum_value_by_name: FxHashMap<String, (u32, u32)>,
79    /// Hand-authored `@GlobalScope`/`@GDScript` pseudo-constants (`PI`/`TAU`/`INF`/`NAN`).
80    pub(crate) global_consts: Vec<gdscript_layer::GlobalConst>,
81    /// Hand-authored GDScript builtin functions (`preload`/`range`/`len`/…).
82    pub(crate) gdscript_builtins: Vec<gdscript_layer::BuiltinFn>,
83    /// Cached id of the `int` builtin (used to type engine-class integer constants).
84    pub(crate) int_builtin: Option<BuiltinId>,
85    /// Per-symbol Markdown documentation, when loaded (native `bundled-docs` embed, or a host
86    /// `set_docs` call). `None` on `wasm32`/when docs aren't bundled — hover then shows the
87    /// signature only, never an error.
88    pub(crate) docs: Option<DocStore>,
89}
90
91impl EngineApi {
92    /// Build the indexed model from a freshly decoded [`ApiData`], rebuilding the name indices
93    /// and merging the hand-authored GDScript layer.
94    #[must_use]
95    pub fn from_data(data: ApiData) -> Self {
96        let mut class_by_name = FxHashMap::default();
97        for (i, c) in data.classes.iter().enumerate() {
98            class_by_name.insert(
99                c.name.clone(),
100                ClassId(u32::try_from(i).unwrap_or(u32::MAX)),
101            );
102        }
103        let mut builtin_by_name = FxHashMap::default();
104        for (i, b) in data.builtins.iter().enumerate() {
105            builtin_by_name.insert(
106                b.name.clone(),
107                BuiltinId(u32::try_from(i).unwrap_or(u32::MAX)),
108            );
109        }
110        let singleton_by_name = data
111            .singletons
112            .iter()
113            .map(|(name, id)| (name.clone(), *id))
114            .collect();
115        let mut utility_by_name = FxHashMap::default();
116        for (i, u) in data.utilities.iter().enumerate() {
117            utility_by_name.insert(u.name.clone(), u32::try_from(i).unwrap_or(u32::MAX));
118        }
119        let mut global_enum_by_name = FxHashMap::default();
120        let mut global_enum_value_by_name = FxHashMap::default();
121        for (i, e) in data.global_enums.iter().enumerate() {
122            let ei = u32::try_from(i).unwrap_or(u32::MAX);
123            global_enum_by_name.insert(e.name.clone(), ei);
124            for (j, v) in e.values.iter().enumerate() {
125                global_enum_value_by_name
126                    .entry(v.name.clone())
127                    .or_insert((ei, u32::try_from(j).unwrap_or(u32::MAX)));
128            }
129        }
130        let int_builtin = builtin_by_name.get("int").copied();
131
132        Self {
133            data,
134            class_by_name,
135            builtin_by_name,
136            singleton_by_name,
137            utility_by_name,
138            global_enum_by_name,
139            global_enum_value_by_name,
140            global_consts: gdscript_layer::global_consts(),
141            gdscript_builtins: gdscript_layer::builtin_fns(),
142            int_builtin,
143            docs: None,
144        }
145    }
146
147    /// Install a documentation store (the native `bundled-docs` blob, or a host-supplied one).
148    pub fn set_docs(&mut self, docs: DocStore) {
149        self.docs = Some(docs);
150    }
151
152    /// The Markdown documentation for a [`DocId`], if a doc store is loaded and the handle is in
153    /// range. Returns `None` when docs aren't available (e.g. `wasm32` without a host `set_docs`),
154    /// so every caller degrades to a signature-only hover.
155    #[must_use]
156    pub fn doc(&self, id: DocId) -> Option<&str> {
157        self.docs.as_ref()?.get(id)
158    }
159
160    /// Decode and index an engine-API blob produced by `xtask codegen-api`.
161    ///
162    /// The bytes are copied into a 16-byte-aligned buffer before validation so a misaligned
163    /// source (e.g. `include_bytes!` or a `fetch()`ed `ArrayBuffer`) decodes correctly.
164    ///
165    /// # Errors
166    /// Returns [`LoadError::Decode`] if the blob fails `rkyv` validation.
167    pub fn from_bytes(bytes: &[u8]) -> Result<Self, LoadError> {
168        let mut aligned = rkyv::util::AlignedVec::<16>::new();
169        aligned.extend_from_slice(bytes);
170        let data = rkyv::from_bytes::<ApiData, rkyv::rancor::Error>(aligned.as_slice())
171            .map_err(|e| LoadError::Decode(e.to_string()))?;
172        Ok(Self::from_data(data))
173    }
174
175    /// The Godot version this model was generated from.
176    #[must_use]
177    pub fn version(&self) -> &ApiVersion {
178        &self.data.version
179    }
180}
181
182impl DocStore {
183    /// Decode an `engine_docs.bin` blob produced by `xtask codegen-api`.
184    ///
185    /// The bytes are copied into a 16-byte-aligned buffer before validation, mirroring
186    /// [`EngineApi::from_bytes`], so a misaligned source decodes correctly.
187    ///
188    /// # Errors
189    /// Returns [`LoadError::Decode`] if the blob fails `rkyv` validation.
190    pub fn from_bytes(bytes: &[u8]) -> Result<Self, LoadError> {
191        let mut aligned = rkyv::util::AlignedVec::<16>::new();
192        aligned.extend_from_slice(bytes);
193        rkyv::from_bytes::<Self, rkyv::rancor::Error>(aligned.as_slice())
194            .map_err(|e| LoadError::Decode(e.to_string()))
195    }
196}
197
198/// The bundled engine-API model, decoded once on first use.
199///
200/// Native-only and gated on the default `bundled-api` feature: the blob is embedded via
201/// `include_bytes!`. On `wasm32` the blob is not embedded — fetch it and use
202/// [`EngineApi::from_bytes`] instead (Playbook §4.5).
203///
204/// # Panics
205/// Panics if the embedded blob fails to decode, which can only happen if `engine_api.bin` was
206/// hand-edited or truncated — `cargo xtask codegen-api` always emits a valid, self-validated
207/// artifact.
208#[cfg(all(feature = "bundled-api", not(target_arch = "wasm32")))]
209#[must_use]
210pub fn bundled() -> &'static EngineApi {
211    use std::sync::OnceLock;
212    static BUNDLED: OnceLock<EngineApi> = OnceLock::new();
213    static BYTES: &[u8] = include_bytes!("engine_api.bin");
214    BUNDLED.get_or_init(|| {
215        let mut api =
216            EngineApi::from_bytes(BYTES).expect("the bundled engine-API blob must be valid");
217        // The doc store is a separate, native-only embed (`bundled-docs`): it never enters the
218        // wasm-fetched `engine_api.bin`, so the playground download stays lean. A decode failure
219        // is non-fatal — hover simply falls back to signature-only.
220        #[cfg(feature = "bundled-docs")]
221        {
222            static DOC_BYTES: &[u8] = include_bytes!("engine_docs.bin");
223            if let Ok(docs) = DocStore::from_bytes(DOC_BYTES) {
224                api.set_docs(docs);
225            }
226        }
227        api
228    })
229}
230
231#[cfg(test)]
232mod tests {
233    #[test]
234    fn generated_metadata_is_present() {
235        // Regenerated by `cargo xtask codegen-api`; the version string is always populated.
236        assert!(!crate::generated::GODOT_VERSION.is_empty());
237    }
238
239    // The bundled blob is native-only behind the default feature (see `bundled`).
240    #[cfg(all(feature = "bundled-api", not(target_arch = "wasm32")))]
241    #[test]
242    fn bundled_blob_loads_and_resolves_golden_symbols() {
243        let api = crate::bundled();
244
245        // Version came through the blob and MATCHES `generated.rs` (the two artifacts are
246        // regenerated together by `cargo xtask codegen-api` — a mismatch means a stale blob).
247        let expected: Vec<u32> = crate::generated::GODOT_VERSION
248            .split(['.', '-'])
249            .take(2)
250            .filter_map(|p| p.parse().ok())
251            .collect();
252        assert_eq!(api.version().major, expected[0]);
253        assert_eq!(api.version().minor, expected[1]);
254
255        // Direct + inherited member resolution and the inheritance walk.
256        let node = api.class_by_name("Node").expect("Node class present");
257        let node2d = api.class_by_name("Node2D").expect("Node2D class present");
258        assert!(api.lookup_member(node, "add_child").is_some());
259        assert!(api.is_subclass(node2d, node), "Node2D is a Node");
260        assert!(
261            api.lookup_member(node2d, "add_child").is_some(),
262            "add_child is inherited onto Node2D"
263        );
264
265        // The `recv.<TAB>` candidate set includes inherited members, deduped.
266        let members = api.members_of(node2d);
267        assert!(members.iter().any(|m| m.name() == "add_child"));
268        assert!(members.iter().any(|m| m.name() == "position"));
269
270        // Singletons, builtins + operators, the enum-property getter cross-ref.
271        assert!(api.singleton("Input").is_some());
272        let v2 = api
273            .builtin_by_name("Vector2")
274            .expect("Vector2 builtin present");
275        assert!(api.builtin_member(v2, "x").is_some());
276        assert!(api.builtin_operators(v2).iter().any(|o| o.op == "+"));
277        let process_mode = api
278            .class(node)
279            .properties
280            .iter()
281            .find(|p| p.name == "process_mode")
282            .expect("Node.process_mode present");
283        assert!(
284            process_mode.enum_of.is_some(),
285            "process_mode is recovered as enum-typed from its getter"
286        );
287
288        // The hand-authored GDScript layer merged at load.
289        assert!(api.global_const("PI").is_some());
290        assert!(api.gdscript_builtin("preload").is_some());
291    }
292
293    // Hover docs are a separate native-only embed (`bundled-docs`).
294    #[cfg(all(feature = "bundled-docs", not(target_arch = "wasm32")))]
295    #[test]
296    fn bundled_docs_resolve_and_are_converted() {
297        use crate::MemberRef;
298        let api = crate::bundled();
299        let node = api.class_by_name("Node").expect("Node class");
300
301        // The class itself and a well-known method carry Markdown hover docs.
302        assert!(
303            api.class(node).doc.and_then(|id| api.doc(id)).is_some(),
304            "Node has a class hover doc"
305        );
306        let MemberRef::Method(m) = api.lookup_member(node, "add_child").expect("add_child") else {
307            panic!("add_child is a method");
308        };
309        let doc = m
310            .doc
311            .and_then(|id| api.doc(id))
312            .expect("add_child hover doc");
313        // BBCode was converted to Markdown — no `[/…]` closing tags survive.
314        assert!(!doc.contains("[/"), "BBCode leaked into hover: {doc:?}");
315    }
316}