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