Skip to main content

gdscript_hir/
ty.rs

1//! The Phase-2 type model (Playbook §2/§3.5): the gradual [`Ty`] lattice over `Variant`, the
2//! hard/soft [`TypeSource`], the ported `is_assignable` compatibility check, and `TyRef`→`Ty`
3//! resolution against the engine API.
4//!
5//! GDScript is gradually typed over one runtime value type, `Variant`. Three top-ish types are
6//! kept distinct on purpose: [`Ty::Variant`] (the absorbing gradual top), [`Ty::Unknown`] (the
7//! Phase-3 cross-file seam — never warns, never cascades, elided from hover), and [`Ty::Error`]
8//! (already diagnosed — suppresses further cascade).
9
10use gdscript_api::{BuiltinId, ClassId, ElemRef, EngineApi, TyRef};
11use smol_str::SmolStr;
12
13/// An opaque reference to another `.gd` script (by `class_name`/path). Resolved to a concrete
14/// type only in Phase 3; in Phase 2 it never appears (the seam returns [`Ty::Unknown`] instead),
15/// but the variant exists so the upgrade is additive.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub struct ScriptRefId(pub u32);
18
19/// An interned signal signature id (Phase 3+).
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub struct SignalSigId(pub u32);
22
23/// A reference to an **inner** `class Name:` declared inside a script file. Identity = the declaring
24/// file (a `FileId.0`) + the dotted path to the inner class within it (`Inner`, or `Outer.Inner`
25/// when nested). The analyzer collapses the meta-vs-instance distinction (like a `class_name`/
26/// `ScriptRef`): the same `Ty::InnerClass` is the class value (`Inner.CONST`, `Inner.new()`) and an
27/// instance of it (`Inner.new().method()`).
28#[derive(Debug, Clone, PartialEq, Eq, Hash)]
29pub struct InnerClassRef {
30    /// The declaring file (`FileId.0`).
31    pub file: u32,
32    /// The dotted path to the inner class within the file (`Inner` / `Outer.Inner`).
33    pub path: SmolStr,
34}
35
36impl InnerClassRef {
37    /// The inner class's own (last-segment) name — the hover/inlay label.
38    #[must_use]
39    pub fn name(&self) -> &str {
40        self.path.rsplit('.').next().unwrap_or(&self.path)
41    }
42}
43
44/// A reference to an enum type, kept as the qualified name it was written with. Phase 2 does not
45/// resolve it to a concrete enum table — `is_assignable` only needs the *kind* (enum values are
46/// assignable to `int`), and hover shows the qualified name.
47#[derive(Debug, Clone, PartialEq, Eq, Hash)]
48pub struct EnumRef {
49    /// The dotted name (`Node.ProcessMode`, `Error`, …).
50    pub qualified: SmolStr,
51    /// Whether the source was a `bitfield::`.
52    pub bitfield: bool,
53}
54
55/// A Phase-2 type. `Clone` not `Copy` (the `Box`es in `Array`/`Dict`).
56#[derive(Debug, Clone, PartialEq, Eq, Hash)]
57pub enum Ty {
58    /// A builtin Variant type (`int`, `float`, `String`, `Vector2`, …).
59    Builtin(BuiltinId),
60    /// An engine class instance, this file's own class, or an inner class.
61    Object(ClassId),
62    /// Another script, opaque in Phase 2 (the seam yields `Unknown` instead).
63    ScriptRef(ScriptRefId),
64    /// An inner `class Name:` declared in this (or another) script file — both the class value and
65    /// an instance of it (collapsed, like `ScriptRef`). Members resolve against the inner class's
66    /// own item-tree + its `extends` chain.
67    InnerClass(InnerClassRef),
68    /// `Array[T]`; a bare `Array` is `Array(Box::new(Ty::Variant))`.
69    Array(Box<Ty>),
70    /// `Dictionary[K, V]`; a bare `Dictionary` is `Dict(Variant, Variant)`.
71    Dict(Box<Ty>, Box<Ty>),
72    /// An enum type (an enum value is assignable to `int`).
73    Enum(EnumRef),
74    /// A `Signal` value.
75    Signal(Option<SignalSigId>),
76    /// A `Callable` value.
77    Callable,
78    /// No value (`void`).
79    Void,
80    /// The gradual top / escape hatch (≈ engine `VARIANT` ≈ Pyright `Any`).
81    Variant,
82    /// The Phase-3 cross-file seam marker — distinct from `Variant`. Never warns, never appears
83    /// in hover, never cascades a diagnostic.
84    Unknown,
85    /// An already-reported error; suppresses downstream diagnostics.
86    Error,
87}
88
89impl Ty {
90    /// A bare `Array` (`Array[Variant]`).
91    #[must_use]
92    pub fn array_of_variant() -> Self {
93        Self::Array(Box::new(Self::Variant))
94    }
95
96    /// A bare `Dictionary` (`Dictionary[Variant, Variant]`).
97    #[must_use]
98    pub fn dict_of_variant() -> Self {
99        Self::Dict(Box::new(Self::Variant), Box::new(Self::Variant))
100    }
101
102    /// Whether this is the gradual top `Variant`.
103    #[must_use]
104    pub fn is_variant(&self) -> bool {
105        matches!(self, Self::Variant)
106    }
107
108    /// Whether this is the cross-file seam marker.
109    #[must_use]
110    pub fn is_unknown(&self) -> bool {
111        matches!(self, Self::Unknown)
112    }
113
114    /// Whether this is the already-reported error marker.
115    #[must_use]
116    pub fn is_error(&self) -> bool {
117        matches!(self, Self::Error)
118    }
119
120    /// Whether a diagnostic should be suppressed because this type carries no information
121    /// (`Variant`/`Unknown`/`Error`) — the receivers on which `UNSAFE_*` etc. must never fire.
122    #[must_use]
123    pub fn is_uninformative(&self) -> bool {
124        matches!(self, Self::Variant | Self::Unknown | Self::Error)
125    }
126
127    /// A display label for hover / inlay hints, or `None` when the type is `Unknown` (elided —
128    /// the Phase-3 seam) so we never render a placeholder.
129    #[must_use]
130    pub fn label(&self, api: &EngineApi) -> Option<String> {
131        Some(match self {
132            Self::Builtin(id) => api.builtin(*id).name.clone(),
133            Self::Object(id) => api.class(*id).name.clone(),
134            Self::Array(elem) => match elem.label(api) {
135                Some(e) if e != "Variant" => format!("Array[{e}]"),
136                _ => "Array".to_owned(),
137            },
138            Self::Dict(k, v) => match (k.label(api), v.label(api)) {
139                (Some(k), Some(v)) if k != "Variant" || v != "Variant" => {
140                    format!("Dictionary[{k}, {v}]")
141                }
142                _ => "Dictionary".to_owned(),
143            },
144            Self::Enum(e) => e.qualified.to_string(),
145            Self::Signal(_) => "Signal".to_owned(),
146            Self::Callable => "Callable".to_owned(),
147            Self::Void => "void".to_owned(),
148            Self::Variant => "Variant".to_owned(),
149            // An inner class shows its own (last-segment) name.
150            Self::InnerClass(r) => r.name().to_owned(),
151            // `ScriptRef` (opaque) and the seam/error markers carry no display label.
152            Self::ScriptRef(_) | Self::Unknown | Self::Error => return None,
153        })
154    }
155}
156
157/// How a binding's type was established (Playbook §2). The ordering is load-bearing: a type is
158/// *hard* (statically enforced) iff its source is greater than [`TypeSource::Inferred`].
159#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
160pub enum TypeSource {
161    /// No type known yet.
162    Undetected,
163    /// Inferred from an initializer (`:=` / soft) — best-effort; downgraded to `Variant` on
164    /// conflict rather than erroring.
165    Inferred,
166    /// Inferred-but-annotated as inferred (`var x := e` once accepted).
167    AnnotatedInferred,
168    /// Explicitly annotated (`var x: T`) — a mismatch is an error.
169    AnnotatedExplicit,
170}
171
172impl TypeSource {
173    /// A *hard* type is statically enforced (mismatch = error). A *soft* (`Inferred`) type is
174    /// best-effort and downgraded to `Variant` on conflict.
175    #[must_use]
176    pub fn is_hard(self) -> bool {
177        self > Self::Inferred
178    }
179}
180
181/// A typed binding: its [`Ty`] plus how the type was established.
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct TypedBinding {
184    /// The binding's type.
185    pub ty: Ty,
186    /// How it was established.
187    pub source: TypeSource,
188}
189
190/// The outcome of [`is_assignable`] — richer than a bool so the caller can raise the right
191/// diagnostic (Playbook §3.5/§5).
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub enum Assign {
194    /// Cleanly assignable.
195    Ok,
196    /// Assignable, but the source is `Variant` — a gradual (unchecked) escape.
197    OkUnsafe,
198    /// `float` stored into an `int` slot (`NARROWING_CONVERSION`).
199    Narrowing,
200    /// `int` used where an enum is expected (`INT_AS_ENUM_WITHOUT_CAST`).
201    IntAsEnum,
202    /// Not assignable (`TYPE_MISMATCH`).
203    No,
204}
205
206/// Whether `name` is a `Packed*Array` builtin (`PackedStringArray`, `PackedVector2Array`, …).
207fn is_packed_array(name: &str) -> bool {
208    name.starts_with("Packed") && name.ends_with("Array")
209}
210
211/// Godot's implicit conversions between two **builtin** types (the engine's `Variant::can_convert`
212/// for the value-prop slots GDScript accepts silently). Covers the numeric / vector widening +
213/// narrowing, `bool`↔`int`, the `String`/`StringName`/`NodePath` family, and `Array`↔`Packed*Array`.
214#[allow(
215    clippy::unnested_or_patterns,
216    reason = "a flat (from, to) conversion table reads more clearly than maximally-nested patterns"
217)]
218fn builtin_conversion(from: &str, to: &str) -> Assign {
219    match (from, to) {
220        // Narrowing (NARROWING_CONVERSION, not a hard mismatch): float→int, float-vec→int-vec.
221        ("float", "int")
222        | ("Vector2", "Vector2i")
223        | ("Vector3", "Vector3i")
224        | ("Vector4", "Vector4i")
225        | ("Rect2", "Rect2i") => Assign::Narrowing,
226        // Widening / interchangeable value types Godot converts silently.
227        ("int", "float")
228        | ("bool", "int")
229        | ("bool", "float")
230        | ("int", "bool")
231        | ("Vector2i", "Vector2")
232        | ("Vector3i", "Vector3")
233        | ("Vector4i", "Vector4")
234        | ("Rect2i", "Rect2")
235        | ("String", "StringName")
236        | ("String", "NodePath")
237        | ("StringName", "String")
238        | ("NodePath", "String") => Assign::Ok,
239        // A bare `Array` ↔ a `Packed*Array` is a runtime element-checked conversion Godot allows.
240        ("Array", t) if is_packed_array(t) => Assign::Ok,
241        (f, "Array") if is_packed_array(f) => Assign::Ok,
242        _ => Assign::No,
243    }
244}
245
246/// Whether a value of type `from` may be assigned to a slot of type `to` (the engine's
247/// `check_type_compatibility`, ported — Playbook §3.5). **Order matters.**
248#[must_use]
249pub fn is_assignable(api: &EngineApi, from: &Ty, to: &Ty) -> Assign {
250    // 1. Anything assigns to `Variant`.
251    if to.is_variant() {
252        return Assign::Ok;
253    }
254    // 2/3. Never cascade through the seam / error markers; a `Variant` source is the gradual
255    // escape (allowed-but-unsafe). These precede the structural checks deliberately.
256    if matches!(from, Ty::Unknown | Ty::Error) || matches!(to, Ty::Unknown | Ty::Error) {
257        return Assign::Ok;
258    }
259    if from.is_variant() {
260        return Assign::OkUnsafe;
261    }
262
263    match to {
264        Ty::Builtin(to_id) => {
265            let to_name = api.builtin(*to_id).name.as_str();
266            match from {
267                Ty::Builtin(from_id) if from_id == to_id => Assign::Ok,
268                Ty::Builtin(from_id) => {
269                    builtin_conversion(api.builtin(*from_id).name.as_str(), to_name)
270                }
271                // An enum value is assignable to `int`.
272                Ty::Enum(_) if to_name == "int" => Assign::Ok,
273                // A bare/typed `Array` (a `[…]` literal) assigns to any `Packed*Array`: Godot
274                // runtime-converts + validates the elements at runtime — never a static mismatch.
275                Ty::Array(_) if is_packed_array(to_name) => Assign::Ok,
276                _ => Assign::No,
277            }
278        }
279        Ty::Enum(to_enum) => match from {
280            Ty::Enum(from_enum) if from_enum == to_enum => Assign::Ok,
281            // A *different* enum's value is an `int` at runtime: Godot wants a cast
282            // (`INT_AS_ENUM_WITHOUT_CAST`) — never a hard `TYPE_MISMATCH`.
283            Ty::Enum(_) => Assign::IntAsEnum,
284            // `int` → enum without a cast.
285            Ty::Builtin(id) if api.builtin(*id).name == "int" => Assign::IntAsEnum,
286            _ => Assign::No,
287        },
288        Ty::Object(to_class) => match from {
289            Ty::Object(from_class) if api.is_subclass(*from_class, *to_class) => Assign::Ok,
290            // Downcast (a base value into a derived slot): permitted with a runtime check —
291            // unsafe, but not a hard error. Real code relies on `var c: Control = get_child(0)`.
292            Ty::Object(from_class) if api.is_subclass(*to_class, *from_class) => Assign::OkUnsafe,
293            // A script reference / inner-class value is opaque — treat like the seam (an inner class
294            // IS-A its base, and we don't resolve the base chain here), never a hard mismatch.
295            Ty::ScriptRef(_) | Ty::InnerClass(_) => Assign::Ok,
296            _ => Assign::No,
297        },
298        // Typed arrays are invariant — but only between two *informative* element types
299        // (`Array[Button]` ↛ `Array[Node]`). A bare `Array`/`Array[Variant]`, or an
300        // `Array[Unknown]` (cross-file element), assigns freely: the engine permits
301        // untyped→typed with a runtime check, and the seam must never hard-error.
302        Ty::Array(to_elem) => match from {
303            Ty::Array(from_elem)
304                if from_elem == to_elem
305                    || from_elem.is_uninformative()
306                    || to_elem.is_uninformative() =>
307            {
308                Assign::Ok
309            }
310            _ => Assign::No,
311        },
312        Ty::Dict(to_k, to_v) => match from {
313            Ty::Dict(from_k, from_v)
314                if (from_k == to_k || from_k.is_uninformative() || to_k.is_uninformative())
315                    && (from_v == to_v || from_v.is_uninformative() || to_v.is_uninformative()) =>
316            {
317                Assign::Ok
318            }
319            _ => Assign::No,
320        },
321        Ty::Signal(_) => {
322            if matches!(from, Ty::Signal(_)) {
323                Assign::Ok
324            } else {
325                Assign::No
326            }
327        }
328        Ty::Callable => {
329            if matches!(from, Ty::Callable) {
330                Assign::Ok
331            } else {
332                Assign::No
333            }
334        }
335        Ty::Void => {
336            if matches!(from, Ty::Void) {
337                Assign::Ok
338            } else {
339                Assign::No
340            }
341        }
342        // An opaque script-ref / inner-class target, and the `Variant`/`Unknown`/`Error` targets
343        // already handled above, all accept anything.
344        Ty::ScriptRef(_) | Ty::InnerClass(_) | Ty::Variant | Ty::Unknown | Ty::Error => Assign::Ok,
345    }
346}
347
348/// Resolve an engine-API [`TyRef`] (the unresolved form stored in the model) to a [`Ty`].
349#[must_use]
350pub fn resolve_tyref(api: &EngineApi, tyref: &TyRef) -> Ty {
351    match tyref {
352        TyRef::Void => Ty::Void,
353        TyRef::Variant => Ty::Variant,
354        // `Array`/`Dictionary`/`Callable`/`Signal` are engine builtins, but we keep dedicated
355        // `Ty` variants for them (a lambda is `Ty::Callable`, `[]` is `Ty::Array`); normalize
356        // the bare builtin form so annotations, constructors, and values all agree.
357        TyRef::Builtin(id) => match api.builtin(*id).name.as_str() {
358            "Callable" => Ty::Callable,
359            "Signal" => Ty::Signal(None),
360            "Array" => Ty::array_of_variant(),
361            "Dictionary" => Ty::dict_of_variant(),
362            _ => Ty::Builtin(*id),
363        },
364        TyRef::Class(id) => Ty::Object(*id),
365        TyRef::TypedArray(elem) => Ty::Array(Box::new(resolve_elemref(api, elem))),
366        TyRef::TypedDict(k, v) => Ty::Dict(
367            Box::new(resolve_elemref(api, k)),
368            Box::new(resolve_elemref(api, v)),
369        ),
370        TyRef::Enum {
371            qualified,
372            bitfield,
373        } => Ty::Enum(EnumRef {
374            qualified: SmolStr::new(qualified),
375            bitfield: *bitfield,
376        }),
377    }
378}
379
380/// Resolve a typed-container element [`ElemRef`] to a [`Ty`].
381#[must_use]
382pub fn resolve_elemref(_api: &EngineApi, elem: &ElemRef) -> Ty {
383    match elem {
384        ElemRef::Variant => Ty::Variant,
385        ElemRef::Builtin(id) => Ty::Builtin(*id),
386        ElemRef::Class(id) => Ty::Object(*id),
387        ElemRef::Enum {
388            qualified,
389            bitfield,
390        } => Ty::Enum(EnumRef {
391            qualified: SmolStr::new(qualified),
392            bitfield: *bitfield,
393        }),
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    fn ty_of(api: &EngineApi, builtin: &str) -> Ty {
402        Ty::Builtin(api.builtin_by_name(builtin).expect("known builtin"))
403    }
404
405    #[test]
406    fn type_source_hardness() {
407        assert!(!TypeSource::Undetected.is_hard());
408        assert!(!TypeSource::Inferred.is_hard());
409        assert!(TypeSource::AnnotatedInferred.is_hard());
410        assert!(TypeSource::AnnotatedExplicit.is_hard());
411    }
412
413    #[test]
414    fn variant_and_seam_assignability() {
415        let api = gdscript_api::bundled();
416        let int = ty_of(api, "int");
417        // Anything assigns to Variant; Variant into a typed slot is allowed-but-unsafe.
418        assert_eq!(is_assignable(api, &int, &Ty::Variant), Assign::Ok);
419        assert_eq!(is_assignable(api, &Ty::Variant, &int), Assign::OkUnsafe);
420        // The seam and error markers never cascade, in either direction.
421        assert_eq!(is_assignable(api, &Ty::Unknown, &int), Assign::Ok);
422        assert_eq!(is_assignable(api, &int, &Ty::Unknown), Assign::Ok);
423        assert_eq!(is_assignable(api, &Ty::Error, &int), Assign::Ok);
424    }
425
426    #[test]
427    fn numeric_conversions() {
428        let api = gdscript_api::bundled();
429        let int = ty_of(api, "int");
430        let float = ty_of(api, "float");
431        assert_eq!(is_assignable(api, &int, &float), Assign::Ok); // widening, silent
432        assert_eq!(is_assignable(api, &float, &int), Assign::Narrowing);
433        assert_eq!(is_assignable(api, &int, &int), Assign::Ok);
434        let string = ty_of(api, "String");
435        assert_eq!(is_assignable(api, &string, &int), Assign::No);
436    }
437
438    #[test]
439    fn object_subclassing() {
440        let api = gdscript_api::bundled();
441        let node = Ty::Object(api.class_by_name("Node").unwrap());
442        let node2d = Ty::Object(api.class_by_name("Node2D").unwrap());
443        // Node2D is a Node (upcast → Ok); a Node into a Node2D slot is a downcast — permitted
444        // with a runtime check (unsafe), not a hard mismatch.
445        assert_eq!(is_assignable(api, &node2d, &node), Assign::Ok);
446        assert_eq!(is_assignable(api, &node, &node2d), Assign::OkUnsafe);
447        // An unrelated builtin is a real mismatch.
448        let s = ty_of(api, "String");
449        assert_eq!(is_assignable(api, &s, &node), Assign::No);
450    }
451
452    #[test]
453    fn arrays_are_invariant() {
454        let api = gdscript_api::bundled();
455        let int = ty_of(api, "int");
456        let float = ty_of(api, "float");
457        let arr_int = Ty::Array(Box::new(int.clone()));
458        let arr_int2 = Ty::Array(Box::new(int));
459        let arr_float = Ty::Array(Box::new(float));
460        assert_eq!(is_assignable(api, &arr_int, &arr_int2), Assign::Ok);
461        // No covariance: Array[int] is not assignable to Array[float] even though int->float.
462        assert_eq!(is_assignable(api, &arr_int2, &arr_float), Assign::No);
463    }
464
465    #[test]
466    fn enum_int_bridge() {
467        let api = gdscript_api::bundled();
468        let int = ty_of(api, "int");
469        let e = Ty::Enum(EnumRef {
470            qualified: SmolStr::new("Node.ProcessMode"),
471            bitfield: false,
472        });
473        assert_eq!(is_assignable(api, &e, &int), Assign::Ok); // enum -> int
474        assert_eq!(is_assignable(api, &int, &e), Assign::IntAsEnum); // int -> enum (warn)
475    }
476
477    #[test]
478    fn label_elides_unknown() {
479        let api = gdscript_api::bundled();
480        assert_eq!(Ty::Unknown.label(api), None);
481        assert_eq!(ty_of(api, "int").label(api).as_deref(), Some("int"));
482        assert_eq!(
483            Ty::Array(Box::new(ty_of(api, "int"))).label(api).as_deref(),
484            Some("Array[int]")
485        );
486    }
487}