Skip to main content

gdscript_hir/
resolve.rs

1//! Name & type resolution (Playbook §3.2/§3.5): the [`resolve_external`] Phase-3 seam, the
2//! GDScript source-annotation → [`Ty`] resolver, base-class resolution, the per-class
3//! [`ClassScope`] (the class-member tier of the binder), and global resolution.
4//!
5//! The binder's lookup order (local → class member → inherited → global) is *driven* by
6//! [`crate::infer`]; this module supplies the class-member and global tiers plus the type
7//! resolution all tiers share. Everything here is a pure function of the item tree + the
8//! `Arc`-shared [`EngineApi`] — no body, no cross-file state.
9
10use cstree::util::NodeOrToken;
11use gdscript_api::gdscript_layer::LayerTy;
12use gdscript_api::{BuiltinId, ClassId, EngineApi};
13use gdscript_db::Db;
14use gdscript_syntax::{GdNode, SyntaxKind};
15use rustc_hash::FxHashMap;
16use smol_str::SmolStr;
17
18use crate::item_tree::{ExtendsRef, ItemTree, Member};
19use crate::ty::{EnumRef, ScriptRefId, Ty};
20
21/// A reference that *would* require another file to resolve — the Phase-3 boundary. Phase 2
22/// never reaches across files, so every variant resolves to the same non-cascading
23/// [`Ty::Unknown`]; Phase 3 reimplements only [`resolve_external`], leaving every inference
24/// body unchanged (Playbook §0 — "the biggest enabler in the whole phase; protect it").
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum ExternalRef {
27    /// A `class_name`-registered global from another script.
28    ClassName(SmolStr),
29    /// An `extends "res://…"` / `extends Other.Inner` target.
30    ExtendsPath(SmolStr),
31    /// A `preload(...)`/`load(...)` resource.
32    Preload(SmolStr),
33    /// A project autoload singleton.
34    Autoload(SmolStr),
35}
36
37/// **The Phase-3 seam.** Resolve a cross-file reference. In Phase 2 this is *always*
38/// [`Ty::Unknown`] — a type that never warns, never cascades a diagnostic, and is elided from
39/// hover. Funnel every "would need another file" path through here so Phase 3 has exactly one
40/// function to reimplement.
41#[must_use]
42pub fn resolve_external(db: &dyn Db, r: &ExternalRef) -> Ty {
43    match r {
44        // M1: a project-global `class_name` → its script reference.
45        ExternalRef::ClassName(name) => resolve_class_name(db, name),
46        // M3: `preload("res://x.gd")` → the declaring file's `ScriptRef` (a compile-time constant
47        // SCRIPT meta-type in Godot; `reduce_preload` — resolved by `res://` PATH, independent of
48        // `class_name`, so a script with no `class_name` is still preloadable). We reuse the
49        // `ScriptRef` representation: `X.new()` → instance, `X.member`/`X.CONST` resolve via the
50        // same `script_member_walk` as a `class_name` reference (the analyzer already collapses
51        // the meta-vs-instance distinction, like a bare `class_name`).
52        ExternalRef::Preload(path) => resolve_res_path(db, path),
53        // M3: `extends "res://x.gd"` lights up the same path map. A *relative* / dotted form
54        // (`extends "sibling.gd"`, `extends A.B`) stays the seam — relative-path anchoring is a
55        // documented follow-up (needs the importing file's dir; 0 occurrences in the corpus).
56        ExternalRef::ExtendsPath(path) if is_resource_path(path) => resolve_res_path(db, path),
57        // M4: a `*`-flagged autoload singleton's bare name → its script `ScriptRef` — a `.gd`
58        // directly, or a `.tscn` via its root node's attached script (Phase-4 scene-root sharpening).
59        ExternalRef::Autoload(name) => resolve_autoload(db, name),
60        // `load(...)` is never routed here (it stays an opaque runtime call). Dotted `extends`
61        // remains the seam.
62        ExternalRef::ExtendsPath(_) => Ty::Unknown,
63    }
64}
65
66/// Resolve a `*`-singleton autoload's bare name (M4). A `.gd` autoload resolves by **path** to its
67/// declaring file's [`Ty::ScriptRef`] (so `.member`/`.new()` walk via the script member table,
68/// even when the script has no `class_name`). A scene (`.tscn`/`.scn`) or any other resource
69/// autoload stays the **seam** ([`Ty::Unknown`]): typing it as bare `Node` would *false-warn* on
70/// the scene root script's own members (e.g. `Music.play()`), which we cannot see until Phase 4
71/// scene parsing recovers the root's real type — the conservative seam keeps zero false positives.
72/// No project config, a non-singleton name, or a dangling path is likewise the seam.
73fn resolve_autoload(db: &dyn Db, name: &str) -> Ty {
74    let Some(config) = db.project_config() else {
75        return Ty::Unknown;
76    };
77    let Some(path) = crate::queries::autoload_registry(db, config)
78        .resolve_path(name)
79        .cloned()
80    else {
81        return Ty::Unknown;
82    };
83    resolve_autoload_path_ty(db, &path)
84}
85
86/// Resolve **any** autoload's bare name (singleton OR loaded-but-not-global) to its type — the
87/// `/root/Name` node-path bridge (a non-`*` autoload is loaded at `/root/Name` but is not a global,
88/// so it is reachable only through that absolute path, never a bare name). Same path→type rules as
89/// [`resolve_autoload`]; the seam otherwise. Used by `infer::resolve_node_path` for `/root/Name`.
90#[must_use]
91pub fn resolve_autoload_any(db: &dyn Db, name: &str) -> Ty {
92    let Some(config) = db.project_config() else {
93        return Ty::Unknown;
94    };
95    let Some(path) = crate::queries::autoload_registry(db, config)
96        .resolve_any_path(name)
97        .cloned()
98    else {
99        return Ty::Unknown;
100    };
101    resolve_autoload_path_ty(db, &path)
102}
103
104/// Resolve an autoload's resource `path` to its type: a `.gd` → its declaring file's `ScriptRef`; a
105/// scene (`.tscn`/`.tres`) → its root's attached script (`resolve_scene_autoload`); anything else →
106/// the seam. Shared by [`resolve_autoload`] (singletons) and [`resolve_autoload_any`] (`/root/Name`).
107fn resolve_autoload_path_ty(db: &dyn Db, path: &str) -> Ty {
108    if is_gdscript_path(path) {
109        resolve_res_path(db, path)
110    } else if is_scene_path(path) {
111        resolve_scene_autoload(db, path)
112    } else {
113        Ty::Unknown
114    }
115}
116
117/// A `*`-autoload pointing at a scene (`.tscn`/`.tres`) resolves to its **root node's attached
118/// script** — the singleton-scene pattern (`Music="*res://music.tscn"` whose root has
119/// `script=music.gd`), so `Music.play()` checks against the real script (Phase-4 unblocked this; the
120/// scene model is now ingested). A root with no script, or an un-loaded scene, → the conservative
121/// seam. (Typing a script-less root by its native `type=` would need the engine API, which
122/// `resolve_external` doesn't carry — a follow-up; the attached-script case is the common one.)
123fn resolve_scene_autoload(db: &dyn Db, scene_path: &str) -> Ty {
124    let Some(root) = db.source_root() else {
125        return Ty::Unknown;
126    };
127    let Some(&scene_file) = crate::queries::res_path_registry(db, root).get(scene_path) else {
128        return Ty::Unknown; // the scene isn't loaded into the VFS
129    };
130    let Some(ft) = db.file_text(scene_file) else {
131        return Ty::Unknown;
132    };
133    let scene = crate::queries::scene_model(db, ft);
134    // 1. An attached script on the root (`script=ExtResource`) — the most specific (a `.tscn`).
135    if let Some(script_path) = scene
136        .root
137        .and_then(|idx| scene.node(idx))
138        .and_then(|root_node| root_node.script.as_ref())
139        .and_then(|id| scene.ext_resources.get(id))
140        .and_then(|ext| ext.path.as_deref())
141    {
142        let ty = resolve_res_path(db, script_path);
143        if !ty.is_uninformative() {
144            return ty;
145        }
146    }
147    // 2. The `.tscn` header `script_class="…"` shortcut, or a `.tres`'s own `resource_type` — the
148    //    resource's `class_name`, recorded without resolving the script file (so a script-less root
149    //    that still carries its class_name resolves). Resolve it through the project class_name
150    //    registry. (Typing a root by its native `type=` alone would need the engine API, which this
151    //    seam doesn't carry — a follow-up; resolving the recorded class_name is the common case.)
152    for class_name in [scene.script_class.as_ref(), scene.resource_type.as_ref()]
153        .into_iter()
154        .flatten()
155    {
156        let ty = resolve_external(db, &ExternalRef::ClassName(class_name.clone()));
157        if !ty.is_uninformative() {
158            return ty;
159        }
160    }
161    Ty::Unknown
162}
163
164/// Whether a resource path is a Godot scene/resource (`.tscn`/`.tres`).
165fn is_scene_path(p: &str) -> bool {
166    p.rsplit('.')
167        .next()
168        .is_some_and(|ext| ext.eq_ignore_ascii_case("tscn") || ext.eq_ignore_ascii_case("tres"))
169}
170
171/// Whether a resource path is a GDScript file (the `.cs` C# case is out of scope → seam). Compare
172/// the final extension rather than `ends_with` so a `.GD` (case quirk) still matches.
173fn is_gdscript_path(p: &str) -> bool {
174    p.rsplit('.')
175        .next()
176        .is_some_and(|ext| ext.eq_ignore_ascii_case("gd"))
177}
178
179/// Whether a path is an engine resource URI we resolve project-root-absolutely (no anchor
180/// needed). Godot also accepts relative `preload`/`extends` paths anchored to the importing
181/// script's directory; those are a documented follow-up (they need the importing file's path
182/// threaded into resolution, and the reference corpus has none).
183fn is_resource_path(p: &str) -> bool {
184    p.starts_with("res://") || p.starts_with("user://")
185}
186
187/// Anchor a `preload`/`extends` resource path to an absolute `res://`/`user://` path the way Godot
188/// does (`reduce_preload`: `script_path.get_base_dir().path_join(p).simplify_path()`): an already-
189/// absolute path passes through unchanged; a RELATIVE path is joined to `importing`'s directory and
190/// simplified (`.`/`..` collapsed). `None` only when the path is relative and `importing` carries no
191/// resource anchor — the conservative seam (never a false resolution).
192#[must_use]
193pub fn anchor_res_path(importing: Option<&str>, raw: &str) -> Option<SmolStr> {
194    if is_resource_path(raw) {
195        return Some(SmolStr::new(raw));
196    }
197    let (scheme, rest) = importing?.split_once("://")?;
198    let dir = rest.rsplit_once('/').map_or("", |(d, _)| d);
199    let joined = if dir.is_empty() {
200        format!("{scheme}://{raw}")
201    } else {
202        format!("{scheme}://{dir}/{raw}")
203    };
204    Some(SmolStr::new(simplify_resource_path(&joined)))
205}
206
207/// Collapse `.`/`..`/empty segments in a `scheme://…` resource path (Godot's `simplify_path`).
208fn simplify_resource_path(path: &str) -> String {
209    let (scheme, rest) = path.split_once("://").unwrap_or(("res", path));
210    let mut out: Vec<&str> = Vec::new();
211    for seg in rest.split('/') {
212        match seg {
213            "" | "." => {}
214            ".." => {
215                out.pop();
216            }
217            s => out.push(s),
218        }
219    }
220    format!("{scheme}://{}", out.join("/"))
221}
222
223/// Resolve a `res://` resource path to the declaring file's [`Ty::ScriptRef`] via the project
224/// [`res_path_registry`](crate::queries::res_path_registry), or the seam ([`Ty::Unknown`]) when
225/// no project is loaded or the path maps to no known file (a dangling `preload` — imprecise, but
226/// never a false diagnostic).
227fn resolve_res_path(db: &dyn Db, path: &str) -> Ty {
228    // Only a GDScript resource has a script `ScriptRef`. A `.tscn`/`.tres`/`.png`/… resolves to a
229    // PackedScene/Resource, not a script — typing it as a `ScriptRef` would wrongly accept
230    // `X.new()` and member access on it (scene-root typing is Phase 4). The `res_path_registry`
231    // only indexes `.gd` files today, but gate defensively so a future scene-ingesting loader
232    // cannot mis-type `preload("res://x.tscn")`. Non-`.gd` → the conservative seam.
233    if !is_gdscript_path(path) {
234        return Ty::Unknown;
235    }
236    let Some(root) = db.source_root() else {
237        return Ty::Unknown;
238    };
239    match crate::queries::res_path_registry(db, root).get(path) {
240        Some(file) => Ty::ScriptRef(ScriptRefId(file.0)),
241        None => Ty::Unknown,
242    }
243}
244
245/// Resolve a global `class_name` against the project registry (M1): the script's
246/// [`Ty::ScriptRef`], or the seam ([`Ty::Unknown`]) when no project is loaded or the name is not
247/// a registered global class. The `ScriptRefId` is the declaring file's `FileId`.
248fn resolve_class_name(db: &dyn Db, name: &str) -> Ty {
249    let Some(root) = db.source_root() else {
250        return Ty::Unknown;
251    };
252    match crate::queries::global_registry(db, root).resolve(name) {
253        Some(file) => Ty::ScriptRef(ScriptRefId(file.file_id(db).0)),
254        None => Ty::Unknown,
255    }
256}
257
258// ---- type-annotation resolution ----------------------------------------------------------
259
260/// Resolve a `## @return-tuple(T0, T1, …)` doc-tag's element type names to a [`Ty::Tuple`] — the
261/// ONE mapping both the same-file call path (`infer::func_call_return_ty`) and the cross-file
262/// member table (`queries::script_class`) use, so the two can never diverge.
263#[must_use]
264pub fn resolve_tuple_return(db: &dyn Db, api: &EngineApi, names: &[SmolStr]) -> Ty {
265    Ty::Tuple(
266        names
267            .iter()
268            .map(|n| resolve_type_name(db, api, n))
269            .collect(),
270    )
271}
272
273/// Resolve a GDScript source type annotation (a `TypeRef` CST node) to a [`Ty`]. Handles
274/// `void`/`Variant`, builtins, engine classes, `Array`/`Array[T]`, `Dictionary`/
275/// `Dictionary[K, V]`, global enums, and `Class.Enum`; an unknown bare name is treated as a
276/// (cross-file) `class_name` and funneled through the [`resolve_external`] seam.
277#[must_use]
278pub fn resolve_type_ref(db: &dyn Db, api: &EngineApi, node: &GdNode) -> Ty {
279    // The leading dotted name comes from this node's *direct* `Ident`/`void` tokens; the type
280    // arguments (`[...]`) are *direct child* `TypeRef` nodes (the grammar nests them).
281    let names: Vec<String> = node
282        .children_with_tokens()
283        .filter_map(NodeOrToken::into_token)
284        .filter(|t| matches!(t.kind(), SyntaxKind::Ident | SyntaxKind::VoidKw))
285        .map(|t| t.text().to_owned())
286        .collect();
287    let args: Vec<GdNode> = node
288        .children()
289        .filter(|c| c.kind() == SyntaxKind::TypeRef)
290        .cloned()
291        .collect();
292    resolve_named(db, api, &names, &args)
293}
294
295/// Resolve a bare type *name* (no type arguments) — for callers that only have a string
296/// (completion detail, inlay display).
297#[must_use]
298pub fn resolve_type_name(db: &dyn Db, api: &EngineApi, name: &str) -> Ty {
299    resolve_named(db, api, std::slice::from_ref(&name.to_owned()), &[])
300}
301
302fn resolve_named(db: &dyn Db, api: &EngineApi, names: &[String], args: &[GdNode]) -> Ty {
303    let Some(head) = names.first() else {
304        return Ty::Variant;
305    };
306    if names.len() == 1 {
307        match head.as_str() {
308            "void" => return Ty::Void,
309            "Variant" => return Ty::Variant,
310            // Dedicated variants (see `resolve_tyref`) so annotations match lambda/signal values.
311            "Callable" => return Ty::Callable,
312            "Signal" => return Ty::Signal(None),
313            "Array" => return Ty::Array(Box::new(elem_arg(db, api, args, 0))),
314            "Dictionary" => {
315                return Ty::Dict(
316                    Box::new(elem_arg(db, api, args, 0)),
317                    Box::new(elem_arg(db, api, args, 1)),
318                );
319            }
320            _ => {}
321        }
322        if let Some(b) = api.builtin_by_name(head) {
323            return Ty::Builtin(b);
324        }
325        if let Some(c) = api.class_by_name(head) {
326            return Ty::Object(c);
327        }
328        if let Some(e) = api.global_enum(head) {
329            return Ty::Enum(EnumRef {
330                qualified: SmolStr::new(head),
331                bitfield: e.is_bitfield,
332            });
333        }
334        // Unknown bare name → most likely another script's `class_name` → the seam.
335        return resolve_external(db, &ExternalRef::ClassName(SmolStr::new(head)));
336    }
337    // Dotted: try `Class.Enum`; anything else (inner class, namespaced) is the seam.
338    if names.len() == 2
339        && let Some(c) = api.class_by_name(&names[0])
340        && let Some(e) = api.class(c).enums.iter().find(|e| e.name == names[1])
341    {
342        return Ty::Enum(EnumRef {
343            qualified: SmolStr::new(names.join(".")),
344            bitfield: e.is_bitfield,
345        });
346    }
347    resolve_external(db, &ExternalRef::ExtendsPath(SmolStr::new(names.join("."))))
348}
349
350/// Resolve the `i`-th type argument as a container element, collapsing a nested typed
351/// container to `Variant` (Phase 2 does not track nested element types — Playbook §2). A
352/// missing argument (bare `Array`/`Dictionary`) is `Variant`.
353fn elem_arg(db: &dyn Db, api: &EngineApi, args: &[GdNode], i: usize) -> Ty {
354    match args.get(i) {
355        Some(node) => match resolve_type_ref(db, api, node) {
356            Ty::Array(_) | Ty::Dict(..) => Ty::Variant,
357            other => other,
358        },
359        None => Ty::Variant,
360    }
361}
362
363/// Map a coarse engine-layer [`LayerTy`] (used by the hand-authored GDScript layer, which
364/// predates the loaded model's real ids) to a [`Ty`].
365#[must_use]
366pub fn layer_to_ty(api: &EngineApi, lt: LayerTy) -> Ty {
367    match lt {
368        LayerTy::Float => builtin(api, "float"),
369        LayerTy::Int => builtin(api, "int"),
370        LayerTy::Bool => builtin(api, "bool"),
371        LayerTy::Str => builtin(api, "String"),
372        LayerTy::Array => Ty::array_of_variant(),
373        LayerTy::Dictionary => Ty::dict_of_variant(),
374        LayerTy::Color => builtin(api, "Color"),
375        LayerTy::Variant => Ty::Variant,
376        LayerTy::Unknown => Ty::Unknown,
377        LayerTy::Void => Ty::Void,
378    }
379}
380
381fn builtin(api: &EngineApi, name: &str) -> Ty {
382    api.builtin_by_name(name).map_or(Ty::Variant, Ty::Builtin)
383}
384
385// ---- base + class scope ------------------------------------------------------------------
386
387/// Resolve a file's (or inner class's) base type from its `extends`. A bare engine-class name
388/// resolves to `Object(id)`; a script-path / dotted / unknown base goes through the seam to
389/// `Unknown`. With no `extends`, a script implicitly extends `RefCounted`.
390#[must_use]
391pub fn resolve_base(db: &dyn Db, api: &EngineApi, tree: &ItemTree, anchor: Option<&str>) -> Ty {
392    match &tree.extends {
393        None => api
394            .class_by_name("RefCounted")
395            .map_or(Ty::Unknown, Ty::Object),
396        Some(ExtendsRef::Name(n)) => api.class_by_name(n).map_or_else(
397            || resolve_external(db, &ExternalRef::ClassName(n.clone())),
398            Ty::Object,
399        ),
400        // A string-path base (`extends "res://x.gd"` / `extends "sibling.gd"`): anchor a relative
401        // path to the importing file's directory (Godot `get_base_dir().path_join()`), then resolve.
402        Some(ExtendsRef::ScriptPath(p)) => match anchor_res_path(anchor, p) {
403            Some(abs) => resolve_external(db, &ExternalRef::ExtendsPath(abs)),
404            None => Ty::Unknown,
405        },
406        // A dotted base (`extends A.B`) is a namespaced name, not a path — the seam.
407        Some(ExtendsRef::Path(p)) => resolve_external(db, &ExternalRef::ExtendsPath(p.clone())),
408        // `extends "res://x.gd".Inner` selects an inner class we can't model yet — the seam, never the
409        // outer script (correct-or-refuse: no false member access against the outer class).
410        Some(ExtendsRef::ScriptPathInner(_)) => Ty::Unknown,
411    }
412}
413
414/// What a class-level name resolves to within [`ClassScope`].
415#[derive(Debug, Clone, Copy, PartialEq, Eq)]
416pub enum ClassItem {
417    /// A declared member (index into [`ItemTree::members`]).
418    Member(usize),
419    /// A variant of an *anonymous* `enum { … }` (a class-level `int` constant).
420    EnumVariant,
421}
422
423/// The class-member tier of the binder (Playbook §3.2 step 2): this file's own members + the
424/// resolved base type. Anonymous-enum variants are flattened in as `int` constants.
425#[derive(Debug, Clone)]
426pub struct ClassScope<'a> {
427    /// The lowered item tree this scope describes.
428    pub tree: &'a ItemTree,
429    /// The resolved base type (`Object(id)` for an engine base, else `Unknown`).
430    pub base: Ty,
431    /// The static type of `self` in this class's bodies. Defaults to [`base`](Self::base), but
432    /// `analyze_file` overrides it with the script's *own* [`Ty::ScriptRef`] so that member access
433    /// on an **aliased** `self` (`var me := self; me.own_method()`) walks the file's own members
434    /// instead of only the engine base — otherwise a real own-method call would false-warn
435    /// `UNSAFE_METHOD_ACCESS`. (Direct `self.member` already uses the own-member fast path.)
436    pub self_ty: Ty,
437    /// Resolved types of this class's own fields (`var`/`const`), seeded by a first inference
438    /// pass over the field initializers so member references see the *inferred* type (e.g.
439    /// `var n := 0` → `int`), not just the annotation. Empty until populated.
440    pub member_types: FxHashMap<SmolStr, Ty>,
441    members: FxHashMap<SmolStr, ClassItem>,
442}
443
444impl<'a> ClassScope<'a> {
445    /// Build the scope for `tree` against the engine model.
446    #[must_use]
447    pub fn new(db: &dyn Db, api: &EngineApi, tree: &'a ItemTree, anchor: Option<&str>) -> Self {
448        let mut members = FxHashMap::default();
449        for (i, m) in tree.members.iter().enumerate() {
450            match m {
451                Member::Enum(e) if e.name.is_none() => {
452                    // Anonymous enum: its variants become bare class-level `int` constants.
453                    for v in &e.variants {
454                        members.insert(v.clone(), ClassItem::EnumVariant);
455                    }
456                }
457                _ => {
458                    if let Some(name) = m.name() {
459                        members
460                            .entry(SmolStr::new(name))
461                            .or_insert(ClassItem::Member(i));
462                    }
463                }
464            }
465        }
466        let base = resolve_base(db, api, tree, anchor);
467        Self {
468            tree,
469            self_ty: base.clone(),
470            base,
471            member_types: FxHashMap::default(),
472            members,
473        }
474    }
475
476    /// Resolve a name against this class's own members (not the base chain).
477    #[must_use]
478    pub fn lookup(&self, name: &str) -> Option<ClassItem> {
479        self.members.get(name).copied()
480    }
481
482    /// The member behind a [`ClassItem::Member`].
483    #[must_use]
484    pub fn member(&self, item: ClassItem) -> Option<&'a Member> {
485        match item {
486            ClassItem::Member(i) => self.tree.members.get(i),
487            ClassItem::EnumVariant => None,
488        }
489    }
490}
491
492// ---- global resolution -------------------------------------------------------------------
493
494/// What a bare *global* name resolves to (Playbook §3.2 step 4). The caller ([`crate::infer`])
495/// decides how to use it given the syntactic context (bare value vs. call vs. `.`-access).
496#[derive(Debug, Clone, PartialEq, Eq)]
497pub enum GlobalDef {
498    /// A pseudo-constant value (`PI` → `float`).
499    Const(Ty),
500    /// An engine singleton instance (`Input` → `Object(Input)`).
501    Singleton(ClassId),
502    /// A GDScript builtin function (`preload`/`range`/`len`/…).
503    Builtin,
504    /// A `@GlobalScope` utility function (`sin`, `print`, …).
505    Utility,
506    /// A builtin Variant type name used as a value / constructor (`Vector2`, `int`).
507    BuiltinType(BuiltinId),
508    /// An engine class name used as a value / constructor / type (`Node`, `Resource`).
509    ClassType(ClassId),
510    /// A global enum namespace (`Error`, `Key`) — a set of `int` constants.
511    GlobalEnum,
512}
513
514/// Resolve a bare global identifier. Order is deliberate: pseudo-constants and singletons take
515/// precedence over the same-named type (bare `Input` is the singleton instance, not the class).
516#[must_use]
517pub fn resolve_global(api: &EngineApi, name: &str) -> Option<GlobalDef> {
518    if let Some(gc) = api.global_const(name) {
519        return Some(GlobalDef::Const(layer_to_ty(api, gc.ty)));
520    }
521    if let Some(cid) = api.singleton(name) {
522        return Some(GlobalDef::Singleton(cid));
523    }
524    if api.gdscript_builtin(name).is_some() {
525        return Some(GlobalDef::Builtin);
526    }
527    if api.utility(name).is_some() {
528        return Some(GlobalDef::Utility);
529    }
530    if let Some(bid) = api.builtin_by_name(name) {
531        return Some(GlobalDef::BuiltinType(bid));
532    }
533    if let Some(cid) = api.class_by_name(name) {
534        return Some(GlobalDef::ClassType(cid));
535    }
536    if api.global_enum(name).is_some() {
537        return Some(GlobalDef::GlobalEnum);
538    }
539    // A `@GlobalScope` enum VALUE used bare (`MOUSE_BUTTON_LEFT`, `OK`, `TYPE_STRING`, …). Typed
540    // as its DECLARING enum — the same `EnumRef` a `MouseButton` annotation resolves to (see
541    // `resolve_named` above) — so `var b: MouseButton = MOUSE_BUTTON_LEFT` is `Enum → Enum`
542    // (`Assign::Ok`), never a false `INT_AS_ENUM_WITHOUT_CAST`; an enum value stays freely
543    // `int`-assignable via `ty::is_assignable`. Last so it can never shadow a real type name.
544    if let Some((e, _)) = api.global_enum_value(name) {
545        return Some(GlobalDef::Const(Ty::Enum(EnumRef {
546            qualified: SmolStr::new(&e.name),
547            bitfield: e.is_bitfield,
548        })));
549    }
550    None
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556    use crate::item_tree::item_tree;
557    use gdscript_syntax::parse;
558
559    fn api() -> &'static EngineApi {
560        gdscript_api::bundled()
561    }
562
563    fn db() -> gdscript_db::RootDatabase {
564        gdscript_db::RootDatabase::default()
565    }
566
567    /// Resolve the first `TypeRef` node found in `decl` source.
568    fn ty_of_annotation(src: &str) -> Ty {
569        let parse = parse(src);
570        let root = parse.syntax_node();
571        let type_ref = gdscript_syntax::ast::descendants(&root)
572            .into_iter()
573            .find(|n| n.kind() == SyntaxKind::TypeRef)
574            .expect("a TypeRef node");
575        resolve_type_ref(&db(), api(), &type_ref)
576    }
577
578    #[test]
579    fn seam_is_unknown() {
580        assert_eq!(
581            resolve_external(&db(), &ExternalRef::ClassName(SmolStr::new("MyClass"))),
582            Ty::Unknown
583        );
584    }
585
586    #[test]
587    fn builtin_and_class_annotations() {
588        assert_eq!(
589            ty_of_annotation("var x: int\n"),
590            Ty::Builtin(api().builtin_by_name("int").unwrap())
591        );
592        assert_eq!(
593            ty_of_annotation("var n: Node\n"),
594            Ty::Object(api().class_by_name("Node").unwrap())
595        );
596        assert_eq!(ty_of_annotation("func f() -> void:\n\tpass\n"), Ty::Void);
597    }
598
599    #[test]
600    fn typed_container_annotations() {
601        let int = Ty::Builtin(api().builtin_by_name("int").unwrap());
602        assert_eq!(
603            ty_of_annotation("var a: Array[int]\n"),
604            Ty::Array(Box::new(int.clone()))
605        );
606        assert_eq!(ty_of_annotation("var a: Array\n"), Ty::array_of_variant());
607        assert_eq!(
608            ty_of_annotation("var d: Dictionary[String, int]\n"),
609            Ty::Dict(
610                Box::new(Ty::Builtin(api().builtin_by_name("String").unwrap())),
611                Box::new(int)
612            )
613        );
614        // Nested typed containers collapse to Variant (Playbook §2).
615        assert_eq!(
616            ty_of_annotation("var a: Array[Array[int]]\n"),
617            Ty::Array(Box::new(Ty::Variant))
618        );
619    }
620
621    #[test]
622    fn unknown_annotation_is_seam_not_error() {
623        // A user `class_name` we can't see (no false diagnostic territory).
624        assert_eq!(ty_of_annotation("var p: MyPlayer\n"), Ty::Unknown);
625    }
626
627    #[test]
628    fn base_resolution() {
629        let extends_node = item_tree(&parse("extends Node2D\n").syntax_node());
630        assert_eq!(
631            resolve_base(&db(), api(), &extends_node, None),
632            Ty::Object(api().class_by_name("Node2D").unwrap())
633        );
634        // No extends → implicit RefCounted.
635        let no_extends = item_tree(&parse("var x = 1\n").syntax_node());
636        assert_eq!(
637            resolve_base(&db(), api(), &no_extends, None),
638            Ty::Object(api().class_by_name("RefCounted").unwrap())
639        );
640        // Script-path base with no project loaded → seam.
641        let script_base = item_tree(&parse("extends \"res://b.gd\"\n").syntax_node());
642        assert_eq!(resolve_base(&db(), api(), &script_base, None), Ty::Unknown);
643    }
644
645    #[test]
646    fn anchor_res_path_absolute_passes_through() {
647        assert_eq!(
648            anchor_res_path(Some("res://a/b.gd"), "res://x.gd").as_deref(),
649            Some("res://x.gd")
650        );
651        assert_eq!(
652            anchor_res_path(None, "user://x.gd").as_deref(),
653            Some("user://x.gd")
654        );
655    }
656
657    #[test]
658    fn anchor_res_path_relative_anchors_to_importing_dir() {
659        let from = Some("res://entities/player.gd");
660        // sibling
661        assert_eq!(
662            anchor_res_path(from, "enemy.gd").as_deref(),
663            Some("res://entities/enemy.gd")
664        );
665        // parent traversal (`..`) collapses
666        assert_eq!(
667            anchor_res_path(from, "../core/hooks.gd").as_deref(),
668            Some("res://core/hooks.gd")
669        );
670        // explicit current-dir (`./`)
671        assert_eq!(
672            anchor_res_path(from, "./util.gd").as_deref(),
673            Some("res://entities/util.gd")
674        );
675        // an importer at the project root
676        assert_eq!(
677            anchor_res_path(Some("res://main.gd"), "util.gd").as_deref(),
678            Some("res://util.gd")
679        );
680    }
681
682    #[test]
683    fn anchor_res_path_relative_without_anchor_is_seam() {
684        assert_eq!(anchor_res_path(None, "sibling.gd"), None);
685    }
686
687    #[test]
688    fn class_scope_members_and_anon_enum() {
689        let tree = item_tree(
690            &parse(
691                "var hp := 10\nfunc attack():\n\tpass\nenum { FIRE, ICE }\nenum Named { A, B }\n",
692            )
693            .syntax_node(),
694        );
695        let scope = ClassScope::new(&db(), api(), &tree, None);
696        assert!(matches!(scope.lookup("hp"), Some(ClassItem::Member(_))));
697        assert!(matches!(scope.lookup("attack"), Some(ClassItem::Member(_))));
698        // Anonymous-enum variants flatten into the class scope as int consts.
699        assert_eq!(scope.lookup("FIRE"), Some(ClassItem::EnumVariant));
700        assert_eq!(scope.lookup("ICE"), Some(ClassItem::EnumVariant));
701        // A named enum binds its *name*, not its variants.
702        assert!(matches!(scope.lookup("Named"), Some(ClassItem::Member(_))));
703        assert_eq!(scope.lookup("A"), None);
704    }
705
706    #[test]
707    fn globals() {
708        assert!(matches!(
709            resolve_global(api(), "PI"),
710            Some(GlobalDef::Const(_))
711        ));
712        assert!(matches!(
713            resolve_global(api(), "Input"),
714            Some(GlobalDef::Singleton(_))
715        ));
716        assert!(matches!(
717            resolve_global(api(), "preload"),
718            Some(GlobalDef::Builtin)
719        ));
720        assert!(matches!(
721            resolve_global(api(), "Vector2"),
722            Some(GlobalDef::BuiltinType(_))
723        ));
724        assert!(matches!(
725            resolve_global(api(), "Node"),
726            Some(GlobalDef::ClassType(_))
727        ));
728        assert!(resolve_global(api(), "definitely_not_a_global").is_none());
729    }
730}