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