1use std::sync::Arc;
15
16use gdscript_db::{Db, FileText, ProjectConfig, SourceRoot, parse};
17use rustc_hash::{FxHashMap, FxHashSet};
18use smol_str::SmolStr;
19
20use gdscript_base::FileId;
21use gdscript_scene::{NodeIdx, SceneModel};
22
23use crate::infer::FileInference;
24use crate::item_tree::{ItemTree, Member};
25use crate::ty::{ScriptRefId, Ty};
26use crate::warnings::{SuppressionMap, WarningSettings};
27
28#[salsa::tracked]
31pub fn item_tree(db: &dyn Db, file: FileText) -> Arc<ItemTree> {
32 crate::item_tree::item_tree(&parse(db, file).syntax_node())
33}
34
35#[salsa::tracked]
38pub fn analyze_file(db: &dyn Db, file: FileText) -> Arc<FileInference> {
39 match db.engine() {
40 Some(api) => Arc::new(crate::infer::analyze_file(
41 db,
42 api,
43 &parse(db, file).syntax_node(),
44 file.file_id(db),
45 )),
46 None => Arc::new(FileInference::default()),
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Default)]
52pub struct GlobalRegistry {
53 classes: FxHashMap<SmolStr, FileText>,
54}
55
56impl GlobalRegistry {
57 #[must_use]
59 pub fn resolve(&self, name: &str) -> Option<FileText> {
60 self.classes.get(name).copied()
61 }
62
63 #[must_use]
65 pub fn len(&self) -> usize {
66 self.classes.len()
67 }
68
69 #[must_use]
71 pub fn is_empty(&self) -> bool {
72 self.classes.is_empty()
73 }
74
75 pub fn iter(&self) -> impl Iterator<Item = (&SmolStr, FileText)> + '_ {
77 self.classes.iter().map(|(k, v)| (k, *v))
78 }
79}
80
81#[salsa::tracked]
86pub fn file_class_name(db: &dyn Db, file: FileText) -> Option<SmolStr> {
87 item_tree(db, file).class_name.clone()
88}
89
90#[salsa::tracked]
95pub fn global_registry(db: &dyn Db, root: SourceRoot) -> Arc<GlobalRegistry> {
96 let mut classes = FxHashMap::default();
97 for &file in root.files(db) {
98 if let Some(name) = file_class_name(db, file) {
99 classes.entry(name).or_insert(file);
100 }
101 }
102 Arc::new(GlobalRegistry { classes })
103}
104
105#[salsa::tracked]
111pub fn class_name_collisions(db: &dyn Db, root: SourceRoot) -> Arc<FxHashSet<SmolStr>> {
112 let mut seen: FxHashSet<SmolStr> = FxHashSet::default();
113 let mut dups: FxHashSet<SmolStr> = FxHashSet::default();
114 for &file in root.files(db) {
115 if let Some(name) = file_class_name(db, file)
116 && !seen.insert(name.clone())
117 {
118 dups.insert(name);
119 }
120 }
121 Arc::new(dups)
122}
123
124#[salsa::tracked]
131pub fn res_path_registry(db: &dyn Db, root: SourceRoot) -> Arc<FxHashMap<SmolStr, FileId>> {
132 let mut map = FxHashMap::default();
133 for &file in root.files(db) {
134 if let Some(path) = file.res_path(db) {
135 map.entry(path).or_insert_with(|| file.file_id(db));
136 }
137 }
138 Arc::new(map)
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Default)]
146pub struct AutoloadRegistry {
147 singletons: FxHashMap<SmolStr, SmolStr>,
149 loaded: FxHashMap<SmolStr, SmolStr>,
152}
153
154impl AutoloadRegistry {
155 #[must_use]
159 pub fn resolve_path(&self, name: &str) -> Option<&SmolStr> {
160 self.singletons.get(name)
161 }
162
163 #[must_use]
166 pub fn resolve_any_path(&self, name: &str) -> Option<&SmolStr> {
167 self.singletons.get(name).or_else(|| self.loaded.get(name))
168 }
169
170 #[must_use]
172 pub fn len(&self) -> usize {
173 self.singletons.len()
174 }
175
176 #[must_use]
178 pub fn is_empty(&self) -> bool {
179 self.singletons.is_empty()
180 }
181}
182
183#[salsa::tracked]
186pub fn autoload_registry(db: &dyn Db, config: ProjectConfig) -> Arc<AutoloadRegistry> {
187 let mut singletons = FxHashMap::default();
188 let mut loaded = FxHashMap::default();
189 for e in crate::project::parse_autoloads(config.project_godot_text(db)) {
190 if e.is_singleton {
191 singletons.entry(e.name).or_insert(e.path);
192 } else {
193 loaded.entry(e.name).or_insert(e.path);
194 }
195 }
196 Arc::new(AutoloadRegistry { singletons, loaded })
197}
198
199#[salsa::tracked]
209pub fn engine_version(db: &dyn Db, config: ProjectConfig) -> Option<(u32, u32)> {
210 crate::project::parse_engine_version(config.project_godot_text(db))
211}
212
213#[must_use]
216pub fn project_engine_version(db: &dyn Db) -> Option<(u32, u32)> {
217 engine_version(db, db.project_config()?)
218}
219
220#[salsa::tracked]
226pub fn warning_settings(db: &dyn Db, config: ProjectConfig) -> Arc<WarningSettings> {
227 let text = config.project_godot_text(db);
228 let engine =
229 crate::project::parse_engine_version(text).unwrap_or_else(crate::warnings::bundled_version);
230 Arc::new(crate::project::parse_warning_settings(text, engine))
231}
232
233#[salsa::tracked]
237pub fn suppression_map(db: &dyn Db, file: FileText) -> Arc<SuppressionMap> {
238 Arc::new(crate::warnings::build_suppression_map(
239 &parse(db, file).syntax_node(),
240 file.text(db),
241 ))
242}
243
244#[derive(Debug, Clone, PartialEq, Eq)]
247pub enum MemberSig {
248 Method(Ty),
250 Field(Ty),
252 Signal,
254}
255
256#[derive(Debug, Clone, PartialEq, Eq)]
262pub struct ScriptClass {
263 members: FxHashMap<SmolStr, MemberSig>,
264 base: Ty,
265}
266
267impl ScriptClass {
268 #[must_use]
271 pub fn member(&self, name: &str) -> Option<&MemberSig> {
272 self.members.get(name)
273 }
274
275 #[must_use]
277 pub fn base(&self) -> &Ty {
278 &self.base
279 }
280}
281
282#[must_use]
286pub fn script_ref_name(db: &dyn Db, sref: ScriptRefId) -> Option<SmolStr> {
287 let file = db.file_text(FileId(sref.0))?;
288 file_class_name(db, file)
289}
290
291#[salsa::tracked]
294pub fn script_class(db: &dyn Db, file: FileText) -> Arc<ScriptClass> {
295 let tree = item_tree(db, file);
296 let Some(api) = db.engine() else {
297 return Arc::new(ScriptClass {
298 members: FxHashMap::default(),
299 base: Ty::Unknown,
300 });
301 };
302 let resolve_ann = |ann: Option<&str>| -> Ty {
303 ann.map_or(Ty::Variant, |t| {
304 crate::resolve::resolve_type_name(db, api, t)
305 })
306 };
307 let mut members = FxHashMap::default();
308 for m in &tree.members {
309 let Some(name) = m.name() else { continue };
310 let sig = match m {
311 Member::Func(f) => MemberSig::Method(resolve_ann(f.return_type.as_deref())),
312 Member::Var(v) => MemberSig::Field(resolve_ann(v.type_ref.as_deref())),
313 Member::Const(c) => MemberSig::Field(
318 c.type_ref
319 .is_none()
320 .then_some(c.preload_path.as_deref())
321 .flatten()
322 .and_then(|raw| {
323 crate::resolve::anchor_res_path(file.res_path(db).as_deref(), raw)
324 })
325 .map_or_else(
326 || resolve_ann(c.type_ref.as_deref()),
327 |abs| {
328 crate::resolve::resolve_external(
329 db,
330 &crate::resolve::ExternalRef::Preload(abs),
331 )
332 },
333 ),
334 ),
335 Member::Signal(_) => MemberSig::Signal,
336 Member::Enum(_) | Member::Class(_) => continue,
338 };
339 members.insert(SmolStr::new(name), sig);
340 }
341 let base = crate::resolve::resolve_base(db, api, &tree, file.res_path(db).as_deref());
344 Arc::new(ScriptClass { members, base })
345}
346
347#[must_use]
352pub fn is_scene_path(path: &str) -> bool {
353 let ext = path.rsplit('.').next().unwrap_or("");
354 ext.eq_ignore_ascii_case("tscn") || ext.eq_ignore_ascii_case("tres")
355}
356
357#[salsa::tracked]
361pub fn scene_model(db: &dyn Db, file: FileText) -> Arc<SceneModel> {
362 let is_scene = file.res_path(db).as_deref().is_some_and(is_scene_path);
363 if is_scene {
364 Arc::new(gdscript_scene::parse_scene(file.text(db)))
365 } else {
366 Arc::new(gdscript_scene::parse_scene(""))
367 }
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq)]
373pub struct SceneAttach {
374 pub scene: FileId,
376 pub node: NodeIdx,
378 pub ambiguous: bool,
381}
382
383#[salsa::tracked]
390pub fn script_scene_index(db: &dyn Db, root: SourceRoot) -> Arc<FxHashMap<SmolStr, SceneAttach>> {
391 let mut map: FxHashMap<SmolStr, SceneAttach> = FxHashMap::default();
392 for &file in root.files(db) {
393 if !file.res_path(db).as_deref().is_some_and(is_scene_path) {
394 continue;
395 }
396 let model = scene_model(db, file);
397 let scene = file.file_id(db);
398 for (i, node) in model.nodes.iter().enumerate() {
399 let Some(script_id) = node.script.as_ref() else {
400 continue;
401 };
402 let Some(path) = model
403 .ext_resources
404 .get(script_id)
405 .and_then(|e| e.path.clone())
406 else {
407 continue;
408 };
409 let node = NodeIdx(u32::try_from(i).unwrap_or(u32::MAX));
410 match map.get_mut(&path) {
411 Some(existing) => existing.ambiguous = true,
413 None => {
414 map.insert(
415 path,
416 SceneAttach {
417 scene,
418 node,
419 ambiguous: false,
420 },
421 );
422 }
423 }
424 }
425 }
426 Arc::new(map)
427}
428
429#[salsa::tracked]
434pub fn script_scene_attachments(
435 db: &dyn Db,
436 root: SourceRoot,
437) -> Arc<FxHashMap<SmolStr, Vec<(FileId, NodeIdx)>>> {
438 let mut map: FxHashMap<SmolStr, Vec<(FileId, NodeIdx)>> = FxHashMap::default();
439 for &file in root.files(db) {
440 if !file.res_path(db).as_deref().is_some_and(is_scene_path) {
441 continue;
442 }
443 let model = scene_model(db, file);
444 let scene = file.file_id(db);
445 for (i, node) in model.nodes.iter().enumerate() {
446 let Some(path) = node
447 .script
448 .as_ref()
449 .and_then(|id| model.ext_resources.get(id))
450 .and_then(|e| e.path.clone())
451 else {
452 continue;
453 };
454 map.entry(path)
455 .or_default()
456 .push((scene, NodeIdx(u32::try_from(i).unwrap_or(u32::MAX))));
457 }
458 }
459 Arc::new(map)
460}
461
462#[must_use]
467pub fn scene_context(db: &dyn Db, file: FileText) -> Option<SceneContext> {
468 let res_path = file.res_path(db)?;
469 let root = db.source_root()?;
470 let attach = *script_scene_index(db, root).get(res_path.as_str())?;
471 let scene_file = db.file_text(attach.scene)?;
472 Some(SceneContext {
473 scene: attach.scene,
474 model: scene_model(db, scene_file),
475 attach: attach.node,
476 ambiguous: attach.ambiguous,
477 })
478}
479
480#[derive(Debug, Clone)]
483pub struct SceneContext {
484 pub scene: FileId,
486 pub model: Arc<SceneModel>,
488 pub attach: NodeIdx,
490 pub ambiguous: bool,
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use gdscript_base::FileId;
498 use gdscript_db::RootDatabase;
499 use salsa::Durability;
500
501 fn db_with(src: &str) -> (RootDatabase, FileText) {
502 let mut db = RootDatabase::default();
503 db.set_file_text(FileId(0), src, Durability::LOW);
504 let ft = db.file_text(FileId(0)).unwrap();
505 (db, ft)
506 }
507
508 #[test]
509 fn tracked_item_tree_matches_the_plain_fn() {
510 let (db, ft) = db_with("class_name Foo\nfunc f():\n\tpass\n");
511 let tree = item_tree(&db, ft);
512 assert_eq!(tree.class_name.as_deref(), Some("Foo"));
513 assert_eq!(item_tree(&db, ft), tree);
515 }
516
517 #[test]
518 fn tracked_analyze_file_runs_inference() {
519 let (db, ft) = db_with("func add(a: int, b: int) -> int:\n\treturn a + b\n");
520 let fi = analyze_file(&db, ft);
521 assert!(!fi.units.is_empty());
523 assert!(fi.diagnostics.is_empty());
524 }
525
526 use std::sync::atomic::{AtomicU32, Ordering};
536
537 static WITNESS_RUNS: AtomicU32 = AtomicU32::new(0);
538
539 #[salsa::tracked]
541 fn class_name_witness(db: &dyn gdscript_db::Db, file: FileText) -> Option<smol_str::SmolStr> {
542 WITNESS_RUNS.fetch_add(1, Ordering::SeqCst);
543 item_tree(db, file).class_name.clone()
544 }
545
546 #[test]
547 fn body_edit_does_not_invalidate_signature_queries() {
548 let mut db = RootDatabase::default();
549 db.set_file_text(
550 FileId(0),
551 "class_name Foo\nfunc f():\n\tvar a := 1\n",
552 Durability::LOW,
553 );
554 let ft = db.file_text(FileId(0)).unwrap();
555
556 assert_eq!(class_name_witness(&db, ft).as_deref(), Some("Foo"));
558 let runs_after_warm = WITNESS_RUNS.load(Ordering::SeqCst);
559
560 db.set_file_text(
563 FileId(0),
564 "class_name Foo\nfunc f():\n\tvar a := 2\n",
565 Durability::LOW,
566 );
567 assert_eq!(class_name_witness(&db, ft).as_deref(), Some("Foo"));
568
569 assert_eq!(
570 WITNESS_RUNS.load(Ordering::SeqCst),
571 runs_after_warm,
572 "REGRESSION: a body edit re-ran a signature-only query — the item_tree firewall broke",
573 );
574 }
575
576 #[test]
577 fn global_registry_resolves_class_names_across_files() {
578 let mut db = RootDatabase::default();
579 db.set_file_text(
580 FileId(0),
581 "class_name Player\nfunc f():\n\tpass\n",
582 Durability::LOW,
583 );
584 db.set_file_text(
585 FileId(1),
586 "class_name Enemy\nvar hp := 10\n",
587 Durability::LOW,
588 );
589 db.set_file_text(FileId(2), "func no_class():\n\tpass\n", Durability::LOW);
590 db.sync_source_root();
591 let root = db.source_root().unwrap();
592
593 let reg = global_registry(&db, root);
594 assert_eq!(reg.len(), 2);
595 assert_eq!(reg.resolve("Player"), db.file_text(FileId(0)));
596 assert_eq!(reg.resolve("Enemy"), db.file_text(FileId(1)));
597 assert!(reg.resolve("Nonexistent").is_none());
598 }
599
600 static REGISTRY_OBSERVED: AtomicU32 = AtomicU32::new(0);
607
608 #[salsa::tracked]
610 fn observe_registry(db: &dyn gdscript_db::Db, root: SourceRoot) -> usize {
611 REGISTRY_OBSERVED.fetch_add(1, Ordering::SeqCst);
612 global_registry(db, root).len()
613 }
614
615 #[test]
616 fn body_edit_does_not_invalidate_the_global_registry() {
617 let mut db = RootDatabase::default();
618 db.set_file_text(
619 FileId(0),
620 "class_name Player\nfunc f():\n\tvar a := 1\n",
621 Durability::LOW,
622 );
623 db.set_file_text(FileId(1), "class_name Enemy\n", Durability::LOW);
624 db.sync_source_root();
625 let root = db.source_root().unwrap();
626
627 assert_eq!(observe_registry(&db, root), 2);
628 let runs = REGISTRY_OBSERVED.load(Ordering::SeqCst);
629
630 db.set_file_text(
633 FileId(0),
634 "class_name Player\nfunc f():\n\tvar a := 123456\n",
635 Durability::LOW,
636 );
637
638 assert_eq!(observe_registry(&db, root), 2);
639 assert_eq!(
640 REGISTRY_OBSERVED.load(Ordering::SeqCst),
641 runs,
642 "REGRESSION: a body edit re-ran a global_registry consumer — the cross-file firewall broke",
643 );
644 }
645
646 #[test]
647 fn cross_file_class_name_member_resolves() {
648 let mut db = RootDatabase::default();
649 db.set_file_text(
650 FileId(0),
651 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
652 Durability::LOW,
653 );
654 db.set_file_text(
655 FileId(1),
656 "func use_it():\n\tvar w := Widget.make()\n",
657 Durability::LOW,
658 );
659 db.sync_source_root();
660
661 let file1 = db.file_text(FileId(1)).unwrap();
662 let fi = analyze_file(&db, file1);
663 let api = db.engine().unwrap();
664
665 let unit = fi
668 .units
669 .iter()
670 .find(|u| !u.result.bindings.is_empty())
671 .expect("a unit with a binding");
672 assert_eq!(
673 unit.result.bindings[0].ty.label(api).as_deref(),
674 Some("int")
675 );
676 assert!(
677 fi.diagnostics.is_empty(),
678 "unexpected diagnostics: {:?}",
679 fi.diagnostics
680 );
681 }
682
683 use crate::infer::SHADOWED_GLOBAL_IDENTIFIER;
686
687 fn shadow_codes(fi: &Arc<FileInference>) -> Vec<&str> {
688 fi.diagnostics
689 .iter()
690 .filter(|d| d.code == SHADOWED_GLOBAL_IDENTIFIER)
691 .map(|d| d.code.as_str())
692 .collect()
693 }
694
695 #[test]
696 fn class_name_collisions_names_only_the_duplicates() {
697 let mut db = RootDatabase::default();
698 db.set_file_text(FileId(0), "class_name Dup\n", Durability::LOW);
699 db.set_file_text(FileId(1), "class_name Dup\n", Durability::LOW);
700 db.set_file_text(FileId(2), "class_name Unique\n", Durability::LOW);
701 db.sync_source_root();
702 let root = db.source_root().unwrap();
703
704 let cols = class_name_collisions(&db, root);
705 assert!(cols.contains(&SmolStr::new("Dup")));
706 assert!(
707 !cols.contains(&SmolStr::new("Unique")),
708 "a singly-declared class_name is not a collision",
709 );
710 assert_eq!(cols.len(), 1);
711 }
712
713 #[test]
714 fn missing_tool_warns_when_extending_a_tool_base_without_tool() {
715 let mut db = RootDatabase::default();
716 db.set_file_text(FileId(0), "@tool\nclass_name ToolBase\n", Durability::LOW);
717 db.set_file_text(
718 FileId(1),
719 "extends ToolBase\nfunc f():\n\tpass\n",
720 Durability::LOW,
721 );
722 db.sync_source_root();
723 let derived = analyze_file(&db, db.file_text(FileId(1)).unwrap());
724 assert!(
725 derived
726 .raw_warnings
727 .iter()
728 .any(|w| w.code.as_str() == "MISSING_TOOL"),
729 "{:?}",
730 derived
731 .raw_warnings
732 .iter()
733 .map(|w| w.code.as_str())
734 .collect::<Vec<_>>()
735 );
736 let base = analyze_file(&db, db.file_text(FileId(0)).unwrap());
738 assert!(
739 !base
740 .raw_warnings
741 .iter()
742 .any(|w| w.code.as_str() == "MISSING_TOOL")
743 );
744 }
745
746 #[test]
747 fn a_tool_class_extending_a_tool_base_is_silent() {
748 let mut db = RootDatabase::default();
749 db.set_file_text(FileId(0), "@tool\nclass_name ToolBase\n", Durability::LOW);
750 db.set_file_text(FileId(1), "@tool\nextends ToolBase\n", Durability::LOW);
751 db.sync_source_root();
752 let derived = analyze_file(&db, db.file_text(FileId(1)).unwrap());
753 assert!(
754 !derived
755 .raw_warnings
756 .iter()
757 .any(|w| w.code.as_str() == "MISSING_TOOL")
758 );
759 }
760
761 #[test]
762 fn duplicate_class_name_warns_at_both_declarations() {
763 let mut db = RootDatabase::default();
764 db.set_file_text(
765 FileId(0),
766 "class_name Dup\nfunc f():\n\tpass\n",
767 Durability::LOW,
768 );
769 db.set_file_text(FileId(1), "class_name Dup\nvar x := 1\n", Durability::LOW);
770 db.sync_source_root();
771
772 for fid in [0, 1] {
773 let fi = analyze_file(&db, db.file_text(FileId(fid)).unwrap());
774 assert!(
775 shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
776 "file {fid} should warn on the duplicate class_name: {:?}",
777 fi.diagnostics
778 );
779 let d = fi
781 .diagnostics
782 .iter()
783 .find(|d| d.code == SHADOWED_GLOBAL_IDENTIFIER)
784 .unwrap();
785 assert_eq!(d.range, gdscript_base::TextRange::new(11, 14));
786 }
787 }
788
789 #[test]
790 fn class_name_shadowing_an_engine_class_warns() {
791 let mut db = RootDatabase::default();
792 db.set_file_text(
794 FileId(0),
795 "class_name Node\nfunc f():\n\tpass\n",
796 Durability::LOW,
797 );
798 db.sync_source_root();
799
800 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
801 assert!(
802 shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
803 "class_name Node must warn (shadows the engine class): {:?}",
804 fi.diagnostics
805 );
806 }
807
808 #[test]
809 fn class_name_shadowing_a_builtin_type_warns() {
810 let mut db = RootDatabase::default();
811 db.set_file_text(FileId(0), "class_name Vector2\n", Durability::LOW);
813 db.sync_source_root();
814
815 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
816 assert!(
817 shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
818 "{:?}",
819 fi.diagnostics
820 );
821 }
822
823 #[test]
824 fn class_name_shadowing_a_star_autoload_warns() {
825 let mut db = RootDatabase::default();
826 db.set_file_text(
827 FileId(0),
828 "class_name Game\nfunc f():\n\tpass\n",
829 Durability::LOW,
830 );
831 db.set_file_path(FileId(0), "res://game.gd");
832 db.set_project_config("[autoload]\nGame=\"*res://other.gd\"\n");
834 db.sync_source_root();
835
836 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
837 assert!(
838 shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
839 "class_name Game must warn (shadows the `*Game` autoload): {:?}",
840 fi.diagnostics
841 );
842 }
843
844 #[test]
845 fn unique_non_shadowing_class_name_does_not_warn() {
846 let mut db = RootDatabase::default();
848 db.set_file_text(
849 FileId(0),
850 "class_name MyVeryOwnUniquePlayer\nfunc f():\n\tpass\n",
851 Durability::LOW,
852 );
853 db.set_file_text(
854 FileId(1),
855 "class_name AnotherUniqueEnemy\n",
856 Durability::LOW,
857 );
858 db.sync_source_root();
859
860 for fid in [0, 1] {
861 let fi = analyze_file(&db, db.file_text(FileId(fid)).unwrap());
862 assert!(
863 shadow_codes(&fi).is_empty(),
864 "file {fid}: a unique class_name must not warn: {:?}",
865 fi.diagnostics
866 );
867 }
868 }
869
870 #[test]
871 fn unknown_member_on_script_ref_is_seam_not_warning() {
872 let mut db = RootDatabase::default();
873 db.set_file_text(
874 FileId(0),
875 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
876 Durability::LOW,
877 );
878 db.set_file_text(
879 FileId(1),
880 "func use_it():\n\tWidget.not_a_member()\n",
881 Durability::LOW,
882 );
883 db.sync_source_root();
884
885 let file1 = db.file_text(FileId(1)).unwrap();
886 let fi = analyze_file(&db, file1);
887 assert!(
889 fi.diagnostics.is_empty(),
890 "a missing member on a ScriptRef must not warn: {:?}",
891 fi.diagnostics
892 );
893 }
894
895 #[test]
896 fn inherited_members_resolve_through_user_and_engine_bases() {
897 let mut db = RootDatabase::default();
898 db.set_file_text(
900 FileId(0),
901 "class_name Base\nextends Node\nfunc base_method() -> int:\n\treturn 1\n",
902 Durability::LOW,
903 );
904 db.set_file_text(
905 FileId(1),
906 "class_name Derived\nextends Base\nfunc own() -> String:\n\treturn \"x\"\n",
907 Durability::LOW,
908 );
909 db.set_file_text(
910 FileId(2),
911 "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",
912 Durability::LOW,
913 );
914 db.sync_source_root();
915 let api = db.engine().unwrap();
916
917 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
918 let unit = fi
919 .units
920 .iter()
921 .find(|u| u.result.bindings.len() >= 4)
922 .expect("use_it unit with 4 bindings");
923 assert_eq!(
925 unit.result.bindings[1].ty.label(api).as_deref(),
926 Some("String")
927 );
928 assert_eq!(
929 unit.result.bindings[2].ty.label(api).as_deref(),
930 Some("int")
931 );
932 assert_eq!(
933 unit.result.bindings[3].ty.label(api).as_deref(),
934 Some("int")
935 );
936 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
937 }
938
939 #[test]
940 fn cyclic_extends_flags_each_cycle_member_and_terminates() {
941 use crate::infer::CYCLIC_INHERITANCE;
942 let mut db = RootDatabase::default();
943 db.set_file_text(FileId(0), "class_name A\nextends B\n", Durability::LOW);
946 db.set_file_text(FileId(1), "class_name B\nextends A\n", Durability::LOW);
947 db.set_file_text(
949 FileId(2),
950 "func use_it():\n\tvar a: A\n\tvar x := a.nope()\n",
951 Durability::LOW,
952 );
953 db.sync_source_root();
954
955 for id in [FileId(0), FileId(1)] {
957 let fi = analyze_file(&db, db.file_text(id).unwrap());
958 let cyclic: Vec<_> = fi
959 .diagnostics
960 .iter()
961 .filter(|d| d.code == CYCLIC_INHERITANCE)
962 .collect();
963 assert_eq!(cyclic.len(), 1, "file {id:?}: {:?}", fi.diagnostics);
964 }
965
966 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
969 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
970 }
971
972 #[test]
973 fn cyclic_extends_via_res_path_two_files_flags_no_hang() {
974 use crate::infer::CYCLIC_INHERITANCE;
975 let mut db = RootDatabase::default();
976 set_with_path(&mut db, 0, "res://a.gd", "extends \"res://b.gd\"\n");
978 set_with_path(&mut db, 1, "res://b.gd", "extends \"res://a.gd\"\n");
979 db.sync_source_root();
980
981 for id in [FileId(0), FileId(1)] {
982 let fi = analyze_file(&db, db.file_text(id).unwrap());
983 assert!(
984 fi.diagnostics.iter().any(|d| d.code == CYCLIC_INHERITANCE),
985 "file {id:?} expected CYCLIC_INHERITANCE: {:?}",
986 fi.diagnostics
987 );
988 }
989 }
990
991 #[test]
992 fn deep_acyclic_extends_chain_does_not_false_fire() {
993 use crate::infer::CYCLIC_INHERITANCE;
994 let mut db = RootDatabase::default();
995 db.set_file_text(FileId(0), "class_name C0\nextends C1\n", Durability::LOW);
998 db.set_file_text(FileId(1), "class_name C1\nextends C2\n", Durability::LOW);
999 db.set_file_text(FileId(2), "class_name C2\nextends C3\n", Durability::LOW);
1000 db.set_file_text(FileId(3), "class_name C3\nextends C4\n", Durability::LOW);
1001 db.set_file_text(FileId(4), "class_name C4\nextends Node\n", Durability::LOW);
1002 db.sync_source_root();
1003
1004 for id in (0..5).map(FileId) {
1005 let fi = analyze_file(&db, db.file_text(id).unwrap());
1006 assert!(
1007 !fi.diagnostics.iter().any(|d| d.code == CYCLIC_INHERITANCE),
1008 "file {id:?} false-fired CYCLIC_INHERITANCE: {:?}",
1009 fi.diagnostics
1010 );
1011 }
1012 }
1013
1014 fn set_with_path(db: &mut RootDatabase, id: u32, path: &str, src: &str) {
1018 db.set_file_text(FileId(id), src, Durability::LOW);
1019 db.set_file_path(FileId(id), path);
1020 }
1021
1022 #[test]
1023 fn res_path_registry_maps_paths_to_files() {
1024 let mut db = RootDatabase::default();
1025 set_with_path(&mut db, 0, "res://a.gd", "class_name A\n");
1026 set_with_path(&mut db, 1, "res://sub/b.gd", "func f():\n\tpass\n");
1027 db.set_file_text(FileId(2), "func no_path():\n\tpass\n", Durability::LOW); db.sync_source_root();
1029 let root = db.source_root().unwrap();
1030
1031 let reg = res_path_registry(&db, root);
1032 assert_eq!(reg.get("res://a.gd"), Some(&FileId(0)));
1033 assert_eq!(reg.get("res://sub/b.gd"), Some(&FileId(1)));
1034 assert!(reg.get("res://missing.gd").is_none());
1035 assert_eq!(reg.len(), 2);
1037 }
1038
1039 static RES_REGISTRY_OBSERVED: AtomicU32 = AtomicU32::new(0);
1044
1045 #[salsa::tracked]
1046 fn observe_res_registry(db: &dyn gdscript_db::Db, root: SourceRoot) -> usize {
1047 RES_REGISTRY_OBSERVED.fetch_add(1, Ordering::SeqCst);
1048 res_path_registry(db, root).len()
1049 }
1050
1051 #[test]
1052 fn body_edit_does_not_invalidate_the_res_path_registry() {
1053 let mut db = RootDatabase::default();
1054 set_with_path(&mut db, 0, "res://a.gd", "func f():\n\tvar a := 1\n");
1055 db.sync_source_root();
1056 let root = db.source_root().unwrap();
1057
1058 assert_eq!(observe_res_registry(&db, root), 1);
1059 let runs = RES_REGISTRY_OBSERVED.load(Ordering::SeqCst);
1060
1061 db.set_file_text(FileId(0), "func f():\n\tvar a := 123456\n", Durability::LOW);
1064
1065 assert_eq!(observe_res_registry(&db, root), 1);
1066 assert_eq!(
1067 RES_REGISTRY_OBSERVED.load(Ordering::SeqCst),
1068 runs,
1069 "REGRESSION: a body edit re-ran a res_path_registry consumer — the path firewall broke",
1070 );
1071 }
1072
1073 #[test]
1074 fn preload_const_resolves_to_script_ref_members() {
1075 let mut db = RootDatabase::default();
1076 set_with_path(
1077 &mut db,
1078 0,
1079 "res://widget.gd",
1080 "class_name Widget\nfunc make() -> int:\n\treturn 5\nconst MAX := 10\n",
1081 );
1082 set_with_path(
1083 &mut db,
1084 1,
1085 "res://main.gd",
1086 "const W = preload(\"res://widget.gd\")\nfunc use_it():\n\tvar a := W.make()\n\tvar b := W.new()\n",
1087 );
1088 db.sync_source_root();
1089 let api = db.engine().unwrap();
1090
1091 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1092 let unit = fi
1093 .units
1094 .iter()
1095 .find(|u| u.result.bindings.len() >= 2)
1096 .expect("use_it unit with 2 bindings");
1097 assert_eq!(
1099 unit.result.bindings[0].ty.label(api).as_deref(),
1100 Some("int")
1101 );
1102 assert!(
1103 matches!(unit.result.bindings[1].ty, Ty::ScriptRef(_)),
1104 "W.new() should be a script instance, got {:?}",
1105 unit.result.bindings[1].ty
1106 );
1107 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1108 }
1109
1110 #[test]
1111 fn cross_file_preload_const_member_resolves() {
1112 let mut db = RootDatabase::default();
1116 set_with_path(
1117 &mut db,
1118 0,
1119 "res://widget.gd",
1120 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1121 );
1122 set_with_path(
1123 &mut db,
1124 1,
1125 "res://holder.gd",
1126 "class_name Holder\nconst W = preload(\"res://widget.gd\")\n",
1127 );
1128 set_with_path(
1129 &mut db,
1130 2,
1131 "res://user.gd",
1132 "func use_it():\n\tvar a := Holder.W.make()\n",
1133 );
1134 db.sync_source_root();
1135 let api = db.engine().unwrap();
1136
1137 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1138 let unit = fi
1139 .units
1140 .iter()
1141 .find(|u| !u.result.bindings.is_empty())
1142 .expect("use_it unit");
1143 assert_eq!(
1145 unit.result.bindings[0].ty.label(api).as_deref(),
1146 Some("int"),
1147 "Holder.W.make() should resolve cross-file to int, got {:?}",
1148 unit.result.bindings[0].ty
1149 );
1150 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1151 }
1152
1153 #[test]
1154 fn preload_of_script_without_class_name_resolves() {
1155 let mut db = RootDatabase::default();
1158 set_with_path(
1159 &mut db,
1160 0,
1161 "res://helper.gd",
1162 "func help() -> String:\n\treturn \"x\"\n",
1163 );
1164 set_with_path(
1165 &mut db,
1166 1,
1167 "res://main.gd",
1168 "func use_it():\n\tvar h := preload(\"res://helper.gd\")\n\tvar s := h.help()\n",
1169 );
1170 db.sync_source_root();
1171 let api = db.engine().unwrap();
1172
1173 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1174 let unit = fi
1175 .units
1176 .iter()
1177 .find(|u| u.result.bindings.len() >= 2)
1178 .expect("use_it unit");
1179 assert!(
1180 matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
1181 "preload of a class_name-less script must still resolve: {:?}",
1182 unit.result.bindings[0].ty
1183 );
1184 assert_eq!(
1185 unit.result.bindings[1].ty.label(api).as_deref(),
1186 Some("String")
1187 );
1188 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1189 }
1190
1191 #[test]
1192 fn extends_res_path_inherits_members() {
1193 let mut db = RootDatabase::default();
1194 set_with_path(
1196 &mut db,
1197 0,
1198 "res://base.gd",
1199 "extends Node\nfunc base_method() -> int:\n\treturn 1\n",
1200 );
1201 set_with_path(
1202 &mut db,
1203 1,
1204 "res://derived.gd",
1205 "class_name Derived\nextends \"res://base.gd\"\nfunc own() -> String:\n\treturn \"x\"\n",
1206 );
1207 set_with_path(
1208 &mut db,
1209 2,
1210 "res://main.gd",
1211 "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",
1212 );
1213 db.sync_source_root();
1214 let api = db.engine().unwrap();
1215
1216 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1217 let unit = fi
1218 .units
1219 .iter()
1220 .find(|u| u.result.bindings.len() >= 4)
1221 .expect("use_it unit with 4 bindings");
1222 assert_eq!(
1225 unit.result.bindings[1].ty.label(api).as_deref(),
1226 Some("String")
1227 );
1228 assert_eq!(
1229 unit.result.bindings[2].ty.label(api).as_deref(),
1230 Some("int")
1231 );
1232 assert_eq!(
1233 unit.result.bindings[3].ty.label(api).as_deref(),
1234 Some("int")
1235 );
1236 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1237 }
1238
1239 #[test]
1240 fn relative_extends_path_anchors_to_importing_dir() {
1241 let mut db = RootDatabase::default();
1242 set_with_path(
1244 &mut db,
1245 0,
1246 "res://entities/base.gd",
1247 "extends Node\nfunc base_method() -> int:\n\treturn 1\n",
1248 );
1249 set_with_path(
1251 &mut db,
1252 1,
1253 "res://entities/derived.gd",
1254 "class_name Derived\nextends \"base.gd\"\nfunc own() -> String:\n\treturn \"x\"\n",
1255 );
1256 set_with_path(
1257 &mut db,
1258 2,
1259 "res://main.gd",
1260 "func use_it():\n\tvar d: Derived\n\tvar a := d.own()\n\tvar b := d.base_method()\n",
1261 );
1262 db.sync_source_root();
1263 let api = db.engine().unwrap();
1264 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1265 let unit = fi
1266 .units
1267 .iter()
1268 .find(|u| u.result.bindings.len() >= 3)
1269 .expect("use_it unit with 3 bindings (d, a, b)");
1270 assert_eq!(
1272 unit.result.bindings[1].ty.label(api).as_deref(),
1273 Some("String")
1274 );
1275 assert_eq!(
1276 unit.result.bindings[2].ty.label(api).as_deref(),
1277 Some("int"),
1278 "base_method() must resolve through the relative `extends \"base.gd\"`"
1279 );
1280 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1281 }
1282
1283 #[test]
1284 fn dangling_preload_is_seam_not_panic() {
1285 let mut db = RootDatabase::default();
1286 set_with_path(
1287 &mut db,
1288 0,
1289 "res://main.gd",
1290 "func use_it():\n\tvar x := preload(\"res://does_not_exist.gd\")\n\tx.whatever()\n",
1291 );
1292 db.sync_source_root();
1293 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1295 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1296 }
1297
1298 #[test]
1299 fn non_gd_preload_resource_stays_seam() {
1300 let mut db = RootDatabase::default();
1305 set_with_path(&mut db, 0, "res://scene.tscn", "class_name SceneRoot\n");
1306 set_with_path(
1307 &mut db,
1308 1,
1309 "res://main.gd",
1310 "func f():\n\tvar s := preload(\"res://scene.tscn\")\n",
1311 );
1312 db.sync_source_root();
1313
1314 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1315 let unit = fi
1316 .units
1317 .iter()
1318 .find(|u| !u.result.bindings.is_empty())
1319 .expect("f unit");
1320 assert!(
1321 !matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
1322 "a non-.gd preload must stay the seam, got {:?}",
1323 unit.result.bindings[0].ty
1324 );
1325 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1326 }
1327
1328 #[test]
1329 fn load_literal_stays_opaque_not_aliased_to_preload() {
1330 let mut db = RootDatabase::default();
1331 set_with_path(
1332 &mut db,
1333 0,
1334 "res://widget.gd",
1335 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1336 );
1337 set_with_path(
1338 &mut db,
1339 1,
1340 "res://main.gd",
1341 "func use_it():\n\tvar w := load(\"res://widget.gd\")\n",
1342 );
1343 db.sync_source_root();
1344
1345 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1346 let unit = fi
1347 .units
1348 .iter()
1349 .find(|u| !u.result.bindings.is_empty())
1350 .expect("use_it unit");
1351 assert!(
1354 !matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
1355 "load() must stay opaque, not alias preload: {:?}",
1356 unit.result.bindings[0].ty
1357 );
1358 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1359 }
1360
1361 #[test]
1362 fn is_narrows_to_a_user_class_cross_file() {
1363 let mut db = RootDatabase::default();
1368 db.set_file_text(
1369 FileId(0),
1370 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1371 Durability::LOW,
1372 );
1373 db.set_file_text(
1374 FileId(1),
1375 "func use_it(x):\n\tif x is Widget:\n\t\tvar n := x.make()\n",
1376 Durability::LOW,
1377 );
1378 db.sync_source_root();
1379 let api = db.engine().unwrap();
1380
1381 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1382 assert!(
1384 fi.units
1385 .iter()
1386 .flat_map(|u| &u.result.bindings)
1387 .any(|b| b.ty.label(api).as_deref() == Some("int")),
1388 "`x.make()` after `is Widget` should narrow + resolve to int",
1389 );
1390 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1391 }
1392
1393 #[test]
1394 fn as_casts_to_a_user_class_cross_file() {
1395 let mut db = RootDatabase::default();
1397 db.set_file_text(
1398 FileId(0),
1399 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1400 Durability::LOW,
1401 );
1402 db.set_file_text(
1403 FileId(1),
1404 "func use_it(x):\n\tvar n := (x as Widget).make()\n",
1405 Durability::LOW,
1406 );
1407 db.sync_source_root();
1408 let api = db.engine().unwrap();
1409
1410 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1411 assert!(
1412 fi.units
1413 .iter()
1414 .flat_map(|u| &u.result.bindings)
1415 .any(|b| b.ty.label(api).as_deref() == Some("int")),
1416 "`(x as Widget).make()` should resolve to int",
1417 );
1418 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1419 }
1420
1421 #[test]
1422 fn renaming_a_files_path_reindexes_the_registry() {
1423 let mut db = RootDatabase::default();
1425 set_with_path(&mut db, 0, "res://old.gd", "class_name A\n");
1426 db.sync_source_root();
1427 let root = db.source_root().unwrap();
1428 assert_eq!(
1429 res_path_registry(&db, root).get("res://old.gd"),
1430 Some(&FileId(0))
1431 );
1432
1433 db.set_file_path(FileId(0), "res://new.gd");
1434 let root = db.source_root().unwrap();
1435 let reg = res_path_registry(&db, root);
1436 assert_eq!(reg.get("res://new.gd"), Some(&FileId(0)));
1437 assert!(reg.get("res://old.gd").is_none());
1438 }
1439
1440 #[test]
1443 fn star_autoload_scene_resolves_via_its_root_script() {
1444 let mut db = RootDatabase::default();
1448 db.set_file_text(
1450 FileId(0),
1451 "func volume() -> int:\n\treturn 5\n",
1452 Durability::LOW,
1453 );
1454 db.set_file_path(FileId(0), "res://music.gd");
1455 db.set_file_text(
1457 FileId(1),
1458 "[gd_scene format=3]\n\
1459 [ext_resource type=\"Script\" path=\"res://music.gd\" id=\"1\"]\n\
1460 [node name=\"Music\" type=\"Node\"]\n\
1461 script = ExtResource(\"1\")\n",
1462 Durability::LOW,
1463 );
1464 db.set_file_path(FileId(1), "res://music.tscn");
1465 db.set_file_text(
1466 FileId(2),
1467 "func f():\n\tvar v := Music.volume()\n",
1468 Durability::LOW,
1469 );
1470 db.set_file_path(FileId(2), "res://main.gd");
1471 db.set_project_config("[autoload]\nMusic=\"*res://music.tscn\"\n");
1472 db.sync_source_root();
1473 let api = db.engine().unwrap();
1474
1475 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1476 let unit = fi
1477 .units
1478 .iter()
1479 .find(|u| !u.result.bindings.is_empty())
1480 .expect("f unit");
1481 assert_eq!(
1482 unit.result.bindings[0].ty.label(api).as_deref(),
1483 Some("int"),
1484 "Music.volume() should resolve via the scene root's script",
1485 );
1486 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1487 }
1488
1489 #[test]
1490 fn star_autoload_scene_resolves_via_script_class_shortcut() {
1491 let mut db = RootDatabase::default();
1496 db.set_file_text(
1497 FileId(0),
1498 "class_name MusicPlayer\nfunc volume() -> int:\n\treturn 5\n",
1499 Durability::LOW,
1500 );
1501 db.set_file_path(FileId(0), "res://music.gd");
1502 db.set_file_text(
1503 FileId(1),
1504 "[gd_scene format=3 script_class=\"MusicPlayer\"]\n[node name=\"Root\" type=\"Node\"]\n",
1505 Durability::LOW,
1506 );
1507 db.set_file_path(FileId(1), "res://music.tscn");
1508 db.set_file_text(
1509 FileId(2),
1510 "func f():\n\tvar v := Audio.volume()\n",
1511 Durability::LOW,
1512 );
1513 db.set_file_path(FileId(2), "res://main.gd");
1514 db.set_project_config("[autoload]\nAudio=\"*res://music.tscn\"\n");
1515 db.sync_source_root();
1516 let api = db.engine().unwrap();
1517
1518 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1519 let unit = fi
1520 .units
1521 .iter()
1522 .find(|u| !u.result.bindings.is_empty())
1523 .expect("f unit");
1524 assert_eq!(
1525 unit.result.bindings[0].ty.label(api).as_deref(),
1526 Some("int"),
1527 "Audio.volume() should resolve via the scene's script_class= shortcut",
1528 );
1529 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1530 }
1531
1532 #[test]
1533 fn engine_version_from_project_config_is_firewalled_against_body_edits() {
1534 let mut db = RootDatabase::default();
1535 db.set_file_text(FileId(0), "func f():\n\tpass\n", Durability::LOW);
1536 db.set_file_path(FileId(0), "res://main.gd");
1537 db.set_project_config("[application]\nconfig/features=PackedStringArray(\"4.6\")\n");
1538 db.sync_source_root();
1539 assert_eq!(project_engine_version(&db), Some((4, 6)));
1540
1541 db.set_file_text(FileId(0), "func f():\n\tvar x := 1\n", Durability::LOW);
1544 db.sync_source_root();
1545 assert_eq!(project_engine_version(&db), Some((4, 6)));
1546
1547 let empty = RootDatabase::default();
1549 assert_eq!(project_engine_version(&empty), None);
1550 }
1551
1552 #[test]
1553 fn star_autoload_gdscript_resolves_as_global_and_members() {
1554 let mut db = RootDatabase::default();
1555 db.set_file_text(
1557 FileId(0),
1558 "func score() -> int:\n\treturn 0\n",
1559 Durability::LOW,
1560 );
1561 db.set_file_path(FileId(0), "res://game.gd");
1562 db.set_file_text(
1563 FileId(1),
1564 "func f():\n\tvar s := Game.score()\n",
1565 Durability::LOW,
1566 );
1567 db.set_file_path(FileId(1), "res://main.gd");
1568 db.set_project_config("[autoload]\nGame=\"*res://game.gd\"\n");
1569 db.sync_source_root();
1570 let api = db.engine().unwrap();
1571
1572 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1573 let unit = fi
1574 .units
1575 .iter()
1576 .find(|u| !u.result.bindings.is_empty())
1577 .expect("f unit");
1578 assert_eq!(
1580 unit.result.bindings[0].ty.label(api).as_deref(),
1581 Some("int")
1582 );
1583 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1584 }
1585
1586 #[test]
1587 fn non_star_autoload_is_not_a_global() {
1588 let mut db = RootDatabase::default();
1589 db.set_file_text(
1590 FileId(0),
1591 "func score() -> int:\n\treturn 0\n",
1592 Durability::LOW,
1593 );
1594 db.set_file_path(FileId(0), "res://game.gd");
1595 db.set_file_text(
1596 FileId(1),
1597 "func f():\n\tvar s := Game.score()\n",
1598 Durability::LOW,
1599 );
1600 db.set_file_path(FileId(1), "res://main.gd");
1601 db.set_project_config("[autoload]\nGame=\"res://game.gd\"\n");
1603 db.sync_source_root();
1604 let api = db.engine().unwrap();
1605
1606 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1607 let unit = fi
1608 .units
1609 .iter()
1610 .find(|u| !u.result.bindings.is_empty())
1611 .expect("f unit");
1612 assert_eq!(unit.result.bindings[0].ty.label(api), None);
1614 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1615 }
1616
1617 #[test]
1618 fn tscn_autoload_is_the_seam_never_false_warns() {
1619 let mut db = RootDatabase::default();
1620 db.set_file_text(FileId(0), "func f():\n\tHud.play_song()\n", Durability::LOW);
1623 db.set_file_path(FileId(0), "res://main.gd");
1624 db.set_project_config("[autoload]\nHud=\"*res://hud.tscn\"\n");
1625 db.sync_source_root();
1626
1627 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1628 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1630 }
1631
1632 static AUTOLOAD_OBSERVED: AtomicU32 = AtomicU32::new(0);
1636
1637 #[salsa::tracked]
1638 fn observe_autoload_registry(db: &dyn gdscript_db::Db, config: ProjectConfig) -> usize {
1639 AUTOLOAD_OBSERVED.fetch_add(1, Ordering::SeqCst);
1640 autoload_registry(db, config).len()
1641 }
1642
1643 #[test]
1644 fn autoload_registry_firewalled_against_body_edits() {
1645 let mut db = RootDatabase::default();
1646 db.set_file_text(FileId(0), "func f():\n\tvar a := 1\n", Durability::LOW);
1647 db.set_file_path(FileId(0), "res://game.gd");
1648 db.set_project_config("[autoload]\nGame=\"*res://game.gd\"\n");
1649 db.sync_source_root();
1650 let config = db.project_config().unwrap();
1651
1652 assert_eq!(observe_autoload_registry(&db, config), 1);
1653 let runs = AUTOLOAD_OBSERVED.load(Ordering::SeqCst);
1654
1655 db.set_file_text(FileId(0), "func f():\n\tvar a := 999999\n", Durability::LOW);
1658
1659 assert_eq!(observe_autoload_registry(&db, config), 1);
1660 assert_eq!(
1661 AUTOLOAD_OBSERVED.load(Ordering::SeqCst),
1662 runs,
1663 "REGRESSION: a body edit re-ran an autoload_registry consumer — the config firewall broke",
1664 );
1665 }
1666
1667 static ANALYZE_OBSERVED: AtomicU32 = AtomicU32::new(0);
1673
1674 #[salsa::tracked]
1675 fn observe_analyze_file(db: &dyn gdscript_db::Db, file: FileText) -> usize {
1676 ANALYZE_OBSERVED.fetch_add(1, Ordering::SeqCst);
1677 analyze_file(db, file).raw_warnings.len()
1678 }
1679
1680 #[test]
1681 fn warning_level_edit_does_not_invalidate_analyze_file() {
1682 use crate::warnings::{WarnLevel, WarningCode};
1683
1684 let mut db = RootDatabase::default();
1685 db.set_file_text(
1686 FileId(0),
1687 "func f():\n\tvar x = 5 / 2\n\treturn x\n",
1688 Durability::LOW,
1689 );
1690 db.set_file_path(FileId(0), "res://game.gd");
1691 db.set_project_config(
1692 "[autoload]\nGame=\"*res://game.gd\"\n[debug]\ngdscript/warnings/integer_division=2\n",
1693 );
1694 db.sync_source_root();
1695 let file = db.file_text(FileId(0)).unwrap();
1696 let config = db.project_config().unwrap();
1697
1698 assert_eq!(observe_analyze_file(&db, file), 2);
1701 let runs = ANALYZE_OBSERVED.load(Ordering::SeqCst);
1702 assert_eq!(
1703 warning_settings(&db, config)
1704 .per_code
1705 .get(&WarningCode::IntegerDivision),
1706 Some(&WarnLevel::Error),
1707 );
1708
1709 db.set_project_config(
1713 "[autoload]\nGame=\"*res://game.gd\"\n[debug]\ngdscript/warnings/integer_division=1\n",
1714 );
1715 assert_eq!(observe_analyze_file(&db, file), 2);
1716 assert_eq!(
1717 ANALYZE_OBSERVED.load(Ordering::SeqCst),
1718 runs,
1719 "REGRESSION: a warning-level edit re-ran analyze_file — the W1 gating firewall broke",
1720 );
1721 assert_eq!(
1723 warning_settings(&db, config)
1724 .per_code
1725 .get(&WarningCode::IntegerDivision),
1726 Some(&WarnLevel::Warn),
1727 );
1728 }
1729
1730 #[test]
1731 fn aliased_self_resolves_own_members_no_false_unsafe() {
1732 let mut db = RootDatabase::default();
1736 db.set_file_text(
1737 FileId(0),
1738 "extends Node\nfunc own() -> int:\n\treturn 1\nfunc use_it():\n\tvar me := self\n\tvar n := me.own()\n",
1739 Durability::LOW,
1740 );
1741 db.sync_source_root();
1742 let api = db.engine().unwrap();
1743
1744 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1745 assert!(
1747 fi.units
1748 .iter()
1749 .flat_map(|u| &u.result.bindings)
1750 .any(|b| b.ty.label(api).as_deref() == Some("int")),
1751 "aliased self.own() should resolve to int",
1752 );
1753 assert!(
1754 fi.diagnostics.is_empty(),
1755 "no false UNSAFE on aliased self: {:?}",
1756 fi.diagnostics
1757 );
1758 }
1759
1760 #[test]
1761 fn is_userbase_narrows_to_derived_but_not_un_narrowed_to_base() {
1762 let mut db = RootDatabase::default();
1763 db.set_file_text(
1764 FileId(0),
1765 "class_name Base\nfunc base_m() -> int:\n\treturn 1\n",
1766 Durability::LOW,
1767 );
1768 db.set_file_text(
1769 FileId(1),
1770 "class_name Derived\nextends Base\nfunc own_m() -> String:\n\treturn \"x\"\n",
1771 Durability::LOW,
1772 );
1773 db.set_file_text(
1776 FileId(2),
1777 "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",
1778 Durability::LOW,
1779 );
1780 db.sync_source_root();
1781 let api = db.engine().unwrap();
1782
1783 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1784 let strings = fi
1785 .units
1786 .iter()
1787 .flat_map(|u| &u.result.bindings)
1788 .filter(|b| b.ty.label(api).as_deref() == Some("String"))
1789 .count();
1790 assert!(
1792 strings >= 2,
1793 "expected both own_m() calls to type as String (narrow-down + widen-only), got {strings}",
1794 );
1795 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1796 }
1797
1798 fn scene_db(scene_text: &str, gd_text: &str) -> RootDatabase {
1802 let mut db = RootDatabase::default();
1803 db.set_file_text(FileId(0), scene_text, Durability::LOW);
1804 db.set_file_path(FileId(0), "res://main.tscn");
1805 db.set_file_text(FileId(1), gd_text, Durability::LOW);
1806 db.set_file_path(FileId(1), "res://main.gd");
1807 db.sync_source_root();
1808 db
1809 }
1810
1811 fn binding_labels(db: &RootDatabase) -> Vec<String> {
1812 let api = db.engine().unwrap();
1813 let fi = analyze_file(db, db.file_text(FileId(1)).unwrap());
1814 assert!(
1815 fi.diagnostics.is_empty(),
1816 "unexpected diags: {:?}",
1817 fi.diagnostics
1818 );
1819 fi.units
1820 .iter()
1821 .flat_map(|u| &u.result.bindings)
1822 .filter_map(|b| b.ty.label(api))
1823 .collect()
1824 }
1825
1826 const SCENE: &str = "[gd_scene format=3]\n\
1827 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1828 [node name=\"Root\" type=\"Control\"]\n\
1829 script = ExtResource(\"1\")\n\
1830 [node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
1831 [node name=\"Box\" type=\"VBoxContainer\" parent=\"Panel\"]\n\
1832 [node name=\"Btn\" type=\"Button\" parent=\"Panel/Box\"]\n\
1833 unique_name_in_owner = true\n";
1834
1835 #[test]
1836 fn dollar_path_types_to_the_concrete_node() {
1837 let db = scene_db(
1839 SCENE,
1840 "extends Control\nfunc _ready():\n\tvar b := $Panel/Box/Btn\n",
1841 );
1842 assert!(
1843 binding_labels(&db).iter().any(|l| l == "Button"),
1844 "$Panel/Box/Btn should type as Button",
1845 );
1846 }
1847
1848 #[test]
1849 fn unique_name_path_types_to_the_concrete_node() {
1850 let db = scene_db(SCENE, "extends Control\nfunc _ready():\n\tvar b := %Btn\n");
1852 assert!(
1853 binding_labels(&db).iter().any(|l| l == "Button"),
1854 "%Btn should type as Button"
1855 );
1856 }
1857
1858 #[test]
1859 fn onready_var_from_a_node_path_is_typed() {
1860 let db = scene_db(
1863 SCENE,
1864 "extends Control\n@onready var btn := $Panel/Box/Btn\n",
1865 );
1866 assert!(
1867 binding_labels(&db).iter().any(|l| l == "Button"),
1868 "@onready var := $Path should type to Button",
1869 );
1870 }
1871
1872 #[test]
1873 fn get_node_string_literal_types_like_dollar() {
1874 let db = scene_db(
1876 SCENE,
1877 "extends Control\nfunc _ready():\n\tvar b := get_node(\"Panel/Box/Btn\")\n",
1878 );
1879 assert!(
1880 binding_labels(&db).iter().any(|l| l == "Button"),
1881 "get_node(\"...\") should type as Button",
1882 );
1883 }
1884
1885 #[test]
1886 fn self_get_node_string_literal_types_like_dollar() {
1887 let db = scene_db(
1890 SCENE,
1891 "extends Control\nfunc _ready():\n\tvar b := self.get_node(\"Panel/Box/Btn\")\n",
1892 );
1893 assert!(
1894 binding_labels(&db).iter().any(|l| l == "Button"),
1895 "self.get_node(\"...\") should type as Button",
1896 );
1897 }
1898
1899 #[test]
1900 fn attached_script_refines_the_node_type() {
1901 let mut db = RootDatabase::default();
1904 db.set_file_text(
1905 FileId(0),
1906 "[gd_scene format=3]\n\
1907 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1908 [ext_resource type=\"Script\" path=\"res://fancy.gd\" id=\"2\"]\n\
1909 [node name=\"Root\" type=\"Control\"]\n\
1910 script = ExtResource(\"1\")\n\
1911 [node name=\"That\" type=\"Button\" parent=\".\"]\n\
1912 script = ExtResource(\"2\")\n",
1913 Durability::LOW,
1914 );
1915 db.set_file_path(FileId(0), "res://main.tscn");
1916 db.set_file_text(
1917 FileId(1),
1918 "extends Control\nfunc _ready():\n\tvar n := $That.fancy()\n",
1919 Durability::LOW,
1920 );
1921 db.set_file_path(FileId(1), "res://main.gd");
1922 db.set_file_text(
1923 FileId(2),
1924 "class_name Fancy\nextends Button\nfunc fancy() -> int:\n\treturn 1\n",
1925 Durability::LOW,
1926 );
1927 db.set_file_path(FileId(2), "res://fancy.gd");
1928 db.sync_source_root();
1929 assert!(
1930 binding_labels(&db).iter().any(|l| l == "int"),
1931 "$That.fancy() should resolve via the attached script Fancy",
1932 );
1933 }
1934
1935 #[test]
1936 fn computed_or_unresolvable_node_path_stays_node_without_warning() {
1937 let mut db = RootDatabase::default();
1940 db.set_file_text(
1941 FileId(1),
1942 "extends Node\nfunc f(p: NodePath):\n\tvar a := get_node(p)\n\tvar b := $Nope\n",
1946 Durability::LOW,
1947 );
1948 db.set_file_path(FileId(1), "res://lone.gd");
1949 db.sync_source_root();
1950 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1951 assert!(
1952 fi.diagnostics.is_empty(),
1953 "no false node-path warnings: {:?}",
1954 fi.diagnostics
1955 );
1956 }
1957
1958 fn has_invalid_node_path(db: &RootDatabase) -> bool {
1961 let fi = analyze_file(db, db.file_text(FileId(1)).unwrap());
1962 fi.diagnostics
1963 .iter()
1964 .any(|d| d.code == crate::infer::INVALID_NODE_PATH)
1965 }
1966
1967 #[test]
1968 fn invalid_node_path_warns_when_genuinely_absent_in_a_single_owning_scene() {
1969 let db = scene_db(SCENE, "extends Control\nfunc _ready():\n\tvar b := $Nope\n");
1970 assert!(
1971 has_invalid_node_path(&db),
1972 "$Nope is absent in the one owning scene → warn"
1973 );
1974 }
1975
1976 #[test]
1977 fn escape_and_absolute_paths_never_warn() {
1978 let db = scene_db(
1980 SCENE,
1981 "extends Control\nfunc _ready():\n\tvar a := $\"../Sibling\"\n\tvar c := $\"/root/Global\"\n",
1982 );
1983 assert!(!has_invalid_node_path(&db), "escape paths must not warn");
1984 }
1985
1986 #[test]
1987 fn path_descending_into_an_instanced_subscene_never_warns() {
1988 let db = scene_db(
1991 "[gd_scene format=3]\n\
1992 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1993 [ext_resource type=\"PackedScene\" path=\"res://player.tscn\" id=\"2\"]\n\
1994 [node name=\"Root\" type=\"Control\"]\n\
1995 script = ExtResource(\"1\")\n\
1996 [node name=\"Player\" parent=\".\" instance=ExtResource(\"2\")]\n",
1997 "extends Control\nfunc _ready():\n\tvar g := $Player/Gun\n",
1998 );
1999 assert!(
2000 !has_invalid_node_path(&db),
2001 "into-instance miss must not warn"
2002 );
2003 }
2004
2005 #[test]
2006 fn ambiguous_multi_scene_attachment_suppresses_the_invalid_warning() {
2007 let mut db = RootDatabase::default();
2010 db.set_file_text(
2011 FileId(0),
2012 "[gd_scene format=3]\n\
2013 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
2014 [node name=\"Root\" type=\"Control\"]\n\
2015 script = ExtResource(\"1\")\n\
2016 [node name=\"Alpha\" type=\"Button\" parent=\".\"]\n",
2017 Durability::LOW,
2018 );
2019 db.set_file_path(FileId(0), "res://a.tscn");
2020 db.set_file_text(
2021 FileId(2),
2022 "[gd_scene format=3]\n\
2023 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
2024 [node name=\"Root\" type=\"Control\"]\n\
2025 script = ExtResource(\"1\")\n\
2026 [node name=\"Beta\" type=\"Button\" parent=\".\"]\n",
2027 Durability::LOW,
2028 );
2029 db.set_file_path(FileId(2), "res://b.tscn");
2030 db.set_file_text(
2031 FileId(1),
2032 "extends Control\nfunc _ready():\n\tvar b := $Beta\n",
2033 Durability::LOW,
2034 );
2035 db.set_file_path(FileId(1), "res://main.gd");
2036 db.sync_source_root();
2037 assert!(
2038 !has_invalid_node_path(&db),
2039 "ambiguous multi-scene attachment must not warn"
2040 );
2041 }
2042
2043 #[test]
2046 fn instanced_node_recurses_into_the_subscene_root_script() {
2047 let mut db = RootDatabase::default();
2052 db.set_file_text(
2053 FileId(0),
2054 "[gd_scene format=3]\n\
2055 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
2056 [ext_resource type=\"PackedScene\" path=\"res://enemy.tscn\" id=\"2\"]\n\
2057 [node name=\"Root\" type=\"Control\"]\n\
2058 script = ExtResource(\"1\")\n\
2059 [node name=\"Enemy\" parent=\".\" instance=ExtResource(\"2\")]\n",
2060 Durability::LOW,
2061 );
2062 db.set_file_path(FileId(0), "res://main.tscn");
2063 db.set_file_text(
2064 FileId(1),
2065 "extends Control\nfunc _ready():\n\tvar e := $Enemy.hp()\n",
2066 Durability::LOW,
2067 );
2068 db.set_file_path(FileId(1), "res://main.gd");
2069 db.set_file_text(
2070 FileId(2),
2071 "[gd_scene format=3]\n\
2072 [ext_resource type=\"Script\" path=\"res://enemy.gd\" id=\"1\"]\n\
2073 [node name=\"Enemy\" type=\"Button\"]\n\
2074 script = ExtResource(\"1\")\n",
2075 Durability::LOW,
2076 );
2077 db.set_file_path(FileId(2), "res://enemy.tscn");
2078 db.set_file_text(
2079 FileId(3),
2080 "class_name Enemy\nextends Button\nfunc hp() -> int:\n\treturn 1\n",
2081 Durability::LOW,
2082 );
2083 db.set_file_path(FileId(3), "res://enemy.gd");
2084 db.sync_source_root();
2085 assert!(
2086 binding_labels(&db).iter().any(|l| l == "int"),
2087 "$Enemy.hp() should recurse into the instanced sub-scene root's script Enemy",
2088 );
2089 }
2090
2091 #[test]
2092 fn path_into_an_instanced_subscene_types_the_inner_node() {
2093 let mut db = RootDatabase::default();
2099 db.set_file_text(
2100 FileId(0),
2101 "[gd_scene format=3]\n\
2102 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
2103 [ext_resource type=\"PackedScene\" path=\"res://enemy.tscn\" id=\"2\"]\n\
2104 [node name=\"Root\" type=\"Control\"]\n\
2105 script = ExtResource(\"1\")\n\
2106 [node name=\"Enemy\" parent=\".\" instance=ExtResource(\"2\")]\n",
2107 Durability::LOW,
2108 );
2109 db.set_file_path(FileId(0), "res://main.tscn");
2110 db.set_file_text(
2111 FileId(1),
2112 "extends Control\nfunc _ready():\n\tvar s := $Enemy/Sprite\n\tvar n := $Enemy/Nope\n",
2113 Durability::LOW,
2114 );
2115 db.set_file_path(FileId(1), "res://main.gd");
2116 db.set_file_text(
2117 FileId(2),
2118 "[gd_scene format=3]\n\
2119 [node name=\"Enemy\" type=\"Node2D\"]\n\
2120 [node name=\"Sprite\" type=\"Sprite2D\" parent=\".\"]\n",
2121 Durability::LOW,
2122 );
2123 db.set_file_path(FileId(2), "res://enemy.tscn");
2124 db.sync_source_root();
2125 assert!(
2126 binding_labels(&db).iter().any(|l| l == "Sprite2D"),
2127 "$Enemy/Sprite should type as the sub-scene's Sprite (Sprite2D)",
2128 );
2129 }
2130
2131 #[test]
2132 fn override_child_under_an_instance_types_from_the_subscene() {
2133 let mut db = RootDatabase::default();
2139 db.set_file_text(
2140 FileId(0),
2141 "[gd_scene format=3]\n\
2142 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
2143 [ext_resource type=\"PackedScene\" path=\"res://enemy.tscn\" id=\"2\"]\n\
2144 [node name=\"Root\" type=\"Control\"]\n\
2145 script = ExtResource(\"1\")\n\
2146 [node name=\"Enemy\" parent=\".\" instance=ExtResource(\"2\")]\n\
2147 [node name=\"Sprite\" parent=\"Enemy\"]\n\
2148 modulate = Color(1, 0, 0, 1)\n",
2149 Durability::LOW,
2150 );
2151 db.set_file_path(FileId(0), "res://main.tscn");
2152 db.set_file_text(
2153 FileId(1),
2154 "extends Control\nfunc _ready():\n\tvar s := $Enemy/Sprite\n",
2155 Durability::LOW,
2156 );
2157 db.set_file_path(FileId(1), "res://main.gd");
2158 db.set_file_text(
2159 FileId(2),
2160 "[gd_scene format=3]\n\
2161 [node name=\"Enemy\" type=\"Node2D\"]\n\
2162 [node name=\"Sprite\" type=\"Sprite2D\" parent=\".\"]\n",
2163 Durability::LOW,
2164 );
2165 db.set_file_path(FileId(2), "res://enemy.tscn");
2166 db.sync_source_root();
2167 assert!(
2168 binding_labels(&db).iter().any(|l| l == "Sprite2D"),
2169 "an override child under an instance types from the sub-scene (Sprite2D), got: {:?}",
2170 binding_labels(&db),
2171 );
2172 }
2173
2174 #[test]
2175 fn inner_class_value_and_members_type_via_its_item_tree() {
2176 let mut db = RootDatabase::default();
2182 db.set_file_text(
2183 FileId(0),
2184 "class Inner extends Node:\n\
2185 \tvar hp: int = 5\n\
2186 \tfunc ping() -> int:\n\
2187 \t\treturn 1\n\
2188 \tfunc combo() -> int:\n\
2189 \t\tvar s := self.get_class()\n\
2190 \t\treturn hp + ping()\n\
2191 func f():\n\
2192 \tvar x := Inner.new()\n\
2193 \tvar a := x.hp\n\
2194 \tvar b := x.ping()\n\
2195 \tvar c := x.get_class()\n",
2196 Durability::LOW,
2197 );
2198 db.sync_source_root();
2199 let ft = db.file_text(FileId(0)).unwrap();
2200 let fi = analyze_file(&db, ft);
2201 assert!(
2202 fi.diagnostics.is_empty(),
2203 "no hard diagnostics expected: {:?}",
2204 fi.diagnostics
2205 );
2206 let api = db.engine().unwrap();
2207 let labels: Vec<String> = fi
2208 .units
2209 .iter()
2210 .flat_map(|u| &u.result.bindings)
2211 .filter_map(|b| b.ty.label(api))
2212 .collect();
2213 assert!(
2214 labels.iter().filter(|l| *l == "int").count() >= 2,
2215 "x.hp and x.ping() should both type as int: {labels:?}"
2216 );
2217 assert!(
2218 labels.iter().any(|l| l == "String"),
2219 "x.get_class() should resolve via the inner class's `extends Node` chain to String: {labels:?}"
2220 );
2221 }
2222
2223 #[test]
2226 fn unique_name_subpath_resolves_to_the_child_without_warning() {
2227 let db = scene_db(
2231 "[gd_scene format=3]\n\
2232 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
2233 [node name=\"Root\" type=\"Control\"]\n\
2234 script = ExtResource(\"1\")\n\
2235 [node name=\"Box\" type=\"VBoxContainer\" parent=\".\"]\n\
2236 unique_name_in_owner = true\n\
2237 [node name=\"Btn\" type=\"Button\" parent=\"Box\"]\n",
2238 "extends Control\nfunc _ready():\n\tvar b := %Box/Btn\n",
2239 );
2240 assert!(
2241 binding_labels(&db).iter().any(|l| l == "Button"),
2242 "%Box/Btn → Button (and no false INVALID_NODE_PATH)",
2243 );
2244 }
2245
2246 #[test]
2247 fn percent_prefixed_string_paths_resolve_as_unique_without_warning() {
2248 let db = scene_db(
2251 SCENE,
2252 "extends Control\nfunc _ready():\n\tvar a := get_node(\"%Btn\")\n\tvar b := $\"%Btn\"\n",
2253 );
2254 let labels = binding_labels(&db);
2255 assert!(
2256 labels.iter().filter(|l| *l == "Button").count() >= 2,
2257 "both %Btn string forms should resolve to Button: {labels:?}",
2258 );
2259 }
2260}