Skip to main content

gdscript_hir/
queries.rs

1//! The salsa-tracked entry points for the semantic layer, layered on `gdscript-db`'s
2//! [`parse`](gdscript_db::parse) query.
3//!
4//! These are the memoized queries the IDE layer drives. The heavy lifting stays in
5//! [`crate::item_tree`] / [`crate::infer`] as plain `(parsed file) -> value` functions; this
6//! module only wraps them so their results are cached per revision and recomputed
7//! incrementally. [`item_tree`] is the **firewall** query (Playbook §4): it reads only the
8//! parse, never a function body, so an unchanged set of signatures backdates across body edits.
9//!
10//! Phase-3 note: cross-file resolution (the `resolve_external` seam) is still `Ty::Unknown`
11//! here — M1 threads `&dyn Db` + `FileId` into inference to light it up. M0 only swaps the
12//! cache engine; the single-file results are byte-identical.
13
14use std::sync::Arc;
15
16use gdscript_db::{Db, FileText, ProjectConfig, SourceRoot, parse};
17use rustc_hash::{FxHashMap, FxHashSet};
18use smol_str::SmolStr;
19
20use gdscript_base::FileId;
21use gdscript_scene::{NodeIdx, SceneModel};
22
23use crate::infer::FileInference;
24use crate::item_tree::{ItemTree, Member};
25use crate::ty::{ScriptRefId, Ty};
26
27/// The item tree for `file` (signatures only — the body-edit firewall). Memoized; recomputes
28/// when the parse changes but backdates when the resulting signatures are unchanged.
29#[salsa::tracked]
30pub fn item_tree(db: &dyn Db, file: FileText) -> Arc<ItemTree> {
31    crate::item_tree::item_tree(&parse(db, file).syntax_node())
32}
33
34/// Whole-file inference for `file`. With no engine model available (`wasm32`, until the host
35/// wires the fetched blob in) this is an empty result — matching the Phase-2 graceful path.
36#[salsa::tracked]
37pub fn analyze_file(db: &dyn Db, file: FileText) -> Arc<FileInference> {
38    match db.engine() {
39        Some(api) => Arc::new(crate::infer::analyze_file(
40            db,
41            api,
42            &parse(db, file).syntax_node(),
43            file.file_id(db),
44        )),
45        None => Arc::new(FileInference::default()),
46    }
47}
48
49/// The project-wide global `class_name` registry: each registered name → the file declaring it.
50#[derive(Debug, Clone, PartialEq, Eq, Default)]
51pub struct GlobalRegistry {
52    classes: FxHashMap<SmolStr, FileText>,
53}
54
55impl GlobalRegistry {
56    /// The file declaring `name` as a global `class_name`, if any.
57    #[must_use]
58    pub fn resolve(&self, name: &str) -> Option<FileText> {
59        self.classes.get(name).copied()
60    }
61
62    /// The number of registered global classes.
63    #[must_use]
64    pub fn len(&self) -> usize {
65        self.classes.len()
66    }
67
68    /// Whether no global class is registered.
69    #[must_use]
70    pub fn is_empty(&self) -> bool {
71        self.classes.is_empty()
72    }
73
74    /// Every registered `(class_name, declaring file)` pair (for workspace symbols).
75    pub fn iter(&self) -> impl Iterator<Item = (&SmolStr, FileText)> + '_ {
76        self.classes.iter().map(|(k, v)| (k, *v))
77    }
78}
79
80/// A file's `class_name`, if it declares one — the **offset-free projection** of its item tree
81/// that [`global_registry`] depends on. It reads only `item_tree(file).class_name` (never a byte
82/// range), so a body edit re-runs `item_tree` but this query *backdates* (its value is
83/// unchanged), leaving the registry — and everything cross-file — undisturbed by a keystroke.
84#[salsa::tracked]
85pub fn file_class_name(db: &dyn Db, file: FileText) -> Option<SmolStr> {
86    item_tree(db, file).class_name.clone()
87}
88
89/// The project-wide global `class_name` registry. Keyed on the [`SourceRoot`] file-set input and
90/// the per-file [`file_class_name`] projections. A duplicate `class_name` keeps the first by
91/// `FileId` order (the file set is sorted), so resolution is deterministic. Collision *diagnostics*
92/// (warning at each duplicate declaration) are the separate [`class_name_collisions`] projection.
93#[salsa::tracked]
94pub fn global_registry(db: &dyn Db, root: SourceRoot) -> Arc<GlobalRegistry> {
95    let mut classes = FxHashMap::default();
96    for &file in root.files(db) {
97        if let Some(name) = file_class_name(db, file) {
98            classes.entry(name).or_insert(file);
99        }
100    }
101    Arc::new(GlobalRegistry { classes })
102}
103
104/// The set of global `class_name`s declared by **more than one** file in `root` — the shadowing
105/// diagnostic's cross-file half. Mirrors [`global_registry`]'s firewall exactly: it reads only the
106/// offset-free [`file_class_name`] projection of each file (never a body or byte range), so a
107/// keystroke never rebuilds it. `global_registry` keeps the *first* declarer silently; this query
108/// names the duplicates so [`crate::infer::analyze_file`] can warn at each colliding declaration.
109#[salsa::tracked]
110pub fn class_name_collisions(db: &dyn Db, root: SourceRoot) -> Arc<FxHashSet<SmolStr>> {
111    let mut seen: FxHashSet<SmolStr> = FxHashSet::default();
112    let mut dups: FxHashSet<SmolStr> = FxHashSet::default();
113    for &file in root.files(db) {
114        if let Some(name) = file_class_name(db, file)
115            && !seen.insert(name.clone())
116        {
117            dups.insert(name);
118        }
119    }
120    Arc::new(dups)
121}
122
123/// The project-wide `res:// path → FileId` registry (M3): the map `preload("res://x.gd")` and
124/// `extends "res://x.gd"` resolve through. Keyed on the [`SourceRoot`] file-set input and each
125/// file's `res_path` salsa-input field. `res_path` is a *separate* input field from `text`
126/// (salsa tracks input fields individually), so this registry **backdates across body edits**
127/// exactly like [`global_registry`] — a keystroke never rebuilds it. A duplicate path keeps the
128/// first by `FileId` order (the file set is sorted), matching `global_registry`'s policy.
129#[salsa::tracked]
130pub fn res_path_registry(db: &dyn Db, root: SourceRoot) -> Arc<FxHashMap<SmolStr, FileId>> {
131    let mut map = FxHashMap::default();
132    for &file in root.files(db) {
133        if let Some(path) = file.res_path(db) {
134            map.entry(path).or_insert_with(|| file.file_id(db));
135        }
136    }
137    Arc::new(map)
138}
139
140/// The project's autoload **singletons** (`*`-flagged `[autoload]` entries) — the bare names that
141/// resolve as globals in code. Maps each singleton name → its resource path (M4). Non-singleton
142/// autoloads are deliberately excluded (loaded-but-not-global). Keyed on [`ProjectConfig`] alone
143/// (it iterates only the config text), so it backdates across every `.gd` keystroke.
144#[derive(Debug, Clone, PartialEq, Eq, Default)]
145pub struct AutoloadRegistry {
146    singletons: FxHashMap<SmolStr, SmolStr>,
147}
148
149impl AutoloadRegistry {
150    /// The resource path of the singleton autoload named `name`, if any.
151    #[must_use]
152    pub fn resolve_path(&self, name: &str) -> Option<&SmolStr> {
153        self.singletons.get(name)
154    }
155
156    /// The number of registered singleton autoloads.
157    #[must_use]
158    pub fn len(&self) -> usize {
159        self.singletons.len()
160    }
161
162    /// Whether no singleton autoload is registered.
163    #[must_use]
164    pub fn is_empty(&self) -> bool {
165        self.singletons.is_empty()
166    }
167}
168
169/// The project-wide autoload-singleton registry, parsed from `project.godot` (M4). Only
170/// `*`-flagged entries become globals; a duplicate name keeps the first (deterministic).
171#[salsa::tracked]
172pub fn autoload_registry(db: &dyn Db, config: ProjectConfig) -> Arc<AutoloadRegistry> {
173    let mut singletons = FxHashMap::default();
174    for e in crate::project::parse_autoloads(config.project_godot_text(db)) {
175        if e.is_singleton {
176            singletons.entry(e.name).or_insert(e.path);
177        }
178    }
179    Arc::new(AutoloadRegistry { singletons })
180}
181
182/// One member of a script class, as a cross-file reference sees it (a resolved type, never a
183/// byte range).
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub enum MemberSig {
186    /// A method — its resolved return type.
187    Method(Ty),
188    /// A `var` / `const` — its resolved type.
189    Field(Ty),
190    /// A signal.
191    Signal,
192}
193
194/// A script class's own members, by name, plus its resolved `extends` base — the **offset-free
195/// projection** a cross-file reference resolves against. Reads only `item_tree` signatures (+
196/// annotation/base resolution), never bodies or byte ranges, so it backdates on body edits (the
197/// cross-file firewall). Member lookup walks the base chain (M2): own members here, inherited
198/// ones via [`base`](ScriptClass::base).
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct ScriptClass {
201    members: FxHashMap<SmolStr, MemberSig>,
202    base: Ty,
203}
204
205impl ScriptClass {
206    /// The signature of the member named `name`, if the class declares one *itself* (not
207    /// inherited — the caller walks [`base`](ScriptClass::base) for inherited members).
208    #[must_use]
209    pub fn member(&self, name: &str) -> Option<&MemberSig> {
210        self.members.get(name)
211    }
212
213    /// The resolved `extends` base: an engine `Object`, a user `ScriptRef`, or `Unknown`.
214    #[must_use]
215    pub fn base(&self) -> &Ty {
216        &self.base
217    }
218}
219
220/// The `class_name` behind a [`ScriptRef`](crate::ty::Ty::ScriptRef), for display (hover /
221/// inlay). `Ty::label` cannot resolve this on its own — it has only the engine model, not the
222/// project registry.
223#[must_use]
224pub fn script_ref_name(db: &dyn Db, sref: ScriptRefId) -> Option<SmolStr> {
225    let file = db.file_text(FileId(sref.0))?;
226    file_class_name(db, file)
227}
228
229/// The member table of the script in `file`. Member types are resolved against the engine model
230/// and the registry (a member typed as another `class_name` resolves to its `ScriptRef`).
231#[salsa::tracked]
232pub fn script_class(db: &dyn Db, file: FileText) -> Arc<ScriptClass> {
233    let tree = item_tree(db, file);
234    let Some(api) = db.engine() else {
235        return Arc::new(ScriptClass {
236            members: FxHashMap::default(),
237            base: Ty::Unknown,
238        });
239    };
240    let resolve_ann = |ann: Option<&str>| -> Ty {
241        ann.map_or(Ty::Variant, |t| {
242            crate::resolve::resolve_type_name(db, api, t)
243        })
244    };
245    let mut members = FxHashMap::default();
246    for m in &tree.members {
247        let Some(name) = m.name() else { continue };
248        let sig = match m {
249            Member::Func(f) => MemberSig::Method(resolve_ann(f.return_type.as_deref())),
250            Member::Var(v) => MemberSig::Field(resolve_ann(v.type_ref.as_deref())),
251            // `const X = preload("res://…")` (no annotation) resolves cross-file to the preloaded
252            // script's `ScriptRef` (the SCRIPT meta-type) — the same resolution the declaring file does
253            // same-file, which the offset-free projection otherwise drops. A relative path is anchored
254            // to this file's dir. An explicit annotation wins.
255            Member::Const(c) => MemberSig::Field(
256                c.type_ref
257                    .is_none()
258                    .then_some(c.preload_path.as_deref())
259                    .flatten()
260                    .and_then(|raw| {
261                        crate::resolve::anchor_res_path(file.res_path(db).as_deref(), raw)
262                    })
263                    .map_or_else(
264                        || resolve_ann(c.type_ref.as_deref()),
265                        |abs| {
266                            crate::resolve::resolve_external(
267                                db,
268                                &crate::resolve::ExternalRef::Preload(abs),
269                            )
270                        },
271                    ),
272            ),
273            Member::Signal(_) => MemberSig::Signal,
274            // Enums + inner classes aren't modeled as instance members yet (M2+).
275            Member::Enum(_) | Member::Class(_) => continue,
276        };
277        members.insert(SmolStr::new(name), sig);
278    }
279    // The resolved `extends` base — a user `ScriptRef` (another class_name / "res://…") walks
280    // into the inheritance chain; an engine `Object` ends it at the API table.
281    let base = crate::resolve::resolve_base(db, api, &tree, file.res_path(db).as_deref());
282    Arc::new(ScriptClass { members, base })
283}
284
285// ---- M1: scenes (.tscn/.tres) ------------------------------------------------------------
286
287/// Whether a `res://` path is a *text* scene/resource we parse (`.tscn`/`.tres`). Binary
288/// `.scn`/`.res` are detected-and-degraded by the parser, but we don't waste a parse on a `.gd`.
289fn is_scene_path(path: &str) -> bool {
290    let ext = path.rsplit('.').next().unwrap_or("");
291    ext.eq_ignore_ascii_case("tscn") || ext.eq_ignore_ascii_case("tres")
292}
293
294/// The parsed [`SceneModel`] for `file` (M1) — memoized; recomputes only when the file text
295/// changes. A non-scene file (a `.gd`, or no `res://` path) yields an empty model (so the query is
296/// total). The pure `gdscript_scene::parse_scene` is the cache body; this just wraps + gates it.
297#[salsa::tracked]
298pub fn scene_model(db: &dyn Db, file: FileText) -> Arc<SceneModel> {
299    let is_scene = file.res_path(db).as_deref().is_some_and(is_scene_path);
300    if is_scene {
301        Arc::new(gdscript_scene::parse_scene(file.text(db)))
302    } else {
303        Arc::new(gdscript_scene::parse_scene(""))
304    }
305}
306
307/// Where a script (`.gd`) is attached in a scene: the owning scene file + the node carrying the
308/// `script = ExtResource(...)`. `$Path` in that script resolves relative to this node.
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310pub struct SceneAttach {
311    /// The owning scene's file.
312    pub scene: FileId,
313    /// The node the script attaches to (the `$`-path base).
314    pub node: NodeIdx,
315    /// Whether the script attaches to **more than one** scene (the first kept here). When `true`,
316    /// a `$Path` valid in another scene must not be flagged `INVALID_NODE_PATH` (no false positive).
317    pub ambiguous: bool,
318}
319
320/// The project-wide **script → owning scene** index (M1): each `.gd`'s `res://` path → the (first)
321/// scene + node that attaches it. Built by scanning every scene's `ext_resources` for a
322/// `type="Script"` reference. Keyed on the [`SourceRoot`] file-set + each scene file's text (via
323/// [`scene_model`]); a `.gd` **body** edit never touches a `.tscn` text, so this **backdates across
324/// `.gd` keystrokes** — the firewall (a scene edit correctly invalidates it). A duplicate (one
325/// script in many scenes) keeps the first by `FileId` order (the slice's single-scene policy).
326#[salsa::tracked]
327pub fn script_scene_index(db: &dyn Db, root: SourceRoot) -> Arc<FxHashMap<SmolStr, SceneAttach>> {
328    let mut map: FxHashMap<SmolStr, SceneAttach> = FxHashMap::default();
329    for &file in root.files(db) {
330        if !file.res_path(db).as_deref().is_some_and(is_scene_path) {
331            continue;
332        }
333        let model = scene_model(db, file);
334        let scene = file.file_id(db);
335        for (i, node) in model.nodes.iter().enumerate() {
336            let Some(script_id) = node.script.as_ref() else {
337                continue;
338            };
339            let Some(path) = model
340                .ext_resources
341                .get(script_id)
342                .and_then(|e| e.path.clone())
343            else {
344                continue;
345            };
346            let node = NodeIdx(u32::try_from(i).unwrap_or(u32::MAX));
347            match map.get_mut(&path) {
348                // already attached by an earlier scene → ambiguous (keep the first).
349                Some(existing) => existing.ambiguous = true,
350                None => {
351                    map.insert(
352                        path,
353                        SceneAttach {
354                            scene,
355                            node,
356                            ambiguous: false,
357                        },
358                    );
359                }
360            }
361        }
362    }
363    Arc::new(map)
364}
365
366/// The owning-scene context for the script in `file` (M1): the scene's [`FileId`], the parsed
367/// scene, and the attach node, so `$Path`/`%Unique`/`get_node("…")` can resolve (and go-to-def can
368/// jump into the `.tscn`). `None` when the project has no scene attaching this script (the
369/// overwhelmingly common single-file / dynamic-UI case → node paths stay `Node`).
370#[must_use]
371pub fn scene_context(db: &dyn Db, file: FileText) -> Option<SceneContext> {
372    let res_path = file.res_path(db)?;
373    let root = db.source_root()?;
374    let attach = *script_scene_index(db, root).get(res_path.as_str())?;
375    let scene_file = db.file_text(attach.scene)?;
376    Some(SceneContext {
377        scene: attach.scene,
378        model: scene_model(db, scene_file),
379        attach: attach.node,
380        ambiguous: attach.ambiguous,
381    })
382}
383
384/// The resolved owning-scene context for a script — the scene file, its model, the attach node, and
385/// whether the attachment is ambiguous (multi-scene). Returned by [`scene_context`].
386#[derive(Debug, Clone)]
387pub struct SceneContext {
388    /// The owning scene's file.
389    pub scene: FileId,
390    /// The parsed scene model.
391    pub model: Arc<SceneModel>,
392    /// The node the script attaches to (the `$`-path base).
393    pub attach: NodeIdx,
394    /// Whether the script attaches to multiple scenes (suppresses `INVALID_NODE_PATH`).
395    pub ambiguous: bool,
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use gdscript_base::FileId;
402    use gdscript_db::RootDatabase;
403    use salsa::Durability;
404
405    fn db_with(src: &str) -> (RootDatabase, FileText) {
406        let mut db = RootDatabase::default();
407        db.set_file_text(FileId(0), src, Durability::LOW);
408        let ft = db.file_text(FileId(0)).unwrap();
409        (db, ft)
410    }
411
412    #[test]
413    fn tracked_item_tree_matches_the_plain_fn() {
414        let (db, ft) = db_with("class_name Foo\nfunc f():\n\tpass\n");
415        let tree = item_tree(&db, ft);
416        assert_eq!(tree.class_name.as_deref(), Some("Foo"));
417        // Memoized: a second query is the same Arc value.
418        assert_eq!(item_tree(&db, ft), tree);
419    }
420
421    #[test]
422    fn tracked_analyze_file_runs_inference() {
423        let (db, ft) = db_with("func add(a: int, b: int) -> int:\n\treturn a + b\n");
424        let fi = analyze_file(&db, ft);
425        // The engine model is present on native, so inference produced a unit.
426        assert!(!fi.units.is_empty());
427        assert!(fi.diagnostics.is_empty());
428    }
429
430    // ---- the body-edit firewall (the M0 CI gate, Playbook §4) -----------------------------
431    //
432    // A query that reads only `item_tree` (signatures) must NOT recompute when a function body
433    // changes: editing a body changes the parse, `item_tree` re-validates but its value is
434    // unchanged, so salsa BACKDATES it and dependents are spared. We witness this with a counter
435    // bumped inside a signature-only tracked query (a standard salsa test idiom — the counter is
436    // test-only impurity that does not affect the result). `class_name_witness` is also the seed
437    // of M1's global `class_name` registry.
438
439    use std::sync::atomic::{AtomicU32, Ordering};
440
441    static WITNESS_RUNS: AtomicU32 = AtomicU32::new(0);
442
443    /// Depends ONLY on `item_tree` (never on a body). Counts its own executions.
444    #[salsa::tracked]
445    fn class_name_witness(db: &dyn gdscript_db::Db, file: FileText) -> Option<smol_str::SmolStr> {
446        WITNESS_RUNS.fetch_add(1, Ordering::SeqCst);
447        item_tree(db, file).class_name.clone()
448    }
449
450    #[test]
451    fn body_edit_does_not_invalidate_signature_queries() {
452        let mut db = RootDatabase::default();
453        db.set_file_text(
454            FileId(0),
455            "class_name Foo\nfunc f():\n\tvar a := 1\n",
456            Durability::LOW,
457        );
458        let ft = db.file_text(FileId(0)).unwrap();
459
460        // Warm the cache.
461        assert_eq!(class_name_witness(&db, ft).as_deref(), Some("Foo"));
462        let runs_after_warm = WITNESS_RUNS.load(Ordering::SeqCst);
463
464        // Edit ONLY a function body, keeping byte length (`1` -> `2`): signatures are unchanged,
465        // so `item_tree` backdates and the firewall holds.
466        db.set_file_text(
467            FileId(0),
468            "class_name Foo\nfunc f():\n\tvar a := 2\n",
469            Durability::LOW,
470        );
471        assert_eq!(class_name_witness(&db, ft).as_deref(), Some("Foo"));
472
473        assert_eq!(
474            WITNESS_RUNS.load(Ordering::SeqCst),
475            runs_after_warm,
476            "REGRESSION: a body edit re-ran a signature-only query — the item_tree firewall broke",
477        );
478    }
479
480    #[test]
481    fn global_registry_resolves_class_names_across_files() {
482        let mut db = RootDatabase::default();
483        db.set_file_text(
484            FileId(0),
485            "class_name Player\nfunc f():\n\tpass\n",
486            Durability::LOW,
487        );
488        db.set_file_text(
489            FileId(1),
490            "class_name Enemy\nvar hp := 10\n",
491            Durability::LOW,
492        );
493        db.set_file_text(FileId(2), "func no_class():\n\tpass\n", Durability::LOW);
494        db.sync_source_root();
495        let root = db.source_root().unwrap();
496
497        let reg = global_registry(&db, root);
498        assert_eq!(reg.len(), 2);
499        assert_eq!(reg.resolve("Player"), db.file_text(FileId(0)));
500        assert_eq!(reg.resolve("Enemy"), db.file_text(FileId(1)));
501        assert!(reg.resolve("Nonexistent").is_none());
502    }
503
504    // The TRUE downstream firewall (the M1 reframe of the pinned M0 limitation): a body edit must
505    // not invalidate the project-wide registry. `file_class_name` is offset-free, so even a
506    // *length-changing* body edit — which shifts `item_tree`'s byte ranges and forces it to
507    // re-execute — leaves `file_class_name` backdating (its value, the class name, is unchanged).
508    // The registry, and every consumer of it, is therefore untouched by a keystroke.
509
510    static REGISTRY_OBSERVED: AtomicU32 = AtomicU32::new(0);
511
512    /// Test-only consumer of the registry; re-runs iff the registry's value actually changes.
513    #[salsa::tracked]
514    fn observe_registry(db: &dyn gdscript_db::Db, root: SourceRoot) -> usize {
515        REGISTRY_OBSERVED.fetch_add(1, Ordering::SeqCst);
516        global_registry(db, root).len()
517    }
518
519    #[test]
520    fn body_edit_does_not_invalidate_the_global_registry() {
521        let mut db = RootDatabase::default();
522        db.set_file_text(
523            FileId(0),
524            "class_name Player\nfunc f():\n\tvar a := 1\n",
525            Durability::LOW,
526        );
527        db.set_file_text(FileId(1), "class_name Enemy\n", Durability::LOW);
528        db.sync_source_root();
529        let root = db.source_root().unwrap();
530
531        assert_eq!(observe_registry(&db, root), 2);
532        let runs = REGISTRY_OBSERVED.load(Ordering::SeqCst);
533
534        // A length-CHANGING body edit (`1` -> `123456`) — NO sync_source_root (a body edit is not
535        // a structure change). The class name is unchanged, so the registry must not recompute.
536        db.set_file_text(
537            FileId(0),
538            "class_name Player\nfunc f():\n\tvar a := 123456\n",
539            Durability::LOW,
540        );
541
542        assert_eq!(observe_registry(&db, root), 2);
543        assert_eq!(
544            REGISTRY_OBSERVED.load(Ordering::SeqCst),
545            runs,
546            "REGRESSION: a body edit re-ran a global_registry consumer — the cross-file firewall broke",
547        );
548    }
549
550    #[test]
551    fn cross_file_class_name_member_resolves() {
552        let mut db = RootDatabase::default();
553        db.set_file_text(
554            FileId(0),
555            "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
556            Durability::LOW,
557        );
558        db.set_file_text(
559            FileId(1),
560            "func use_it():\n\tvar w := Widget.make()\n",
561            Durability::LOW,
562        );
563        db.sync_source_root();
564
565        let file1 = db.file_text(FileId(1)).unwrap();
566        let fi = analyze_file(&db, file1);
567        let api = db.engine().unwrap();
568
569        // `w := Widget.make()` resolves `Widget` (a cross-file class_name) to its ScriptRef, then
570        // its `make` method to its `int` return type.
571        let unit = fi
572            .units
573            .iter()
574            .find(|u| !u.result.bindings.is_empty())
575            .expect("a unit with a binding");
576        assert_eq!(
577            unit.result.bindings[0].ty.label(api).as_deref(),
578            Some("int")
579        );
580        assert!(
581            fi.diagnostics.is_empty(),
582            "unexpected diagnostics: {:?}",
583            fi.diagnostics
584        );
585    }
586
587    // ---- W2: class_name collision / shadowing diagnostics ---------------------------------
588
589    use crate::infer::SHADOWED_GLOBAL_IDENTIFIER;
590
591    fn shadow_codes(fi: &Arc<FileInference>) -> Vec<&str> {
592        fi.diagnostics
593            .iter()
594            .filter(|d| d.code == SHADOWED_GLOBAL_IDENTIFIER)
595            .map(|d| d.code.as_str())
596            .collect()
597    }
598
599    #[test]
600    fn class_name_collisions_names_only_the_duplicates() {
601        let mut db = RootDatabase::default();
602        db.set_file_text(FileId(0), "class_name Dup\n", Durability::LOW);
603        db.set_file_text(FileId(1), "class_name Dup\n", Durability::LOW);
604        db.set_file_text(FileId(2), "class_name Unique\n", Durability::LOW);
605        db.sync_source_root();
606        let root = db.source_root().unwrap();
607
608        let cols = class_name_collisions(&db, root);
609        assert!(cols.contains(&SmolStr::new("Dup")));
610        assert!(
611            !cols.contains(&SmolStr::new("Unique")),
612            "a singly-declared class_name is not a collision",
613        );
614        assert_eq!(cols.len(), 1);
615    }
616
617    #[test]
618    fn duplicate_class_name_warns_at_both_declarations() {
619        let mut db = RootDatabase::default();
620        db.set_file_text(
621            FileId(0),
622            "class_name Dup\nfunc f():\n\tpass\n",
623            Durability::LOW,
624        );
625        db.set_file_text(FileId(1), "class_name Dup\nvar x := 1\n", Durability::LOW);
626        db.sync_source_root();
627
628        for fid in [0, 1] {
629            let fi = analyze_file(&db, db.file_text(FileId(fid)).unwrap());
630            assert!(
631                shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
632                "file {fid} should warn on the duplicate class_name: {:?}",
633                fi.diagnostics
634            );
635            // The warning points at the NAME (`Dup` at offset 11), not byte 0 or the keyword.
636            let d = fi
637                .diagnostics
638                .iter()
639                .find(|d| d.code == SHADOWED_GLOBAL_IDENTIFIER)
640                .unwrap();
641            assert_eq!(d.range, gdscript_base::TextRange::new(11, 14));
642        }
643    }
644
645    #[test]
646    fn class_name_shadowing_an_engine_class_warns() {
647        let mut db = RootDatabase::default();
648        // `Node` is an engine class — declaring `class_name Node` shadows it.
649        db.set_file_text(
650            FileId(0),
651            "class_name Node\nfunc f():\n\tpass\n",
652            Durability::LOW,
653        );
654        db.sync_source_root();
655
656        let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
657        assert!(
658            shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
659            "class_name Node must warn (shadows the engine class): {:?}",
660            fi.diagnostics
661        );
662    }
663
664    #[test]
665    fn class_name_shadowing_a_builtin_type_warns() {
666        let mut db = RootDatabase::default();
667        // `Vector2` is a builtin Variant type — a `class_name Vector2` hides it.
668        db.set_file_text(FileId(0), "class_name Vector2\n", Durability::LOW);
669        db.sync_source_root();
670
671        let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
672        assert!(
673            shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
674            "{:?}",
675            fi.diagnostics
676        );
677    }
678
679    #[test]
680    fn class_name_shadowing_a_star_autoload_warns() {
681        let mut db = RootDatabase::default();
682        db.set_file_text(
683            FileId(0),
684            "class_name Game\nfunc f():\n\tpass\n",
685            Durability::LOW,
686        );
687        db.set_file_path(FileId(0), "res://game.gd");
688        // A `*`-singleton named `Game` — the class_name now hides the autoload global.
689        db.set_project_config("[autoload]\nGame=\"*res://other.gd\"\n");
690        db.sync_source_root();
691
692        let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
693        assert!(
694            shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
695            "class_name Game must warn (shadows the `*Game` autoload): {:?}",
696            fi.diagnostics
697        );
698    }
699
700    #[test]
701    fn unique_non_shadowing_class_name_does_not_warn() {
702        // No false positive: a one-of-a-kind name that is no engine/builtin/autoload symbol.
703        let mut db = RootDatabase::default();
704        db.set_file_text(
705            FileId(0),
706            "class_name MyVeryOwnUniquePlayer\nfunc f():\n\tpass\n",
707            Durability::LOW,
708        );
709        db.set_file_text(
710            FileId(1),
711            "class_name AnotherUniqueEnemy\n",
712            Durability::LOW,
713        );
714        db.sync_source_root();
715
716        for fid in [0, 1] {
717            let fi = analyze_file(&db, db.file_text(FileId(fid)).unwrap());
718            assert!(
719                shadow_codes(&fi).is_empty(),
720                "file {fid}: a unique class_name must not warn: {:?}",
721                fi.diagnostics
722            );
723        }
724    }
725
726    #[test]
727    fn unknown_member_on_script_ref_is_seam_not_warning() {
728        let mut db = RootDatabase::default();
729        db.set_file_text(
730            FileId(0),
731            "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
732            Durability::LOW,
733        );
734        db.set_file_text(
735            FileId(1),
736            "func use_it():\n\tWidget.not_a_member()\n",
737            Durability::LOW,
738        );
739        db.sync_source_root();
740
741        let file1 = db.file_text(FileId(1)).unwrap();
742        let fi = analyze_file(&db, file1);
743        // A member we don't model is the seam (Unknown) — never UNSAFE_METHOD_ACCESS.
744        assert!(
745            fi.diagnostics.is_empty(),
746            "a missing member on a ScriptRef must not warn: {:?}",
747            fi.diagnostics
748        );
749    }
750
751    #[test]
752    fn inherited_members_resolve_through_user_and_engine_bases() {
753        let mut db = RootDatabase::default();
754        // Derived -> Base (user) -> Node (engine) -> … -> Object.
755        db.set_file_text(
756            FileId(0),
757            "class_name Base\nextends Node\nfunc base_method() -> int:\n\treturn 1\n",
758            Durability::LOW,
759        );
760        db.set_file_text(
761            FileId(1),
762            "class_name Derived\nextends Base\nfunc own() -> String:\n\treturn \"x\"\n",
763            Durability::LOW,
764        );
765        db.set_file_text(
766            FileId(2),
767            "func use_it():\n\tvar d: Derived\n\tvar own := d.own()\n\tvar from_base := d.base_method()\n\tvar from_engine := d.get_instance_id()\n",
768            Durability::LOW,
769        );
770        db.sync_source_root();
771        let api = db.engine().unwrap();
772
773        let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
774        let unit = fi
775            .units
776            .iter()
777            .find(|u| u.result.bindings.len() >= 4)
778            .expect("use_it unit with 4 bindings");
779        // [0]=d, [1]=own (own member), [2]=base_method (user base), [3]=get_instance_id (engine base).
780        assert_eq!(
781            unit.result.bindings[1].ty.label(api).as_deref(),
782            Some("String")
783        );
784        assert_eq!(
785            unit.result.bindings[2].ty.label(api).as_deref(),
786            Some("int")
787        );
788        assert_eq!(
789            unit.result.bindings[3].ty.label(api).as_deref(),
790            Some("int")
791        );
792        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
793    }
794
795    #[test]
796    fn cyclic_extends_flags_each_cycle_member_and_terminates() {
797        use crate::infer::CYCLIC_INHERITANCE;
798        let mut db = RootDatabase::default();
799        // A extends B extends A — illegal in Godot. The member walk must not loop, AND each file on
800        // the cycle must be flagged `CYCLIC_INHERITANCE` at its own `extends` decl.
801        db.set_file_text(FileId(0), "class_name A\nextends B\n", Durability::LOW);
802        db.set_file_text(FileId(1), "class_name B\nextends A\n", Durability::LOW);
803        // A third, ACYCLIC file that merely USES `A` — it is not on the cycle, so it must stay clean.
804        db.set_file_text(
805            FileId(2),
806            "func use_it():\n\tvar a: A\n\tvar x := a.nope()\n",
807            Durability::LOW,
808        );
809        db.sync_source_root();
810
811        // Each cycle member is flagged exactly once, at its own `extends`.
812        for id in [FileId(0), FileId(1)] {
813            let fi = analyze_file(&db, db.file_text(id).unwrap());
814            let cyclic: Vec<_> = fi
815                .diagnostics
816                .iter()
817                .filter(|d| d.code == CYCLIC_INHERITANCE)
818                .collect();
819            assert_eq!(cyclic.len(), 1, "file {id:?}: {:?}", fi.diagnostics);
820        }
821
822        // The user file is off the cycle — `a.nope()` walks A->B->A->… and bottoms out at the seam
823        // (no panic/hang, no diagnostic on this file).
824        let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
825        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
826    }
827
828    #[test]
829    fn cyclic_extends_via_res_path_two_files_flags_no_hang() {
830        use crate::infer::CYCLIC_INHERITANCE;
831        let mut db = RootDatabase::default();
832        // a.gd extends "res://b.gd"; b.gd extends "res://a.gd" — a 2-file `res://` path cycle.
833        set_with_path(&mut db, 0, "res://a.gd", "extends \"res://b.gd\"\n");
834        set_with_path(&mut db, 1, "res://b.gd", "extends \"res://a.gd\"\n");
835        db.sync_source_root();
836
837        for id in [FileId(0), FileId(1)] {
838            let fi = analyze_file(&db, db.file_text(id).unwrap());
839            assert!(
840                fi.diagnostics.iter().any(|d| d.code == CYCLIC_INHERITANCE),
841                "file {id:?} expected CYCLIC_INHERITANCE: {:?}",
842                fi.diagnostics
843            );
844        }
845    }
846
847    #[test]
848    fn deep_acyclic_extends_chain_does_not_false_fire() {
849        use crate::infer::CYCLIC_INHERITANCE;
850        let mut db = RootDatabase::default();
851        // A 5-deep ACYCLIC chain bottoming out at an engine base: C0 -> C1 -> ... -> C4 -> Node.
852        // None revisits the start, so NONE may be flagged `CYCLIC_INHERITANCE`.
853        db.set_file_text(FileId(0), "class_name C0\nextends C1\n", Durability::LOW);
854        db.set_file_text(FileId(1), "class_name C1\nextends C2\n", Durability::LOW);
855        db.set_file_text(FileId(2), "class_name C2\nextends C3\n", Durability::LOW);
856        db.set_file_text(FileId(3), "class_name C3\nextends C4\n", Durability::LOW);
857        db.set_file_text(FileId(4), "class_name C4\nextends Node\n", Durability::LOW);
858        db.sync_source_root();
859
860        for id in (0..5).map(FileId) {
861            let fi = analyze_file(&db, db.file_text(id).unwrap());
862            assert!(
863                !fi.diagnostics.iter().any(|d| d.code == CYCLIC_INHERITANCE),
864                "file {id:?} false-fired CYCLIC_INHERITANCE: {:?}",
865                fi.diagnostics
866            );
867        }
868    }
869
870    // ---- M3: res:// path map + preload / extends "res://…" const-aliasing -----------------
871
872    /// Add a file with both its text and its `res://` path (the loader's add-time pair).
873    fn set_with_path(db: &mut RootDatabase, id: u32, path: &str, src: &str) {
874        db.set_file_text(FileId(id), src, Durability::LOW);
875        db.set_file_path(FileId(id), path);
876    }
877
878    #[test]
879    fn res_path_registry_maps_paths_to_files() {
880        let mut db = RootDatabase::default();
881        set_with_path(&mut db, 0, "res://a.gd", "class_name A\n");
882        set_with_path(&mut db, 1, "res://sub/b.gd", "func f():\n\tpass\n");
883        db.set_file_text(FileId(2), "func no_path():\n\tpass\n", Durability::LOW); // no res:// path
884        db.sync_source_root();
885        let root = db.source_root().unwrap();
886
887        let reg = res_path_registry(&db, root);
888        assert_eq!(reg.get("res://a.gd"), Some(&FileId(0)));
889        assert_eq!(reg.get("res://sub/b.gd"), Some(&FileId(1)));
890        assert!(reg.get("res://missing.gd").is_none());
891        // A file with no path contributes nothing.
892        assert_eq!(reg.len(), 2);
893    }
894
895    // The res:// path firewall: a body edit must not rebuild the path registry. `res_path` is a
896    // *separate* salsa-input field from `text`, so even a length-changing body edit (which
897    // re-runs `item_tree`) leaves `res_path` — and the registry — untouched.
898
899    static RES_REGISTRY_OBSERVED: AtomicU32 = AtomicU32::new(0);
900
901    #[salsa::tracked]
902    fn observe_res_registry(db: &dyn gdscript_db::Db, root: SourceRoot) -> usize {
903        RES_REGISTRY_OBSERVED.fetch_add(1, Ordering::SeqCst);
904        res_path_registry(db, root).len()
905    }
906
907    #[test]
908    fn body_edit_does_not_invalidate_the_res_path_registry() {
909        let mut db = RootDatabase::default();
910        set_with_path(&mut db, 0, "res://a.gd", "func f():\n\tvar a := 1\n");
911        db.sync_source_root();
912        let root = db.source_root().unwrap();
913
914        assert_eq!(observe_res_registry(&db, root), 1);
915        let runs = RES_REGISTRY_OBSERVED.load(Ordering::SeqCst);
916
917        // Length-CHANGING body edit, NO path re-set, NO sync_source_root: the path is unchanged,
918        // so the registry must not recompute.
919        db.set_file_text(FileId(0), "func f():\n\tvar a := 123456\n", Durability::LOW);
920
921        assert_eq!(observe_res_registry(&db, root), 1);
922        assert_eq!(
923            RES_REGISTRY_OBSERVED.load(Ordering::SeqCst),
924            runs,
925            "REGRESSION: a body edit re-ran a res_path_registry consumer — the path firewall broke",
926        );
927    }
928
929    #[test]
930    fn preload_const_resolves_to_script_ref_members() {
931        let mut db = RootDatabase::default();
932        set_with_path(
933            &mut db,
934            0,
935            "res://widget.gd",
936            "class_name Widget\nfunc make() -> int:\n\treturn 5\nconst MAX := 10\n",
937        );
938        set_with_path(
939            &mut db,
940            1,
941            "res://main.gd",
942            "const W = preload(\"res://widget.gd\")\nfunc use_it():\n\tvar a := W.make()\n\tvar b := W.new()\n",
943        );
944        db.sync_source_root();
945        let api = db.engine().unwrap();
946
947        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
948        let unit = fi
949            .units
950            .iter()
951            .find(|u| u.result.bindings.len() >= 2)
952            .expect("use_it unit with 2 bindings");
953        // W.make() → int; W.new() → an instance of Widget (a ScriptRef).
954        assert_eq!(
955            unit.result.bindings[0].ty.label(api).as_deref(),
956            Some("int")
957        );
958        assert!(
959            matches!(unit.result.bindings[1].ty, Ty::ScriptRef(_)),
960            "W.new() should be a script instance, got {:?}",
961            unit.result.bindings[1].ty
962        );
963        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
964    }
965
966    #[test]
967    fn cross_file_preload_const_member_resolves() {
968        // The xfile-preload-const fix: another file reading `Holder.W` where
969        // `const W = preload("res://widget.gd")` resolves W to the preloaded script. Previously the
970        // offset-free script_class projection saw only the const's (absent) annotation → Variant.
971        let mut db = RootDatabase::default();
972        set_with_path(
973            &mut db,
974            0,
975            "res://widget.gd",
976            "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
977        );
978        set_with_path(
979            &mut db,
980            1,
981            "res://holder.gd",
982            "class_name Holder\nconst W = preload(\"res://widget.gd\")\n",
983        );
984        set_with_path(
985            &mut db,
986            2,
987            "res://user.gd",
988            "func use_it():\n\tvar a := Holder.W.make()\n",
989        );
990        db.sync_source_root();
991        let api = db.engine().unwrap();
992
993        let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
994        let unit = fi
995            .units
996            .iter()
997            .find(|u| !u.result.bindings.is_empty())
998            .expect("use_it unit");
999        // Holder.W → Widget's ScriptRef → .make() → int (cross-file, through the const).
1000        assert_eq!(
1001            unit.result.bindings[0].ty.label(api).as_deref(),
1002            Some("int"),
1003            "Holder.W.make() should resolve cross-file to int, got {:?}",
1004            unit.result.bindings[0].ty
1005        );
1006        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1007    }
1008
1009    #[test]
1010    fn preload_of_script_without_class_name_resolves() {
1011        // The key distinction from M1: preload resolves by PATH, so a script with *no* class_name
1012        // (absent from the global_registry) is still resolved.
1013        let mut db = RootDatabase::default();
1014        set_with_path(
1015            &mut db,
1016            0,
1017            "res://helper.gd",
1018            "func help() -> String:\n\treturn \"x\"\n",
1019        );
1020        set_with_path(
1021            &mut db,
1022            1,
1023            "res://main.gd",
1024            "func use_it():\n\tvar h := preload(\"res://helper.gd\")\n\tvar s := h.help()\n",
1025        );
1026        db.sync_source_root();
1027        let api = db.engine().unwrap();
1028
1029        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1030        let unit = fi
1031            .units
1032            .iter()
1033            .find(|u| u.result.bindings.len() >= 2)
1034            .expect("use_it unit");
1035        assert!(
1036            matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
1037            "preload of a class_name-less script must still resolve: {:?}",
1038            unit.result.bindings[0].ty
1039        );
1040        assert_eq!(
1041            unit.result.bindings[1].ty.label(api).as_deref(),
1042            Some("String")
1043        );
1044        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1045    }
1046
1047    #[test]
1048    fn extends_res_path_inherits_members() {
1049        let mut db = RootDatabase::default();
1050        // base.gd has NO class_name — reachable only by its res:// path.
1051        set_with_path(
1052            &mut db,
1053            0,
1054            "res://base.gd",
1055            "extends Node\nfunc base_method() -> int:\n\treturn 1\n",
1056        );
1057        set_with_path(
1058            &mut db,
1059            1,
1060            "res://derived.gd",
1061            "class_name Derived\nextends \"res://base.gd\"\nfunc own() -> String:\n\treturn \"x\"\n",
1062        );
1063        set_with_path(
1064            &mut db,
1065            2,
1066            "res://main.gd",
1067            "func use_it():\n\tvar d: Derived\n\tvar a := d.own()\n\tvar b := d.base_method()\n\tvar c := d.get_instance_id()\n",
1068        );
1069        db.sync_source_root();
1070        let api = db.engine().unwrap();
1071
1072        let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1073        let unit = fi
1074            .units
1075            .iter()
1076            .find(|u| u.result.bindings.len() >= 4)
1077            .expect("use_it unit with 4 bindings");
1078        // own() (own member), base_method() (via the res:// user base), get_instance_id() (the
1079        // engine base behind base.gd).
1080        assert_eq!(
1081            unit.result.bindings[1].ty.label(api).as_deref(),
1082            Some("String")
1083        );
1084        assert_eq!(
1085            unit.result.bindings[2].ty.label(api).as_deref(),
1086            Some("int")
1087        );
1088        assert_eq!(
1089            unit.result.bindings[3].ty.label(api).as_deref(),
1090            Some("int")
1091        );
1092        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1093    }
1094
1095    #[test]
1096    fn relative_extends_path_anchors_to_importing_dir() {
1097        let mut db = RootDatabase::default();
1098        // base.gd under entities/, reachable only by path (no class_name).
1099        set_with_path(
1100            &mut db,
1101            0,
1102            "res://entities/base.gd",
1103            "extends Node\nfunc base_method() -> int:\n\treturn 1\n",
1104        );
1105        // derived.gd in the SAME dir uses a RELATIVE `extends "base.gd"` (anchored to entities/).
1106        set_with_path(
1107            &mut db,
1108            1,
1109            "res://entities/derived.gd",
1110            "class_name Derived\nextends \"base.gd\"\nfunc own() -> String:\n\treturn \"x\"\n",
1111        );
1112        set_with_path(
1113            &mut db,
1114            2,
1115            "res://main.gd",
1116            "func use_it():\n\tvar d: Derived\n\tvar a := d.own()\n\tvar b := d.base_method()\n",
1117        );
1118        db.sync_source_root();
1119        let api = db.engine().unwrap();
1120        let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1121        let unit = fi
1122            .units
1123            .iter()
1124            .find(|u| u.result.bindings.len() >= 3)
1125            .expect("use_it unit with 3 bindings (d, a, b)");
1126        // bindings: [0]=`d: Derived`, [1]=own() (own member), [2]=base_method() (relative-extends base).
1127        assert_eq!(
1128            unit.result.bindings[1].ty.label(api).as_deref(),
1129            Some("String")
1130        );
1131        assert_eq!(
1132            unit.result.bindings[2].ty.label(api).as_deref(),
1133            Some("int"),
1134            "base_method() must resolve through the relative `extends \"base.gd\"`"
1135        );
1136        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1137    }
1138
1139    #[test]
1140    fn dangling_preload_is_seam_not_panic() {
1141        let mut db = RootDatabase::default();
1142        set_with_path(
1143            &mut db,
1144            0,
1145            "res://main.gd",
1146            "func use_it():\n\tvar x := preload(\"res://does_not_exist.gd\")\n\tx.whatever()\n",
1147        );
1148        db.sync_source_root();
1149        // An unresolvable path → the seam (Unknown): no diagnostic, no panic.
1150        let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1151        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1152    }
1153
1154    #[test]
1155    fn non_gd_preload_resource_stays_seam() {
1156        // A `preload` of a non-`.gd` resource must NOT resolve to a script `ScriptRef`, even if the
1157        // path is in the res:// registry — typing a `.tscn`/PackedScene as a script would wrongly
1158        // accept `.new()`/member access (scene-root typing is Phase 4). Defensive gate (the loader
1159        // indexes only `.gd` today, but a future scene-ingesting loader must not mis-type this).
1160        let mut db = RootDatabase::default();
1161        set_with_path(&mut db, 0, "res://scene.tscn", "class_name SceneRoot\n");
1162        set_with_path(
1163            &mut db,
1164            1,
1165            "res://main.gd",
1166            "func f():\n\tvar s := preload(\"res://scene.tscn\")\n",
1167        );
1168        db.sync_source_root();
1169
1170        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1171        let unit = fi
1172            .units
1173            .iter()
1174            .find(|u| !u.result.bindings.is_empty())
1175            .expect("f unit");
1176        assert!(
1177            !matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
1178            "a non-.gd preload must stay the seam, got {:?}",
1179            unit.result.bindings[0].ty
1180        );
1181        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1182    }
1183
1184    #[test]
1185    fn load_literal_stays_opaque_not_aliased_to_preload() {
1186        let mut db = RootDatabase::default();
1187        set_with_path(
1188            &mut db,
1189            0,
1190            "res://widget.gd",
1191            "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1192        );
1193        set_with_path(
1194            &mut db,
1195            1,
1196            "res://main.gd",
1197            "func use_it():\n\tvar w := load(\"res://widget.gd\")\n",
1198        );
1199        db.sync_source_root();
1200
1201        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1202        let unit = fi
1203            .units
1204            .iter()
1205            .find(|u| !u.result.bindings.is_empty())
1206            .expect("use_it unit");
1207        // `load(...)` is an ordinary runtime call returning an opaque Resource — it must NOT be
1208        // aliased to `preload` (no script ScriptRef, no static `.new()` typing).
1209        assert!(
1210            !matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
1211            "load() must stay opaque, not alias preload: {:?}",
1212            unit.result.bindings[0].ty
1213        );
1214        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1215    }
1216
1217    #[test]
1218    fn is_narrows_to_a_user_class_cross_file() {
1219        // `if x is Widget:` narrows `x` to the user `ScriptRef`, so `x.make()` resolves to its
1220        // cross-file return type — the is/as-over-user-types path (already works once ScriptRef
1221        // is informative; M4 just gates it). `int` here PROVES narrowing: without it `x` stays
1222        // Variant and `x.make()` would be Variant.
1223        let mut db = RootDatabase::default();
1224        db.set_file_text(
1225            FileId(0),
1226            "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1227            Durability::LOW,
1228        );
1229        db.set_file_text(
1230            FileId(1),
1231            "func use_it(x):\n\tif x is Widget:\n\t\tvar n := x.make()\n",
1232            Durability::LOW,
1233        );
1234        db.sync_source_root();
1235        let api = db.engine().unwrap();
1236
1237        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1238        // (bindings include the param `x`; assert *some* binding — the `n` one — is int.)
1239        assert!(
1240            fi.units
1241                .iter()
1242                .flat_map(|u| &u.result.bindings)
1243                .any(|b| b.ty.label(api).as_deref() == Some("int")),
1244            "`x.make()` after `is Widget` should narrow + resolve to int",
1245        );
1246        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1247    }
1248
1249    #[test]
1250    fn as_casts_to_a_user_class_cross_file() {
1251        // `(x as Widget).make()` types the cast as the user `ScriptRef`, so `.make()` → int.
1252        let mut db = RootDatabase::default();
1253        db.set_file_text(
1254            FileId(0),
1255            "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1256            Durability::LOW,
1257        );
1258        db.set_file_text(
1259            FileId(1),
1260            "func use_it(x):\n\tvar n := (x as Widget).make()\n",
1261            Durability::LOW,
1262        );
1263        db.sync_source_root();
1264        let api = db.engine().unwrap();
1265
1266        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1267        assert!(
1268            fi.units
1269                .iter()
1270                .flat_map(|u| &u.result.bindings)
1271                .any(|b| b.ty.label(api).as_deref() == Some("int")),
1272            "`(x as Widget).make()` should resolve to int",
1273        );
1274        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1275    }
1276
1277    #[test]
1278    fn renaming_a_files_path_reindexes_the_registry() {
1279        // A path change (rename) DOES update the registry (it is not a body edit).
1280        let mut db = RootDatabase::default();
1281        set_with_path(&mut db, 0, "res://old.gd", "class_name A\n");
1282        db.sync_source_root();
1283        let root = db.source_root().unwrap();
1284        assert_eq!(
1285            res_path_registry(&db, root).get("res://old.gd"),
1286            Some(&FileId(0))
1287        );
1288
1289        db.set_file_path(FileId(0), "res://new.gd");
1290        let root = db.source_root().unwrap();
1291        let reg = res_path_registry(&db, root);
1292        assert_eq!(reg.get("res://new.gd"), Some(&FileId(0)));
1293        assert!(reg.get("res://old.gd").is_none());
1294    }
1295
1296    // ---- M4: autoloads (project.godot [autoload]) + is/as widen-only narrowing --------------
1297
1298    #[test]
1299    fn star_autoload_scene_resolves_via_its_root_script() {
1300        // A `*`-autoload pointing at a `.tscn` whose root has an attached script resolves to that
1301        // script (the singleton-scene pattern) — `Music.volume()` → int, no false UNSAFE. This was
1302        // deferred to Phase 4 (scene ingestion); now closed.
1303        let mut db = RootDatabase::default();
1304        // music.gd (no class_name — resolved by the scene root's script= path).
1305        db.set_file_text(
1306            FileId(0),
1307            "func volume() -> int:\n\treturn 5\n",
1308            Durability::LOW,
1309        );
1310        db.set_file_path(FileId(0), "res://music.gd");
1311        // music.tscn: a root Node with script=music.gd.
1312        db.set_file_text(
1313            FileId(1),
1314            "[gd_scene format=3]\n\
1315             [ext_resource type=\"Script\" path=\"res://music.gd\" id=\"1\"]\n\
1316             [node name=\"Music\" type=\"Node\"]\n\
1317             script = ExtResource(\"1\")\n",
1318            Durability::LOW,
1319        );
1320        db.set_file_path(FileId(1), "res://music.tscn");
1321        db.set_file_text(
1322            FileId(2),
1323            "func f():\n\tvar v := Music.volume()\n",
1324            Durability::LOW,
1325        );
1326        db.set_file_path(FileId(2), "res://main.gd");
1327        db.set_project_config("[autoload]\nMusic=\"*res://music.tscn\"\n");
1328        db.sync_source_root();
1329        let api = db.engine().unwrap();
1330
1331        let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1332        let unit = fi
1333            .units
1334            .iter()
1335            .find(|u| !u.result.bindings.is_empty())
1336            .expect("f unit");
1337        assert_eq!(
1338            unit.result.bindings[0].ty.label(api).as_deref(),
1339            Some("int"),
1340            "Music.volume() should resolve via the scene root's script",
1341        );
1342        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1343    }
1344
1345    #[test]
1346    fn star_autoload_scene_resolves_via_script_class_shortcut() {
1347        // A `*`-autoload `.tscn` whose root has NO `script=` ext_resource but carries the header
1348        // `script_class="…"` shortcut resolves through the class_name registry (the recorded
1349        // shortcut, without a script ext_resource). Autoload name `Audio` ≠ class_name `MusicPlayer`
1350        // so the resolution can ONLY go via the scene's script_class shortcut.
1351        let mut db = RootDatabase::default();
1352        db.set_file_text(
1353            FileId(0),
1354            "class_name MusicPlayer\nfunc volume() -> int:\n\treturn 5\n",
1355            Durability::LOW,
1356        );
1357        db.set_file_path(FileId(0), "res://music.gd");
1358        db.set_file_text(
1359            FileId(1),
1360            "[gd_scene format=3 script_class=\"MusicPlayer\"]\n[node name=\"Root\" type=\"Node\"]\n",
1361            Durability::LOW,
1362        );
1363        db.set_file_path(FileId(1), "res://music.tscn");
1364        db.set_file_text(
1365            FileId(2),
1366            "func f():\n\tvar v := Audio.volume()\n",
1367            Durability::LOW,
1368        );
1369        db.set_file_path(FileId(2), "res://main.gd");
1370        db.set_project_config("[autoload]\nAudio=\"*res://music.tscn\"\n");
1371        db.sync_source_root();
1372        let api = db.engine().unwrap();
1373
1374        let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1375        let unit = fi
1376            .units
1377            .iter()
1378            .find(|u| !u.result.bindings.is_empty())
1379            .expect("f unit");
1380        assert_eq!(
1381            unit.result.bindings[0].ty.label(api).as_deref(),
1382            Some("int"),
1383            "Audio.volume() should resolve via the scene's script_class= shortcut",
1384        );
1385        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1386    }
1387
1388    #[test]
1389    fn star_autoload_gdscript_resolves_as_global_and_members() {
1390        let mut db = RootDatabase::default();
1391        // `game.gd` has NO class_name — the autoload resolves it by PATH (not the class registry).
1392        db.set_file_text(
1393            FileId(0),
1394            "func score() -> int:\n\treturn 0\n",
1395            Durability::LOW,
1396        );
1397        db.set_file_path(FileId(0), "res://game.gd");
1398        db.set_file_text(
1399            FileId(1),
1400            "func f():\n\tvar s := Game.score()\n",
1401            Durability::LOW,
1402        );
1403        db.set_file_path(FileId(1), "res://main.gd");
1404        db.set_project_config("[autoload]\nGame=\"*res://game.gd\"\n");
1405        db.sync_source_root();
1406        let api = db.engine().unwrap();
1407
1408        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1409        let unit = fi
1410            .units
1411            .iter()
1412            .find(|u| !u.result.bindings.is_empty())
1413            .expect("f unit");
1414        // `Game` (a *-singleton) resolves to its ScriptRef; `Game.score()` → int.
1415        assert_eq!(
1416            unit.result.bindings[0].ty.label(api).as_deref(),
1417            Some("int")
1418        );
1419        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1420    }
1421
1422    #[test]
1423    fn non_star_autoload_is_not_a_global() {
1424        let mut db = RootDatabase::default();
1425        db.set_file_text(
1426            FileId(0),
1427            "func score() -> int:\n\treturn 0\n",
1428            Durability::LOW,
1429        );
1430        db.set_file_path(FileId(0), "res://game.gd");
1431        db.set_file_text(
1432            FileId(1),
1433            "func f():\n\tvar s := Game.score()\n",
1434            Durability::LOW,
1435        );
1436        db.set_file_path(FileId(1), "res://main.gd");
1437        // No leading `*` → loaded-but-not-global; the bare name `Game` must NOT resolve.
1438        db.set_project_config("[autoload]\nGame=\"res://game.gd\"\n");
1439        db.sync_source_root();
1440        let api = db.engine().unwrap();
1441
1442        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1443        let unit = fi
1444            .units
1445            .iter()
1446            .find(|u| !u.result.bindings.is_empty())
1447            .expect("f unit");
1448        // `Game` → seam (Unknown), so `s` is uninformative (no `int`); and NO diagnostic.
1449        assert_eq!(unit.result.bindings[0].ty.label(api), None);
1450        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1451    }
1452
1453    #[test]
1454    fn tscn_autoload_is_the_seam_never_false_warns() {
1455        let mut db = RootDatabase::default();
1456        // A scene (`.tscn`) autoload: typing it `Node` would false-warn on the root script's own
1457        // members, so it stays the seam (scene-root typing is Phase 4).
1458        db.set_file_text(FileId(0), "func f():\n\tHud.play_song()\n", Durability::LOW);
1459        db.set_file_path(FileId(0), "res://main.gd");
1460        db.set_project_config("[autoload]\nHud=\"*res://hud.tscn\"\n");
1461        db.sync_source_root();
1462
1463        let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1464        // `Hud.play_song()` on a seam receiver → no diagnostic (no false UNSAFE_METHOD_ACCESS).
1465        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1466    }
1467
1468    // The autoload firewall: a `.gd` body edit must not rebuild the autoload registry, which is
1469    // keyed only on the `ProjectConfig` input (not on file text).
1470
1471    static AUTOLOAD_OBSERVED: AtomicU32 = AtomicU32::new(0);
1472
1473    #[salsa::tracked]
1474    fn observe_autoload_registry(db: &dyn gdscript_db::Db, config: ProjectConfig) -> usize {
1475        AUTOLOAD_OBSERVED.fetch_add(1, Ordering::SeqCst);
1476        autoload_registry(db, config).len()
1477    }
1478
1479    #[test]
1480    fn autoload_registry_firewalled_against_body_edits() {
1481        let mut db = RootDatabase::default();
1482        db.set_file_text(FileId(0), "func f():\n\tvar a := 1\n", Durability::LOW);
1483        db.set_file_path(FileId(0), "res://game.gd");
1484        db.set_project_config("[autoload]\nGame=\"*res://game.gd\"\n");
1485        db.sync_source_root();
1486        let config = db.project_config().unwrap();
1487
1488        assert_eq!(observe_autoload_registry(&db, config), 1);
1489        let runs = AUTOLOAD_OBSERVED.load(Ordering::SeqCst);
1490
1491        // Length-changing `.gd` body edit, NO set_project_config: the autoload registry must not
1492        // recompute (its sole input — ProjectConfig — is untouched).
1493        db.set_file_text(FileId(0), "func f():\n\tvar a := 999999\n", Durability::LOW);
1494
1495        assert_eq!(observe_autoload_registry(&db, config), 1);
1496        assert_eq!(
1497            AUTOLOAD_OBSERVED.load(Ordering::SeqCst),
1498            runs,
1499            "REGRESSION: a body edit re-ran an autoload_registry consumer — the config firewall broke",
1500        );
1501    }
1502
1503    #[test]
1504    fn aliased_self_resolves_own_members_no_false_unsafe() {
1505        // `var me := self; me.own()` must resolve `own` via the file's OWN members — self is the
1506        // script's own class (a self-ScriptRef), not just its engine base. Before the fix `me` was
1507        // typed as the base (`Node`), so `me.own()` false-warned UNSAFE_METHOD_ACCESS.
1508        let mut db = RootDatabase::default();
1509        db.set_file_text(
1510            FileId(0),
1511            "extends Node\nfunc own() -> int:\n\treturn 1\nfunc use_it():\n\tvar me := self\n\tvar n := me.own()\n",
1512            Durability::LOW,
1513        );
1514        db.sync_source_root();
1515        let api = db.engine().unwrap();
1516
1517        let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1518        // `me.own()` resolves to int (own member via aliased self) — proves it isn't the seam.
1519        assert!(
1520            fi.units
1521                .iter()
1522                .flat_map(|u| &u.result.bindings)
1523                .any(|b| b.ty.label(api).as_deref() == Some("int")),
1524            "aliased self.own() should resolve to int",
1525        );
1526        assert!(
1527            fi.diagnostics.is_empty(),
1528            "no false UNSAFE on aliased self: {:?}",
1529            fi.diagnostics
1530        );
1531    }
1532
1533    #[test]
1534    fn is_userbase_narrows_to_derived_but_not_un_narrowed_to_base() {
1535        let mut db = RootDatabase::default();
1536        db.set_file_text(
1537            FileId(0),
1538            "class_name Base\nfunc base_m() -> int:\n\treturn 1\n",
1539            Durability::LOW,
1540        );
1541        db.set_file_text(
1542            FileId(1),
1543            "class_name Derived\nextends Base\nfunc own_m() -> String:\n\treturn \"x\"\n",
1544            Durability::LOW,
1545        );
1546        // (a) untyped `x` + `is Derived` → narrow to Derived → `x.own_m()` resolves (String).
1547        // (b) `d: Derived` + `is Base` → widen-only: d STAYS Derived → `d.own_m()` resolves (String).
1548        db.set_file_text(
1549            FileId(2),
1550            "func use_it(x):\n\tif x is Derived:\n\t\tvar a := x.own_m()\n\tvar d: Derived\n\tif d is Base:\n\t\tvar b := d.own_m()\n",
1551            Durability::LOW,
1552        );
1553        db.sync_source_root();
1554        let api = db.engine().unwrap();
1555
1556        let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1557        let strings = fi
1558            .units
1559            .iter()
1560            .flat_map(|u| &u.result.bindings)
1561            .filter(|b| b.ty.label(api).as_deref() == Some("String"))
1562            .count();
1563        // Both `own_m()` calls resolve to String: proves narrow-to-Derived AND no un-narrow-to-Base.
1564        assert!(
1565            strings >= 2,
1566            "expected both own_m() calls to type as String (narrow-down + widen-only), got {strings}",
1567        );
1568        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1569    }
1570
1571    // ---- M1: scene-aware node-path typing ($Path / %Unique) -------------------------------
1572
1573    /// A db with file 0 = a scene and file 1 = its attached script, both with res:// paths.
1574    fn scene_db(scene_text: &str, gd_text: &str) -> RootDatabase {
1575        let mut db = RootDatabase::default();
1576        db.set_file_text(FileId(0), scene_text, Durability::LOW);
1577        db.set_file_path(FileId(0), "res://main.tscn");
1578        db.set_file_text(FileId(1), gd_text, Durability::LOW);
1579        db.set_file_path(FileId(1), "res://main.gd");
1580        db.sync_source_root();
1581        db
1582    }
1583
1584    fn binding_labels(db: &RootDatabase) -> Vec<String> {
1585        let api = db.engine().unwrap();
1586        let fi = analyze_file(db, db.file_text(FileId(1)).unwrap());
1587        assert!(
1588            fi.diagnostics.is_empty(),
1589            "unexpected diags: {:?}",
1590            fi.diagnostics
1591        );
1592        fi.units
1593            .iter()
1594            .flat_map(|u| &u.result.bindings)
1595            .filter_map(|b| b.ty.label(api))
1596            .collect()
1597    }
1598
1599    const SCENE: &str = "[gd_scene format=3]\n\
1600        [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1601        [node name=\"Root\" type=\"Control\"]\n\
1602        script = ExtResource(\"1\")\n\
1603        [node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
1604        [node name=\"Box\" type=\"VBoxContainer\" parent=\"Panel\"]\n\
1605        [node name=\"Btn\" type=\"Button\" parent=\"Panel/Box\"]\n\
1606        unique_name_in_owner = true\n";
1607
1608    #[test]
1609    fn dollar_path_types_to_the_concrete_node() {
1610        // `$Panel/Box/Btn` → Button (not bare Node) — the killer feature, zero annotations.
1611        let db = scene_db(
1612            SCENE,
1613            "extends Control\nfunc _ready():\n\tvar b := $Panel/Box/Btn\n",
1614        );
1615        assert!(
1616            binding_labels(&db).iter().any(|l| l == "Button"),
1617            "$Panel/Box/Btn should type as Button",
1618        );
1619    }
1620
1621    #[test]
1622    fn unique_name_path_types_to_the_concrete_node() {
1623        // `%Btn` resolves via unique_name_in_owner → Button.
1624        let db = scene_db(SCENE, "extends Control\nfunc _ready():\n\tvar b := %Btn\n");
1625        assert!(
1626            binding_labels(&db).iter().any(|l| l == "Button"),
1627            "%Btn should type as Button"
1628        );
1629    }
1630
1631    #[test]
1632    fn onready_var_from_a_node_path_is_typed() {
1633        // `@onready var x := $Path` types `x` from the resolved node at the decl site. (`:=` is the
1634        // typed form; plain `=` stays `Variant` per Godot's gradual typing — Phase-2 rule.)
1635        let db = scene_db(
1636            SCENE,
1637            "extends Control\n@onready var btn := $Panel/Box/Btn\n",
1638        );
1639        assert!(
1640            binding_labels(&db).iter().any(|l| l == "Button"),
1641            "@onready var := $Path should type to Button",
1642        );
1643    }
1644
1645    #[test]
1646    fn get_node_string_literal_types_like_dollar() {
1647        // `get_node("Panel/Box/Btn")` (string literal) types identically to `$Panel/Box/Btn`.
1648        let db = scene_db(
1649            SCENE,
1650            "extends Control\nfunc _ready():\n\tvar b := get_node(\"Panel/Box/Btn\")\n",
1651        );
1652        assert!(
1653            binding_labels(&db).iter().any(|l| l == "Button"),
1654            "get_node(\"...\") should type as Button",
1655        );
1656    }
1657
1658    #[test]
1659    fn self_get_node_string_literal_types_like_dollar() {
1660        // `self.get_node("…")` (explicit self = the attach node) types like the bare form; a foreign
1661        // receiver `obj.get_node("…")` stays a normal call → `Node` (can't resolve another node's path).
1662        let db = scene_db(
1663            SCENE,
1664            "extends Control\nfunc _ready():\n\tvar b := self.get_node(\"Panel/Box/Btn\")\n",
1665        );
1666        assert!(
1667            binding_labels(&db).iter().any(|l| l == "Button"),
1668            "self.get_node(\"...\") should type as Button",
1669        );
1670    }
1671
1672    #[test]
1673    fn attached_script_refines_the_node_type() {
1674        // A node `type="Button"` + `script=Fancy.gd (class_name Fancy)` → `$That` is `Fancy`, so
1675        // `$That.fancy()` resolves to its cross-file return type (proving the script refine).
1676        let mut db = RootDatabase::default();
1677        db.set_file_text(
1678            FileId(0),
1679            "[gd_scene format=3]\n\
1680             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1681             [ext_resource type=\"Script\" path=\"res://fancy.gd\" id=\"2\"]\n\
1682             [node name=\"Root\" type=\"Control\"]\n\
1683             script = ExtResource(\"1\")\n\
1684             [node name=\"That\" type=\"Button\" parent=\".\"]\n\
1685             script = ExtResource(\"2\")\n",
1686            Durability::LOW,
1687        );
1688        db.set_file_path(FileId(0), "res://main.tscn");
1689        db.set_file_text(
1690            FileId(1),
1691            "extends Control\nfunc _ready():\n\tvar n := $That.fancy()\n",
1692            Durability::LOW,
1693        );
1694        db.set_file_path(FileId(1), "res://main.gd");
1695        db.set_file_text(
1696            FileId(2),
1697            "class_name Fancy\nextends Button\nfunc fancy() -> int:\n\treturn 1\n",
1698            Durability::LOW,
1699        );
1700        db.set_file_path(FileId(2), "res://fancy.gd");
1701        db.sync_source_root();
1702        assert!(
1703            binding_labels(&db).iter().any(|l| l == "int"),
1704            "$That.fancy() should resolve via the attached script Fancy",
1705        );
1706    }
1707
1708    #[test]
1709    fn computed_or_unresolvable_node_path_stays_node_without_warning() {
1710        // A computed `get_node(var)` and a `$Nope` with no owning scene both stay `Node` — never a
1711        // false node-path warning.
1712        let mut db = RootDatabase::default();
1713        db.set_file_text(
1714            FileId(1),
1715            // `p: NodePath` so the computed `get_node(p)` still exercises node-path resolution but
1716            // without an (orthogonal, legitimate) UNSAFE_CALL_ARGUMENT on an untyped Variant arg —
1717            // that warning has its own tests in `infer`.
1718            "extends Node\nfunc f(p: NodePath):\n\tvar a := get_node(p)\n\tvar b := $Nope\n",
1719            Durability::LOW,
1720        );
1721        db.set_file_path(FileId(1), "res://lone.gd");
1722        db.sync_source_root();
1723        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1724        assert!(
1725            fi.diagnostics.is_empty(),
1726            "no false node-path warnings: {:?}",
1727            fi.diagnostics
1728        );
1729    }
1730
1731    // ---- M2: INVALID_NODE_PATH (the no-false-positive contract) ----------------------------
1732
1733    fn has_invalid_node_path(db: &RootDatabase) -> bool {
1734        let fi = analyze_file(db, db.file_text(FileId(1)).unwrap());
1735        fi.diagnostics
1736            .iter()
1737            .any(|d| d.code == crate::infer::INVALID_NODE_PATH)
1738    }
1739
1740    #[test]
1741    fn invalid_node_path_warns_when_genuinely_absent_in_a_single_owning_scene() {
1742        let db = scene_db(SCENE, "extends Control\nfunc _ready():\n\tvar b := $Nope\n");
1743        assert!(
1744            has_invalid_node_path(&db),
1745            "$Nope is absent in the one owning scene → warn"
1746        );
1747    }
1748
1749    #[test]
1750    fn escape_and_absolute_paths_never_warn() {
1751        // `..` and absolute `/root/…` escape the scene slice — silent, never INVALID_NODE_PATH.
1752        let db = scene_db(
1753            SCENE,
1754            "extends Control\nfunc _ready():\n\tvar a := $\"../Sibling\"\n\tvar c := $\"/root/Global\"\n",
1755        );
1756        assert!(!has_invalid_node_path(&db), "escape paths must not warn");
1757    }
1758
1759    #[test]
1760    fn path_descending_into_an_instanced_subscene_never_warns() {
1761        // Root > Player(instance=…). `$Player/Gun` misses below an instance we don't recurse into —
1762        // silent (the node may well exist inside the sub-scene).
1763        let db = scene_db(
1764            "[gd_scene format=3]\n\
1765             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1766             [ext_resource type=\"PackedScene\" path=\"res://player.tscn\" id=\"2\"]\n\
1767             [node name=\"Root\" type=\"Control\"]\n\
1768             script = ExtResource(\"1\")\n\
1769             [node name=\"Player\" parent=\".\" instance=ExtResource(\"2\")]\n",
1770            "extends Control\nfunc _ready():\n\tvar g := $Player/Gun\n",
1771        );
1772        assert!(
1773            !has_invalid_node_path(&db),
1774            "into-instance miss must not warn"
1775        );
1776    }
1777
1778    #[test]
1779    fn ambiguous_multi_scene_attachment_suppresses_the_invalid_warning() {
1780        // main.gd attaches to BOTH a.tscn (child Alpha) and b.tscn (child Beta). `$Beta` is absent in
1781        // a.tscn (kept first) but present in b.tscn → ambiguous → no false INVALID_NODE_PATH.
1782        let mut db = RootDatabase::default();
1783        db.set_file_text(
1784            FileId(0),
1785            "[gd_scene format=3]\n\
1786             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1787             [node name=\"Root\" type=\"Control\"]\n\
1788             script = ExtResource(\"1\")\n\
1789             [node name=\"Alpha\" type=\"Button\" parent=\".\"]\n",
1790            Durability::LOW,
1791        );
1792        db.set_file_path(FileId(0), "res://a.tscn");
1793        db.set_file_text(
1794            FileId(2),
1795            "[gd_scene format=3]\n\
1796             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1797             [node name=\"Root\" type=\"Control\"]\n\
1798             script = ExtResource(\"1\")\n\
1799             [node name=\"Beta\" type=\"Button\" parent=\".\"]\n",
1800            Durability::LOW,
1801        );
1802        db.set_file_path(FileId(2), "res://b.tscn");
1803        db.set_file_text(
1804            FileId(1),
1805            "extends Control\nfunc _ready():\n\tvar b := $Beta\n",
1806            Durability::LOW,
1807        );
1808        db.set_file_path(FileId(1), "res://main.gd");
1809        db.sync_source_root();
1810        assert!(
1811            !has_invalid_node_path(&db),
1812            "ambiguous multi-scene attachment must not warn"
1813        );
1814    }
1815
1816    // ---- M3: instanced sub-scene recursion ------------------------------------------------
1817
1818    #[test]
1819    fn instanced_node_recurses_into_the_subscene_root_script() {
1820        // main.tscn: Root(script=main.gd) > Enemy(instance=enemy.tscn). enemy.tscn's root carries
1821        // script=enemy.gd (class_name Enemy, `hp() -> int`). `$Enemy.hp()` must recurse into the
1822        // sub-scene root, refine to the Enemy script, and resolve the cross-file method → `int`
1823        // (proving the instance recursion + script refine; a bare `Node` would have no `hp()`).
1824        let mut db = RootDatabase::default();
1825        db.set_file_text(
1826            FileId(0),
1827            "[gd_scene format=3]\n\
1828             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1829             [ext_resource type=\"PackedScene\" path=\"res://enemy.tscn\" id=\"2\"]\n\
1830             [node name=\"Root\" type=\"Control\"]\n\
1831             script = ExtResource(\"1\")\n\
1832             [node name=\"Enemy\" parent=\".\" instance=ExtResource(\"2\")]\n",
1833            Durability::LOW,
1834        );
1835        db.set_file_path(FileId(0), "res://main.tscn");
1836        db.set_file_text(
1837            FileId(1),
1838            "extends Control\nfunc _ready():\n\tvar e := $Enemy.hp()\n",
1839            Durability::LOW,
1840        );
1841        db.set_file_path(FileId(1), "res://main.gd");
1842        db.set_file_text(
1843            FileId(2),
1844            "[gd_scene format=3]\n\
1845             [ext_resource type=\"Script\" path=\"res://enemy.gd\" id=\"1\"]\n\
1846             [node name=\"Enemy\" type=\"Button\"]\n\
1847             script = ExtResource(\"1\")\n",
1848            Durability::LOW,
1849        );
1850        db.set_file_path(FileId(2), "res://enemy.tscn");
1851        db.set_file_text(
1852            FileId(3),
1853            "class_name Enemy\nextends Button\nfunc hp() -> int:\n\treturn 1\n",
1854            Durability::LOW,
1855        );
1856        db.set_file_path(FileId(3), "res://enemy.gd");
1857        db.sync_source_root();
1858        assert!(
1859            binding_labels(&db).iter().any(|l| l == "int"),
1860            "$Enemy.hp() should recurse into the instanced sub-scene root's script Enemy",
1861        );
1862    }
1863
1864    // ---- Phase-4 hunt fixes: `%`-segment paths (no false INVALID_NODE_PATH) ----------------
1865
1866    #[test]
1867    fn unique_name_subpath_resolves_to_the_child_without_warning() {
1868        // `%Box/Btn`: resolve the unique `%Box`, then walk `/Btn` to its Button child — idiomatic
1869        // Godot. Must type as Button and NOT raise INVALID_NODE_PATH (the bare-map lookup of the
1870        // whole joined "Box/Btn" used to miss → false warning).
1871        let db = scene_db(
1872            "[gd_scene format=3]\n\
1873             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1874             [node name=\"Root\" type=\"Control\"]\n\
1875             script = ExtResource(\"1\")\n\
1876             [node name=\"Box\" type=\"VBoxContainer\" parent=\".\"]\n\
1877             unique_name_in_owner = true\n\
1878             [node name=\"Btn\" type=\"Button\" parent=\"Box\"]\n",
1879            "extends Control\nfunc _ready():\n\tvar b := %Box/Btn\n",
1880        );
1881        assert!(
1882            binding_labels(&db).iter().any(|l| l == "Button"),
1883            "%Box/Btn → Button (and no false INVALID_NODE_PATH)",
1884        );
1885    }
1886
1887    #[test]
1888    fn percent_prefixed_string_paths_resolve_as_unique_without_warning() {
1889        // `get_node("%Btn")` and `$"%Btn"` are unique-name lookups (the `%` prefix lives inside the
1890        // string), NOT a child literally named "%Btn". Must type as Button with no INVALID_NODE_PATH.
1891        let db = scene_db(
1892            SCENE,
1893            "extends Control\nfunc _ready():\n\tvar a := get_node(\"%Btn\")\n\tvar b := $\"%Btn\"\n",
1894        );
1895        let labels = binding_labels(&db);
1896        assert!(
1897            labels.iter().filter(|l| *l == "Button").count() >= 2,
1898            "both %Btn string forms should resolve to Button: {labels:?}",
1899        );
1900    }
1901}