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    /// A SYNTHESIZED fixed-shape array with per-position element types (`[Variant, Callable]` —
71    /// a React-style hook's `[value, setter]` pair). GDScript has no tuple syntax, so no
72    /// annotation ever resolves to this; it only arises from the `## @return-tuple(...)` doc-tag
73    /// (BUG A3). At runtime it IS an untyped `Array`: assignability widens it to
74    /// `Array[Variant]`, and `label` renders it as the array it is — the positional info exists
75    /// solely so a constant index (`pair[1]`) projects the element's real type.
76    Tuple(Vec<Ty>),
77    /// `Dictionary[K, V]`; a bare `Dictionary` is `Dict(Variant, Variant)`.
78    Dict(Box<Ty>, Box<Ty>),
79    /// An enum type (an enum value is assignable to `int`).
80    Enum(EnumRef),
81    /// A `Signal` value.
82    Signal(Option<SignalSigId>),
83    /// A `Callable` value.
84    Callable,
85    /// No value (`void`).
86    Void,
87    /// The gradual top / escape hatch (≈ engine `VARIANT` ≈ Pyright `Any`).
88    Variant,
89    /// The Phase-3 cross-file seam marker — distinct from `Variant`. Never warns, never appears
90    /// in hover, never cascades a diagnostic.
91    Unknown,
92    /// An already-reported error; suppresses downstream diagnostics.
93    Error,
94}
95
96impl Ty {
97    /// A bare `Array` (`Array[Variant]`).
98    #[must_use]
99    pub fn array_of_variant() -> Self {
100        Self::Array(Box::new(Self::Variant))
101    }
102
103    /// A bare `Dictionary` (`Dictionary[Variant, Variant]`).
104    #[must_use]
105    pub fn dict_of_variant() -> Self {
106        Self::Dict(Box::new(Self::Variant), Box::new(Self::Variant))
107    }
108
109    /// The array a [`Ty::Tuple`] IS at runtime (`Array[Variant]`) — the widening every
110    /// non-positional consumer (assignability, labels, iteration) sees. Identity otherwise.
111    #[must_use]
112    pub fn widen_tuple(&self) -> Ty {
113        match self {
114            Self::Tuple(_) => Self::array_of_variant(),
115            other => other.clone(),
116        }
117    }
118
119    /// Whether this is the gradual top `Variant`.
120    #[must_use]
121    pub fn is_variant(&self) -> bool {
122        matches!(self, Self::Variant)
123    }
124
125    /// Whether this is the cross-file seam marker.
126    #[must_use]
127    pub fn is_unknown(&self) -> bool {
128        matches!(self, Self::Unknown)
129    }
130
131    /// Whether this is the already-reported error marker.
132    #[must_use]
133    pub fn is_error(&self) -> bool {
134        matches!(self, Self::Error)
135    }
136
137    /// Whether a diagnostic should be suppressed because this type carries no information
138    /// (`Variant`/`Unknown`/`Error`) — the receivers on which `UNSAFE_*` etc. must never fire.
139    #[must_use]
140    pub fn is_uninformative(&self) -> bool {
141        matches!(self, Self::Variant | Self::Unknown | Self::Error)
142    }
143
144    /// A display label for hover / inlay hints, or `None` when the type is `Unknown` (elided —
145    /// the Phase-3 seam) so we never render a placeholder.
146    #[must_use]
147    pub fn label(&self, api: &EngineApi) -> Option<String> {
148        Some(match self {
149            Self::Builtin(id) => api.builtin(*id).name.clone(),
150            Self::Object(id) => api.class(*id).name.clone(),
151            Self::Array(elem) => match elem.label(api) {
152                Some(e) if e != "Variant" => format!("Array[{e}]"),
153                _ => "Array".to_owned(),
154            },
155            Self::Dict(k, v) => match (k.label(api), v.label(api)) {
156                (Some(k), Some(v)) if k != "Variant" || v != "Variant" => {
157                    format!("Dictionary[{k}, {v}]")
158                }
159                _ => "Dictionary".to_owned(),
160            },
161            Self::Enum(e) => e.qualified.to_string(),
162            // A tuple renders as the array it is at runtime — never syntax a user can't write.
163            Self::Tuple(_) => "Array".to_owned(),
164            Self::Signal(_) => "Signal".to_owned(),
165            Self::Callable => "Callable".to_owned(),
166            Self::Void => "void".to_owned(),
167            Self::Variant => "Variant".to_owned(),
168            // An inner class shows its own (last-segment) name.
169            Self::InnerClass(r) => r.name().to_owned(),
170            // `ScriptRef` (opaque) and the seam/error markers carry no display label.
171            Self::ScriptRef(_) | Self::Unknown | Self::Error => return None,
172        })
173    }
174}
175
176/// How a binding's type was established (Playbook §2). The ordering is load-bearing: a type is
177/// *hard* (statically enforced) iff its source is greater than [`TypeSource::Inferred`].
178#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
179pub enum TypeSource {
180    /// No type known yet.
181    Undetected,
182    /// Inferred from an initializer (`:=` / soft) — best-effort; downgraded to `Variant` on
183    /// conflict rather than erroring.
184    Inferred,
185    /// Inferred-but-annotated as inferred (`var x := e` once accepted).
186    AnnotatedInferred,
187    /// Explicitly annotated (`var x: T`) — a mismatch is an error.
188    AnnotatedExplicit,
189}
190
191impl TypeSource {
192    /// A *hard* type is statically enforced (mismatch = error). A *soft* (`Inferred`) type is
193    /// best-effort and downgraded to `Variant` on conflict.
194    #[must_use]
195    pub fn is_hard(self) -> bool {
196        self > Self::Inferred
197    }
198}
199
200/// A typed binding: its [`Ty`] plus how the type was established.
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct TypedBinding {
203    /// The binding's type.
204    pub ty: Ty,
205    /// How it was established.
206    pub source: TypeSource,
207}
208
209/// The outcome of [`is_assignable`] — richer than a bool so the caller can raise the right
210/// diagnostic (Playbook §3.5/§5).
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212pub enum Assign {
213    /// Cleanly assignable.
214    Ok,
215    /// Assignable, but the source is `Variant` — a gradual (unchecked) escape.
216    OkUnsafe,
217    /// `float` stored into an `int` slot (`NARROWING_CONVERSION`).
218    Narrowing,
219    /// `int` used where an enum is expected (`INT_AS_ENUM_WITHOUT_CAST`).
220    IntAsEnum,
221    /// Not assignable (`TYPE_MISMATCH`).
222    No,
223}
224
225/// Whether `name` is a `Packed*Array` builtin (`PackedStringArray`, `PackedVector2Array`, …).
226fn is_packed_array(name: &str) -> bool {
227    name.starts_with("Packed") && name.ends_with("Array")
228}
229
230/// Godot's implicit conversions between two **builtin** types (the engine's `Variant::can_convert`
231/// for the value-prop slots GDScript accepts silently). Covers the numeric / vector widening +
232/// narrowing, `bool`↔`int`, the `String`/`StringName`/`NodePath` family, and `Array`↔`Packed*Array`.
233#[allow(
234    clippy::unnested_or_patterns,
235    reason = "a flat (from, to) conversion table reads more clearly than maximally-nested patterns"
236)]
237fn builtin_conversion(from: &str, to: &str) -> Assign {
238    match (from, to) {
239        // Narrowing (NARROWING_CONVERSION, not a hard mismatch): float→int, float-vec→int-vec.
240        ("float", "int")
241        | ("Vector2", "Vector2i")
242        | ("Vector3", "Vector3i")
243        | ("Vector4", "Vector4i")
244        | ("Rect2", "Rect2i") => Assign::Narrowing,
245        // Widening / interchangeable value types Godot converts silently.
246        ("int", "float")
247        | ("bool", "int")
248        | ("bool", "float")
249        | ("int", "bool")
250        | ("Vector2i", "Vector2")
251        | ("Vector3i", "Vector3")
252        | ("Vector4i", "Vector4")
253        | ("Rect2i", "Rect2")
254        | ("String", "StringName")
255        | ("String", "NodePath")
256        | ("StringName", "String")
257        | ("NodePath", "String") => Assign::Ok,
258        // A bare `Array` ↔ a `Packed*Array` is a runtime element-checked conversion Godot allows.
259        ("Array", t) if is_packed_array(t) => Assign::Ok,
260        (f, "Array") if is_packed_array(f) => Assign::Ok,
261        _ => Assign::No,
262    }
263}
264
265/// Whether a value of type `from` may be assigned to a slot of type `to` (the engine's
266/// `check_type_compatibility`, ported — Playbook §3.5). **Order matters.**
267#[must_use]
268pub fn is_assignable(api: &EngineApi, from: &Ty, to: &Ty) -> Assign {
269    // 1. Anything assigns to `Variant`.
270    if to.is_variant() {
271        return Assign::Ok;
272    }
273    // 2/3. Never cascade through the seam / error markers; a `Variant` source is the gradual
274    // escape (allowed-but-unsafe). These precede the structural checks deliberately.
275    if matches!(from, Ty::Unknown | Ty::Error) || matches!(to, Ty::Unknown | Ty::Error) {
276        return Assign::Ok;
277    }
278    if from.is_variant() {
279        return Assign::OkUnsafe;
280    }
281    // A tuple SOURCE assigns exactly like the untyped `Array` it is at runtime (widen-only — a
282    // tuple never REJECTS anything its array form would accept). A tuple is never a valid
283    // TARGET: no annotation resolves to it, so a `to` tuple only arises from a tuple-typed
284    // binding re-checked against itself — widen that side too, below.
285    if matches!(from, Ty::Tuple(_)) {
286        return is_assignable(api, &from.widen_tuple(), to);
287    }
288
289    match to {
290        Ty::Builtin(to_id) => {
291            let to_name = api.builtin(*to_id).name.as_str();
292            match from {
293                Ty::Builtin(from_id) if from_id == to_id => Assign::Ok,
294                Ty::Builtin(from_id) => {
295                    builtin_conversion(api.builtin(*from_id).name.as_str(), to_name)
296                }
297                // An enum value is assignable to `int`.
298                Ty::Enum(_) if to_name == "int" => Assign::Ok,
299                // A bare/typed `Array` (a `[…]` literal) assigns to any `Packed*Array`: Godot
300                // runtime-converts + validates the elements at runtime — never a static mismatch.
301                Ty::Array(_) if is_packed_array(to_name) => Assign::Ok,
302                _ => Assign::No,
303            }
304        }
305        Ty::Enum(to_enum) => {
306            // A BITFIELD enum is int-friendly by design: real code OR-combines its flags
307            // (`size_flags_horizontal = SIZE_SHRINK_BEGIN | SIZE_EXPAND`, an `int` expression),
308            // and Godot's `INT_AS_ENUM_WITHOUT_CAST` applies to plain enums only.
309            if to_enum.bitfield {
310                return match from {
311                    Ty::Enum(_) => Assign::Ok,
312                    Ty::Builtin(id) if api.builtin(*id).name == "int" => Assign::Ok,
313                    _ => Assign::No,
314                };
315            }
316            match from {
317                // Same enum — compared by QUALIFIED NAME only: the `bitfield` flag is a
318                // representation detail different resolution paths may record differently, and
319                // it must never make an enum incompatible with itself.
320                Ty::Enum(from_enum) if from_enum.qualified == to_enum.qualified => Assign::Ok,
321                // A *different* enum's value is an `int` at runtime: Godot wants a cast
322                // (`INT_AS_ENUM_WITHOUT_CAST`) — never a hard `TYPE_MISMATCH`.
323                Ty::Enum(_) => Assign::IntAsEnum,
324                // `int` → enum without a cast.
325                Ty::Builtin(id) if api.builtin(*id).name == "int" => Assign::IntAsEnum,
326                _ => Assign::No,
327            }
328        }
329        Ty::Object(to_class) => match from {
330            Ty::Object(from_class) if api.is_subclass(*from_class, *to_class) => Assign::Ok,
331            // Downcast (a base value into a derived slot): permitted with a runtime check —
332            // unsafe, but not a hard error. Real code relies on `var c: Control = get_child(0)`.
333            Ty::Object(from_class) if api.is_subclass(*to_class, *from_class) => Assign::OkUnsafe,
334            // A script reference / inner-class value is opaque — treat like the seam (an inner class
335            // IS-A its base, and we don't resolve the base chain here), never a hard mismatch.
336            Ty::ScriptRef(_) | Ty::InnerClass(_) => Assign::Ok,
337            _ => Assign::No,
338        },
339        // Typed arrays are invariant — but only between two *informative* element types
340        // (`Array[Button]` ↛ `Array[Node]`). A bare `Array`/`Array[Variant]`, or an
341        // `Array[Unknown]` (cross-file element), assigns freely: the engine permits
342        // untyped→typed with a runtime check, and the seam must never hard-error.
343        Ty::Array(to_elem) => match from {
344            Ty::Array(from_elem)
345                if from_elem == to_elem
346                    || from_elem.is_uninformative()
347                    || to_elem.is_uninformative() =>
348            {
349                Assign::Ok
350            }
351            _ => Assign::No,
352        },
353        Ty::Dict(to_k, to_v) => match from {
354            Ty::Dict(from_k, from_v)
355                if (from_k == to_k || from_k.is_uninformative() || to_k.is_uninformative())
356                    && (from_v == to_v || from_v.is_uninformative() || to_v.is_uninformative()) =>
357            {
358                Assign::Ok
359            }
360            _ => Assign::No,
361        },
362        Ty::Signal(_) => {
363            if matches!(from, Ty::Signal(_)) {
364                Assign::Ok
365            } else {
366                Assign::No
367            }
368        }
369        Ty::Callable => {
370            if matches!(from, Ty::Callable) {
371                Assign::Ok
372            } else {
373                Assign::No
374            }
375        }
376        Ty::Void => {
377            if matches!(from, Ty::Void) {
378                Assign::Ok
379            } else {
380                Assign::No
381            }
382        }
383        // A tuple target behaves as its runtime array form (see the widen-only rule above).
384        Ty::Tuple(_) => is_assignable(api, from, &to.widen_tuple()),
385        // An opaque script-ref / inner-class target, and the `Variant`/`Unknown`/`Error` targets
386        // already handled above, all accept anything.
387        Ty::ScriptRef(_) | Ty::InnerClass(_) | Ty::Variant | Ty::Unknown | Ty::Error => Assign::Ok,
388    }
389}
390
391/// Resolve an engine-API [`TyRef`] (the unresolved form stored in the model) to a [`Ty`].
392#[must_use]
393pub fn resolve_tyref(api: &EngineApi, tyref: &TyRef) -> Ty {
394    match tyref {
395        TyRef::Void => Ty::Void,
396        TyRef::Variant => Ty::Variant,
397        // `Array`/`Dictionary`/`Callable`/`Signal` are engine builtins, but we keep dedicated
398        // `Ty` variants for them (a lambda is `Ty::Callable`, `[]` is `Ty::Array`); normalize
399        // the bare builtin form so annotations, constructors, and values all agree.
400        TyRef::Builtin(id) => match api.builtin(*id).name.as_str() {
401            "Callable" => Ty::Callable,
402            "Signal" => Ty::Signal(None),
403            "Array" => Ty::array_of_variant(),
404            "Dictionary" => Ty::dict_of_variant(),
405            _ => Ty::Builtin(*id),
406        },
407        TyRef::Class(id) => Ty::Object(*id),
408        TyRef::TypedArray(elem) => Ty::Array(Box::new(resolve_elemref(api, elem))),
409        TyRef::TypedDict(k, v) => Ty::Dict(
410            Box::new(resolve_elemref(api, k)),
411            Box::new(resolve_elemref(api, v)),
412        ),
413        TyRef::Enum {
414            qualified,
415            bitfield,
416        } => Ty::Enum(EnumRef {
417            qualified: SmolStr::new(qualified),
418            bitfield: *bitfield,
419        }),
420    }
421}
422
423/// Resolve a typed-container element [`ElemRef`] to a [`Ty`].
424#[must_use]
425pub fn resolve_elemref(_api: &EngineApi, elem: &ElemRef) -> Ty {
426    match elem {
427        ElemRef::Variant => Ty::Variant,
428        ElemRef::Builtin(id) => Ty::Builtin(*id),
429        ElemRef::Class(id) => Ty::Object(*id),
430        ElemRef::Enum {
431            qualified,
432            bitfield,
433        } => Ty::Enum(EnumRef {
434            qualified: SmolStr::new(qualified),
435            bitfield: *bitfield,
436        }),
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    fn ty_of(api: &EngineApi, builtin: &str) -> Ty {
445        Ty::Builtin(api.builtin_by_name(builtin).expect("known builtin"))
446    }
447
448    #[test]
449    fn type_source_hardness() {
450        assert!(!TypeSource::Undetected.is_hard());
451        assert!(!TypeSource::Inferred.is_hard());
452        assert!(TypeSource::AnnotatedInferred.is_hard());
453        assert!(TypeSource::AnnotatedExplicit.is_hard());
454    }
455
456    #[test]
457    fn variant_and_seam_assignability() {
458        let api = gdscript_api::bundled();
459        let int = ty_of(api, "int");
460        // Anything assigns to Variant; Variant into a typed slot is allowed-but-unsafe.
461        assert_eq!(is_assignable(api, &int, &Ty::Variant), Assign::Ok);
462        assert_eq!(is_assignable(api, &Ty::Variant, &int), Assign::OkUnsafe);
463        // The seam and error markers never cascade, in either direction.
464        assert_eq!(is_assignable(api, &Ty::Unknown, &int), Assign::Ok);
465        assert_eq!(is_assignable(api, &int, &Ty::Unknown), Assign::Ok);
466        assert_eq!(is_assignable(api, &Ty::Error, &int), Assign::Ok);
467    }
468
469    #[test]
470    fn numeric_conversions() {
471        let api = gdscript_api::bundled();
472        let int = ty_of(api, "int");
473        let float = ty_of(api, "float");
474        assert_eq!(is_assignable(api, &int, &float), Assign::Ok); // widening, silent
475        assert_eq!(is_assignable(api, &float, &int), Assign::Narrowing);
476        assert_eq!(is_assignable(api, &int, &int), Assign::Ok);
477        let string = ty_of(api, "String");
478        assert_eq!(is_assignable(api, &string, &int), Assign::No);
479    }
480
481    #[test]
482    fn object_subclassing() {
483        let api = gdscript_api::bundled();
484        let node = Ty::Object(api.class_by_name("Node").unwrap());
485        let node2d = Ty::Object(api.class_by_name("Node2D").unwrap());
486        // Node2D is a Node (upcast → Ok); a Node into a Node2D slot is a downcast — permitted
487        // with a runtime check (unsafe), not a hard mismatch.
488        assert_eq!(is_assignable(api, &node2d, &node), Assign::Ok);
489        assert_eq!(is_assignable(api, &node, &node2d), Assign::OkUnsafe);
490        // An unrelated builtin is a real mismatch.
491        let s = ty_of(api, "String");
492        assert_eq!(is_assignable(api, &s, &node), Assign::No);
493    }
494
495    #[test]
496    fn arrays_are_invariant() {
497        let api = gdscript_api::bundled();
498        let int = ty_of(api, "int");
499        let float = ty_of(api, "float");
500        let arr_int = Ty::Array(Box::new(int.clone()));
501        let arr_int2 = Ty::Array(Box::new(int));
502        let arr_float = Ty::Array(Box::new(float));
503        assert_eq!(is_assignable(api, &arr_int, &arr_int2), Assign::Ok);
504        // No covariance: Array[int] is not assignable to Array[float] even though int->float.
505        assert_eq!(is_assignable(api, &arr_int2, &arr_float), Assign::No);
506    }
507
508    #[test]
509    fn enum_int_bridge() {
510        let api = gdscript_api::bundled();
511        let int = ty_of(api, "int");
512        let e = Ty::Enum(EnumRef {
513            qualified: SmolStr::new("Node.ProcessMode"),
514            bitfield: false,
515        });
516        assert_eq!(is_assignable(api, &e, &int), Assign::Ok); // enum -> int
517        assert_eq!(is_assignable(api, &int, &e), Assign::IntAsEnum); // int -> enum (warn)
518    }
519
520    #[test]
521    fn label_elides_unknown() {
522        let api = gdscript_api::bundled();
523        assert_eq!(Ty::Unknown.label(api), None);
524        assert_eq!(ty_of(api, "int").label(api).as_deref(), Some("int"));
525        assert_eq!(
526            Ty::Array(Box::new(ty_of(api, "int"))).label(api).as_deref(),
527            Some("Array[int]")
528        );
529    }
530}