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