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}