use std::sync::Arc;
use gdscript_db::{Db, FileText, ProjectConfig, SourceRoot, parse};
use rustc_hash::FxHashMap;
use smol_str::SmolStr;
use gdscript_base::FileId;
use gdscript_scene::{NodeIdx, SceneModel};
use crate::infer::FileInference;
use crate::item_tree::{ItemTree, Member};
use crate::ty::{ScriptRefId, Ty};
#[salsa::tracked]
pub fn item_tree(db: &dyn Db, file: FileText) -> Arc<ItemTree> {
crate::item_tree::item_tree(&parse(db, file).syntax_node())
}
#[salsa::tracked]
pub fn analyze_file(db: &dyn Db, file: FileText) -> Arc<FileInference> {
match db.engine() {
Some(api) => Arc::new(crate::infer::analyze_file(
db,
api,
&parse(db, file).syntax_node(),
file.file_id(db),
)),
None => Arc::new(FileInference::default()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct GlobalRegistry {
classes: FxHashMap<SmolStr, FileText>,
}
impl GlobalRegistry {
#[must_use]
pub fn resolve(&self, name: &str) -> Option<FileText> {
self.classes.get(name).copied()
}
#[must_use]
pub fn len(&self) -> usize {
self.classes.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.classes.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&SmolStr, FileText)> + '_ {
self.classes.iter().map(|(k, v)| (k, *v))
}
}
#[salsa::tracked]
pub fn file_class_name(db: &dyn Db, file: FileText) -> Option<SmolStr> {
item_tree(db, file).class_name.clone()
}
#[salsa::tracked]
pub fn global_registry(db: &dyn Db, root: SourceRoot) -> Arc<GlobalRegistry> {
let mut classes = FxHashMap::default();
for &file in root.files(db) {
if let Some(name) = file_class_name(db, file) {
classes.entry(name).or_insert(file);
}
}
Arc::new(GlobalRegistry { classes })
}
#[salsa::tracked]
pub fn res_path_registry(db: &dyn Db, root: SourceRoot) -> Arc<FxHashMap<SmolStr, FileId>> {
let mut map = FxHashMap::default();
for &file in root.files(db) {
if let Some(path) = file.res_path(db) {
map.entry(path).or_insert_with(|| file.file_id(db));
}
}
Arc::new(map)
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct AutoloadRegistry {
singletons: FxHashMap<SmolStr, SmolStr>,
}
impl AutoloadRegistry {
#[must_use]
pub fn resolve_path(&self, name: &str) -> Option<&SmolStr> {
self.singletons.get(name)
}
#[must_use]
pub fn len(&self) -> usize {
self.singletons.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.singletons.is_empty()
}
}
#[salsa::tracked]
pub fn autoload_registry(db: &dyn Db, config: ProjectConfig) -> Arc<AutoloadRegistry> {
let mut singletons = FxHashMap::default();
for e in crate::project::parse_autoloads(config.project_godot_text(db)) {
if e.is_singleton {
singletons.entry(e.name).or_insert(e.path);
}
}
Arc::new(AutoloadRegistry { singletons })
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MemberSig {
Method(Ty),
Field(Ty),
Signal,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScriptClass {
members: FxHashMap<SmolStr, MemberSig>,
base: Ty,
}
impl ScriptClass {
#[must_use]
pub fn member(&self, name: &str) -> Option<&MemberSig> {
self.members.get(name)
}
#[must_use]
pub fn base(&self) -> &Ty {
&self.base
}
}
#[must_use]
pub fn script_ref_name(db: &dyn Db, sref: ScriptRefId) -> Option<SmolStr> {
let file = db.file_text(FileId(sref.0))?;
file_class_name(db, file)
}
#[salsa::tracked]
pub fn script_class(db: &dyn Db, file: FileText) -> Arc<ScriptClass> {
let tree = item_tree(db, file);
let Some(api) = db.engine() else {
return Arc::new(ScriptClass {
members: FxHashMap::default(),
base: Ty::Unknown,
});
};
let resolve_ann = |ann: Option<&str>| -> Ty {
ann.map_or(Ty::Variant, |t| {
crate::resolve::resolve_type_name(db, api, t)
})
};
let mut members = FxHashMap::default();
for m in &tree.members {
let Some(name) = m.name() else { continue };
let sig = match m {
Member::Func(f) => MemberSig::Method(resolve_ann(f.return_type.as_deref())),
Member::Var(v) => MemberSig::Field(resolve_ann(v.type_ref.as_deref())),
Member::Const(c) => MemberSig::Field(resolve_ann(c.type_ref.as_deref())),
Member::Signal(_) => MemberSig::Signal,
Member::Enum(_) | Member::Class(_) => continue,
};
members.insert(SmolStr::new(name), sig);
}
let base = crate::resolve::resolve_base(db, api, &tree);
Arc::new(ScriptClass { members, base })
}
fn is_scene_path(path: &str) -> bool {
let ext = path.rsplit('.').next().unwrap_or("");
ext.eq_ignore_ascii_case("tscn") || ext.eq_ignore_ascii_case("tres")
}
#[salsa::tracked]
pub fn scene_model(db: &dyn Db, file: FileText) -> Arc<SceneModel> {
let is_scene = file.res_path(db).as_deref().is_some_and(is_scene_path);
if is_scene {
Arc::new(gdscript_scene::parse_scene(file.text(db)))
} else {
Arc::new(gdscript_scene::parse_scene(""))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SceneAttach {
pub scene: FileId,
pub node: NodeIdx,
pub ambiguous: bool,
}
#[salsa::tracked]
pub fn script_scene_index(db: &dyn Db, root: SourceRoot) -> Arc<FxHashMap<SmolStr, SceneAttach>> {
let mut map: FxHashMap<SmolStr, SceneAttach> = FxHashMap::default();
for &file in root.files(db) {
if !file.res_path(db).as_deref().is_some_and(is_scene_path) {
continue;
}
let model = scene_model(db, file);
let scene = file.file_id(db);
for (i, node) in model.nodes.iter().enumerate() {
let Some(script_id) = node.script.as_ref() else {
continue;
};
let Some(path) = model
.ext_resources
.get(script_id)
.and_then(|e| e.path.clone())
else {
continue;
};
let node = NodeIdx(u32::try_from(i).unwrap_or(u32::MAX));
match map.get_mut(&path) {
Some(existing) => existing.ambiguous = true,
None => {
map.insert(
path,
SceneAttach {
scene,
node,
ambiguous: false,
},
);
}
}
}
}
Arc::new(map)
}
#[must_use]
pub fn scene_context(db: &dyn Db, file: FileText) -> Option<SceneContext> {
let res_path = file.res_path(db)?;
let root = db.source_root()?;
let attach = *script_scene_index(db, root).get(res_path.as_str())?;
let scene_file = db.file_text(attach.scene)?;
Some(SceneContext {
scene: attach.scene,
model: scene_model(db, scene_file),
attach: attach.node,
ambiguous: attach.ambiguous,
})
}
#[derive(Debug, Clone)]
pub struct SceneContext {
pub scene: FileId,
pub model: Arc<SceneModel>,
pub attach: NodeIdx,
pub ambiguous: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use gdscript_base::FileId;
use gdscript_db::RootDatabase;
use salsa::Durability;
fn db_with(src: &str) -> (RootDatabase, FileText) {
let mut db = RootDatabase::default();
db.set_file_text(FileId(0), src, Durability::LOW);
let ft = db.file_text(FileId(0)).unwrap();
(db, ft)
}
#[test]
fn tracked_item_tree_matches_the_plain_fn() {
let (db, ft) = db_with("class_name Foo\nfunc f():\n\tpass\n");
let tree = item_tree(&db, ft);
assert_eq!(tree.class_name.as_deref(), Some("Foo"));
assert_eq!(item_tree(&db, ft), tree);
}
#[test]
fn tracked_analyze_file_runs_inference() {
let (db, ft) = db_with("func add(a: int, b: int) -> int:\n\treturn a + b\n");
let fi = analyze_file(&db, ft);
assert!(!fi.units.is_empty());
assert!(fi.diagnostics.is_empty());
}
use std::sync::atomic::{AtomicU32, Ordering};
static WITNESS_RUNS: AtomicU32 = AtomicU32::new(0);
#[salsa::tracked]
fn class_name_witness(db: &dyn gdscript_db::Db, file: FileText) -> Option<smol_str::SmolStr> {
WITNESS_RUNS.fetch_add(1, Ordering::SeqCst);
item_tree(db, file).class_name.clone()
}
#[test]
fn body_edit_does_not_invalidate_signature_queries() {
let mut db = RootDatabase::default();
db.set_file_text(
FileId(0),
"class_name Foo\nfunc f():\n\tvar a := 1\n",
Durability::LOW,
);
let ft = db.file_text(FileId(0)).unwrap();
assert_eq!(class_name_witness(&db, ft).as_deref(), Some("Foo"));
let runs_after_warm = WITNESS_RUNS.load(Ordering::SeqCst);
db.set_file_text(
FileId(0),
"class_name Foo\nfunc f():\n\tvar a := 2\n",
Durability::LOW,
);
assert_eq!(class_name_witness(&db, ft).as_deref(), Some("Foo"));
assert_eq!(
WITNESS_RUNS.load(Ordering::SeqCst),
runs_after_warm,
"REGRESSION: a body edit re-ran a signature-only query — the item_tree firewall broke",
);
}
#[test]
fn global_registry_resolves_class_names_across_files() {
let mut db = RootDatabase::default();
db.set_file_text(
FileId(0),
"class_name Player\nfunc f():\n\tpass\n",
Durability::LOW,
);
db.set_file_text(
FileId(1),
"class_name Enemy\nvar hp := 10\n",
Durability::LOW,
);
db.set_file_text(FileId(2), "func no_class():\n\tpass\n", Durability::LOW);
db.sync_source_root();
let root = db.source_root().unwrap();
let reg = global_registry(&db, root);
assert_eq!(reg.len(), 2);
assert_eq!(reg.resolve("Player"), db.file_text(FileId(0)));
assert_eq!(reg.resolve("Enemy"), db.file_text(FileId(1)));
assert!(reg.resolve("Nonexistent").is_none());
}
static REGISTRY_OBSERVED: AtomicU32 = AtomicU32::new(0);
#[salsa::tracked]
fn observe_registry(db: &dyn gdscript_db::Db, root: SourceRoot) -> usize {
REGISTRY_OBSERVED.fetch_add(1, Ordering::SeqCst);
global_registry(db, root).len()
}
#[test]
fn body_edit_does_not_invalidate_the_global_registry() {
let mut db = RootDatabase::default();
db.set_file_text(
FileId(0),
"class_name Player\nfunc f():\n\tvar a := 1\n",
Durability::LOW,
);
db.set_file_text(FileId(1), "class_name Enemy\n", Durability::LOW);
db.sync_source_root();
let root = db.source_root().unwrap();
assert_eq!(observe_registry(&db, root), 2);
let runs = REGISTRY_OBSERVED.load(Ordering::SeqCst);
db.set_file_text(
FileId(0),
"class_name Player\nfunc f():\n\tvar a := 123456\n",
Durability::LOW,
);
assert_eq!(observe_registry(&db, root), 2);
assert_eq!(
REGISTRY_OBSERVED.load(Ordering::SeqCst),
runs,
"REGRESSION: a body edit re-ran a global_registry consumer — the cross-file firewall broke",
);
}
#[test]
fn cross_file_class_name_member_resolves() {
let mut db = RootDatabase::default();
db.set_file_text(
FileId(0),
"class_name Widget\nfunc make() -> int:\n\treturn 5\n",
Durability::LOW,
);
db.set_file_text(
FileId(1),
"func use_it():\n\tvar w := Widget.make()\n",
Durability::LOW,
);
db.sync_source_root();
let file1 = db.file_text(FileId(1)).unwrap();
let fi = analyze_file(&db, file1);
let api = db.engine().unwrap();
let unit = fi
.units
.iter()
.find(|u| !u.result.bindings.is_empty())
.expect("a unit with a binding");
assert_eq!(
unit.result.bindings[0].ty.label(api).as_deref(),
Some("int")
);
assert!(
fi.diagnostics.is_empty(),
"unexpected diagnostics: {:?}",
fi.diagnostics
);
}
#[test]
fn unknown_member_on_script_ref_is_seam_not_warning() {
let mut db = RootDatabase::default();
db.set_file_text(
FileId(0),
"class_name Widget\nfunc make() -> int:\n\treturn 5\n",
Durability::LOW,
);
db.set_file_text(
FileId(1),
"func use_it():\n\tWidget.not_a_member()\n",
Durability::LOW,
);
db.sync_source_root();
let file1 = db.file_text(FileId(1)).unwrap();
let fi = analyze_file(&db, file1);
assert!(
fi.diagnostics.is_empty(),
"a missing member on a ScriptRef must not warn: {:?}",
fi.diagnostics
);
}
#[test]
fn inherited_members_resolve_through_user_and_engine_bases() {
let mut db = RootDatabase::default();
db.set_file_text(
FileId(0),
"class_name Base\nextends Node\nfunc base_method() -> int:\n\treturn 1\n",
Durability::LOW,
);
db.set_file_text(
FileId(1),
"class_name Derived\nextends Base\nfunc own() -> String:\n\treturn \"x\"\n",
Durability::LOW,
);
db.set_file_text(
FileId(2),
"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",
Durability::LOW,
);
db.sync_source_root();
let api = db.engine().unwrap();
let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
let unit = fi
.units
.iter()
.find(|u| u.result.bindings.len() >= 4)
.expect("use_it unit with 4 bindings");
assert_eq!(
unit.result.bindings[1].ty.label(api).as_deref(),
Some("String")
);
assert_eq!(
unit.result.bindings[2].ty.label(api).as_deref(),
Some("int")
);
assert_eq!(
unit.result.bindings[3].ty.label(api).as_deref(),
Some("int")
);
assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
}
#[test]
fn cyclic_extends_terminates() {
let mut db = RootDatabase::default();
db.set_file_text(FileId(0), "class_name A\nextends B\n", Durability::LOW);
db.set_file_text(FileId(1), "class_name B\nextends A\n", Durability::LOW);
db.set_file_text(
FileId(2),
"func use_it():\n\tvar a: A\n\tvar x := a.nope()\n",
Durability::LOW,
);
db.sync_source_root();
let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
}
fn set_with_path(db: &mut RootDatabase, id: u32, path: &str, src: &str) {
db.set_file_text(FileId(id), src, Durability::LOW);
db.set_file_path(FileId(id), path);
}
#[test]
fn res_path_registry_maps_paths_to_files() {
let mut db = RootDatabase::default();
set_with_path(&mut db, 0, "res://a.gd", "class_name A\n");
set_with_path(&mut db, 1, "res://sub/b.gd", "func f():\n\tpass\n");
db.set_file_text(FileId(2), "func no_path():\n\tpass\n", Durability::LOW); db.sync_source_root();
let root = db.source_root().unwrap();
let reg = res_path_registry(&db, root);
assert_eq!(reg.get("res://a.gd"), Some(&FileId(0)));
assert_eq!(reg.get("res://sub/b.gd"), Some(&FileId(1)));
assert!(reg.get("res://missing.gd").is_none());
assert_eq!(reg.len(), 2);
}
static RES_REGISTRY_OBSERVED: AtomicU32 = AtomicU32::new(0);
#[salsa::tracked]
fn observe_res_registry(db: &dyn gdscript_db::Db, root: SourceRoot) -> usize {
RES_REGISTRY_OBSERVED.fetch_add(1, Ordering::SeqCst);
res_path_registry(db, root).len()
}
#[test]
fn body_edit_does_not_invalidate_the_res_path_registry() {
let mut db = RootDatabase::default();
set_with_path(&mut db, 0, "res://a.gd", "func f():\n\tvar a := 1\n");
db.sync_source_root();
let root = db.source_root().unwrap();
assert_eq!(observe_res_registry(&db, root), 1);
let runs = RES_REGISTRY_OBSERVED.load(Ordering::SeqCst);
db.set_file_text(FileId(0), "func f():\n\tvar a := 123456\n", Durability::LOW);
assert_eq!(observe_res_registry(&db, root), 1);
assert_eq!(
RES_REGISTRY_OBSERVED.load(Ordering::SeqCst),
runs,
"REGRESSION: a body edit re-ran a res_path_registry consumer — the path firewall broke",
);
}
#[test]
fn preload_const_resolves_to_script_ref_members() {
let mut db = RootDatabase::default();
set_with_path(
&mut db,
0,
"res://widget.gd",
"class_name Widget\nfunc make() -> int:\n\treturn 5\nconst MAX := 10\n",
);
set_with_path(
&mut db,
1,
"res://main.gd",
"const W = preload(\"res://widget.gd\")\nfunc use_it():\n\tvar a := W.make()\n\tvar b := W.new()\n",
);
db.sync_source_root();
let api = db.engine().unwrap();
let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
let unit = fi
.units
.iter()
.find(|u| u.result.bindings.len() >= 2)
.expect("use_it unit with 2 bindings");
assert_eq!(
unit.result.bindings[0].ty.label(api).as_deref(),
Some("int")
);
assert!(
matches!(unit.result.bindings[1].ty, Ty::ScriptRef(_)),
"W.new() should be a script instance, got {:?}",
unit.result.bindings[1].ty
);
assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
}
#[test]
fn preload_of_script_without_class_name_resolves() {
let mut db = RootDatabase::default();
set_with_path(
&mut db,
0,
"res://helper.gd",
"func help() -> String:\n\treturn \"x\"\n",
);
set_with_path(
&mut db,
1,
"res://main.gd",
"func use_it():\n\tvar h := preload(\"res://helper.gd\")\n\tvar s := h.help()\n",
);
db.sync_source_root();
let api = db.engine().unwrap();
let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
let unit = fi
.units
.iter()
.find(|u| u.result.bindings.len() >= 2)
.expect("use_it unit");
assert!(
matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
"preload of a class_name-less script must still resolve: {:?}",
unit.result.bindings[0].ty
);
assert_eq!(
unit.result.bindings[1].ty.label(api).as_deref(),
Some("String")
);
assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
}
#[test]
fn extends_res_path_inherits_members() {
let mut db = RootDatabase::default();
set_with_path(
&mut db,
0,
"res://base.gd",
"extends Node\nfunc base_method() -> int:\n\treturn 1\n",
);
set_with_path(
&mut db,
1,
"res://derived.gd",
"class_name Derived\nextends \"res://base.gd\"\nfunc own() -> String:\n\treturn \"x\"\n",
);
set_with_path(
&mut db,
2,
"res://main.gd",
"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",
);
db.sync_source_root();
let api = db.engine().unwrap();
let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
let unit = fi
.units
.iter()
.find(|u| u.result.bindings.len() >= 4)
.expect("use_it unit with 4 bindings");
assert_eq!(
unit.result.bindings[1].ty.label(api).as_deref(),
Some("String")
);
assert_eq!(
unit.result.bindings[2].ty.label(api).as_deref(),
Some("int")
);
assert_eq!(
unit.result.bindings[3].ty.label(api).as_deref(),
Some("int")
);
assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
}
#[test]
fn dangling_preload_is_seam_not_panic() {
let mut db = RootDatabase::default();
set_with_path(
&mut db,
0,
"res://main.gd",
"func use_it():\n\tvar x := preload(\"res://does_not_exist.gd\")\n\tx.whatever()\n",
);
db.sync_source_root();
let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
}
#[test]
fn non_gd_preload_resource_stays_seam() {
let mut db = RootDatabase::default();
set_with_path(&mut db, 0, "res://scene.tscn", "class_name SceneRoot\n");
set_with_path(
&mut db,
1,
"res://main.gd",
"func f():\n\tvar s := preload(\"res://scene.tscn\")\n",
);
db.sync_source_root();
let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
let unit = fi
.units
.iter()
.find(|u| !u.result.bindings.is_empty())
.expect("f unit");
assert!(
!matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
"a non-.gd preload must stay the seam, got {:?}",
unit.result.bindings[0].ty
);
assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
}
#[test]
fn load_literal_stays_opaque_not_aliased_to_preload() {
let mut db = RootDatabase::default();
set_with_path(
&mut db,
0,
"res://widget.gd",
"class_name Widget\nfunc make() -> int:\n\treturn 5\n",
);
set_with_path(
&mut db,
1,
"res://main.gd",
"func use_it():\n\tvar w := load(\"res://widget.gd\")\n",
);
db.sync_source_root();
let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
let unit = fi
.units
.iter()
.find(|u| !u.result.bindings.is_empty())
.expect("use_it unit");
assert!(
!matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
"load() must stay opaque, not alias preload: {:?}",
unit.result.bindings[0].ty
);
assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
}
#[test]
fn is_narrows_to_a_user_class_cross_file() {
let mut db = RootDatabase::default();
db.set_file_text(
FileId(0),
"class_name Widget\nfunc make() -> int:\n\treturn 5\n",
Durability::LOW,
);
db.set_file_text(
FileId(1),
"func use_it(x):\n\tif x is Widget:\n\t\tvar n := x.make()\n",
Durability::LOW,
);
db.sync_source_root();
let api = db.engine().unwrap();
let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
assert!(
fi.units
.iter()
.flat_map(|u| &u.result.bindings)
.any(|b| b.ty.label(api).as_deref() == Some("int")),
"`x.make()` after `is Widget` should narrow + resolve to int",
);
assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
}
#[test]
fn as_casts_to_a_user_class_cross_file() {
let mut db = RootDatabase::default();
db.set_file_text(
FileId(0),
"class_name Widget\nfunc make() -> int:\n\treturn 5\n",
Durability::LOW,
);
db.set_file_text(
FileId(1),
"func use_it(x):\n\tvar n := (x as Widget).make()\n",
Durability::LOW,
);
db.sync_source_root();
let api = db.engine().unwrap();
let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
assert!(
fi.units
.iter()
.flat_map(|u| &u.result.bindings)
.any(|b| b.ty.label(api).as_deref() == Some("int")),
"`(x as Widget).make()` should resolve to int",
);
assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
}
#[test]
fn renaming_a_files_path_reindexes_the_registry() {
let mut db = RootDatabase::default();
set_with_path(&mut db, 0, "res://old.gd", "class_name A\n");
db.sync_source_root();
let root = db.source_root().unwrap();
assert_eq!(
res_path_registry(&db, root).get("res://old.gd"),
Some(&FileId(0))
);
db.set_file_path(FileId(0), "res://new.gd");
let root = db.source_root().unwrap();
let reg = res_path_registry(&db, root);
assert_eq!(reg.get("res://new.gd"), Some(&FileId(0)));
assert!(reg.get("res://old.gd").is_none());
}
#[test]
fn star_autoload_scene_resolves_via_its_root_script() {
let mut db = RootDatabase::default();
db.set_file_text(
FileId(0),
"func volume() -> int:\n\treturn 5\n",
Durability::LOW,
);
db.set_file_path(FileId(0), "res://music.gd");
db.set_file_text(
FileId(1),
"[gd_scene format=3]\n\
[ext_resource type=\"Script\" path=\"res://music.gd\" id=\"1\"]\n\
[node name=\"Music\" type=\"Node\"]\n\
script = ExtResource(\"1\")\n",
Durability::LOW,
);
db.set_file_path(FileId(1), "res://music.tscn");
db.set_file_text(
FileId(2),
"func f():\n\tvar v := Music.volume()\n",
Durability::LOW,
);
db.set_file_path(FileId(2), "res://main.gd");
db.set_project_config("[autoload]\nMusic=\"*res://music.tscn\"\n");
db.sync_source_root();
let api = db.engine().unwrap();
let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
let unit = fi
.units
.iter()
.find(|u| !u.result.bindings.is_empty())
.expect("f unit");
assert_eq!(
unit.result.bindings[0].ty.label(api).as_deref(),
Some("int"),
"Music.volume() should resolve via the scene root's script",
);
assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
}
#[test]
fn star_autoload_gdscript_resolves_as_global_and_members() {
let mut db = RootDatabase::default();
db.set_file_text(
FileId(0),
"func score() -> int:\n\treturn 0\n",
Durability::LOW,
);
db.set_file_path(FileId(0), "res://game.gd");
db.set_file_text(
FileId(1),
"func f():\n\tvar s := Game.score()\n",
Durability::LOW,
);
db.set_file_path(FileId(1), "res://main.gd");
db.set_project_config("[autoload]\nGame=\"*res://game.gd\"\n");
db.sync_source_root();
let api = db.engine().unwrap();
let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
let unit = fi
.units
.iter()
.find(|u| !u.result.bindings.is_empty())
.expect("f unit");
assert_eq!(
unit.result.bindings[0].ty.label(api).as_deref(),
Some("int")
);
assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
}
#[test]
fn non_star_autoload_is_not_a_global() {
let mut db = RootDatabase::default();
db.set_file_text(
FileId(0),
"func score() -> int:\n\treturn 0\n",
Durability::LOW,
);
db.set_file_path(FileId(0), "res://game.gd");
db.set_file_text(
FileId(1),
"func f():\n\tvar s := Game.score()\n",
Durability::LOW,
);
db.set_file_path(FileId(1), "res://main.gd");
db.set_project_config("[autoload]\nGame=\"res://game.gd\"\n");
db.sync_source_root();
let api = db.engine().unwrap();
let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
let unit = fi
.units
.iter()
.find(|u| !u.result.bindings.is_empty())
.expect("f unit");
assert_eq!(unit.result.bindings[0].ty.label(api), None);
assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
}
#[test]
fn tscn_autoload_is_the_seam_never_false_warns() {
let mut db = RootDatabase::default();
db.set_file_text(FileId(0), "func f():\n\tHud.play_song()\n", Durability::LOW);
db.set_file_path(FileId(0), "res://main.gd");
db.set_project_config("[autoload]\nHud=\"*res://hud.tscn\"\n");
db.sync_source_root();
let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
}
static AUTOLOAD_OBSERVED: AtomicU32 = AtomicU32::new(0);
#[salsa::tracked]
fn observe_autoload_registry(db: &dyn gdscript_db::Db, config: ProjectConfig) -> usize {
AUTOLOAD_OBSERVED.fetch_add(1, Ordering::SeqCst);
autoload_registry(db, config).len()
}
#[test]
fn autoload_registry_firewalled_against_body_edits() {
let mut db = RootDatabase::default();
db.set_file_text(FileId(0), "func f():\n\tvar a := 1\n", Durability::LOW);
db.set_file_path(FileId(0), "res://game.gd");
db.set_project_config("[autoload]\nGame=\"*res://game.gd\"\n");
db.sync_source_root();
let config = db.project_config().unwrap();
assert_eq!(observe_autoload_registry(&db, config), 1);
let runs = AUTOLOAD_OBSERVED.load(Ordering::SeqCst);
db.set_file_text(FileId(0), "func f():\n\tvar a := 999999\n", Durability::LOW);
assert_eq!(observe_autoload_registry(&db, config), 1);
assert_eq!(
AUTOLOAD_OBSERVED.load(Ordering::SeqCst),
runs,
"REGRESSION: a body edit re-ran an autoload_registry consumer — the config firewall broke",
);
}
#[test]
fn aliased_self_resolves_own_members_no_false_unsafe() {
let mut db = RootDatabase::default();
db.set_file_text(
FileId(0),
"extends Node\nfunc own() -> int:\n\treturn 1\nfunc use_it():\n\tvar me := self\n\tvar n := me.own()\n",
Durability::LOW,
);
db.sync_source_root();
let api = db.engine().unwrap();
let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
assert!(
fi.units
.iter()
.flat_map(|u| &u.result.bindings)
.any(|b| b.ty.label(api).as_deref() == Some("int")),
"aliased self.own() should resolve to int",
);
assert!(
fi.diagnostics.is_empty(),
"no false UNSAFE on aliased self: {:?}",
fi.diagnostics
);
}
#[test]
fn is_userbase_narrows_to_derived_but_not_un_narrowed_to_base() {
let mut db = RootDatabase::default();
db.set_file_text(
FileId(0),
"class_name Base\nfunc base_m() -> int:\n\treturn 1\n",
Durability::LOW,
);
db.set_file_text(
FileId(1),
"class_name Derived\nextends Base\nfunc own_m() -> String:\n\treturn \"x\"\n",
Durability::LOW,
);
db.set_file_text(
FileId(2),
"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",
Durability::LOW,
);
db.sync_source_root();
let api = db.engine().unwrap();
let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
let strings = fi
.units
.iter()
.flat_map(|u| &u.result.bindings)
.filter(|b| b.ty.label(api).as_deref() == Some("String"))
.count();
assert!(
strings >= 2,
"expected both own_m() calls to type as String (narrow-down + widen-only), got {strings}",
);
assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
}
fn scene_db(scene_text: &str, gd_text: &str) -> RootDatabase {
let mut db = RootDatabase::default();
db.set_file_text(FileId(0), scene_text, Durability::LOW);
db.set_file_path(FileId(0), "res://main.tscn");
db.set_file_text(FileId(1), gd_text, Durability::LOW);
db.set_file_path(FileId(1), "res://main.gd");
db.sync_source_root();
db
}
fn binding_labels(db: &RootDatabase) -> Vec<String> {
let api = db.engine().unwrap();
let fi = analyze_file(db, db.file_text(FileId(1)).unwrap());
assert!(
fi.diagnostics.is_empty(),
"unexpected diags: {:?}",
fi.diagnostics
);
fi.units
.iter()
.flat_map(|u| &u.result.bindings)
.filter_map(|b| b.ty.label(api))
.collect()
}
const SCENE: &str = "[gd_scene format=3]\n\
[ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
[node name=\"Root\" type=\"Control\"]\n\
script = ExtResource(\"1\")\n\
[node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
[node name=\"Box\" type=\"VBoxContainer\" parent=\"Panel\"]\n\
[node name=\"Btn\" type=\"Button\" parent=\"Panel/Box\"]\n\
unique_name_in_owner = true\n";
#[test]
fn dollar_path_types_to_the_concrete_node() {
let db = scene_db(
SCENE,
"extends Control\nfunc _ready():\n\tvar b := $Panel/Box/Btn\n",
);
assert!(
binding_labels(&db).iter().any(|l| l == "Button"),
"$Panel/Box/Btn should type as Button",
);
}
#[test]
fn unique_name_path_types_to_the_concrete_node() {
let db = scene_db(SCENE, "extends Control\nfunc _ready():\n\tvar b := %Btn\n");
assert!(
binding_labels(&db).iter().any(|l| l == "Button"),
"%Btn should type as Button"
);
}
#[test]
fn onready_var_from_a_node_path_is_typed() {
let db = scene_db(
SCENE,
"extends Control\n@onready var btn := $Panel/Box/Btn\n",
);
assert!(
binding_labels(&db).iter().any(|l| l == "Button"),
"@onready var := $Path should type to Button",
);
}
#[test]
fn get_node_string_literal_types_like_dollar() {
let db = scene_db(
SCENE,
"extends Control\nfunc _ready():\n\tvar b := get_node(\"Panel/Box/Btn\")\n",
);
assert!(
binding_labels(&db).iter().any(|l| l == "Button"),
"get_node(\"...\") should type as Button",
);
}
#[test]
fn self_get_node_string_literal_types_like_dollar() {
let db = scene_db(
SCENE,
"extends Control\nfunc _ready():\n\tvar b := self.get_node(\"Panel/Box/Btn\")\n",
);
assert!(
binding_labels(&db).iter().any(|l| l == "Button"),
"self.get_node(\"...\") should type as Button",
);
}
#[test]
fn attached_script_refines_the_node_type() {
let mut db = RootDatabase::default();
db.set_file_text(
FileId(0),
"[gd_scene format=3]\n\
[ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
[ext_resource type=\"Script\" path=\"res://fancy.gd\" id=\"2\"]\n\
[node name=\"Root\" type=\"Control\"]\n\
script = ExtResource(\"1\")\n\
[node name=\"That\" type=\"Button\" parent=\".\"]\n\
script = ExtResource(\"2\")\n",
Durability::LOW,
);
db.set_file_path(FileId(0), "res://main.tscn");
db.set_file_text(
FileId(1),
"extends Control\nfunc _ready():\n\tvar n := $That.fancy()\n",
Durability::LOW,
);
db.set_file_path(FileId(1), "res://main.gd");
db.set_file_text(
FileId(2),
"class_name Fancy\nextends Button\nfunc fancy() -> int:\n\treturn 1\n",
Durability::LOW,
);
db.set_file_path(FileId(2), "res://fancy.gd");
db.sync_source_root();
assert!(
binding_labels(&db).iter().any(|l| l == "int"),
"$That.fancy() should resolve via the attached script Fancy",
);
}
#[test]
fn computed_or_unresolvable_node_path_stays_node_without_warning() {
let mut db = RootDatabase::default();
db.set_file_text(
FileId(1),
"extends Node\nfunc f(p):\n\tvar a := get_node(p)\n\tvar b := $Nope\n",
Durability::LOW,
);
db.set_file_path(FileId(1), "res://lone.gd");
db.sync_source_root();
let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
assert!(
fi.diagnostics.is_empty(),
"no false node-path warnings: {:?}",
fi.diagnostics
);
}
fn has_invalid_node_path(db: &RootDatabase) -> bool {
let fi = analyze_file(db, db.file_text(FileId(1)).unwrap());
fi.diagnostics
.iter()
.any(|d| d.code == crate::infer::INVALID_NODE_PATH)
}
#[test]
fn invalid_node_path_warns_when_genuinely_absent_in_a_single_owning_scene() {
let db = scene_db(SCENE, "extends Control\nfunc _ready():\n\tvar b := $Nope\n");
assert!(
has_invalid_node_path(&db),
"$Nope is absent in the one owning scene → warn"
);
}
#[test]
fn escape_and_absolute_paths_never_warn() {
let db = scene_db(
SCENE,
"extends Control\nfunc _ready():\n\tvar a := $\"../Sibling\"\n\tvar c := $\"/root/Global\"\n",
);
assert!(!has_invalid_node_path(&db), "escape paths must not warn");
}
#[test]
fn path_descending_into_an_instanced_subscene_never_warns() {
let db = scene_db(
"[gd_scene format=3]\n\
[ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
[ext_resource type=\"PackedScene\" path=\"res://player.tscn\" id=\"2\"]\n\
[node name=\"Root\" type=\"Control\"]\n\
script = ExtResource(\"1\")\n\
[node name=\"Player\" parent=\".\" instance=ExtResource(\"2\")]\n",
"extends Control\nfunc _ready():\n\tvar g := $Player/Gun\n",
);
assert!(
!has_invalid_node_path(&db),
"into-instance miss must not warn"
);
}
#[test]
fn ambiguous_multi_scene_attachment_suppresses_the_invalid_warning() {
let mut db = RootDatabase::default();
db.set_file_text(
FileId(0),
"[gd_scene format=3]\n\
[ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
[node name=\"Root\" type=\"Control\"]\n\
script = ExtResource(\"1\")\n\
[node name=\"Alpha\" type=\"Button\" parent=\".\"]\n",
Durability::LOW,
);
db.set_file_path(FileId(0), "res://a.tscn");
db.set_file_text(
FileId(2),
"[gd_scene format=3]\n\
[ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
[node name=\"Root\" type=\"Control\"]\n\
script = ExtResource(\"1\")\n\
[node name=\"Beta\" type=\"Button\" parent=\".\"]\n",
Durability::LOW,
);
db.set_file_path(FileId(2), "res://b.tscn");
db.set_file_text(
FileId(1),
"extends Control\nfunc _ready():\n\tvar b := $Beta\n",
Durability::LOW,
);
db.set_file_path(FileId(1), "res://main.gd");
db.sync_source_root();
assert!(
!has_invalid_node_path(&db),
"ambiguous multi-scene attachment must not warn"
);
}
#[test]
fn instanced_node_recurses_into_the_subscene_root_script() {
let mut db = RootDatabase::default();
db.set_file_text(
FileId(0),
"[gd_scene format=3]\n\
[ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
[ext_resource type=\"PackedScene\" path=\"res://enemy.tscn\" id=\"2\"]\n\
[node name=\"Root\" type=\"Control\"]\n\
script = ExtResource(\"1\")\n\
[node name=\"Enemy\" parent=\".\" instance=ExtResource(\"2\")]\n",
Durability::LOW,
);
db.set_file_path(FileId(0), "res://main.tscn");
db.set_file_text(
FileId(1),
"extends Control\nfunc _ready():\n\tvar e := $Enemy.hp()\n",
Durability::LOW,
);
db.set_file_path(FileId(1), "res://main.gd");
db.set_file_text(
FileId(2),
"[gd_scene format=3]\n\
[ext_resource type=\"Script\" path=\"res://enemy.gd\" id=\"1\"]\n\
[node name=\"Enemy\" type=\"Button\"]\n\
script = ExtResource(\"1\")\n",
Durability::LOW,
);
db.set_file_path(FileId(2), "res://enemy.tscn");
db.set_file_text(
FileId(3),
"class_name Enemy\nextends Button\nfunc hp() -> int:\n\treturn 1\n",
Durability::LOW,
);
db.set_file_path(FileId(3), "res://enemy.gd");
db.sync_source_root();
assert!(
binding_labels(&db).iter().any(|l| l == "int"),
"$Enemy.hp() should recurse into the instanced sub-scene root's script Enemy",
);
}
#[test]
fn unique_name_subpath_resolves_to_the_child_without_warning() {
let db = scene_db(
"[gd_scene format=3]\n\
[ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
[node name=\"Root\" type=\"Control\"]\n\
script = ExtResource(\"1\")\n\
[node name=\"Box\" type=\"VBoxContainer\" parent=\".\"]\n\
unique_name_in_owner = true\n\
[node name=\"Btn\" type=\"Button\" parent=\"Box\"]\n",
"extends Control\nfunc _ready():\n\tvar b := %Box/Btn\n",
);
assert!(
binding_labels(&db).iter().any(|l| l == "Button"),
"%Box/Btn → Button (and no false INVALID_NODE_PATH)",
);
}
#[test]
fn percent_prefixed_string_paths_resolve_as_unique_without_warning() {
let db = scene_db(
SCENE,
"extends Control\nfunc _ready():\n\tvar a := get_node(\"%Btn\")\n\tvar b := $\"%Btn\"\n",
);
let labels = binding_labels(&db);
assert!(
labels.iter().filter(|l| *l == "Button").count() >= 2,
"both %Btn string forms should resolve to Button: {labels:?}",
);
}
}