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 GDScript source type annotation (a `TypeRef` CST node) to a [`Ty`]. Handles
261/// `void`/`Variant`, builtins, engine classes, `Array`/`Array[T]`, `Dictionary`/
262/// `Dictionary[K, V]`, global enums, and `Class.Enum`; an unknown bare name is treated as a
263/// (cross-file) `class_name` and funneled through the [`resolve_external`] seam.
264#[must_use]
265pub fn resolve_type_ref(db: &dyn Db, api: &EngineApi, node: &GdNode) -> Ty {
266    // The leading dotted name comes from this node's *direct* `Ident`/`void` tokens; the type
267    // arguments (`[...]`) are *direct child* `TypeRef` nodes (the grammar nests them).
268    let names: Vec<String> = node
269        .children_with_tokens()
270        .filter_map(NodeOrToken::into_token)
271        .filter(|t| matches!(t.kind(), SyntaxKind::Ident | SyntaxKind::VoidKw))
272        .map(|t| t.text().to_owned())
273        .collect();
274    let args: Vec<GdNode> = node
275        .children()
276        .filter(|c| c.kind() == SyntaxKind::TypeRef)
277        .cloned()
278        .collect();
279    resolve_named(db, api, &names, &args)
280}
281
282/// Resolve a bare type *name* (no type arguments) — for callers that only have a string
283/// (completion detail, inlay display).
284#[must_use]
285pub fn resolve_type_name(db: &dyn Db, api: &EngineApi, name: &str) -> Ty {
286    resolve_named(db, api, std::slice::from_ref(&name.to_owned()), &[])
287}
288
289fn resolve_named(db: &dyn Db, api: &EngineApi, names: &[String], args: &[GdNode]) -> Ty {
290    let Some(head) = names.first() else {
291        return Ty::Variant;
292    };
293    if names.len() == 1 {
294        match head.as_str() {
295            "void" => return Ty::Void,
296            "Variant" => return Ty::Variant,
297            // Dedicated variants (see `resolve_tyref`) so annotations match lambda/signal values.
298            "Callable" => return Ty::Callable,
299            "Signal" => return Ty::Signal(None),
300            "Array" => return Ty::Array(Box::new(elem_arg(db, api, args, 0))),
301            "Dictionary" => {
302                return Ty::Dict(
303                    Box::new(elem_arg(db, api, args, 0)),
304                    Box::new(elem_arg(db, api, args, 1)),
305                );
306            }
307            _ => {}
308        }
309        if let Some(b) = api.builtin_by_name(head) {
310            return Ty::Builtin(b);
311        }
312        if let Some(c) = api.class_by_name(head) {
313            return Ty::Object(c);
314        }
315        if let Some(e) = api.global_enum(head) {
316            return Ty::Enum(EnumRef {
317                qualified: SmolStr::new(head),
318                bitfield: e.is_bitfield,
319            });
320        }
321        // Unknown bare name → most likely another script's `class_name` → the seam.
322        return resolve_external(db, &ExternalRef::ClassName(SmolStr::new(head)));
323    }
324    // Dotted: try `Class.Enum`; anything else (inner class, namespaced) is the seam.
325    if names.len() == 2
326        && let Some(c) = api.class_by_name(&names[0])
327        && let Some(e) = api.class(c).enums.iter().find(|e| e.name == names[1])
328    {
329        return Ty::Enum(EnumRef {
330            qualified: SmolStr::new(names.join(".")),
331            bitfield: e.is_bitfield,
332        });
333    }
334    resolve_external(db, &ExternalRef::ExtendsPath(SmolStr::new(names.join("."))))
335}
336
337/// Resolve the `i`-th type argument as a container element, collapsing a nested typed
338/// container to `Variant` (Phase 2 does not track nested element types — Playbook §2). A
339/// missing argument (bare `Array`/`Dictionary`) is `Variant`.
340fn elem_arg(db: &dyn Db, api: &EngineApi, args: &[GdNode], i: usize) -> Ty {
341    match args.get(i) {
342        Some(node) => match resolve_type_ref(db, api, node) {
343            Ty::Array(_) | Ty::Dict(..) => Ty::Variant,
344            other => other,
345        },
346        None => Ty::Variant,
347    }
348}
349
350/// Map a coarse engine-layer [`LayerTy`] (used by the hand-authored GDScript layer, which
351/// predates the loaded model's real ids) to a [`Ty`].
352#[must_use]
353pub fn layer_to_ty(api: &EngineApi, lt: LayerTy) -> Ty {
354    match lt {
355        LayerTy::Float => builtin(api, "float"),
356        LayerTy::Int => builtin(api, "int"),
357        LayerTy::Bool => builtin(api, "bool"),
358        LayerTy::Str => builtin(api, "String"),
359        LayerTy::Array => Ty::array_of_variant(),
360        LayerTy::Variant => Ty::Variant,
361        LayerTy::Unknown => Ty::Unknown,
362        LayerTy::Void => Ty::Void,
363    }
364}
365
366fn builtin(api: &EngineApi, name: &str) -> Ty {
367    api.builtin_by_name(name).map_or(Ty::Variant, Ty::Builtin)
368}
369
370// ---- base + class scope ------------------------------------------------------------------
371
372/// Resolve a file's (or inner class's) base type from its `extends`. A bare engine-class name
373/// resolves to `Object(id)`; a script-path / dotted / unknown base goes through the seam to
374/// `Unknown`. With no `extends`, a script implicitly extends `RefCounted`.
375#[must_use]
376pub fn resolve_base(db: &dyn Db, api: &EngineApi, tree: &ItemTree, anchor: Option<&str>) -> Ty {
377    match &tree.extends {
378        None => api
379            .class_by_name("RefCounted")
380            .map_or(Ty::Unknown, Ty::Object),
381        Some(ExtendsRef::Name(n)) => api.class_by_name(n).map_or_else(
382            || resolve_external(db, &ExternalRef::ClassName(n.clone())),
383            Ty::Object,
384        ),
385        // A string-path base (`extends "res://x.gd"` / `extends "sibling.gd"`): anchor a relative
386        // path to the importing file's directory (Godot `get_base_dir().path_join()`), then resolve.
387        Some(ExtendsRef::ScriptPath(p)) => match anchor_res_path(anchor, p) {
388            Some(abs) => resolve_external(db, &ExternalRef::ExtendsPath(abs)),
389            None => Ty::Unknown,
390        },
391        // A dotted base (`extends A.B`) is a namespaced name, not a path — the seam.
392        Some(ExtendsRef::Path(p)) => resolve_external(db, &ExternalRef::ExtendsPath(p.clone())),
393        // `extends "res://x.gd".Inner` selects an inner class we can't model yet — the seam, never the
394        // outer script (correct-or-refuse: no false member access against the outer class).
395        Some(ExtendsRef::ScriptPathInner(_)) => Ty::Unknown,
396    }
397}
398
399/// What a class-level name resolves to within [`ClassScope`].
400#[derive(Debug, Clone, Copy, PartialEq, Eq)]
401pub enum ClassItem {
402    /// A declared member (index into [`ItemTree::members`]).
403    Member(usize),
404    /// A variant of an *anonymous* `enum { … }` (a class-level `int` constant).
405    EnumVariant,
406}
407
408/// The class-member tier of the binder (Playbook §3.2 step 2): this file's own members + the
409/// resolved base type. Anonymous-enum variants are flattened in as `int` constants.
410#[derive(Debug, Clone)]
411pub struct ClassScope<'a> {
412    /// The lowered item tree this scope describes.
413    pub tree: &'a ItemTree,
414    /// The resolved base type (`Object(id)` for an engine base, else `Unknown`).
415    pub base: Ty,
416    /// The static type of `self` in this class's bodies. Defaults to [`base`](Self::base), but
417    /// `analyze_file` overrides it with the script's *own* [`Ty::ScriptRef`] so that member access
418    /// on an **aliased** `self` (`var me := self; me.own_method()`) walks the file's own members
419    /// instead of only the engine base — otherwise a real own-method call would false-warn
420    /// `UNSAFE_METHOD_ACCESS`. (Direct `self.member` already uses the own-member fast path.)
421    pub self_ty: Ty,
422    /// Resolved types of this class's own fields (`var`/`const`), seeded by a first inference
423    /// pass over the field initializers so member references see the *inferred* type (e.g.
424    /// `var n := 0` → `int`), not just the annotation. Empty until populated.
425    pub member_types: FxHashMap<SmolStr, Ty>,
426    members: FxHashMap<SmolStr, ClassItem>,
427}
428
429impl<'a> ClassScope<'a> {
430    /// Build the scope for `tree` against the engine model.
431    #[must_use]
432    pub fn new(db: &dyn Db, api: &EngineApi, tree: &'a ItemTree, anchor: Option<&str>) -> Self {
433        let mut members = FxHashMap::default();
434        for (i, m) in tree.members.iter().enumerate() {
435            match m {
436                Member::Enum(e) if e.name.is_none() => {
437                    // Anonymous enum: its variants become bare class-level `int` constants.
438                    for v in &e.variants {
439                        members.insert(v.clone(), ClassItem::EnumVariant);
440                    }
441                }
442                _ => {
443                    if let Some(name) = m.name() {
444                        members
445                            .entry(SmolStr::new(name))
446                            .or_insert(ClassItem::Member(i));
447                    }
448                }
449            }
450        }
451        let base = resolve_base(db, api, tree, anchor);
452        Self {
453            tree,
454            self_ty: base.clone(),
455            base,
456            member_types: FxHashMap::default(),
457            members,
458        }
459    }
460
461    /// Resolve a name against this class's own members (not the base chain).
462    #[must_use]
463    pub fn lookup(&self, name: &str) -> Option<ClassItem> {
464        self.members.get(name).copied()
465    }
466
467    /// The member behind a [`ClassItem::Member`].
468    #[must_use]
469    pub fn member(&self, item: ClassItem) -> Option<&'a Member> {
470        match item {
471            ClassItem::Member(i) => self.tree.members.get(i),
472            ClassItem::EnumVariant => None,
473        }
474    }
475}
476
477// ---- global resolution -------------------------------------------------------------------
478
479/// What a bare *global* name resolves to (Playbook §3.2 step 4). The caller ([`crate::infer`])
480/// decides how to use it given the syntactic context (bare value vs. call vs. `.`-access).
481#[derive(Debug, Clone, PartialEq, Eq)]
482pub enum GlobalDef {
483    /// A pseudo-constant value (`PI` → `float`).
484    Const(Ty),
485    /// An engine singleton instance (`Input` → `Object(Input)`).
486    Singleton(ClassId),
487    /// A GDScript builtin function (`preload`/`range`/`len`/…).
488    Builtin,
489    /// A `@GlobalScope` utility function (`sin`, `print`, …).
490    Utility,
491    /// A builtin Variant type name used as a value / constructor (`Vector2`, `int`).
492    BuiltinType(BuiltinId),
493    /// An engine class name used as a value / constructor / type (`Node`, `Resource`).
494    ClassType(ClassId),
495    /// A global enum namespace (`Error`, `Key`) — a set of `int` constants.
496    GlobalEnum,
497}
498
499/// Resolve a bare global identifier. Order is deliberate: pseudo-constants and singletons take
500/// precedence over the same-named type (bare `Input` is the singleton instance, not the class).
501#[must_use]
502pub fn resolve_global(api: &EngineApi, name: &str) -> Option<GlobalDef> {
503    if let Some(gc) = api.global_const(name) {
504        return Some(GlobalDef::Const(layer_to_ty(api, gc.ty)));
505    }
506    if let Some(cid) = api.singleton(name) {
507        return Some(GlobalDef::Singleton(cid));
508    }
509    if api.gdscript_builtin(name).is_some() {
510        return Some(GlobalDef::Builtin);
511    }
512    if api.utility(name).is_some() {
513        return Some(GlobalDef::Utility);
514    }
515    if let Some(bid) = api.builtin_by_name(name) {
516        return Some(GlobalDef::BuiltinType(bid));
517    }
518    if let Some(cid) = api.class_by_name(name) {
519        return Some(GlobalDef::ClassType(cid));
520    }
521    if api.global_enum(name).is_some() {
522        return Some(GlobalDef::GlobalEnum);
523    }
524    None
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530    use crate::item_tree::item_tree;
531    use gdscript_syntax::parse;
532
533    fn api() -> &'static EngineApi {
534        gdscript_api::bundled()
535    }
536
537    fn db() -> gdscript_db::RootDatabase {
538        gdscript_db::RootDatabase::default()
539    }
540
541    /// Resolve the first `TypeRef` node found in `decl` source.
542    fn ty_of_annotation(src: &str) -> Ty {
543        let parse = parse(src);
544        let root = parse.syntax_node();
545        let type_ref = gdscript_syntax::ast::descendants(&root)
546            .into_iter()
547            .find(|n| n.kind() == SyntaxKind::TypeRef)
548            .expect("a TypeRef node");
549        resolve_type_ref(&db(), api(), &type_ref)
550    }
551
552    #[test]
553    fn seam_is_unknown() {
554        assert_eq!(
555            resolve_external(&db(), &ExternalRef::ClassName(SmolStr::new("MyClass"))),
556            Ty::Unknown
557        );
558    }
559
560    #[test]
561    fn builtin_and_class_annotations() {
562        assert_eq!(
563            ty_of_annotation("var x: int\n"),
564            Ty::Builtin(api().builtin_by_name("int").unwrap())
565        );
566        assert_eq!(
567            ty_of_annotation("var n: Node\n"),
568            Ty::Object(api().class_by_name("Node").unwrap())
569        );
570        assert_eq!(ty_of_annotation("func f() -> void:\n\tpass\n"), Ty::Void);
571    }
572
573    #[test]
574    fn typed_container_annotations() {
575        let int = Ty::Builtin(api().builtin_by_name("int").unwrap());
576        assert_eq!(
577            ty_of_annotation("var a: Array[int]\n"),
578            Ty::Array(Box::new(int.clone()))
579        );
580        assert_eq!(ty_of_annotation("var a: Array\n"), Ty::array_of_variant());
581        assert_eq!(
582            ty_of_annotation("var d: Dictionary[String, int]\n"),
583            Ty::Dict(
584                Box::new(Ty::Builtin(api().builtin_by_name("String").unwrap())),
585                Box::new(int)
586            )
587        );
588        // Nested typed containers collapse to Variant (Playbook §2).
589        assert_eq!(
590            ty_of_annotation("var a: Array[Array[int]]\n"),
591            Ty::Array(Box::new(Ty::Variant))
592        );
593    }
594
595    #[test]
596    fn unknown_annotation_is_seam_not_error() {
597        // A user `class_name` we can't see (no false diagnostic territory).
598        assert_eq!(ty_of_annotation("var p: MyPlayer\n"), Ty::Unknown);
599    }
600
601    #[test]
602    fn base_resolution() {
603        let extends_node = item_tree(&parse("extends Node2D\n").syntax_node());
604        assert_eq!(
605            resolve_base(&db(), api(), &extends_node, None),
606            Ty::Object(api().class_by_name("Node2D").unwrap())
607        );
608        // No extends → implicit RefCounted.
609        let no_extends = item_tree(&parse("var x = 1\n").syntax_node());
610        assert_eq!(
611            resolve_base(&db(), api(), &no_extends, None),
612            Ty::Object(api().class_by_name("RefCounted").unwrap())
613        );
614        // Script-path base with no project loaded → seam.
615        let script_base = item_tree(&parse("extends \"res://b.gd\"\n").syntax_node());
616        assert_eq!(resolve_base(&db(), api(), &script_base, None), Ty::Unknown);
617    }
618
619    #[test]
620    fn anchor_res_path_absolute_passes_through() {
621        assert_eq!(
622            anchor_res_path(Some("res://a/b.gd"), "res://x.gd").as_deref(),
623            Some("res://x.gd")
624        );
625        assert_eq!(
626            anchor_res_path(None, "user://x.gd").as_deref(),
627            Some("user://x.gd")
628        );
629    }
630
631    #[test]
632    fn anchor_res_path_relative_anchors_to_importing_dir() {
633        let from = Some("res://entities/player.gd");
634        // sibling
635        assert_eq!(
636            anchor_res_path(from, "enemy.gd").as_deref(),
637            Some("res://entities/enemy.gd")
638        );
639        // parent traversal (`..`) collapses
640        assert_eq!(
641            anchor_res_path(from, "../core/hooks.gd").as_deref(),
642            Some("res://core/hooks.gd")
643        );
644        // explicit current-dir (`./`)
645        assert_eq!(
646            anchor_res_path(from, "./util.gd").as_deref(),
647            Some("res://entities/util.gd")
648        );
649        // an importer at the project root
650        assert_eq!(
651            anchor_res_path(Some("res://main.gd"), "util.gd").as_deref(),
652            Some("res://util.gd")
653        );
654    }
655
656    #[test]
657    fn anchor_res_path_relative_without_anchor_is_seam() {
658        assert_eq!(anchor_res_path(None, "sibling.gd"), None);
659    }
660
661    #[test]
662    fn class_scope_members_and_anon_enum() {
663        let tree = item_tree(
664            &parse(
665                "var hp := 10\nfunc attack():\n\tpass\nenum { FIRE, ICE }\nenum Named { A, B }\n",
666            )
667            .syntax_node(),
668        );
669        let scope = ClassScope::new(&db(), api(), &tree, None);
670        assert!(matches!(scope.lookup("hp"), Some(ClassItem::Member(_))));
671        assert!(matches!(scope.lookup("attack"), Some(ClassItem::Member(_))));
672        // Anonymous-enum variants flatten into the class scope as int consts.
673        assert_eq!(scope.lookup("FIRE"), Some(ClassItem::EnumVariant));
674        assert_eq!(scope.lookup("ICE"), Some(ClassItem::EnumVariant));
675        // A named enum binds its *name*, not its variants.
676        assert!(matches!(scope.lookup("Named"), Some(ClassItem::Member(_))));
677        assert_eq!(scope.lookup("A"), None);
678    }
679
680    #[test]
681    fn globals() {
682        assert!(matches!(
683            resolve_global(api(), "PI"),
684            Some(GlobalDef::Const(_))
685        ));
686        assert!(matches!(
687            resolve_global(api(), "Input"),
688            Some(GlobalDef::Singleton(_))
689        ));
690        assert!(matches!(
691            resolve_global(api(), "preload"),
692            Some(GlobalDef::Builtin)
693        ));
694        assert!(matches!(
695            resolve_global(api(), "Vector2"),
696            Some(GlobalDef::BuiltinType(_))
697        ));
698        assert!(matches!(
699            resolve_global(api(), "Node"),
700            Some(GlobalDef::ClassType(_))
701        ));
702        assert!(resolve_global(api(), "definitely_not_a_global").is_none());
703    }
704}