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}