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;
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
92/// diagnostics are an M1 follow-up.)
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 project-wide `res:// path → FileId` registry (M3): the map `preload("res://x.gd")` and
105/// `extends "res://x.gd"` resolve through. Keyed on the [`SourceRoot`] file-set input and each
106/// file's `res_path` salsa-input field. `res_path` is a *separate* input field from `text`
107/// (salsa tracks input fields individually), so this registry **backdates across body edits**
108/// exactly like [`global_registry`] — a keystroke never rebuilds it. A duplicate path keeps the
109/// first by `FileId` order (the file set is sorted), matching `global_registry`'s policy.
110#[salsa::tracked]
111pub fn res_path_registry(db: &dyn Db, root: SourceRoot) -> Arc<FxHashMap<SmolStr, FileId>> {
112    let mut map = FxHashMap::default();
113    for &file in root.files(db) {
114        if let Some(path) = file.res_path(db) {
115            map.entry(path).or_insert_with(|| file.file_id(db));
116        }
117    }
118    Arc::new(map)
119}
120
121/// The project's autoload **singletons** (`*`-flagged `[autoload]` entries) — the bare names that
122/// resolve as globals in code. Maps each singleton name → its resource path (M4). Non-singleton
123/// autoloads are deliberately excluded (loaded-but-not-global). Keyed on [`ProjectConfig`] alone
124/// (it iterates only the config text), so it backdates across every `.gd` keystroke.
125#[derive(Debug, Clone, PartialEq, Eq, Default)]
126pub struct AutoloadRegistry {
127    singletons: FxHashMap<SmolStr, SmolStr>,
128}
129
130impl AutoloadRegistry {
131    /// The resource path of the singleton autoload named `name`, if any.
132    #[must_use]
133    pub fn resolve_path(&self, name: &str) -> Option<&SmolStr> {
134        self.singletons.get(name)
135    }
136
137    /// The number of registered singleton autoloads.
138    #[must_use]
139    pub fn len(&self) -> usize {
140        self.singletons.len()
141    }
142
143    /// Whether no singleton autoload is registered.
144    #[must_use]
145    pub fn is_empty(&self) -> bool {
146        self.singletons.is_empty()
147    }
148}
149
150/// The project-wide autoload-singleton registry, parsed from `project.godot` (M4). Only
151/// `*`-flagged entries become globals; a duplicate name keeps the first (deterministic).
152#[salsa::tracked]
153pub fn autoload_registry(db: &dyn Db, config: ProjectConfig) -> Arc<AutoloadRegistry> {
154    let mut singletons = FxHashMap::default();
155    for e in crate::project::parse_autoloads(config.project_godot_text(db)) {
156        if e.is_singleton {
157            singletons.entry(e.name).or_insert(e.path);
158        }
159    }
160    Arc::new(AutoloadRegistry { singletons })
161}
162
163/// One member of a script class, as a cross-file reference sees it (a resolved type, never a
164/// byte range).
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum MemberSig {
167    /// A method — its resolved return type.
168    Method(Ty),
169    /// A `var` / `const` — its resolved type.
170    Field(Ty),
171    /// A signal.
172    Signal,
173}
174
175/// A script class's own members, by name, plus its resolved `extends` base — the **offset-free
176/// projection** a cross-file reference resolves against. Reads only `item_tree` signatures (+
177/// annotation/base resolution), never bodies or byte ranges, so it backdates on body edits (the
178/// cross-file firewall). Member lookup walks the base chain (M2): own members here, inherited
179/// ones via [`base`](ScriptClass::base).
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct ScriptClass {
182    members: FxHashMap<SmolStr, MemberSig>,
183    base: Ty,
184}
185
186impl ScriptClass {
187    /// The signature of the member named `name`, if the class declares one *itself* (not
188    /// inherited — the caller walks [`base`](ScriptClass::base) for inherited members).
189    #[must_use]
190    pub fn member(&self, name: &str) -> Option<&MemberSig> {
191        self.members.get(name)
192    }
193
194    /// The resolved `extends` base: an engine `Object`, a user `ScriptRef`, or `Unknown`.
195    #[must_use]
196    pub fn base(&self) -> &Ty {
197        &self.base
198    }
199}
200
201/// The `class_name` behind a [`ScriptRef`](crate::ty::Ty::ScriptRef), for display (hover /
202/// inlay). `Ty::label` cannot resolve this on its own — it has only the engine model, not the
203/// project registry.
204#[must_use]
205pub fn script_ref_name(db: &dyn Db, sref: ScriptRefId) -> Option<SmolStr> {
206    let file = db.file_text(FileId(sref.0))?;
207    file_class_name(db, file)
208}
209
210/// The member table of the script in `file`. Member types are resolved against the engine model
211/// and the registry (a member typed as another `class_name` resolves to its `ScriptRef`).
212#[salsa::tracked]
213pub fn script_class(db: &dyn Db, file: FileText) -> Arc<ScriptClass> {
214    let tree = item_tree(db, file);
215    let Some(api) = db.engine() else {
216        return Arc::new(ScriptClass {
217            members: FxHashMap::default(),
218            base: Ty::Unknown,
219        });
220    };
221    let resolve_ann = |ann: Option<&str>| -> Ty {
222        ann.map_or(Ty::Variant, |t| {
223            crate::resolve::resolve_type_name(db, api, t)
224        })
225    };
226    let mut members = FxHashMap::default();
227    for m in &tree.members {
228        let Some(name) = m.name() else { continue };
229        let sig = match m {
230            Member::Func(f) => MemberSig::Method(resolve_ann(f.return_type.as_deref())),
231            Member::Var(v) => MemberSig::Field(resolve_ann(v.type_ref.as_deref())),
232            Member::Const(c) => MemberSig::Field(resolve_ann(c.type_ref.as_deref())),
233            Member::Signal(_) => MemberSig::Signal,
234            // Enums + inner classes aren't modeled as instance members yet (M2+).
235            Member::Enum(_) | Member::Class(_) => continue,
236        };
237        members.insert(SmolStr::new(name), sig);
238    }
239    // The resolved `extends` base — a user `ScriptRef` (another class_name / "res://…") walks
240    // into the inheritance chain; an engine `Object` ends it at the API table.
241    let base = crate::resolve::resolve_base(db, api, &tree);
242    Arc::new(ScriptClass { members, base })
243}
244
245// ---- M1: scenes (.tscn/.tres) ------------------------------------------------------------
246
247/// Whether a `res://` path is a *text* scene/resource we parse (`.tscn`/`.tres`). Binary
248/// `.scn`/`.res` are detected-and-degraded by the parser, but we don't waste a parse on a `.gd`.
249fn is_scene_path(path: &str) -> bool {
250    let ext = path.rsplit('.').next().unwrap_or("");
251    ext.eq_ignore_ascii_case("tscn") || ext.eq_ignore_ascii_case("tres")
252}
253
254/// The parsed [`SceneModel`] for `file` (M1) — memoized; recomputes only when the file text
255/// changes. A non-scene file (a `.gd`, or no `res://` path) yields an empty model (so the query is
256/// total). The pure `gdscript_scene::parse_scene` is the cache body; this just wraps + gates it.
257#[salsa::tracked]
258pub fn scene_model(db: &dyn Db, file: FileText) -> Arc<SceneModel> {
259    let is_scene = file.res_path(db).as_deref().is_some_and(is_scene_path);
260    if is_scene {
261        Arc::new(gdscript_scene::parse_scene(file.text(db)))
262    } else {
263        Arc::new(gdscript_scene::parse_scene(""))
264    }
265}
266
267/// Where a script (`.gd`) is attached in a scene: the owning scene file + the node carrying the
268/// `script = ExtResource(...)`. `$Path` in that script resolves relative to this node.
269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
270pub struct SceneAttach {
271    /// The owning scene's file.
272    pub scene: FileId,
273    /// The node the script attaches to (the `$`-path base).
274    pub node: NodeIdx,
275    /// Whether the script attaches to **more than one** scene (the first kept here). When `true`,
276    /// a `$Path` valid in another scene must not be flagged `INVALID_NODE_PATH` (no false positive).
277    pub ambiguous: bool,
278}
279
280/// The project-wide **script → owning scene** index (M1): each `.gd`'s `res://` path → the (first)
281/// scene + node that attaches it. Built by scanning every scene's `ext_resources` for a
282/// `type="Script"` reference. Keyed on the [`SourceRoot`] file-set + each scene file's text (via
283/// [`scene_model`]); a `.gd` **body** edit never touches a `.tscn` text, so this **backdates across
284/// `.gd` keystrokes** — the firewall (a scene edit correctly invalidates it). A duplicate (one
285/// script in many scenes) keeps the first by `FileId` order (the slice's single-scene policy).
286#[salsa::tracked]
287pub fn script_scene_index(db: &dyn Db, root: SourceRoot) -> Arc<FxHashMap<SmolStr, SceneAttach>> {
288    let mut map: FxHashMap<SmolStr, SceneAttach> = FxHashMap::default();
289    for &file in root.files(db) {
290        if !file.res_path(db).as_deref().is_some_and(is_scene_path) {
291            continue;
292        }
293        let model = scene_model(db, file);
294        let scene = file.file_id(db);
295        for (i, node) in model.nodes.iter().enumerate() {
296            let Some(script_id) = node.script.as_ref() else {
297                continue;
298            };
299            let Some(path) = model
300                .ext_resources
301                .get(script_id)
302                .and_then(|e| e.path.clone())
303            else {
304                continue;
305            };
306            let node = NodeIdx(u32::try_from(i).unwrap_or(u32::MAX));
307            match map.get_mut(&path) {
308                // already attached by an earlier scene → ambiguous (keep the first).
309                Some(existing) => existing.ambiguous = true,
310                None => {
311                    map.insert(
312                        path,
313                        SceneAttach {
314                            scene,
315                            node,
316                            ambiguous: false,
317                        },
318                    );
319                }
320            }
321        }
322    }
323    Arc::new(map)
324}
325
326/// The owning-scene context for the script in `file` (M1): the scene's [`FileId`], the parsed
327/// scene, and the attach node, so `$Path`/`%Unique`/`get_node("…")` can resolve (and go-to-def can
328/// jump into the `.tscn`). `None` when the project has no scene attaching this script (the
329/// overwhelmingly common single-file / dynamic-UI case → node paths stay `Node`).
330#[must_use]
331pub fn scene_context(db: &dyn Db, file: FileText) -> Option<SceneContext> {
332    let res_path = file.res_path(db)?;
333    let root = db.source_root()?;
334    let attach = *script_scene_index(db, root).get(res_path.as_str())?;
335    let scene_file = db.file_text(attach.scene)?;
336    Some(SceneContext {
337        scene: attach.scene,
338        model: scene_model(db, scene_file),
339        attach: attach.node,
340        ambiguous: attach.ambiguous,
341    })
342}
343
344/// The resolved owning-scene context for a script — the scene file, its model, the attach node, and
345/// whether the attachment is ambiguous (multi-scene). Returned by [`scene_context`].
346#[derive(Debug, Clone)]
347pub struct SceneContext {
348    /// The owning scene's file.
349    pub scene: FileId,
350    /// The parsed scene model.
351    pub model: Arc<SceneModel>,
352    /// The node the script attaches to (the `$`-path base).
353    pub attach: NodeIdx,
354    /// Whether the script attaches to multiple scenes (suppresses `INVALID_NODE_PATH`).
355    pub ambiguous: bool,
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use gdscript_base::FileId;
362    use gdscript_db::RootDatabase;
363    use salsa::Durability;
364
365    fn db_with(src: &str) -> (RootDatabase, FileText) {
366        let mut db = RootDatabase::default();
367        db.set_file_text(FileId(0), src, Durability::LOW);
368        let ft = db.file_text(FileId(0)).unwrap();
369        (db, ft)
370    }
371
372    #[test]
373    fn tracked_item_tree_matches_the_plain_fn() {
374        let (db, ft) = db_with("class_name Foo\nfunc f():\n\tpass\n");
375        let tree = item_tree(&db, ft);
376        assert_eq!(tree.class_name.as_deref(), Some("Foo"));
377        // Memoized: a second query is the same Arc value.
378        assert_eq!(item_tree(&db, ft), tree);
379    }
380
381    #[test]
382    fn tracked_analyze_file_runs_inference() {
383        let (db, ft) = db_with("func add(a: int, b: int) -> int:\n\treturn a + b\n");
384        let fi = analyze_file(&db, ft);
385        // The engine model is present on native, so inference produced a unit.
386        assert!(!fi.units.is_empty());
387        assert!(fi.diagnostics.is_empty());
388    }
389
390    // ---- the body-edit firewall (the M0 CI gate, Playbook §4) -----------------------------
391    //
392    // A query that reads only `item_tree` (signatures) must NOT recompute when a function body
393    // changes: editing a body changes the parse, `item_tree` re-validates but its value is
394    // unchanged, so salsa BACKDATES it and dependents are spared. We witness this with a counter
395    // bumped inside a signature-only tracked query (a standard salsa test idiom — the counter is
396    // test-only impurity that does not affect the result). `class_name_witness` is also the seed
397    // of M1's global `class_name` registry.
398
399    use std::sync::atomic::{AtomicU32, Ordering};
400
401    static WITNESS_RUNS: AtomicU32 = AtomicU32::new(0);
402
403    /// Depends ONLY on `item_tree` (never on a body). Counts its own executions.
404    #[salsa::tracked]
405    fn class_name_witness(db: &dyn gdscript_db::Db, file: FileText) -> Option<smol_str::SmolStr> {
406        WITNESS_RUNS.fetch_add(1, Ordering::SeqCst);
407        item_tree(db, file).class_name.clone()
408    }
409
410    #[test]
411    fn body_edit_does_not_invalidate_signature_queries() {
412        let mut db = RootDatabase::default();
413        db.set_file_text(
414            FileId(0),
415            "class_name Foo\nfunc f():\n\tvar a := 1\n",
416            Durability::LOW,
417        );
418        let ft = db.file_text(FileId(0)).unwrap();
419
420        // Warm the cache.
421        assert_eq!(class_name_witness(&db, ft).as_deref(), Some("Foo"));
422        let runs_after_warm = WITNESS_RUNS.load(Ordering::SeqCst);
423
424        // Edit ONLY a function body, keeping byte length (`1` -> `2`): signatures are unchanged,
425        // so `item_tree` backdates and the firewall holds.
426        db.set_file_text(
427            FileId(0),
428            "class_name Foo\nfunc f():\n\tvar a := 2\n",
429            Durability::LOW,
430        );
431        assert_eq!(class_name_witness(&db, ft).as_deref(), Some("Foo"));
432
433        assert_eq!(
434            WITNESS_RUNS.load(Ordering::SeqCst),
435            runs_after_warm,
436            "REGRESSION: a body edit re-ran a signature-only query — the item_tree firewall broke",
437        );
438    }
439
440    #[test]
441    fn global_registry_resolves_class_names_across_files() {
442        let mut db = RootDatabase::default();
443        db.set_file_text(
444            FileId(0),
445            "class_name Player\nfunc f():\n\tpass\n",
446            Durability::LOW,
447        );
448        db.set_file_text(
449            FileId(1),
450            "class_name Enemy\nvar hp := 10\n",
451            Durability::LOW,
452        );
453        db.set_file_text(FileId(2), "func no_class():\n\tpass\n", Durability::LOW);
454        db.sync_source_root();
455        let root = db.source_root().unwrap();
456
457        let reg = global_registry(&db, root);
458        assert_eq!(reg.len(), 2);
459        assert_eq!(reg.resolve("Player"), db.file_text(FileId(0)));
460        assert_eq!(reg.resolve("Enemy"), db.file_text(FileId(1)));
461        assert!(reg.resolve("Nonexistent").is_none());
462    }
463
464    // The TRUE downstream firewall (the M1 reframe of the pinned M0 limitation): a body edit must
465    // not invalidate the project-wide registry. `file_class_name` is offset-free, so even a
466    // *length-changing* body edit — which shifts `item_tree`'s byte ranges and forces it to
467    // re-execute — leaves `file_class_name` backdating (its value, the class name, is unchanged).
468    // The registry, and every consumer of it, is therefore untouched by a keystroke.
469
470    static REGISTRY_OBSERVED: AtomicU32 = AtomicU32::new(0);
471
472    /// Test-only consumer of the registry; re-runs iff the registry's value actually changes.
473    #[salsa::tracked]
474    fn observe_registry(db: &dyn gdscript_db::Db, root: SourceRoot) -> usize {
475        REGISTRY_OBSERVED.fetch_add(1, Ordering::SeqCst);
476        global_registry(db, root).len()
477    }
478
479    #[test]
480    fn body_edit_does_not_invalidate_the_global_registry() {
481        let mut db = RootDatabase::default();
482        db.set_file_text(
483            FileId(0),
484            "class_name Player\nfunc f():\n\tvar a := 1\n",
485            Durability::LOW,
486        );
487        db.set_file_text(FileId(1), "class_name Enemy\n", Durability::LOW);
488        db.sync_source_root();
489        let root = db.source_root().unwrap();
490
491        assert_eq!(observe_registry(&db, root), 2);
492        let runs = REGISTRY_OBSERVED.load(Ordering::SeqCst);
493
494        // A length-CHANGING body edit (`1` -> `123456`) — NO sync_source_root (a body edit is not
495        // a structure change). The class name is unchanged, so the registry must not recompute.
496        db.set_file_text(
497            FileId(0),
498            "class_name Player\nfunc f():\n\tvar a := 123456\n",
499            Durability::LOW,
500        );
501
502        assert_eq!(observe_registry(&db, root), 2);
503        assert_eq!(
504            REGISTRY_OBSERVED.load(Ordering::SeqCst),
505            runs,
506            "REGRESSION: a body edit re-ran a global_registry consumer — the cross-file firewall broke",
507        );
508    }
509
510    #[test]
511    fn cross_file_class_name_member_resolves() {
512        let mut db = RootDatabase::default();
513        db.set_file_text(
514            FileId(0),
515            "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
516            Durability::LOW,
517        );
518        db.set_file_text(
519            FileId(1),
520            "func use_it():\n\tvar w := Widget.make()\n",
521            Durability::LOW,
522        );
523        db.sync_source_root();
524
525        let file1 = db.file_text(FileId(1)).unwrap();
526        let fi = analyze_file(&db, file1);
527        let api = db.engine().unwrap();
528
529        // `w := Widget.make()` resolves `Widget` (a cross-file class_name) to its ScriptRef, then
530        // its `make` method to its `int` return type.
531        let unit = fi
532            .units
533            .iter()
534            .find(|u| !u.result.bindings.is_empty())
535            .expect("a unit with a binding");
536        assert_eq!(
537            unit.result.bindings[0].ty.label(api).as_deref(),
538            Some("int")
539        );
540        assert!(
541            fi.diagnostics.is_empty(),
542            "unexpected diagnostics: {:?}",
543            fi.diagnostics
544        );
545    }
546
547    #[test]
548    fn unknown_member_on_script_ref_is_seam_not_warning() {
549        let mut db = RootDatabase::default();
550        db.set_file_text(
551            FileId(0),
552            "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
553            Durability::LOW,
554        );
555        db.set_file_text(
556            FileId(1),
557            "func use_it():\n\tWidget.not_a_member()\n",
558            Durability::LOW,
559        );
560        db.sync_source_root();
561
562        let file1 = db.file_text(FileId(1)).unwrap();
563        let fi = analyze_file(&db, file1);
564        // A member we don't model is the seam (Unknown) — never UNSAFE_METHOD_ACCESS.
565        assert!(
566            fi.diagnostics.is_empty(),
567            "a missing member on a ScriptRef must not warn: {:?}",
568            fi.diagnostics
569        );
570    }
571
572    #[test]
573    fn inherited_members_resolve_through_user_and_engine_bases() {
574        let mut db = RootDatabase::default();
575        // Derived -> Base (user) -> Node (engine) -> … -> Object.
576        db.set_file_text(
577            FileId(0),
578            "class_name Base\nextends Node\nfunc base_method() -> int:\n\treturn 1\n",
579            Durability::LOW,
580        );
581        db.set_file_text(
582            FileId(1),
583            "class_name Derived\nextends Base\nfunc own() -> String:\n\treturn \"x\"\n",
584            Durability::LOW,
585        );
586        db.set_file_text(
587            FileId(2),
588            "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",
589            Durability::LOW,
590        );
591        db.sync_source_root();
592        let api = db.engine().unwrap();
593
594        let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
595        let unit = fi
596            .units
597            .iter()
598            .find(|u| u.result.bindings.len() >= 4)
599            .expect("use_it unit with 4 bindings");
600        // [0]=d, [1]=own (own member), [2]=base_method (user base), [3]=get_instance_id (engine base).
601        assert_eq!(
602            unit.result.bindings[1].ty.label(api).as_deref(),
603            Some("String")
604        );
605        assert_eq!(
606            unit.result.bindings[2].ty.label(api).as_deref(),
607            Some("int")
608        );
609        assert_eq!(
610            unit.result.bindings[3].ty.label(api).as_deref(),
611            Some("int")
612        );
613        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
614    }
615
616    #[test]
617    fn cyclic_extends_terminates() {
618        let mut db = RootDatabase::default();
619        // A extends B extends A — illegal in Godot, but the member walk must not loop.
620        db.set_file_text(FileId(0), "class_name A\nextends B\n", Durability::LOW);
621        db.set_file_text(FileId(1), "class_name B\nextends A\n", Durability::LOW);
622        db.set_file_text(
623            FileId(2),
624            "func use_it():\n\tvar a: A\n\tvar x := a.nope()\n",
625            Durability::LOW,
626        );
627        db.sync_source_root();
628
629        // Must terminate (depth cap) — a.nope() walks A->B->A->… and bottoms out at the seam.
630        let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
631        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
632    }
633
634    // ---- M3: res:// path map + preload / extends "res://…" const-aliasing -----------------
635
636    /// Add a file with both its text and its `res://` path (the loader's add-time pair).
637    fn set_with_path(db: &mut RootDatabase, id: u32, path: &str, src: &str) {
638        db.set_file_text(FileId(id), src, Durability::LOW);
639        db.set_file_path(FileId(id), path);
640    }
641
642    #[test]
643    fn res_path_registry_maps_paths_to_files() {
644        let mut db = RootDatabase::default();
645        set_with_path(&mut db, 0, "res://a.gd", "class_name A\n");
646        set_with_path(&mut db, 1, "res://sub/b.gd", "func f():\n\tpass\n");
647        db.set_file_text(FileId(2), "func no_path():\n\tpass\n", Durability::LOW); // no res:// path
648        db.sync_source_root();
649        let root = db.source_root().unwrap();
650
651        let reg = res_path_registry(&db, root);
652        assert_eq!(reg.get("res://a.gd"), Some(&FileId(0)));
653        assert_eq!(reg.get("res://sub/b.gd"), Some(&FileId(1)));
654        assert!(reg.get("res://missing.gd").is_none());
655        // A file with no path contributes nothing.
656        assert_eq!(reg.len(), 2);
657    }
658
659    // The res:// path firewall: a body edit must not rebuild the path registry. `res_path` is a
660    // *separate* salsa-input field from `text`, so even a length-changing body edit (which
661    // re-runs `item_tree`) leaves `res_path` — and the registry — untouched.
662
663    static RES_REGISTRY_OBSERVED: AtomicU32 = AtomicU32::new(0);
664
665    #[salsa::tracked]
666    fn observe_res_registry(db: &dyn gdscript_db::Db, root: SourceRoot) -> usize {
667        RES_REGISTRY_OBSERVED.fetch_add(1, Ordering::SeqCst);
668        res_path_registry(db, root).len()
669    }
670
671    #[test]
672    fn body_edit_does_not_invalidate_the_res_path_registry() {
673        let mut db = RootDatabase::default();
674        set_with_path(&mut db, 0, "res://a.gd", "func f():\n\tvar a := 1\n");
675        db.sync_source_root();
676        let root = db.source_root().unwrap();
677
678        assert_eq!(observe_res_registry(&db, root), 1);
679        let runs = RES_REGISTRY_OBSERVED.load(Ordering::SeqCst);
680
681        // Length-CHANGING body edit, NO path re-set, NO sync_source_root: the path is unchanged,
682        // so the registry must not recompute.
683        db.set_file_text(FileId(0), "func f():\n\tvar a := 123456\n", Durability::LOW);
684
685        assert_eq!(observe_res_registry(&db, root), 1);
686        assert_eq!(
687            RES_REGISTRY_OBSERVED.load(Ordering::SeqCst),
688            runs,
689            "REGRESSION: a body edit re-ran a res_path_registry consumer — the path firewall broke",
690        );
691    }
692
693    #[test]
694    fn preload_const_resolves_to_script_ref_members() {
695        let mut db = RootDatabase::default();
696        set_with_path(
697            &mut db,
698            0,
699            "res://widget.gd",
700            "class_name Widget\nfunc make() -> int:\n\treturn 5\nconst MAX := 10\n",
701        );
702        set_with_path(
703            &mut db,
704            1,
705            "res://main.gd",
706            "const W = preload(\"res://widget.gd\")\nfunc use_it():\n\tvar a := W.make()\n\tvar b := W.new()\n",
707        );
708        db.sync_source_root();
709        let api = db.engine().unwrap();
710
711        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
712        let unit = fi
713            .units
714            .iter()
715            .find(|u| u.result.bindings.len() >= 2)
716            .expect("use_it unit with 2 bindings");
717        // W.make() → int; W.new() → an instance of Widget (a ScriptRef).
718        assert_eq!(
719            unit.result.bindings[0].ty.label(api).as_deref(),
720            Some("int")
721        );
722        assert!(
723            matches!(unit.result.bindings[1].ty, Ty::ScriptRef(_)),
724            "W.new() should be a script instance, got {:?}",
725            unit.result.bindings[1].ty
726        );
727        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
728    }
729
730    #[test]
731    fn preload_of_script_without_class_name_resolves() {
732        // The key distinction from M1: preload resolves by PATH, so a script with *no* class_name
733        // (absent from the global_registry) is still resolved.
734        let mut db = RootDatabase::default();
735        set_with_path(
736            &mut db,
737            0,
738            "res://helper.gd",
739            "func help() -> String:\n\treturn \"x\"\n",
740        );
741        set_with_path(
742            &mut db,
743            1,
744            "res://main.gd",
745            "func use_it():\n\tvar h := preload(\"res://helper.gd\")\n\tvar s := h.help()\n",
746        );
747        db.sync_source_root();
748        let api = db.engine().unwrap();
749
750        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
751        let unit = fi
752            .units
753            .iter()
754            .find(|u| u.result.bindings.len() >= 2)
755            .expect("use_it unit");
756        assert!(
757            matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
758            "preload of a class_name-less script must still resolve: {:?}",
759            unit.result.bindings[0].ty
760        );
761        assert_eq!(
762            unit.result.bindings[1].ty.label(api).as_deref(),
763            Some("String")
764        );
765        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
766    }
767
768    #[test]
769    fn extends_res_path_inherits_members() {
770        let mut db = RootDatabase::default();
771        // base.gd has NO class_name — reachable only by its res:// path.
772        set_with_path(
773            &mut db,
774            0,
775            "res://base.gd",
776            "extends Node\nfunc base_method() -> int:\n\treturn 1\n",
777        );
778        set_with_path(
779            &mut db,
780            1,
781            "res://derived.gd",
782            "class_name Derived\nextends \"res://base.gd\"\nfunc own() -> String:\n\treturn \"x\"\n",
783        );
784        set_with_path(
785            &mut db,
786            2,
787            "res://main.gd",
788            "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",
789        );
790        db.sync_source_root();
791        let api = db.engine().unwrap();
792
793        let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
794        let unit = fi
795            .units
796            .iter()
797            .find(|u| u.result.bindings.len() >= 4)
798            .expect("use_it unit with 4 bindings");
799        // own() (own member), base_method() (via the res:// user base), get_instance_id() (the
800        // engine base behind base.gd).
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 dangling_preload_is_seam_not_panic() {
818        let mut db = RootDatabase::default();
819        set_with_path(
820            &mut db,
821            0,
822            "res://main.gd",
823            "func use_it():\n\tvar x := preload(\"res://does_not_exist.gd\")\n\tx.whatever()\n",
824        );
825        db.sync_source_root();
826        // An unresolvable path → the seam (Unknown): no diagnostic, no panic.
827        let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
828        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
829    }
830
831    #[test]
832    fn non_gd_preload_resource_stays_seam() {
833        // A `preload` of a non-`.gd` resource must NOT resolve to a script `ScriptRef`, even if the
834        // path is in the res:// registry — typing a `.tscn`/PackedScene as a script would wrongly
835        // accept `.new()`/member access (scene-root typing is Phase 4). Defensive gate (the loader
836        // indexes only `.gd` today, but a future scene-ingesting loader must not mis-type this).
837        let mut db = RootDatabase::default();
838        set_with_path(&mut db, 0, "res://scene.tscn", "class_name SceneRoot\n");
839        set_with_path(
840            &mut db,
841            1,
842            "res://main.gd",
843            "func f():\n\tvar s := preload(\"res://scene.tscn\")\n",
844        );
845        db.sync_source_root();
846
847        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
848        let unit = fi
849            .units
850            .iter()
851            .find(|u| !u.result.bindings.is_empty())
852            .expect("f unit");
853        assert!(
854            !matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
855            "a non-.gd preload must stay the seam, got {:?}",
856            unit.result.bindings[0].ty
857        );
858        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
859    }
860
861    #[test]
862    fn load_literal_stays_opaque_not_aliased_to_preload() {
863        let mut db = RootDatabase::default();
864        set_with_path(
865            &mut db,
866            0,
867            "res://widget.gd",
868            "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
869        );
870        set_with_path(
871            &mut db,
872            1,
873            "res://main.gd",
874            "func use_it():\n\tvar w := load(\"res://widget.gd\")\n",
875        );
876        db.sync_source_root();
877
878        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
879        let unit = fi
880            .units
881            .iter()
882            .find(|u| !u.result.bindings.is_empty())
883            .expect("use_it unit");
884        // `load(...)` is an ordinary runtime call returning an opaque Resource — it must NOT be
885        // aliased to `preload` (no script ScriptRef, no static `.new()` typing).
886        assert!(
887            !matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
888            "load() must stay opaque, not alias preload: {:?}",
889            unit.result.bindings[0].ty
890        );
891        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
892    }
893
894    #[test]
895    fn is_narrows_to_a_user_class_cross_file() {
896        // `if x is Widget:` narrows `x` to the user `ScriptRef`, so `x.make()` resolves to its
897        // cross-file return type — the is/as-over-user-types path (already works once ScriptRef
898        // is informative; M4 just gates it). `int` here PROVES narrowing: without it `x` stays
899        // Variant and `x.make()` would be Variant.
900        let mut db = RootDatabase::default();
901        db.set_file_text(
902            FileId(0),
903            "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
904            Durability::LOW,
905        );
906        db.set_file_text(
907            FileId(1),
908            "func use_it(x):\n\tif x is Widget:\n\t\tvar n := x.make()\n",
909            Durability::LOW,
910        );
911        db.sync_source_root();
912        let api = db.engine().unwrap();
913
914        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
915        // (bindings include the param `x`; assert *some* binding — the `n` one — is int.)
916        assert!(
917            fi.units
918                .iter()
919                .flat_map(|u| &u.result.bindings)
920                .any(|b| b.ty.label(api).as_deref() == Some("int")),
921            "`x.make()` after `is Widget` should narrow + resolve to int",
922        );
923        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
924    }
925
926    #[test]
927    fn as_casts_to_a_user_class_cross_file() {
928        // `(x as Widget).make()` types the cast as the user `ScriptRef`, so `.make()` → int.
929        let mut db = RootDatabase::default();
930        db.set_file_text(
931            FileId(0),
932            "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
933            Durability::LOW,
934        );
935        db.set_file_text(
936            FileId(1),
937            "func use_it(x):\n\tvar n := (x as Widget).make()\n",
938            Durability::LOW,
939        );
940        db.sync_source_root();
941        let api = db.engine().unwrap();
942
943        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
944        assert!(
945            fi.units
946                .iter()
947                .flat_map(|u| &u.result.bindings)
948                .any(|b| b.ty.label(api).as_deref() == Some("int")),
949            "`(x as Widget).make()` should resolve to int",
950        );
951        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
952    }
953
954    #[test]
955    fn renaming_a_files_path_reindexes_the_registry() {
956        // A path change (rename) DOES update the registry (it is not a body edit).
957        let mut db = RootDatabase::default();
958        set_with_path(&mut db, 0, "res://old.gd", "class_name A\n");
959        db.sync_source_root();
960        let root = db.source_root().unwrap();
961        assert_eq!(
962            res_path_registry(&db, root).get("res://old.gd"),
963            Some(&FileId(0))
964        );
965
966        db.set_file_path(FileId(0), "res://new.gd");
967        let root = db.source_root().unwrap();
968        let reg = res_path_registry(&db, root);
969        assert_eq!(reg.get("res://new.gd"), Some(&FileId(0)));
970        assert!(reg.get("res://old.gd").is_none());
971    }
972
973    // ---- M4: autoloads (project.godot [autoload]) + is/as widen-only narrowing --------------
974
975    #[test]
976    fn star_autoload_scene_resolves_via_its_root_script() {
977        // A `*`-autoload pointing at a `.tscn` whose root has an attached script resolves to that
978        // script (the singleton-scene pattern) — `Music.volume()` → int, no false UNSAFE. This was
979        // deferred to Phase 4 (scene ingestion); now closed.
980        let mut db = RootDatabase::default();
981        // music.gd (no class_name — resolved by the scene root's script= path).
982        db.set_file_text(
983            FileId(0),
984            "func volume() -> int:\n\treturn 5\n",
985            Durability::LOW,
986        );
987        db.set_file_path(FileId(0), "res://music.gd");
988        // music.tscn: a root Node with script=music.gd.
989        db.set_file_text(
990            FileId(1),
991            "[gd_scene format=3]\n\
992             [ext_resource type=\"Script\" path=\"res://music.gd\" id=\"1\"]\n\
993             [node name=\"Music\" type=\"Node\"]\n\
994             script = ExtResource(\"1\")\n",
995            Durability::LOW,
996        );
997        db.set_file_path(FileId(1), "res://music.tscn");
998        db.set_file_text(
999            FileId(2),
1000            "func f():\n\tvar v := Music.volume()\n",
1001            Durability::LOW,
1002        );
1003        db.set_file_path(FileId(2), "res://main.gd");
1004        db.set_project_config("[autoload]\nMusic=\"*res://music.tscn\"\n");
1005        db.sync_source_root();
1006        let api = db.engine().unwrap();
1007
1008        let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1009        let unit = fi
1010            .units
1011            .iter()
1012            .find(|u| !u.result.bindings.is_empty())
1013            .expect("f unit");
1014        assert_eq!(
1015            unit.result.bindings[0].ty.label(api).as_deref(),
1016            Some("int"),
1017            "Music.volume() should resolve via the scene root's script",
1018        );
1019        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1020    }
1021
1022    #[test]
1023    fn star_autoload_gdscript_resolves_as_global_and_members() {
1024        let mut db = RootDatabase::default();
1025        // `game.gd` has NO class_name — the autoload resolves it by PATH (not the class registry).
1026        db.set_file_text(
1027            FileId(0),
1028            "func score() -> int:\n\treturn 0\n",
1029            Durability::LOW,
1030        );
1031        db.set_file_path(FileId(0), "res://game.gd");
1032        db.set_file_text(
1033            FileId(1),
1034            "func f():\n\tvar s := Game.score()\n",
1035            Durability::LOW,
1036        );
1037        db.set_file_path(FileId(1), "res://main.gd");
1038        db.set_project_config("[autoload]\nGame=\"*res://game.gd\"\n");
1039        db.sync_source_root();
1040        let api = db.engine().unwrap();
1041
1042        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1043        let unit = fi
1044            .units
1045            .iter()
1046            .find(|u| !u.result.bindings.is_empty())
1047            .expect("f unit");
1048        // `Game` (a *-singleton) resolves to its ScriptRef; `Game.score()` → int.
1049        assert_eq!(
1050            unit.result.bindings[0].ty.label(api).as_deref(),
1051            Some("int")
1052        );
1053        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1054    }
1055
1056    #[test]
1057    fn non_star_autoload_is_not_a_global() {
1058        let mut db = RootDatabase::default();
1059        db.set_file_text(
1060            FileId(0),
1061            "func score() -> int:\n\treturn 0\n",
1062            Durability::LOW,
1063        );
1064        db.set_file_path(FileId(0), "res://game.gd");
1065        db.set_file_text(
1066            FileId(1),
1067            "func f():\n\tvar s := Game.score()\n",
1068            Durability::LOW,
1069        );
1070        db.set_file_path(FileId(1), "res://main.gd");
1071        // No leading `*` → loaded-but-not-global; the bare name `Game` must NOT resolve.
1072        db.set_project_config("[autoload]\nGame=\"res://game.gd\"\n");
1073        db.sync_source_root();
1074        let api = db.engine().unwrap();
1075
1076        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1077        let unit = fi
1078            .units
1079            .iter()
1080            .find(|u| !u.result.bindings.is_empty())
1081            .expect("f unit");
1082        // `Game` → seam (Unknown), so `s` is uninformative (no `int`); and NO diagnostic.
1083        assert_eq!(unit.result.bindings[0].ty.label(api), None);
1084        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1085    }
1086
1087    #[test]
1088    fn tscn_autoload_is_the_seam_never_false_warns() {
1089        let mut db = RootDatabase::default();
1090        // A scene (`.tscn`) autoload: typing it `Node` would false-warn on the root script's own
1091        // members, so it stays the seam (scene-root typing is Phase 4).
1092        db.set_file_text(FileId(0), "func f():\n\tHud.play_song()\n", Durability::LOW);
1093        db.set_file_path(FileId(0), "res://main.gd");
1094        db.set_project_config("[autoload]\nHud=\"*res://hud.tscn\"\n");
1095        db.sync_source_root();
1096
1097        let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1098        // `Hud.play_song()` on a seam receiver → no diagnostic (no false UNSAFE_METHOD_ACCESS).
1099        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1100    }
1101
1102    // The autoload firewall: a `.gd` body edit must not rebuild the autoload registry, which is
1103    // keyed only on the `ProjectConfig` input (not on file text).
1104
1105    static AUTOLOAD_OBSERVED: AtomicU32 = AtomicU32::new(0);
1106
1107    #[salsa::tracked]
1108    fn observe_autoload_registry(db: &dyn gdscript_db::Db, config: ProjectConfig) -> usize {
1109        AUTOLOAD_OBSERVED.fetch_add(1, Ordering::SeqCst);
1110        autoload_registry(db, config).len()
1111    }
1112
1113    #[test]
1114    fn autoload_registry_firewalled_against_body_edits() {
1115        let mut db = RootDatabase::default();
1116        db.set_file_text(FileId(0), "func f():\n\tvar a := 1\n", Durability::LOW);
1117        db.set_file_path(FileId(0), "res://game.gd");
1118        db.set_project_config("[autoload]\nGame=\"*res://game.gd\"\n");
1119        db.sync_source_root();
1120        let config = db.project_config().unwrap();
1121
1122        assert_eq!(observe_autoload_registry(&db, config), 1);
1123        let runs = AUTOLOAD_OBSERVED.load(Ordering::SeqCst);
1124
1125        // Length-changing `.gd` body edit, NO set_project_config: the autoload registry must not
1126        // recompute (its sole input — ProjectConfig — is untouched).
1127        db.set_file_text(FileId(0), "func f():\n\tvar a := 999999\n", Durability::LOW);
1128
1129        assert_eq!(observe_autoload_registry(&db, config), 1);
1130        assert_eq!(
1131            AUTOLOAD_OBSERVED.load(Ordering::SeqCst),
1132            runs,
1133            "REGRESSION: a body edit re-ran an autoload_registry consumer — the config firewall broke",
1134        );
1135    }
1136
1137    #[test]
1138    fn aliased_self_resolves_own_members_no_false_unsafe() {
1139        // `var me := self; me.own()` must resolve `own` via the file's OWN members — self is the
1140        // script's own class (a self-ScriptRef), not just its engine base. Before the fix `me` was
1141        // typed as the base (`Node`), so `me.own()` false-warned UNSAFE_METHOD_ACCESS.
1142        let mut db = RootDatabase::default();
1143        db.set_file_text(
1144            FileId(0),
1145            "extends Node\nfunc own() -> int:\n\treturn 1\nfunc use_it():\n\tvar me := self\n\tvar n := me.own()\n",
1146            Durability::LOW,
1147        );
1148        db.sync_source_root();
1149        let api = db.engine().unwrap();
1150
1151        let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1152        // `me.own()` resolves to int (own member via aliased self) — proves it isn't the seam.
1153        assert!(
1154            fi.units
1155                .iter()
1156                .flat_map(|u| &u.result.bindings)
1157                .any(|b| b.ty.label(api).as_deref() == Some("int")),
1158            "aliased self.own() should resolve to int",
1159        );
1160        assert!(
1161            fi.diagnostics.is_empty(),
1162            "no false UNSAFE on aliased self: {:?}",
1163            fi.diagnostics
1164        );
1165    }
1166
1167    #[test]
1168    fn is_userbase_narrows_to_derived_but_not_un_narrowed_to_base() {
1169        let mut db = RootDatabase::default();
1170        db.set_file_text(
1171            FileId(0),
1172            "class_name Base\nfunc base_m() -> int:\n\treturn 1\n",
1173            Durability::LOW,
1174        );
1175        db.set_file_text(
1176            FileId(1),
1177            "class_name Derived\nextends Base\nfunc own_m() -> String:\n\treturn \"x\"\n",
1178            Durability::LOW,
1179        );
1180        // (a) untyped `x` + `is Derived` → narrow to Derived → `x.own_m()` resolves (String).
1181        // (b) `d: Derived` + `is Base` → widen-only: d STAYS Derived → `d.own_m()` resolves (String).
1182        db.set_file_text(
1183            FileId(2),
1184            "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",
1185            Durability::LOW,
1186        );
1187        db.sync_source_root();
1188        let api = db.engine().unwrap();
1189
1190        let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1191        let strings = fi
1192            .units
1193            .iter()
1194            .flat_map(|u| &u.result.bindings)
1195            .filter(|b| b.ty.label(api).as_deref() == Some("String"))
1196            .count();
1197        // Both `own_m()` calls resolve to String: proves narrow-to-Derived AND no un-narrow-to-Base.
1198        assert!(
1199            strings >= 2,
1200            "expected both own_m() calls to type as String (narrow-down + widen-only), got {strings}",
1201        );
1202        assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1203    }
1204
1205    // ---- M1: scene-aware node-path typing ($Path / %Unique) -------------------------------
1206
1207    /// A db with file 0 = a scene and file 1 = its attached script, both with res:// paths.
1208    fn scene_db(scene_text: &str, gd_text: &str) -> RootDatabase {
1209        let mut db = RootDatabase::default();
1210        db.set_file_text(FileId(0), scene_text, Durability::LOW);
1211        db.set_file_path(FileId(0), "res://main.tscn");
1212        db.set_file_text(FileId(1), gd_text, Durability::LOW);
1213        db.set_file_path(FileId(1), "res://main.gd");
1214        db.sync_source_root();
1215        db
1216    }
1217
1218    fn binding_labels(db: &RootDatabase) -> Vec<String> {
1219        let api = db.engine().unwrap();
1220        let fi = analyze_file(db, db.file_text(FileId(1)).unwrap());
1221        assert!(
1222            fi.diagnostics.is_empty(),
1223            "unexpected diags: {:?}",
1224            fi.diagnostics
1225        );
1226        fi.units
1227            .iter()
1228            .flat_map(|u| &u.result.bindings)
1229            .filter_map(|b| b.ty.label(api))
1230            .collect()
1231    }
1232
1233    const SCENE: &str = "[gd_scene format=3]\n\
1234        [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1235        [node name=\"Root\" type=\"Control\"]\n\
1236        script = ExtResource(\"1\")\n\
1237        [node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
1238        [node name=\"Box\" type=\"VBoxContainer\" parent=\"Panel\"]\n\
1239        [node name=\"Btn\" type=\"Button\" parent=\"Panel/Box\"]\n\
1240        unique_name_in_owner = true\n";
1241
1242    #[test]
1243    fn dollar_path_types_to_the_concrete_node() {
1244        // `$Panel/Box/Btn` → Button (not bare Node) — the killer feature, zero annotations.
1245        let db = scene_db(
1246            SCENE,
1247            "extends Control\nfunc _ready():\n\tvar b := $Panel/Box/Btn\n",
1248        );
1249        assert!(
1250            binding_labels(&db).iter().any(|l| l == "Button"),
1251            "$Panel/Box/Btn should type as Button",
1252        );
1253    }
1254
1255    #[test]
1256    fn unique_name_path_types_to_the_concrete_node() {
1257        // `%Btn` resolves via unique_name_in_owner → Button.
1258        let db = scene_db(SCENE, "extends Control\nfunc _ready():\n\tvar b := %Btn\n");
1259        assert!(
1260            binding_labels(&db).iter().any(|l| l == "Button"),
1261            "%Btn should type as Button"
1262        );
1263    }
1264
1265    #[test]
1266    fn onready_var_from_a_node_path_is_typed() {
1267        // `@onready var x := $Path` types `x` from the resolved node at the decl site. (`:=` is the
1268        // typed form; plain `=` stays `Variant` per Godot's gradual typing — Phase-2 rule.)
1269        let db = scene_db(
1270            SCENE,
1271            "extends Control\n@onready var btn := $Panel/Box/Btn\n",
1272        );
1273        assert!(
1274            binding_labels(&db).iter().any(|l| l == "Button"),
1275            "@onready var := $Path should type to Button",
1276        );
1277    }
1278
1279    #[test]
1280    fn get_node_string_literal_types_like_dollar() {
1281        // `get_node("Panel/Box/Btn")` (string literal) types identically to `$Panel/Box/Btn`.
1282        let db = scene_db(
1283            SCENE,
1284            "extends Control\nfunc _ready():\n\tvar b := get_node(\"Panel/Box/Btn\")\n",
1285        );
1286        assert!(
1287            binding_labels(&db).iter().any(|l| l == "Button"),
1288            "get_node(\"...\") should type as Button",
1289        );
1290    }
1291
1292    #[test]
1293    fn self_get_node_string_literal_types_like_dollar() {
1294        // `self.get_node("…")` (explicit self = the attach node) types like the bare form; a foreign
1295        // receiver `obj.get_node("…")` stays a normal call → `Node` (can't resolve another node's path).
1296        let db = scene_db(
1297            SCENE,
1298            "extends Control\nfunc _ready():\n\tvar b := self.get_node(\"Panel/Box/Btn\")\n",
1299        );
1300        assert!(
1301            binding_labels(&db).iter().any(|l| l == "Button"),
1302            "self.get_node(\"...\") should type as Button",
1303        );
1304    }
1305
1306    #[test]
1307    fn attached_script_refines_the_node_type() {
1308        // A node `type="Button"` + `script=Fancy.gd (class_name Fancy)` → `$That` is `Fancy`, so
1309        // `$That.fancy()` resolves to its cross-file return type (proving the script refine).
1310        let mut db = RootDatabase::default();
1311        db.set_file_text(
1312            FileId(0),
1313            "[gd_scene format=3]\n\
1314             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1315             [ext_resource type=\"Script\" path=\"res://fancy.gd\" id=\"2\"]\n\
1316             [node name=\"Root\" type=\"Control\"]\n\
1317             script = ExtResource(\"1\")\n\
1318             [node name=\"That\" type=\"Button\" parent=\".\"]\n\
1319             script = ExtResource(\"2\")\n",
1320            Durability::LOW,
1321        );
1322        db.set_file_path(FileId(0), "res://main.tscn");
1323        db.set_file_text(
1324            FileId(1),
1325            "extends Control\nfunc _ready():\n\tvar n := $That.fancy()\n",
1326            Durability::LOW,
1327        );
1328        db.set_file_path(FileId(1), "res://main.gd");
1329        db.set_file_text(
1330            FileId(2),
1331            "class_name Fancy\nextends Button\nfunc fancy() -> int:\n\treturn 1\n",
1332            Durability::LOW,
1333        );
1334        db.set_file_path(FileId(2), "res://fancy.gd");
1335        db.sync_source_root();
1336        assert!(
1337            binding_labels(&db).iter().any(|l| l == "int"),
1338            "$That.fancy() should resolve via the attached script Fancy",
1339        );
1340    }
1341
1342    #[test]
1343    fn computed_or_unresolvable_node_path_stays_node_without_warning() {
1344        // A computed `get_node(var)` and a `$Nope` with no owning scene both stay `Node` — never a
1345        // false node-path warning.
1346        let mut db = RootDatabase::default();
1347        db.set_file_text(
1348            FileId(1),
1349            "extends Node\nfunc f(p):\n\tvar a := get_node(p)\n\tvar b := $Nope\n",
1350            Durability::LOW,
1351        );
1352        db.set_file_path(FileId(1), "res://lone.gd");
1353        db.sync_source_root();
1354        let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1355        assert!(
1356            fi.diagnostics.is_empty(),
1357            "no false node-path warnings: {:?}",
1358            fi.diagnostics
1359        );
1360    }
1361
1362    // ---- M2: INVALID_NODE_PATH (the no-false-positive contract) ----------------------------
1363
1364    fn has_invalid_node_path(db: &RootDatabase) -> bool {
1365        let fi = analyze_file(db, db.file_text(FileId(1)).unwrap());
1366        fi.diagnostics
1367            .iter()
1368            .any(|d| d.code == crate::infer::INVALID_NODE_PATH)
1369    }
1370
1371    #[test]
1372    fn invalid_node_path_warns_when_genuinely_absent_in_a_single_owning_scene() {
1373        let db = scene_db(SCENE, "extends Control\nfunc _ready():\n\tvar b := $Nope\n");
1374        assert!(
1375            has_invalid_node_path(&db),
1376            "$Nope is absent in the one owning scene → warn"
1377        );
1378    }
1379
1380    #[test]
1381    fn escape_and_absolute_paths_never_warn() {
1382        // `..` and absolute `/root/…` escape the scene slice — silent, never INVALID_NODE_PATH.
1383        let db = scene_db(
1384            SCENE,
1385            "extends Control\nfunc _ready():\n\tvar a := $\"../Sibling\"\n\tvar c := $\"/root/Global\"\n",
1386        );
1387        assert!(!has_invalid_node_path(&db), "escape paths must not warn");
1388    }
1389
1390    #[test]
1391    fn path_descending_into_an_instanced_subscene_never_warns() {
1392        // Root > Player(instance=…). `$Player/Gun` misses below an instance we don't recurse into —
1393        // silent (the node may well exist inside the sub-scene).
1394        let db = scene_db(
1395            "[gd_scene format=3]\n\
1396             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1397             [ext_resource type=\"PackedScene\" path=\"res://player.tscn\" id=\"2\"]\n\
1398             [node name=\"Root\" type=\"Control\"]\n\
1399             script = ExtResource(\"1\")\n\
1400             [node name=\"Player\" parent=\".\" instance=ExtResource(\"2\")]\n",
1401            "extends Control\nfunc _ready():\n\tvar g := $Player/Gun\n",
1402        );
1403        assert!(
1404            !has_invalid_node_path(&db),
1405            "into-instance miss must not warn"
1406        );
1407    }
1408
1409    #[test]
1410    fn ambiguous_multi_scene_attachment_suppresses_the_invalid_warning() {
1411        // main.gd attaches to BOTH a.tscn (child Alpha) and b.tscn (child Beta). `$Beta` is absent in
1412        // a.tscn (kept first) but present in b.tscn → ambiguous → no false INVALID_NODE_PATH.
1413        let mut db = RootDatabase::default();
1414        db.set_file_text(
1415            FileId(0),
1416            "[gd_scene format=3]\n\
1417             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1418             [node name=\"Root\" type=\"Control\"]\n\
1419             script = ExtResource(\"1\")\n\
1420             [node name=\"Alpha\" type=\"Button\" parent=\".\"]\n",
1421            Durability::LOW,
1422        );
1423        db.set_file_path(FileId(0), "res://a.tscn");
1424        db.set_file_text(
1425            FileId(2),
1426            "[gd_scene format=3]\n\
1427             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1428             [node name=\"Root\" type=\"Control\"]\n\
1429             script = ExtResource(\"1\")\n\
1430             [node name=\"Beta\" type=\"Button\" parent=\".\"]\n",
1431            Durability::LOW,
1432        );
1433        db.set_file_path(FileId(2), "res://b.tscn");
1434        db.set_file_text(
1435            FileId(1),
1436            "extends Control\nfunc _ready():\n\tvar b := $Beta\n",
1437            Durability::LOW,
1438        );
1439        db.set_file_path(FileId(1), "res://main.gd");
1440        db.sync_source_root();
1441        assert!(
1442            !has_invalid_node_path(&db),
1443            "ambiguous multi-scene attachment must not warn"
1444        );
1445    }
1446
1447    // ---- M3: instanced sub-scene recursion ------------------------------------------------
1448
1449    #[test]
1450    fn instanced_node_recurses_into_the_subscene_root_script() {
1451        // main.tscn: Root(script=main.gd) > Enemy(instance=enemy.tscn). enemy.tscn's root carries
1452        // script=enemy.gd (class_name Enemy, `hp() -> int`). `$Enemy.hp()` must recurse into the
1453        // sub-scene root, refine to the Enemy script, and resolve the cross-file method → `int`
1454        // (proving the instance recursion + script refine; a bare `Node` would have no `hp()`).
1455        let mut db = RootDatabase::default();
1456        db.set_file_text(
1457            FileId(0),
1458            "[gd_scene format=3]\n\
1459             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1460             [ext_resource type=\"PackedScene\" path=\"res://enemy.tscn\" id=\"2\"]\n\
1461             [node name=\"Root\" type=\"Control\"]\n\
1462             script = ExtResource(\"1\")\n\
1463             [node name=\"Enemy\" parent=\".\" instance=ExtResource(\"2\")]\n",
1464            Durability::LOW,
1465        );
1466        db.set_file_path(FileId(0), "res://main.tscn");
1467        db.set_file_text(
1468            FileId(1),
1469            "extends Control\nfunc _ready():\n\tvar e := $Enemy.hp()\n",
1470            Durability::LOW,
1471        );
1472        db.set_file_path(FileId(1), "res://main.gd");
1473        db.set_file_text(
1474            FileId(2),
1475            "[gd_scene format=3]\n\
1476             [ext_resource type=\"Script\" path=\"res://enemy.gd\" id=\"1\"]\n\
1477             [node name=\"Enemy\" type=\"Button\"]\n\
1478             script = ExtResource(\"1\")\n",
1479            Durability::LOW,
1480        );
1481        db.set_file_path(FileId(2), "res://enemy.tscn");
1482        db.set_file_text(
1483            FileId(3),
1484            "class_name Enemy\nextends Button\nfunc hp() -> int:\n\treturn 1\n",
1485            Durability::LOW,
1486        );
1487        db.set_file_path(FileId(3), "res://enemy.gd");
1488        db.sync_source_root();
1489        assert!(
1490            binding_labels(&db).iter().any(|l| l == "int"),
1491            "$Enemy.hp() should recurse into the instanced sub-scene root's script Enemy",
1492        );
1493    }
1494
1495    // ---- Phase-4 hunt fixes: `%`-segment paths (no false INVALID_NODE_PATH) ----------------
1496
1497    #[test]
1498    fn unique_name_subpath_resolves_to_the_child_without_warning() {
1499        // `%Box/Btn`: resolve the unique `%Box`, then walk `/Btn` to its Button child — idiomatic
1500        // Godot. Must type as Button and NOT raise INVALID_NODE_PATH (the bare-map lookup of the
1501        // whole joined "Box/Btn" used to miss → false warning).
1502        let db = scene_db(
1503            "[gd_scene format=3]\n\
1504             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1505             [node name=\"Root\" type=\"Control\"]\n\
1506             script = ExtResource(\"1\")\n\
1507             [node name=\"Box\" type=\"VBoxContainer\" parent=\".\"]\n\
1508             unique_name_in_owner = true\n\
1509             [node name=\"Btn\" type=\"Button\" parent=\"Box\"]\n",
1510            "extends Control\nfunc _ready():\n\tvar b := %Box/Btn\n",
1511        );
1512        assert!(
1513            binding_labels(&db).iter().any(|l| l == "Button"),
1514            "%Box/Btn → Button (and no false INVALID_NODE_PATH)",
1515        );
1516    }
1517
1518    #[test]
1519    fn percent_prefixed_string_paths_resolve_as_unique_without_warning() {
1520        // `get_node("%Btn")` and `$"%Btn"` are unique-name lookups (the `%` prefix lives inside the
1521        // string), NOT a child literally named "%Btn". Must type as Button with no INVALID_NODE_PATH.
1522        let db = scene_db(
1523            SCENE,
1524            "extends Control\nfunc _ready():\n\tvar a := get_node(\"%Btn\")\n\tvar b := $\"%Btn\"\n",
1525        );
1526        let labels = binding_labels(&db);
1527        assert!(
1528            labels.iter().filter(|l| *l == "Button").count() >= 2,
1529            "both %Btn string forms should resolve to Button: {labels:?}",
1530        );
1531    }
1532}