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}