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}