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 enum type, kept as the qualified name it was written with. Phase 2 does not
24/// resolve it to a concrete enum table — `is_assignable` only needs the *kind* (enum values are
25/// assignable to `int`), and hover shows the qualified name.
26#[derive(Debug, Clone, PartialEq, Eq, Hash)]
27pub struct EnumRef {
28    /// The dotted name (`Node.ProcessMode`, `Error`, …).
29    pub qualified: SmolStr,
30    /// Whether the source was a `bitfield::`.
31    pub bitfield: bool,
32}
33
34/// A Phase-2 type. `Clone` not `Copy` (the `Box`es in `Array`/`Dict`).
35#[derive(Debug, Clone, PartialEq, Eq, Hash)]
36pub enum Ty {
37    /// A builtin Variant type (`int`, `float`, `String`, `Vector2`, …).
38    Builtin(BuiltinId),
39    /// An engine class instance, this file's own class, or an inner class.
40    Object(ClassId),
41    /// Another script, opaque in Phase 2 (the seam yields `Unknown` instead).
42    ScriptRef(ScriptRefId),
43    /// `Array[T]`; a bare `Array` is `Array(Box::new(Ty::Variant))`.
44    Array(Box<Ty>),
45    /// `Dictionary[K, V]`; a bare `Dictionary` is `Dict(Variant, Variant)`.
46    Dict(Box<Ty>, Box<Ty>),
47    /// An enum type (an enum value is assignable to `int`).
48    Enum(EnumRef),
49    /// A `Signal` value.
50    Signal(Option<SignalSigId>),
51    /// A `Callable` value.
52    Callable,
53    /// No value (`void`).
54    Void,
55    /// The gradual top / escape hatch (≈ engine `VARIANT` ≈ Pyright `Any`).
56    Variant,
57    /// The Phase-3 cross-file seam marker — distinct from `Variant`. Never warns, never appears
58    /// in hover, never cascades a diagnostic.
59    Unknown,
60    /// An already-reported error; suppresses downstream diagnostics.
61    Error,
62}
63
64impl Ty {
65    /// A bare `Array` (`Array[Variant]`).
66    #[must_use]
67    pub fn array_of_variant() -> Self {
68        Self::Array(Box::new(Self::Variant))
69    }
70
71    /// A bare `Dictionary` (`Dictionary[Variant, Variant]`).
72    #[must_use]
73    pub fn dict_of_variant() -> Self {
74        Self::Dict(Box::new(Self::Variant), Box::new(Self::Variant))
75    }
76
77    /// Whether this is the gradual top `Variant`.
78    #[must_use]
79    pub fn is_variant(&self) -> bool {
80        matches!(self, Self::Variant)
81    }
82
83    /// Whether this is the cross-file seam marker.
84    #[must_use]
85    pub fn is_unknown(&self) -> bool {
86        matches!(self, Self::Unknown)
87    }
88
89    /// Whether this is the already-reported error marker.
90    #[must_use]
91    pub fn is_error(&self) -> bool {
92        matches!(self, Self::Error)
93    }
94
95    /// Whether a diagnostic should be suppressed because this type carries no information
96    /// (`Variant`/`Unknown`/`Error`) — the receivers on which `UNSAFE_*` etc. must never fire.
97    #[must_use]
98    pub fn is_uninformative(&self) -> bool {
99        matches!(self, Self::Variant | Self::Unknown | Self::Error)
100    }
101
102    /// A display label for hover / inlay hints, or `None` when the type is `Unknown` (elided —
103    /// the Phase-3 seam) so we never render a placeholder.
104    #[must_use]
105    pub fn label(&self, api: &EngineApi) -> Option<String> {
106        Some(match self {
107            Self::Builtin(id) => api.builtin(*id).name.clone(),
108            Self::Object(id) => api.class(*id).name.clone(),
109            Self::Array(elem) => match elem.label(api) {
110                Some(e) if e != "Variant" => format!("Array[{e}]"),
111                _ => "Array".to_owned(),
112            },
113            Self::Dict(k, v) => match (k.label(api), v.label(api)) {
114                (Some(k), Some(v)) if k != "Variant" || v != "Variant" => {
115                    format!("Dictionary[{k}, {v}]")
116                }
117                _ => "Dictionary".to_owned(),
118            },
119            Self::Enum(e) => e.qualified.to_string(),
120            Self::Signal(_) => "Signal".to_owned(),
121            Self::Callable => "Callable".to_owned(),
122            Self::Void => "void".to_owned(),
123            Self::Variant => "Variant".to_owned(),
124            // `ScriptRef` (opaque) and the seam/error markers carry no display label.
125            Self::ScriptRef(_) | Self::Unknown | Self::Error => return None,
126        })
127    }
128}
129
130/// How a binding's type was established (Playbook §2). The ordering is load-bearing: a type is
131/// *hard* (statically enforced) iff its source is greater than [`TypeSource::Inferred`].
132#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
133pub enum TypeSource {
134    /// No type known yet.
135    Undetected,
136    /// Inferred from an initializer (`:=` / soft) — best-effort; downgraded to `Variant` on
137    /// conflict rather than erroring.
138    Inferred,
139    /// Inferred-but-annotated as inferred (`var x := e` once accepted).
140    AnnotatedInferred,
141    /// Explicitly annotated (`var x: T`) — a mismatch is an error.
142    AnnotatedExplicit,
143}
144
145impl TypeSource {
146    /// A *hard* type is statically enforced (mismatch = error). A *soft* (`Inferred`) type is
147    /// best-effort and downgraded to `Variant` on conflict.
148    #[must_use]
149    pub fn is_hard(self) -> bool {
150        self > Self::Inferred
151    }
152}
153
154/// A typed binding: its [`Ty`] plus how the type was established.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct TypedBinding {
157    /// The binding's type.
158    pub ty: Ty,
159    /// How it was established.
160    pub source: TypeSource,
161}
162
163/// The outcome of [`is_assignable`] — richer than a bool so the caller can raise the right
164/// diagnostic (Playbook §3.5/§5).
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum Assign {
167    /// Cleanly assignable.
168    Ok,
169    /// Assignable, but the source is `Variant` — a gradual (unchecked) escape.
170    OkUnsafe,
171    /// `float` stored into an `int` slot (`NARROWING_CONVERSION`).
172    Narrowing,
173    /// `int` used where an enum is expected (`INT_AS_ENUM_WITHOUT_CAST`).
174    IntAsEnum,
175    /// Not assignable (`TYPE_MISMATCH`).
176    No,
177}
178
179/// Whether a value of type `from` may be assigned to a slot of type `to` (the engine's
180/// `check_type_compatibility`, ported — Playbook §3.5). **Order matters.**
181#[must_use]
182pub fn is_assignable(api: &EngineApi, from: &Ty, to: &Ty) -> Assign {
183    // 1. Anything assigns to `Variant`.
184    if to.is_variant() {
185        return Assign::Ok;
186    }
187    // 2/3. Never cascade through the seam / error markers; a `Variant` source is the gradual
188    // escape (allowed-but-unsafe). These precede the structural checks deliberately.
189    if matches!(from, Ty::Unknown | Ty::Error) || matches!(to, Ty::Unknown | Ty::Error) {
190        return Assign::Ok;
191    }
192    if from.is_variant() {
193        return Assign::OkUnsafe;
194    }
195
196    match to {
197        Ty::Builtin(to_id) => match from {
198            Ty::Builtin(from_id) if from_id == to_id => Assign::Ok,
199            Ty::Builtin(from_id) => {
200                let from_name = api.builtin(*from_id).name.as_str();
201                let to_name = api.builtin(*to_id).name.as_str();
202                match (from_name, to_name) {
203                    ("float", "int") => Assign::Narrowing, // NARROWING_CONVERSION
204                    // `int`→`float` widening (silent) + Godot's string-ish auto-conversions.
205                    ("int", "float")
206                    | ("String", "StringName" | "NodePath")
207                    | ("StringName" | "NodePath", "String") => Assign::Ok,
208                    _ => Assign::No,
209                }
210            }
211            // An enum value is assignable to `int`.
212            Ty::Enum(_) if api.builtin(*to_id).name == "int" => Assign::Ok,
213            _ => Assign::No,
214        },
215        Ty::Enum(to_enum) => match from {
216            Ty::Enum(from_enum) if from_enum == to_enum => Assign::Ok,
217            // `int` → enum without a cast.
218            Ty::Builtin(id) if api.builtin(*id).name == "int" => Assign::IntAsEnum,
219            _ => Assign::No,
220        },
221        Ty::Object(to_class) => match from {
222            Ty::Object(from_class) if api.is_subclass(*from_class, *to_class) => Assign::Ok,
223            // Downcast (a base value into a derived slot): permitted with a runtime check —
224            // unsafe, but not a hard error. Real code relies on `var c: Control = get_child(0)`.
225            Ty::Object(from_class) if api.is_subclass(*to_class, *from_class) => Assign::OkUnsafe,
226            // A script reference is opaque — treat like the seam, never a mismatch.
227            Ty::ScriptRef(_) => Assign::Ok,
228            _ => Assign::No,
229        },
230        // Typed arrays are invariant — but only between two *informative* element types
231        // (`Array[Button]` ↛ `Array[Node]`). A bare `Array`/`Array[Variant]`, or an
232        // `Array[Unknown]` (cross-file element), assigns freely: the engine permits
233        // untyped→typed with a runtime check, and the seam must never hard-error.
234        Ty::Array(to_elem) => match from {
235            Ty::Array(from_elem)
236                if from_elem == to_elem
237                    || from_elem.is_uninformative()
238                    || to_elem.is_uninformative() =>
239            {
240                Assign::Ok
241            }
242            _ => Assign::No,
243        },
244        Ty::Dict(to_k, to_v) => match from {
245            Ty::Dict(from_k, from_v)
246                if (from_k == to_k || from_k.is_uninformative() || to_k.is_uninformative())
247                    && (from_v == to_v || from_v.is_uninformative() || to_v.is_uninformative()) =>
248            {
249                Assign::Ok
250            }
251            _ => Assign::No,
252        },
253        Ty::Signal(_) => {
254            if matches!(from, Ty::Signal(_)) {
255                Assign::Ok
256            } else {
257                Assign::No
258            }
259        }
260        Ty::Callable => {
261            if matches!(from, Ty::Callable) {
262                Assign::Ok
263            } else {
264                Assign::No
265            }
266        }
267        Ty::Void => {
268            if matches!(from, Ty::Void) {
269                Assign::Ok
270            } else {
271                Assign::No
272            }
273        }
274        // An opaque script-ref target, and the `Variant`/`Unknown`/`Error` targets already
275        // handled above, all accept anything.
276        Ty::ScriptRef(_) | Ty::Variant | Ty::Unknown | Ty::Error => Assign::Ok,
277    }
278}
279
280/// Resolve an engine-API [`TyRef`] (the unresolved form stored in the model) to a [`Ty`].
281#[must_use]
282pub fn resolve_tyref(api: &EngineApi, tyref: &TyRef) -> Ty {
283    match tyref {
284        TyRef::Void => Ty::Void,
285        TyRef::Variant => Ty::Variant,
286        // `Array`/`Dictionary`/`Callable`/`Signal` are engine builtins, but we keep dedicated
287        // `Ty` variants for them (a lambda is `Ty::Callable`, `[]` is `Ty::Array`); normalize
288        // the bare builtin form so annotations, constructors, and values all agree.
289        TyRef::Builtin(id) => match api.builtin(*id).name.as_str() {
290            "Callable" => Ty::Callable,
291            "Signal" => Ty::Signal(None),
292            "Array" => Ty::array_of_variant(),
293            "Dictionary" => Ty::dict_of_variant(),
294            _ => Ty::Builtin(*id),
295        },
296        TyRef::Class(id) => Ty::Object(*id),
297        TyRef::TypedArray(elem) => Ty::Array(Box::new(resolve_elemref(api, elem))),
298        TyRef::TypedDict(k, v) => Ty::Dict(
299            Box::new(resolve_elemref(api, k)),
300            Box::new(resolve_elemref(api, v)),
301        ),
302        TyRef::Enum {
303            qualified,
304            bitfield,
305        } => Ty::Enum(EnumRef {
306            qualified: SmolStr::new(qualified),
307            bitfield: *bitfield,
308        }),
309    }
310}
311
312/// Resolve a typed-container element [`ElemRef`] to a [`Ty`].
313#[must_use]
314pub fn resolve_elemref(_api: &EngineApi, elem: &ElemRef) -> Ty {
315    match elem {
316        ElemRef::Variant => Ty::Variant,
317        ElemRef::Builtin(id) => Ty::Builtin(*id),
318        ElemRef::Class(id) => Ty::Object(*id),
319        ElemRef::Enum {
320            qualified,
321            bitfield,
322        } => Ty::Enum(EnumRef {
323            qualified: SmolStr::new(qualified),
324            bitfield: *bitfield,
325        }),
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    fn ty_of(api: &EngineApi, builtin: &str) -> Ty {
334        Ty::Builtin(api.builtin_by_name(builtin).expect("known builtin"))
335    }
336
337    #[test]
338    fn type_source_hardness() {
339        assert!(!TypeSource::Undetected.is_hard());
340        assert!(!TypeSource::Inferred.is_hard());
341        assert!(TypeSource::AnnotatedInferred.is_hard());
342        assert!(TypeSource::AnnotatedExplicit.is_hard());
343    }
344
345    #[test]
346    fn variant_and_seam_assignability() {
347        let api = gdscript_api::bundled();
348        let int = ty_of(api, "int");
349        // Anything assigns to Variant; Variant into a typed slot is allowed-but-unsafe.
350        assert_eq!(is_assignable(api, &int, &Ty::Variant), Assign::Ok);
351        assert_eq!(is_assignable(api, &Ty::Variant, &int), Assign::OkUnsafe);
352        // The seam and error markers never cascade, in either direction.
353        assert_eq!(is_assignable(api, &Ty::Unknown, &int), Assign::Ok);
354        assert_eq!(is_assignable(api, &int, &Ty::Unknown), Assign::Ok);
355        assert_eq!(is_assignable(api, &Ty::Error, &int), Assign::Ok);
356    }
357
358    #[test]
359    fn numeric_conversions() {
360        let api = gdscript_api::bundled();
361        let int = ty_of(api, "int");
362        let float = ty_of(api, "float");
363        assert_eq!(is_assignable(api, &int, &float), Assign::Ok); // widening, silent
364        assert_eq!(is_assignable(api, &float, &int), Assign::Narrowing);
365        assert_eq!(is_assignable(api, &int, &int), Assign::Ok);
366        let string = ty_of(api, "String");
367        assert_eq!(is_assignable(api, &string, &int), Assign::No);
368    }
369
370    #[test]
371    fn object_subclassing() {
372        let api = gdscript_api::bundled();
373        let node = Ty::Object(api.class_by_name("Node").unwrap());
374        let node2d = Ty::Object(api.class_by_name("Node2D").unwrap());
375        // Node2D is a Node (upcast → Ok); a Node into a Node2D slot is a downcast — permitted
376        // with a runtime check (unsafe), not a hard mismatch.
377        assert_eq!(is_assignable(api, &node2d, &node), Assign::Ok);
378        assert_eq!(is_assignable(api, &node, &node2d), Assign::OkUnsafe);
379        // An unrelated builtin is a real mismatch.
380        let s = ty_of(api, "String");
381        assert_eq!(is_assignable(api, &s, &node), Assign::No);
382    }
383
384    #[test]
385    fn arrays_are_invariant() {
386        let api = gdscript_api::bundled();
387        let int = ty_of(api, "int");
388        let float = ty_of(api, "float");
389        let arr_int = Ty::Array(Box::new(int.clone()));
390        let arr_int2 = Ty::Array(Box::new(int));
391        let arr_float = Ty::Array(Box::new(float));
392        assert_eq!(is_assignable(api, &arr_int, &arr_int2), Assign::Ok);
393        // No covariance: Array[int] is not assignable to Array[float] even though int->float.
394        assert_eq!(is_assignable(api, &arr_int2, &arr_float), Assign::No);
395    }
396
397    #[test]
398    fn enum_int_bridge() {
399        let api = gdscript_api::bundled();
400        let int = ty_of(api, "int");
401        let e = Ty::Enum(EnumRef {
402            qualified: SmolStr::new("Node.ProcessMode"),
403            bitfield: false,
404        });
405        assert_eq!(is_assignable(api, &e, &int), Assign::Ok); // enum -> int
406        assert_eq!(is_assignable(api, &int, &e), Assign::IntAsEnum); // int -> enum (warn)
407    }
408
409    #[test]
410    fn label_elides_unknown() {
411        let api = gdscript_api::bundled();
412        assert_eq!(Ty::Unknown.label(api), None);
413        assert_eq!(ty_of(api, "int").label(api).as_deref(), Some("int"));
414        assert_eq!(
415            Ty::Array(Box::new(ty_of(api, "int"))).label(api).as_deref(),
416            Some("Array[int]")
417        );
418    }
419}