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(f.tuple_return.as_ref().map_or_else(
315 || resolve_ann(f.return_type.as_deref()),
316 |names| crate::resolve::resolve_tuple_return(db, api, names),
317 )),
318 Member::Var(v) => MemberSig::Field(resolve_ann(v.type_ref.as_deref())),
319 Member::Const(c) => MemberSig::Field(
324 c.type_ref
325 .is_none()
326 .then_some(c.preload_path.as_deref())
327 .flatten()
328 .and_then(|raw| {
329 crate::resolve::anchor_res_path(file.res_path(db).as_deref(), raw)
330 })
331 .map_or_else(
332 || resolve_ann(c.type_ref.as_deref()),
333 |abs| {
334 crate::resolve::resolve_external(
335 db,
336 &crate::resolve::ExternalRef::Preload(abs),
337 )
338 },
339 ),
340 ),
341 Member::Signal(_) => MemberSig::Signal,
342 Member::Enum(_) | Member::Class(_) => continue,
344 };
345 members.insert(SmolStr::new(name), sig);
346 }
347 let base = crate::resolve::resolve_base(db, api, &tree, file.res_path(db).as_deref());
350 Arc::new(ScriptClass { members, base })
351}
352
353#[must_use]
358pub fn is_scene_path(path: &str) -> bool {
359 let ext = path.rsplit('.').next().unwrap_or("");
360 ext.eq_ignore_ascii_case("tscn") || ext.eq_ignore_ascii_case("tres")
361}
362
363#[salsa::tracked]
367pub fn scene_model(db: &dyn Db, file: FileText) -> Arc<SceneModel> {
368 let is_scene = file.res_path(db).as_deref().is_some_and(is_scene_path);
369 if is_scene {
370 Arc::new(gdscript_scene::parse_scene(file.text(db)))
371 } else {
372 Arc::new(gdscript_scene::parse_scene(""))
373 }
374}
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
379pub struct SceneAttach {
380 pub scene: FileId,
382 pub node: NodeIdx,
384 pub ambiguous: bool,
387}
388
389#[salsa::tracked]
396pub fn script_scene_index(db: &dyn Db, root: SourceRoot) -> Arc<FxHashMap<SmolStr, SceneAttach>> {
397 let mut map: FxHashMap<SmolStr, SceneAttach> = FxHashMap::default();
398 for &file in root.files(db) {
399 if !file.res_path(db).as_deref().is_some_and(is_scene_path) {
400 continue;
401 }
402 let model = scene_model(db, file);
403 let scene = file.file_id(db);
404 for (i, node) in model.nodes.iter().enumerate() {
405 let Some(script_id) = node.script.as_ref() else {
406 continue;
407 };
408 let Some(path) = model
409 .ext_resources
410 .get(script_id)
411 .and_then(|e| e.path.clone())
412 else {
413 continue;
414 };
415 let node = NodeIdx(u32::try_from(i).unwrap_or(u32::MAX));
416 match map.get_mut(&path) {
417 Some(existing) => existing.ambiguous = true,
419 None => {
420 map.insert(
421 path,
422 SceneAttach {
423 scene,
424 node,
425 ambiguous: false,
426 },
427 );
428 }
429 }
430 }
431 }
432 Arc::new(map)
433}
434
435#[salsa::tracked]
440pub fn script_scene_attachments(
441 db: &dyn Db,
442 root: SourceRoot,
443) -> Arc<FxHashMap<SmolStr, Vec<(FileId, NodeIdx)>>> {
444 let mut map: FxHashMap<SmolStr, Vec<(FileId, NodeIdx)>> = FxHashMap::default();
445 for &file in root.files(db) {
446 if !file.res_path(db).as_deref().is_some_and(is_scene_path) {
447 continue;
448 }
449 let model = scene_model(db, file);
450 let scene = file.file_id(db);
451 for (i, node) in model.nodes.iter().enumerate() {
452 let Some(path) = node
453 .script
454 .as_ref()
455 .and_then(|id| model.ext_resources.get(id))
456 .and_then(|e| e.path.clone())
457 else {
458 continue;
459 };
460 map.entry(path)
461 .or_default()
462 .push((scene, NodeIdx(u32::try_from(i).unwrap_or(u32::MAX))));
463 }
464 }
465 Arc::new(map)
466}
467
468#[must_use]
473pub fn scene_context(db: &dyn Db, file: FileText) -> Option<SceneContext> {
474 let res_path = file.res_path(db)?;
475 let root = db.source_root()?;
476 let attach = *script_scene_index(db, root).get(res_path.as_str())?;
477 let scene_file = db.file_text(attach.scene)?;
478 Some(SceneContext {
479 scene: attach.scene,
480 model: scene_model(db, scene_file),
481 attach: attach.node,
482 ambiguous: attach.ambiguous,
483 })
484}
485
486#[derive(Debug, Clone)]
489pub struct SceneContext {
490 pub scene: FileId,
492 pub model: Arc<SceneModel>,
494 pub attach: NodeIdx,
496 pub ambiguous: bool,
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 use gdscript_base::FileId;
504 use gdscript_db::RootDatabase;
505 use salsa::Durability;
506
507 fn db_with(src: &str) -> (RootDatabase, FileText) {
508 let mut db = RootDatabase::default();
509 db.set_file_text(FileId(0), src, Durability::LOW);
510 let ft = db.file_text(FileId(0)).unwrap();
511 (db, ft)
512 }
513
514 #[test]
515 fn tracked_item_tree_matches_the_plain_fn() {
516 let (db, ft) = db_with("class_name Foo\nfunc f():\n\tpass\n");
517 let tree = item_tree(&db, ft);
518 assert_eq!(tree.class_name.as_deref(), Some("Foo"));
519 assert_eq!(item_tree(&db, ft), tree);
521 }
522
523 #[test]
524 fn tracked_analyze_file_runs_inference() {
525 let (db, ft) = db_with("func add(a: int, b: int) -> int:\n\treturn a + b\n");
526 let fi = analyze_file(&db, ft);
527 assert!(!fi.units.is_empty());
529 assert!(fi.diagnostics.is_empty());
530 }
531
532 use std::sync::atomic::{AtomicU32, Ordering};
542
543 static WITNESS_RUNS: AtomicU32 = AtomicU32::new(0);
544
545 #[salsa::tracked]
547 fn class_name_witness(db: &dyn gdscript_db::Db, file: FileText) -> Option<smol_str::SmolStr> {
548 WITNESS_RUNS.fetch_add(1, Ordering::SeqCst);
549 item_tree(db, file).class_name.clone()
550 }
551
552 #[test]
553 fn body_edit_does_not_invalidate_signature_queries() {
554 let mut db = RootDatabase::default();
555 db.set_file_text(
556 FileId(0),
557 "class_name Foo\nfunc f():\n\tvar a := 1\n",
558 Durability::LOW,
559 );
560 let ft = db.file_text(FileId(0)).unwrap();
561
562 assert_eq!(class_name_witness(&db, ft).as_deref(), Some("Foo"));
564 let runs_after_warm = WITNESS_RUNS.load(Ordering::SeqCst);
565
566 db.set_file_text(
569 FileId(0),
570 "class_name Foo\nfunc f():\n\tvar a := 2\n",
571 Durability::LOW,
572 );
573 assert_eq!(class_name_witness(&db, ft).as_deref(), Some("Foo"));
574
575 assert_eq!(
576 WITNESS_RUNS.load(Ordering::SeqCst),
577 runs_after_warm,
578 "REGRESSION: a body edit re-ran a signature-only query — the item_tree firewall broke",
579 );
580 }
581
582 #[test]
583 fn global_registry_resolves_class_names_across_files() {
584 let mut db = RootDatabase::default();
585 db.set_file_text(
586 FileId(0),
587 "class_name Player\nfunc f():\n\tpass\n",
588 Durability::LOW,
589 );
590 db.set_file_text(
591 FileId(1),
592 "class_name Enemy\nvar hp := 10\n",
593 Durability::LOW,
594 );
595 db.set_file_text(FileId(2), "func no_class():\n\tpass\n", Durability::LOW);
596 db.sync_source_root();
597 let root = db.source_root().unwrap();
598
599 let reg = global_registry(&db, root);
600 assert_eq!(reg.len(), 2);
601 assert_eq!(reg.resolve("Player"), db.file_text(FileId(0)));
602 assert_eq!(reg.resolve("Enemy"), db.file_text(FileId(1)));
603 assert!(reg.resolve("Nonexistent").is_none());
604 }
605
606 static REGISTRY_OBSERVED: AtomicU32 = AtomicU32::new(0);
613
614 #[salsa::tracked]
616 fn observe_registry(db: &dyn gdscript_db::Db, root: SourceRoot) -> usize {
617 REGISTRY_OBSERVED.fetch_add(1, Ordering::SeqCst);
618 global_registry(db, root).len()
619 }
620
621 #[test]
622 fn body_edit_does_not_invalidate_the_global_registry() {
623 let mut db = RootDatabase::default();
624 db.set_file_text(
625 FileId(0),
626 "class_name Player\nfunc f():\n\tvar a := 1\n",
627 Durability::LOW,
628 );
629 db.set_file_text(FileId(1), "class_name Enemy\n", Durability::LOW);
630 db.sync_source_root();
631 let root = db.source_root().unwrap();
632
633 assert_eq!(observe_registry(&db, root), 2);
634 let runs = REGISTRY_OBSERVED.load(Ordering::SeqCst);
635
636 db.set_file_text(
639 FileId(0),
640 "class_name Player\nfunc f():\n\tvar a := 123456\n",
641 Durability::LOW,
642 );
643
644 assert_eq!(observe_registry(&db, root), 2);
645 assert_eq!(
646 REGISTRY_OBSERVED.load(Ordering::SeqCst),
647 runs,
648 "REGRESSION: a body edit re-ran a global_registry consumer — the cross-file firewall broke",
649 );
650 }
651
652 #[test]
653 fn cross_file_class_name_member_resolves() {
654 let mut db = RootDatabase::default();
655 db.set_file_text(
656 FileId(0),
657 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
658 Durability::LOW,
659 );
660 db.set_file_text(
661 FileId(1),
662 "func use_it():\n\tvar w := Widget.make()\n",
663 Durability::LOW,
664 );
665 db.sync_source_root();
666
667 let file1 = db.file_text(FileId(1)).unwrap();
668 let fi = analyze_file(&db, file1);
669 let api = db.engine().unwrap();
670
671 let unit = fi
674 .units
675 .iter()
676 .find(|u| !u.result.bindings.is_empty())
677 .expect("a unit with a binding");
678 assert_eq!(
679 unit.result.bindings[0].ty.label(api).as_deref(),
680 Some("int")
681 );
682 assert!(
683 fi.diagnostics.is_empty(),
684 "unexpected diagnostics: {:?}",
685 fi.diagnostics
686 );
687 }
688
689 use crate::infer::SHADOWED_GLOBAL_IDENTIFIER;
692
693 fn shadow_codes(fi: &Arc<FileInference>) -> Vec<&str> {
694 fi.diagnostics
695 .iter()
696 .filter(|d| d.code == SHADOWED_GLOBAL_IDENTIFIER)
697 .map(|d| d.code.as_str())
698 .collect()
699 }
700
701 #[test]
702 fn class_name_collisions_names_only_the_duplicates() {
703 let mut db = RootDatabase::default();
704 db.set_file_text(FileId(0), "class_name Dup\n", Durability::LOW);
705 db.set_file_text(FileId(1), "class_name Dup\n", Durability::LOW);
706 db.set_file_text(FileId(2), "class_name Unique\n", Durability::LOW);
707 db.sync_source_root();
708 let root = db.source_root().unwrap();
709
710 let cols = class_name_collisions(&db, root);
711 assert!(cols.contains(&SmolStr::new("Dup")));
712 assert!(
713 !cols.contains(&SmolStr::new("Unique")),
714 "a singly-declared class_name is not a collision",
715 );
716 assert_eq!(cols.len(), 1);
717 }
718
719 #[test]
720 fn missing_tool_warns_when_extending_a_tool_base_without_tool() {
721 let mut db = RootDatabase::default();
722 db.set_file_text(FileId(0), "@tool\nclass_name ToolBase\n", Durability::LOW);
723 db.set_file_text(
724 FileId(1),
725 "extends ToolBase\nfunc f():\n\tpass\n",
726 Durability::LOW,
727 );
728 db.sync_source_root();
729 let derived = analyze_file(&db, db.file_text(FileId(1)).unwrap());
730 assert!(
731 derived
732 .raw_warnings
733 .iter()
734 .any(|w| w.code.as_str() == "MISSING_TOOL"),
735 "{:?}",
736 derived
737 .raw_warnings
738 .iter()
739 .map(|w| w.code.as_str())
740 .collect::<Vec<_>>()
741 );
742 let base = analyze_file(&db, db.file_text(FileId(0)).unwrap());
744 assert!(
745 !base
746 .raw_warnings
747 .iter()
748 .any(|w| w.code.as_str() == "MISSING_TOOL")
749 );
750 }
751
752 #[test]
753 fn a_tool_class_extending_a_tool_base_is_silent() {
754 let mut db = RootDatabase::default();
755 db.set_file_text(FileId(0), "@tool\nclass_name ToolBase\n", Durability::LOW);
756 db.set_file_text(FileId(1), "@tool\nextends ToolBase\n", Durability::LOW);
757 db.sync_source_root();
758 let derived = analyze_file(&db, db.file_text(FileId(1)).unwrap());
759 assert!(
760 !derived
761 .raw_warnings
762 .iter()
763 .any(|w| w.code.as_str() == "MISSING_TOOL")
764 );
765 }
766
767 #[test]
768 fn duplicate_class_name_warns_at_both_declarations() {
769 let mut db = RootDatabase::default();
770 db.set_file_text(
771 FileId(0),
772 "class_name Dup\nfunc f():\n\tpass\n",
773 Durability::LOW,
774 );
775 db.set_file_text(FileId(1), "class_name Dup\nvar x := 1\n", Durability::LOW);
776 db.sync_source_root();
777
778 for fid in [0, 1] {
779 let fi = analyze_file(&db, db.file_text(FileId(fid)).unwrap());
780 assert!(
781 shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
782 "file {fid} should warn on the duplicate class_name: {:?}",
783 fi.diagnostics
784 );
785 let d = fi
787 .diagnostics
788 .iter()
789 .find(|d| d.code == SHADOWED_GLOBAL_IDENTIFIER)
790 .unwrap();
791 assert_eq!(d.range, gdscript_base::TextRange::new(11, 14));
792 }
793 }
794
795 #[test]
796 fn class_name_shadowing_an_engine_class_warns() {
797 let mut db = RootDatabase::default();
798 db.set_file_text(
800 FileId(0),
801 "class_name Node\nfunc f():\n\tpass\n",
802 Durability::LOW,
803 );
804 db.sync_source_root();
805
806 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
807 assert!(
808 shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
809 "class_name Node must warn (shadows the engine class): {:?}",
810 fi.diagnostics
811 );
812 }
813
814 #[test]
815 fn class_name_shadowing_a_builtin_type_warns() {
816 let mut db = RootDatabase::default();
817 db.set_file_text(FileId(0), "class_name Vector2\n", Durability::LOW);
819 db.sync_source_root();
820
821 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
822 assert!(
823 shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
824 "{:?}",
825 fi.diagnostics
826 );
827 }
828
829 #[test]
830 fn class_name_shadowing_a_star_autoload_warns() {
831 let mut db = RootDatabase::default();
832 db.set_file_text(
833 FileId(0),
834 "class_name Game\nfunc f():\n\tpass\n",
835 Durability::LOW,
836 );
837 db.set_file_path(FileId(0), "res://game.gd");
838 db.set_project_config("[autoload]\nGame=\"*res://other.gd\"\n");
840 db.sync_source_root();
841
842 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
843 assert!(
844 shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
845 "class_name Game must warn (shadows the `*Game` autoload): {:?}",
846 fi.diagnostics
847 );
848 }
849
850 #[test]
851 fn unique_non_shadowing_class_name_does_not_warn() {
852 let mut db = RootDatabase::default();
854 db.set_file_text(
855 FileId(0),
856 "class_name MyVeryOwnUniquePlayer\nfunc f():\n\tpass\n",
857 Durability::LOW,
858 );
859 db.set_file_text(
860 FileId(1),
861 "class_name AnotherUniqueEnemy\n",
862 Durability::LOW,
863 );
864 db.sync_source_root();
865
866 for fid in [0, 1] {
867 let fi = analyze_file(&db, db.file_text(FileId(fid)).unwrap());
868 assert!(
869 shadow_codes(&fi).is_empty(),
870 "file {fid}: a unique class_name must not warn: {:?}",
871 fi.diagnostics
872 );
873 }
874 }
875
876 #[test]
877 fn unknown_member_on_script_ref_is_seam_not_warning() {
878 let mut db = RootDatabase::default();
879 db.set_file_text(
880 FileId(0),
881 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
882 Durability::LOW,
883 );
884 db.set_file_text(
885 FileId(1),
886 "func use_it():\n\tWidget.not_a_member()\n",
887 Durability::LOW,
888 );
889 db.sync_source_root();
890
891 let file1 = db.file_text(FileId(1)).unwrap();
892 let fi = analyze_file(&db, file1);
893 assert!(
895 fi.diagnostics.is_empty(),
896 "a missing member on a ScriptRef must not warn: {:?}",
897 fi.diagnostics
898 );
899 }
900
901 #[test]
902 fn inherited_members_resolve_through_user_and_engine_bases() {
903 let mut db = RootDatabase::default();
904 db.set_file_text(
906 FileId(0),
907 "class_name Base\nextends Node\nfunc base_method() -> int:\n\treturn 1\n",
908 Durability::LOW,
909 );
910 db.set_file_text(
911 FileId(1),
912 "class_name Derived\nextends Base\nfunc own() -> String:\n\treturn \"x\"\n",
913 Durability::LOW,
914 );
915 db.set_file_text(
916 FileId(2),
917 "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",
918 Durability::LOW,
919 );
920 db.sync_source_root();
921 let api = db.engine().unwrap();
922
923 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
924 let unit = fi
925 .units
926 .iter()
927 .find(|u| u.result.bindings.len() >= 4)
928 .expect("use_it unit with 4 bindings");
929 assert_eq!(
931 unit.result.bindings[1].ty.label(api).as_deref(),
932 Some("String")
933 );
934 assert_eq!(
935 unit.result.bindings[2].ty.label(api).as_deref(),
936 Some("int")
937 );
938 assert_eq!(
939 unit.result.bindings[3].ty.label(api).as_deref(),
940 Some("int")
941 );
942 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
943 }
944
945 #[test]
946 fn cyclic_extends_flags_each_cycle_member_and_terminates() {
947 use crate::infer::CYCLIC_INHERITANCE;
948 let mut db = RootDatabase::default();
949 db.set_file_text(FileId(0), "class_name A\nextends B\n", Durability::LOW);
952 db.set_file_text(FileId(1), "class_name B\nextends A\n", Durability::LOW);
953 db.set_file_text(
955 FileId(2),
956 "func use_it():\n\tvar a: A\n\tvar x := a.nope()\n",
957 Durability::LOW,
958 );
959 db.sync_source_root();
960
961 for id in [FileId(0), FileId(1)] {
963 let fi = analyze_file(&db, db.file_text(id).unwrap());
964 let cyclic: Vec<_> = fi
965 .diagnostics
966 .iter()
967 .filter(|d| d.code == CYCLIC_INHERITANCE)
968 .collect();
969 assert_eq!(cyclic.len(), 1, "file {id:?}: {:?}", fi.diagnostics);
970 }
971
972 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
975 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
976 }
977
978 #[test]
979 fn cyclic_extends_via_res_path_two_files_flags_no_hang() {
980 use crate::infer::CYCLIC_INHERITANCE;
981 let mut db = RootDatabase::default();
982 set_with_path(&mut db, 0, "res://a.gd", "extends \"res://b.gd\"\n");
984 set_with_path(&mut db, 1, "res://b.gd", "extends \"res://a.gd\"\n");
985 db.sync_source_root();
986
987 for id in [FileId(0), FileId(1)] {
988 let fi = analyze_file(&db, db.file_text(id).unwrap());
989 assert!(
990 fi.diagnostics.iter().any(|d| d.code == CYCLIC_INHERITANCE),
991 "file {id:?} expected CYCLIC_INHERITANCE: {:?}",
992 fi.diagnostics
993 );
994 }
995 }
996
997 #[test]
998 fn deep_acyclic_extends_chain_does_not_false_fire() {
999 use crate::infer::CYCLIC_INHERITANCE;
1000 let mut db = RootDatabase::default();
1001 db.set_file_text(FileId(0), "class_name C0\nextends C1\n", Durability::LOW);
1004 db.set_file_text(FileId(1), "class_name C1\nextends C2\n", Durability::LOW);
1005 db.set_file_text(FileId(2), "class_name C2\nextends C3\n", Durability::LOW);
1006 db.set_file_text(FileId(3), "class_name C3\nextends C4\n", Durability::LOW);
1007 db.set_file_text(FileId(4), "class_name C4\nextends Node\n", Durability::LOW);
1008 db.sync_source_root();
1009
1010 for id in (0..5).map(FileId) {
1011 let fi = analyze_file(&db, db.file_text(id).unwrap());
1012 assert!(
1013 !fi.diagnostics.iter().any(|d| d.code == CYCLIC_INHERITANCE),
1014 "file {id:?} false-fired CYCLIC_INHERITANCE: {:?}",
1015 fi.diagnostics
1016 );
1017 }
1018 }
1019
1020 fn set_with_path(db: &mut RootDatabase, id: u32, path: &str, src: &str) {
1024 db.set_file_text(FileId(id), src, Durability::LOW);
1025 db.set_file_path(FileId(id), path);
1026 }
1027
1028 #[test]
1029 fn res_path_registry_maps_paths_to_files() {
1030 let mut db = RootDatabase::default();
1031 set_with_path(&mut db, 0, "res://a.gd", "class_name A\n");
1032 set_with_path(&mut db, 1, "res://sub/b.gd", "func f():\n\tpass\n");
1033 db.set_file_text(FileId(2), "func no_path():\n\tpass\n", Durability::LOW); db.sync_source_root();
1035 let root = db.source_root().unwrap();
1036
1037 let reg = res_path_registry(&db, root);
1038 assert_eq!(reg.get("res://a.gd"), Some(&FileId(0)));
1039 assert_eq!(reg.get("res://sub/b.gd"), Some(&FileId(1)));
1040 assert!(reg.get("res://missing.gd").is_none());
1041 assert_eq!(reg.len(), 2);
1043 }
1044
1045 static RES_REGISTRY_OBSERVED: AtomicU32 = AtomicU32::new(0);
1050
1051 #[salsa::tracked]
1052 fn observe_res_registry(db: &dyn gdscript_db::Db, root: SourceRoot) -> usize {
1053 RES_REGISTRY_OBSERVED.fetch_add(1, Ordering::SeqCst);
1054 res_path_registry(db, root).len()
1055 }
1056
1057 #[test]
1058 fn body_edit_does_not_invalidate_the_res_path_registry() {
1059 let mut db = RootDatabase::default();
1060 set_with_path(&mut db, 0, "res://a.gd", "func f():\n\tvar a := 1\n");
1061 db.sync_source_root();
1062 let root = db.source_root().unwrap();
1063
1064 assert_eq!(observe_res_registry(&db, root), 1);
1065 let runs = RES_REGISTRY_OBSERVED.load(Ordering::SeqCst);
1066
1067 db.set_file_text(FileId(0), "func f():\n\tvar a := 123456\n", Durability::LOW);
1070
1071 assert_eq!(observe_res_registry(&db, root), 1);
1072 assert_eq!(
1073 RES_REGISTRY_OBSERVED.load(Ordering::SeqCst),
1074 runs,
1075 "REGRESSION: a body edit re-ran a res_path_registry consumer — the path firewall broke",
1076 );
1077 }
1078
1079 #[test]
1080 fn preload_const_resolves_to_script_ref_members() {
1081 let mut db = RootDatabase::default();
1082 set_with_path(
1083 &mut db,
1084 0,
1085 "res://widget.gd",
1086 "class_name Widget\nfunc make() -> int:\n\treturn 5\nconst MAX := 10\n",
1087 );
1088 set_with_path(
1089 &mut db,
1090 1,
1091 "res://main.gd",
1092 "const W = preload(\"res://widget.gd\")\nfunc use_it():\n\tvar a := W.make()\n\tvar b := W.new()\n",
1093 );
1094 db.sync_source_root();
1095 let api = db.engine().unwrap();
1096
1097 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1098 let unit = fi
1099 .units
1100 .iter()
1101 .find(|u| u.result.bindings.len() >= 2)
1102 .expect("use_it unit with 2 bindings");
1103 assert_eq!(
1105 unit.result.bindings[0].ty.label(api).as_deref(),
1106 Some("int")
1107 );
1108 assert!(
1109 matches!(unit.result.bindings[1].ty, Ty::ScriptRef(_)),
1110 "W.new() should be a script instance, got {:?}",
1111 unit.result.bindings[1].ty
1112 );
1113 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1114 }
1115
1116 #[test]
1117 fn cross_file_preload_const_member_resolves() {
1118 let mut db = RootDatabase::default();
1122 set_with_path(
1123 &mut db,
1124 0,
1125 "res://widget.gd",
1126 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1127 );
1128 set_with_path(
1129 &mut db,
1130 1,
1131 "res://holder.gd",
1132 "class_name Holder\nconst W = preload(\"res://widget.gd\")\n",
1133 );
1134 set_with_path(
1135 &mut db,
1136 2,
1137 "res://user.gd",
1138 "func use_it():\n\tvar a := Holder.W.make()\n",
1139 );
1140 db.sync_source_root();
1141 let api = db.engine().unwrap();
1142
1143 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1144 let unit = fi
1145 .units
1146 .iter()
1147 .find(|u| !u.result.bindings.is_empty())
1148 .expect("use_it unit");
1149 assert_eq!(
1151 unit.result.bindings[0].ty.label(api).as_deref(),
1152 Some("int"),
1153 "Holder.W.make() should resolve cross-file to int, got {:?}",
1154 unit.result.bindings[0].ty
1155 );
1156 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1157 }
1158
1159 #[test]
1160 fn preload_of_script_without_class_name_resolves() {
1161 let mut db = RootDatabase::default();
1164 set_with_path(
1165 &mut db,
1166 0,
1167 "res://helper.gd",
1168 "func help() -> String:\n\treturn \"x\"\n",
1169 );
1170 set_with_path(
1171 &mut db,
1172 1,
1173 "res://main.gd",
1174 "func use_it():\n\tvar h := preload(\"res://helper.gd\")\n\tvar s := h.help()\n",
1175 );
1176 db.sync_source_root();
1177 let api = db.engine().unwrap();
1178
1179 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1180 let unit = fi
1181 .units
1182 .iter()
1183 .find(|u| u.result.bindings.len() >= 2)
1184 .expect("use_it unit");
1185 assert!(
1186 matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
1187 "preload of a class_name-less script must still resolve: {:?}",
1188 unit.result.bindings[0].ty
1189 );
1190 assert_eq!(
1191 unit.result.bindings[1].ty.label(api).as_deref(),
1192 Some("String")
1193 );
1194 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1195 }
1196
1197 #[test]
1198 fn extends_res_path_inherits_members() {
1199 let mut db = RootDatabase::default();
1200 set_with_path(
1202 &mut db,
1203 0,
1204 "res://base.gd",
1205 "extends Node\nfunc base_method() -> int:\n\treturn 1\n",
1206 );
1207 set_with_path(
1208 &mut db,
1209 1,
1210 "res://derived.gd",
1211 "class_name Derived\nextends \"res://base.gd\"\nfunc own() -> String:\n\treturn \"x\"\n",
1212 );
1213 set_with_path(
1214 &mut db,
1215 2,
1216 "res://main.gd",
1217 "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",
1218 );
1219 db.sync_source_root();
1220 let api = db.engine().unwrap();
1221
1222 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1223 let unit = fi
1224 .units
1225 .iter()
1226 .find(|u| u.result.bindings.len() >= 4)
1227 .expect("use_it unit with 4 bindings");
1228 assert_eq!(
1231 unit.result.bindings[1].ty.label(api).as_deref(),
1232 Some("String")
1233 );
1234 assert_eq!(
1235 unit.result.bindings[2].ty.label(api).as_deref(),
1236 Some("int")
1237 );
1238 assert_eq!(
1239 unit.result.bindings[3].ty.label(api).as_deref(),
1240 Some("int")
1241 );
1242 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1243 }
1244
1245 #[test]
1246 fn relative_extends_path_anchors_to_importing_dir() {
1247 let mut db = RootDatabase::default();
1248 set_with_path(
1250 &mut db,
1251 0,
1252 "res://entities/base.gd",
1253 "extends Node\nfunc base_method() -> int:\n\treturn 1\n",
1254 );
1255 set_with_path(
1257 &mut db,
1258 1,
1259 "res://entities/derived.gd",
1260 "class_name Derived\nextends \"base.gd\"\nfunc own() -> String:\n\treturn \"x\"\n",
1261 );
1262 set_with_path(
1263 &mut db,
1264 2,
1265 "res://main.gd",
1266 "func use_it():\n\tvar d: Derived\n\tvar a := d.own()\n\tvar b := d.base_method()\n",
1267 );
1268 db.sync_source_root();
1269 let api = db.engine().unwrap();
1270 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1271 let unit = fi
1272 .units
1273 .iter()
1274 .find(|u| u.result.bindings.len() >= 3)
1275 .expect("use_it unit with 3 bindings (d, a, b)");
1276 assert_eq!(
1278 unit.result.bindings[1].ty.label(api).as_deref(),
1279 Some("String")
1280 );
1281 assert_eq!(
1282 unit.result.bindings[2].ty.label(api).as_deref(),
1283 Some("int"),
1284 "base_method() must resolve through the relative `extends \"base.gd\"`"
1285 );
1286 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1287 }
1288
1289 #[test]
1290 fn dangling_preload_is_seam_not_panic() {
1291 let mut db = RootDatabase::default();
1292 set_with_path(
1293 &mut db,
1294 0,
1295 "res://main.gd",
1296 "func use_it():\n\tvar x := preload(\"res://does_not_exist.gd\")\n\tx.whatever()\n",
1297 );
1298 db.sync_source_root();
1299 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1301 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1302 }
1303
1304 #[test]
1305 fn non_gd_preload_resource_stays_seam() {
1306 let mut db = RootDatabase::default();
1311 set_with_path(&mut db, 0, "res://scene.tscn", "class_name SceneRoot\n");
1312 set_with_path(
1313 &mut db,
1314 1,
1315 "res://main.gd",
1316 "func f():\n\tvar s := preload(\"res://scene.tscn\")\n",
1317 );
1318 db.sync_source_root();
1319
1320 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1321 let unit = fi
1322 .units
1323 .iter()
1324 .find(|u| !u.result.bindings.is_empty())
1325 .expect("f unit");
1326 assert!(
1327 !matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
1328 "a non-.gd preload must stay the seam, got {:?}",
1329 unit.result.bindings[0].ty
1330 );
1331 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1332 }
1333
1334 #[test]
1335 fn load_literal_stays_opaque_not_aliased_to_preload() {
1336 let mut db = RootDatabase::default();
1337 set_with_path(
1338 &mut db,
1339 0,
1340 "res://widget.gd",
1341 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1342 );
1343 set_with_path(
1344 &mut db,
1345 1,
1346 "res://main.gd",
1347 "func use_it():\n\tvar w := load(\"res://widget.gd\")\n",
1348 );
1349 db.sync_source_root();
1350
1351 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1352 let unit = fi
1353 .units
1354 .iter()
1355 .find(|u| !u.result.bindings.is_empty())
1356 .expect("use_it unit");
1357 assert!(
1360 !matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
1361 "load() must stay opaque, not alias preload: {:?}",
1362 unit.result.bindings[0].ty
1363 );
1364 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1365 }
1366
1367 #[test]
1368 fn is_narrows_to_a_user_class_cross_file() {
1369 let mut db = RootDatabase::default();
1374 db.set_file_text(
1375 FileId(0),
1376 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1377 Durability::LOW,
1378 );
1379 db.set_file_text(
1380 FileId(1),
1381 "func use_it(x):\n\tif x is Widget:\n\t\tvar n := x.make()\n",
1382 Durability::LOW,
1383 );
1384 db.sync_source_root();
1385 let api = db.engine().unwrap();
1386
1387 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1388 assert!(
1390 fi.units
1391 .iter()
1392 .flat_map(|u| &u.result.bindings)
1393 .any(|b| b.ty.label(api).as_deref() == Some("int")),
1394 "`x.make()` after `is Widget` should narrow + resolve to int",
1395 );
1396 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1397 }
1398
1399 #[test]
1400 fn as_casts_to_a_user_class_cross_file() {
1401 let mut db = RootDatabase::default();
1403 db.set_file_text(
1404 FileId(0),
1405 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1406 Durability::LOW,
1407 );
1408 db.set_file_text(
1409 FileId(1),
1410 "func use_it(x):\n\tvar n := (x as Widget).make()\n",
1411 Durability::LOW,
1412 );
1413 db.sync_source_root();
1414 let api = db.engine().unwrap();
1415
1416 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1417 assert!(
1418 fi.units
1419 .iter()
1420 .flat_map(|u| &u.result.bindings)
1421 .any(|b| b.ty.label(api).as_deref() == Some("int")),
1422 "`(x as Widget).make()` should resolve to int",
1423 );
1424 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1425 }
1426
1427 #[test]
1428 fn renaming_a_files_path_reindexes_the_registry() {
1429 let mut db = RootDatabase::default();
1431 set_with_path(&mut db, 0, "res://old.gd", "class_name A\n");
1432 db.sync_source_root();
1433 let root = db.source_root().unwrap();
1434 assert_eq!(
1435 res_path_registry(&db, root).get("res://old.gd"),
1436 Some(&FileId(0))
1437 );
1438
1439 db.set_file_path(FileId(0), "res://new.gd");
1440 let root = db.source_root().unwrap();
1441 let reg = res_path_registry(&db, root);
1442 assert_eq!(reg.get("res://new.gd"), Some(&FileId(0)));
1443 assert!(reg.get("res://old.gd").is_none());
1444 }
1445
1446 #[test]
1449 fn star_autoload_scene_resolves_via_its_root_script() {
1450 let mut db = RootDatabase::default();
1454 db.set_file_text(
1456 FileId(0),
1457 "func volume() -> int:\n\treturn 5\n",
1458 Durability::LOW,
1459 );
1460 db.set_file_path(FileId(0), "res://music.gd");
1461 db.set_file_text(
1463 FileId(1),
1464 "[gd_scene format=3]\n\
1465 [ext_resource type=\"Script\" path=\"res://music.gd\" id=\"1\"]\n\
1466 [node name=\"Music\" type=\"Node\"]\n\
1467 script = ExtResource(\"1\")\n",
1468 Durability::LOW,
1469 );
1470 db.set_file_path(FileId(1), "res://music.tscn");
1471 db.set_file_text(
1472 FileId(2),
1473 "func f():\n\tvar v := Music.volume()\n",
1474 Durability::LOW,
1475 );
1476 db.set_file_path(FileId(2), "res://main.gd");
1477 db.set_project_config("[autoload]\nMusic=\"*res://music.tscn\"\n");
1478 db.sync_source_root();
1479 let api = db.engine().unwrap();
1480
1481 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1482 let unit = fi
1483 .units
1484 .iter()
1485 .find(|u| !u.result.bindings.is_empty())
1486 .expect("f unit");
1487 assert_eq!(
1488 unit.result.bindings[0].ty.label(api).as_deref(),
1489 Some("int"),
1490 "Music.volume() should resolve via the scene root's script",
1491 );
1492 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1493 }
1494
1495 #[test]
1496 fn star_autoload_scene_resolves_via_script_class_shortcut() {
1497 let mut db = RootDatabase::default();
1502 db.set_file_text(
1503 FileId(0),
1504 "class_name MusicPlayer\nfunc volume() -> int:\n\treturn 5\n",
1505 Durability::LOW,
1506 );
1507 db.set_file_path(FileId(0), "res://music.gd");
1508 db.set_file_text(
1509 FileId(1),
1510 "[gd_scene format=3 script_class=\"MusicPlayer\"]\n[node name=\"Root\" type=\"Node\"]\n",
1511 Durability::LOW,
1512 );
1513 db.set_file_path(FileId(1), "res://music.tscn");
1514 db.set_file_text(
1515 FileId(2),
1516 "func f():\n\tvar v := Audio.volume()\n",
1517 Durability::LOW,
1518 );
1519 db.set_file_path(FileId(2), "res://main.gd");
1520 db.set_project_config("[autoload]\nAudio=\"*res://music.tscn\"\n");
1521 db.sync_source_root();
1522 let api = db.engine().unwrap();
1523
1524 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1525 let unit = fi
1526 .units
1527 .iter()
1528 .find(|u| !u.result.bindings.is_empty())
1529 .expect("f unit");
1530 assert_eq!(
1531 unit.result.bindings[0].ty.label(api).as_deref(),
1532 Some("int"),
1533 "Audio.volume() should resolve via the scene's script_class= shortcut",
1534 );
1535 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1536 }
1537
1538 #[test]
1539 fn engine_version_from_project_config_is_firewalled_against_body_edits() {
1540 let mut db = RootDatabase::default();
1541 db.set_file_text(FileId(0), "func f():\n\tpass\n", Durability::LOW);
1542 db.set_file_path(FileId(0), "res://main.gd");
1543 db.set_project_config("[application]\nconfig/features=PackedStringArray(\"4.6\")\n");
1544 db.sync_source_root();
1545 assert_eq!(project_engine_version(&db), Some((4, 6)));
1546
1547 db.set_file_text(FileId(0), "func f():\n\tvar x := 1\n", Durability::LOW);
1550 db.sync_source_root();
1551 assert_eq!(project_engine_version(&db), Some((4, 6)));
1552
1553 let empty = RootDatabase::default();
1555 assert_eq!(project_engine_version(&empty), None);
1556 }
1557
1558 #[test]
1559 fn star_autoload_gdscript_resolves_as_global_and_members() {
1560 let mut db = RootDatabase::default();
1561 db.set_file_text(
1563 FileId(0),
1564 "func score() -> int:\n\treturn 0\n",
1565 Durability::LOW,
1566 );
1567 db.set_file_path(FileId(0), "res://game.gd");
1568 db.set_file_text(
1569 FileId(1),
1570 "func f():\n\tvar s := Game.score()\n",
1571 Durability::LOW,
1572 );
1573 db.set_file_path(FileId(1), "res://main.gd");
1574 db.set_project_config("[autoload]\nGame=\"*res://game.gd\"\n");
1575 db.sync_source_root();
1576 let api = db.engine().unwrap();
1577
1578 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1579 let unit = fi
1580 .units
1581 .iter()
1582 .find(|u| !u.result.bindings.is_empty())
1583 .expect("f unit");
1584 assert_eq!(
1586 unit.result.bindings[0].ty.label(api).as_deref(),
1587 Some("int")
1588 );
1589 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1590 }
1591
1592 #[test]
1593 fn non_star_autoload_is_not_a_global() {
1594 let mut db = RootDatabase::default();
1595 db.set_file_text(
1596 FileId(0),
1597 "func score() -> int:\n\treturn 0\n",
1598 Durability::LOW,
1599 );
1600 db.set_file_path(FileId(0), "res://game.gd");
1601 db.set_file_text(
1602 FileId(1),
1603 "func f():\n\tvar s := Game.score()\n",
1604 Durability::LOW,
1605 );
1606 db.set_file_path(FileId(1), "res://main.gd");
1607 db.set_project_config("[autoload]\nGame=\"res://game.gd\"\n");
1609 db.sync_source_root();
1610 let api = db.engine().unwrap();
1611
1612 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1613 let unit = fi
1614 .units
1615 .iter()
1616 .find(|u| !u.result.bindings.is_empty())
1617 .expect("f unit");
1618 assert_eq!(unit.result.bindings[0].ty.label(api), None);
1620 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1621 }
1622
1623 #[test]
1624 fn tscn_autoload_is_the_seam_never_false_warns() {
1625 let mut db = RootDatabase::default();
1626 db.set_file_text(FileId(0), "func f():\n\tHud.play_song()\n", Durability::LOW);
1629 db.set_file_path(FileId(0), "res://main.gd");
1630 db.set_project_config("[autoload]\nHud=\"*res://hud.tscn\"\n");
1631 db.sync_source_root();
1632
1633 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1634 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1636 }
1637
1638 static AUTOLOAD_OBSERVED: AtomicU32 = AtomicU32::new(0);
1642
1643 #[salsa::tracked]
1644 fn observe_autoload_registry(db: &dyn gdscript_db::Db, config: ProjectConfig) -> usize {
1645 AUTOLOAD_OBSERVED.fetch_add(1, Ordering::SeqCst);
1646 autoload_registry(db, config).len()
1647 }
1648
1649 #[test]
1650 fn autoload_registry_firewalled_against_body_edits() {
1651 let mut db = RootDatabase::default();
1652 db.set_file_text(FileId(0), "func f():\n\tvar a := 1\n", Durability::LOW);
1653 db.set_file_path(FileId(0), "res://game.gd");
1654 db.set_project_config("[autoload]\nGame=\"*res://game.gd\"\n");
1655 db.sync_source_root();
1656 let config = db.project_config().unwrap();
1657
1658 assert_eq!(observe_autoload_registry(&db, config), 1);
1659 let runs = AUTOLOAD_OBSERVED.load(Ordering::SeqCst);
1660
1661 db.set_file_text(FileId(0), "func f():\n\tvar a := 999999\n", Durability::LOW);
1664
1665 assert_eq!(observe_autoload_registry(&db, config), 1);
1666 assert_eq!(
1667 AUTOLOAD_OBSERVED.load(Ordering::SeqCst),
1668 runs,
1669 "REGRESSION: a body edit re-ran an autoload_registry consumer — the config firewall broke",
1670 );
1671 }
1672
1673 static ANALYZE_OBSERVED: AtomicU32 = AtomicU32::new(0);
1679
1680 #[salsa::tracked]
1681 fn observe_analyze_file(db: &dyn gdscript_db::Db, file: FileText) -> usize {
1682 ANALYZE_OBSERVED.fetch_add(1, Ordering::SeqCst);
1683 analyze_file(db, file).raw_warnings.len()
1684 }
1685
1686 #[test]
1687 fn warning_level_edit_does_not_invalidate_analyze_file() {
1688 use crate::warnings::{WarnLevel, WarningCode};
1689
1690 let mut db = RootDatabase::default();
1691 db.set_file_text(
1692 FileId(0),
1693 "func f():\n\tvar x = 5 / 2\n\treturn x\n",
1694 Durability::LOW,
1695 );
1696 db.set_file_path(FileId(0), "res://game.gd");
1697 db.set_project_config(
1698 "[autoload]\nGame=\"*res://game.gd\"\n[debug]\ngdscript/warnings/integer_division=2\n",
1699 );
1700 db.sync_source_root();
1701 let file = db.file_text(FileId(0)).unwrap();
1702 let config = db.project_config().unwrap();
1703
1704 assert_eq!(observe_analyze_file(&db, file), 2);
1707 let runs = ANALYZE_OBSERVED.load(Ordering::SeqCst);
1708 assert_eq!(
1709 warning_settings(&db, config)
1710 .per_code
1711 .get(&WarningCode::IntegerDivision),
1712 Some(&WarnLevel::Error),
1713 );
1714
1715 db.set_project_config(
1719 "[autoload]\nGame=\"*res://game.gd\"\n[debug]\ngdscript/warnings/integer_division=1\n",
1720 );
1721 assert_eq!(observe_analyze_file(&db, file), 2);
1722 assert_eq!(
1723 ANALYZE_OBSERVED.load(Ordering::SeqCst),
1724 runs,
1725 "REGRESSION: a warning-level edit re-ran analyze_file — the W1 gating firewall broke",
1726 );
1727 assert_eq!(
1729 warning_settings(&db, config)
1730 .per_code
1731 .get(&WarningCode::IntegerDivision),
1732 Some(&WarnLevel::Warn),
1733 );
1734 }
1735
1736 #[test]
1737 fn aliased_self_resolves_own_members_no_false_unsafe() {
1738 let mut db = RootDatabase::default();
1742 db.set_file_text(
1743 FileId(0),
1744 "extends Node\nfunc own() -> int:\n\treturn 1\nfunc use_it():\n\tvar me := self\n\tvar n := me.own()\n",
1745 Durability::LOW,
1746 );
1747 db.sync_source_root();
1748 let api = db.engine().unwrap();
1749
1750 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1751 assert!(
1753 fi.units
1754 .iter()
1755 .flat_map(|u| &u.result.bindings)
1756 .any(|b| b.ty.label(api).as_deref() == Some("int")),
1757 "aliased self.own() should resolve to int",
1758 );
1759 assert!(
1760 fi.diagnostics.is_empty(),
1761 "no false UNSAFE on aliased self: {:?}",
1762 fi.diagnostics
1763 );
1764 }
1765
1766 #[test]
1767 fn is_userbase_narrows_to_derived_but_not_un_narrowed_to_base() {
1768 let mut db = RootDatabase::default();
1769 db.set_file_text(
1770 FileId(0),
1771 "class_name Base\nfunc base_m() -> int:\n\treturn 1\n",
1772 Durability::LOW,
1773 );
1774 db.set_file_text(
1775 FileId(1),
1776 "class_name Derived\nextends Base\nfunc own_m() -> String:\n\treturn \"x\"\n",
1777 Durability::LOW,
1778 );
1779 db.set_file_text(
1782 FileId(2),
1783 "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",
1784 Durability::LOW,
1785 );
1786 db.sync_source_root();
1787 let api = db.engine().unwrap();
1788
1789 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1790 let strings = fi
1791 .units
1792 .iter()
1793 .flat_map(|u| &u.result.bindings)
1794 .filter(|b| b.ty.label(api).as_deref() == Some("String"))
1795 .count();
1796 assert!(
1798 strings >= 2,
1799 "expected both own_m() calls to type as String (narrow-down + widen-only), got {strings}",
1800 );
1801 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1802 }
1803
1804 fn scene_db(scene_text: &str, gd_text: &str) -> RootDatabase {
1808 let mut db = RootDatabase::default();
1809 db.set_file_text(FileId(0), scene_text, Durability::LOW);
1810 db.set_file_path(FileId(0), "res://main.tscn");
1811 db.set_file_text(FileId(1), gd_text, Durability::LOW);
1812 db.set_file_path(FileId(1), "res://main.gd");
1813 db.sync_source_root();
1814 db
1815 }
1816
1817 fn binding_labels(db: &RootDatabase) -> Vec<String> {
1818 let api = db.engine().unwrap();
1819 let fi = analyze_file(db, db.file_text(FileId(1)).unwrap());
1820 assert!(
1821 fi.diagnostics.is_empty(),
1822 "unexpected diags: {:?}",
1823 fi.diagnostics
1824 );
1825 fi.units
1826 .iter()
1827 .flat_map(|u| &u.result.bindings)
1828 .filter_map(|b| b.ty.label(api))
1829 .collect()
1830 }
1831
1832 const SCENE: &str = "[gd_scene format=3]\n\
1833 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1834 [node name=\"Root\" type=\"Control\"]\n\
1835 script = ExtResource(\"1\")\n\
1836 [node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
1837 [node name=\"Box\" type=\"VBoxContainer\" parent=\"Panel\"]\n\
1838 [node name=\"Btn\" type=\"Button\" parent=\"Panel/Box\"]\n\
1839 unique_name_in_owner = true\n";
1840
1841 #[test]
1842 fn dollar_path_types_to_the_concrete_node() {
1843 let db = scene_db(
1845 SCENE,
1846 "extends Control\nfunc _ready():\n\tvar b := $Panel/Box/Btn\n",
1847 );
1848 assert!(
1849 binding_labels(&db).iter().any(|l| l == "Button"),
1850 "$Panel/Box/Btn should type as Button",
1851 );
1852 }
1853
1854 #[test]
1855 fn unique_name_path_types_to_the_concrete_node() {
1856 let db = scene_db(SCENE, "extends Control\nfunc _ready():\n\tvar b := %Btn\n");
1858 assert!(
1859 binding_labels(&db).iter().any(|l| l == "Button"),
1860 "%Btn should type as Button"
1861 );
1862 }
1863
1864 #[test]
1865 fn onready_var_from_a_node_path_is_typed() {
1866 let db = scene_db(
1869 SCENE,
1870 "extends Control\n@onready var btn := $Panel/Box/Btn\n",
1871 );
1872 assert!(
1873 binding_labels(&db).iter().any(|l| l == "Button"),
1874 "@onready var := $Path should type to Button",
1875 );
1876 }
1877
1878 #[test]
1879 fn get_node_string_literal_types_like_dollar() {
1880 let db = scene_db(
1882 SCENE,
1883 "extends Control\nfunc _ready():\n\tvar b := get_node(\"Panel/Box/Btn\")\n",
1884 );
1885 assert!(
1886 binding_labels(&db).iter().any(|l| l == "Button"),
1887 "get_node(\"...\") should type as Button",
1888 );
1889 }
1890
1891 #[test]
1892 fn self_get_node_string_literal_types_like_dollar() {
1893 let db = scene_db(
1896 SCENE,
1897 "extends Control\nfunc _ready():\n\tvar b := self.get_node(\"Panel/Box/Btn\")\n",
1898 );
1899 assert!(
1900 binding_labels(&db).iter().any(|l| l == "Button"),
1901 "self.get_node(\"...\") should type as Button",
1902 );
1903 }
1904
1905 #[test]
1906 fn attached_script_refines_the_node_type() {
1907 let mut db = RootDatabase::default();
1910 db.set_file_text(
1911 FileId(0),
1912 "[gd_scene format=3]\n\
1913 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1914 [ext_resource type=\"Script\" path=\"res://fancy.gd\" id=\"2\"]\n\
1915 [node name=\"Root\" type=\"Control\"]\n\
1916 script = ExtResource(\"1\")\n\
1917 [node name=\"That\" type=\"Button\" parent=\".\"]\n\
1918 script = ExtResource(\"2\")\n",
1919 Durability::LOW,
1920 );
1921 db.set_file_path(FileId(0), "res://main.tscn");
1922 db.set_file_text(
1923 FileId(1),
1924 "extends Control\nfunc _ready():\n\tvar n := $That.fancy()\n",
1925 Durability::LOW,
1926 );
1927 db.set_file_path(FileId(1), "res://main.gd");
1928 db.set_file_text(
1929 FileId(2),
1930 "class_name Fancy\nextends Button\nfunc fancy() -> int:\n\treturn 1\n",
1931 Durability::LOW,
1932 );
1933 db.set_file_path(FileId(2), "res://fancy.gd");
1934 db.sync_source_root();
1935 assert!(
1936 binding_labels(&db).iter().any(|l| l == "int"),
1937 "$That.fancy() should resolve via the attached script Fancy",
1938 );
1939 }
1940
1941 #[test]
1942 fn computed_or_unresolvable_node_path_stays_node_without_warning() {
1943 let mut db = RootDatabase::default();
1946 db.set_file_text(
1947 FileId(1),
1948 "extends Node\nfunc f(p: NodePath):\n\tvar a := get_node(p)\n\tvar b := $Nope\n",
1952 Durability::LOW,
1953 );
1954 db.set_file_path(FileId(1), "res://lone.gd");
1955 db.sync_source_root();
1956 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1957 assert!(
1958 fi.diagnostics.is_empty(),
1959 "no false node-path warnings: {:?}",
1960 fi.diagnostics
1961 );
1962 }
1963
1964 fn has_invalid_node_path(db: &RootDatabase) -> bool {
1967 let fi = analyze_file(db, db.file_text(FileId(1)).unwrap());
1968 fi.diagnostics
1969 .iter()
1970 .any(|d| d.code == crate::infer::INVALID_NODE_PATH)
1971 }
1972
1973 #[test]
1974 fn invalid_node_path_warns_when_genuinely_absent_in_a_single_owning_scene() {
1975 let db = scene_db(SCENE, "extends Control\nfunc _ready():\n\tvar b := $Nope\n");
1976 assert!(
1977 has_invalid_node_path(&db),
1978 "$Nope is absent in the one owning scene → warn"
1979 );
1980 }
1981
1982 #[test]
1983 fn escape_and_absolute_paths_never_warn() {
1984 let db = scene_db(
1986 SCENE,
1987 "extends Control\nfunc _ready():\n\tvar a := $\"../Sibling\"\n\tvar c := $\"/root/Global\"\n",
1988 );
1989 assert!(!has_invalid_node_path(&db), "escape paths must not warn");
1990 }
1991
1992 #[test]
1993 fn path_descending_into_an_instanced_subscene_never_warns() {
1994 let db = scene_db(
1997 "[gd_scene format=3]\n\
1998 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1999 [ext_resource type=\"PackedScene\" path=\"res://player.tscn\" id=\"2\"]\n\
2000 [node name=\"Root\" type=\"Control\"]\n\
2001 script = ExtResource(\"1\")\n\
2002 [node name=\"Player\" parent=\".\" instance=ExtResource(\"2\")]\n",
2003 "extends Control\nfunc _ready():\n\tvar g := $Player/Gun\n",
2004 );
2005 assert!(
2006 !has_invalid_node_path(&db),
2007 "into-instance miss must not warn"
2008 );
2009 }
2010
2011 #[test]
2012 fn ambiguous_multi_scene_attachment_suppresses_the_invalid_warning() {
2013 let mut db = RootDatabase::default();
2016 db.set_file_text(
2017 FileId(0),
2018 "[gd_scene format=3]\n\
2019 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
2020 [node name=\"Root\" type=\"Control\"]\n\
2021 script = ExtResource(\"1\")\n\
2022 [node name=\"Alpha\" type=\"Button\" parent=\".\"]\n",
2023 Durability::LOW,
2024 );
2025 db.set_file_path(FileId(0), "res://a.tscn");
2026 db.set_file_text(
2027 FileId(2),
2028 "[gd_scene format=3]\n\
2029 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
2030 [node name=\"Root\" type=\"Control\"]\n\
2031 script = ExtResource(\"1\")\n\
2032 [node name=\"Beta\" type=\"Button\" parent=\".\"]\n",
2033 Durability::LOW,
2034 );
2035 db.set_file_path(FileId(2), "res://b.tscn");
2036 db.set_file_text(
2037 FileId(1),
2038 "extends Control\nfunc _ready():\n\tvar b := $Beta\n",
2039 Durability::LOW,
2040 );
2041 db.set_file_path(FileId(1), "res://main.gd");
2042 db.sync_source_root();
2043 assert!(
2044 !has_invalid_node_path(&db),
2045 "ambiguous multi-scene attachment must not warn"
2046 );
2047 }
2048
2049 #[test]
2052 fn instanced_node_recurses_into_the_subscene_root_script() {
2053 let mut db = RootDatabase::default();
2058 db.set_file_text(
2059 FileId(0),
2060 "[gd_scene format=3]\n\
2061 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
2062 [ext_resource type=\"PackedScene\" path=\"res://enemy.tscn\" id=\"2\"]\n\
2063 [node name=\"Root\" type=\"Control\"]\n\
2064 script = ExtResource(\"1\")\n\
2065 [node name=\"Enemy\" parent=\".\" instance=ExtResource(\"2\")]\n",
2066 Durability::LOW,
2067 );
2068 db.set_file_path(FileId(0), "res://main.tscn");
2069 db.set_file_text(
2070 FileId(1),
2071 "extends Control\nfunc _ready():\n\tvar e := $Enemy.hp()\n",
2072 Durability::LOW,
2073 );
2074 db.set_file_path(FileId(1), "res://main.gd");
2075 db.set_file_text(
2076 FileId(2),
2077 "[gd_scene format=3]\n\
2078 [ext_resource type=\"Script\" path=\"res://enemy.gd\" id=\"1\"]\n\
2079 [node name=\"Enemy\" type=\"Button\"]\n\
2080 script = ExtResource(\"1\")\n",
2081 Durability::LOW,
2082 );
2083 db.set_file_path(FileId(2), "res://enemy.tscn");
2084 db.set_file_text(
2085 FileId(3),
2086 "class_name Enemy\nextends Button\nfunc hp() -> int:\n\treturn 1\n",
2087 Durability::LOW,
2088 );
2089 db.set_file_path(FileId(3), "res://enemy.gd");
2090 db.sync_source_root();
2091 assert!(
2092 binding_labels(&db).iter().any(|l| l == "int"),
2093 "$Enemy.hp() should recurse into the instanced sub-scene root's script Enemy",
2094 );
2095 }
2096
2097 #[test]
2098 fn path_into_an_instanced_subscene_types_the_inner_node() {
2099 let mut db = RootDatabase::default();
2105 db.set_file_text(
2106 FileId(0),
2107 "[gd_scene format=3]\n\
2108 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
2109 [ext_resource type=\"PackedScene\" path=\"res://enemy.tscn\" id=\"2\"]\n\
2110 [node name=\"Root\" type=\"Control\"]\n\
2111 script = ExtResource(\"1\")\n\
2112 [node name=\"Enemy\" parent=\".\" instance=ExtResource(\"2\")]\n",
2113 Durability::LOW,
2114 );
2115 db.set_file_path(FileId(0), "res://main.tscn");
2116 db.set_file_text(
2117 FileId(1),
2118 "extends Control\nfunc _ready():\n\tvar s := $Enemy/Sprite\n\tvar n := $Enemy/Nope\n",
2119 Durability::LOW,
2120 );
2121 db.set_file_path(FileId(1), "res://main.gd");
2122 db.set_file_text(
2123 FileId(2),
2124 "[gd_scene format=3]\n\
2125 [node name=\"Enemy\" type=\"Node2D\"]\n\
2126 [node name=\"Sprite\" type=\"Sprite2D\" parent=\".\"]\n",
2127 Durability::LOW,
2128 );
2129 db.set_file_path(FileId(2), "res://enemy.tscn");
2130 db.sync_source_root();
2131 assert!(
2132 binding_labels(&db).iter().any(|l| l == "Sprite2D"),
2133 "$Enemy/Sprite should type as the sub-scene's Sprite (Sprite2D)",
2134 );
2135 }
2136
2137 #[test]
2138 fn override_child_under_an_instance_types_from_the_subscene() {
2139 let mut db = RootDatabase::default();
2145 db.set_file_text(
2146 FileId(0),
2147 "[gd_scene format=3]\n\
2148 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
2149 [ext_resource type=\"PackedScene\" path=\"res://enemy.tscn\" id=\"2\"]\n\
2150 [node name=\"Root\" type=\"Control\"]\n\
2151 script = ExtResource(\"1\")\n\
2152 [node name=\"Enemy\" parent=\".\" instance=ExtResource(\"2\")]\n\
2153 [node name=\"Sprite\" parent=\"Enemy\"]\n\
2154 modulate = Color(1, 0, 0, 1)\n",
2155 Durability::LOW,
2156 );
2157 db.set_file_path(FileId(0), "res://main.tscn");
2158 db.set_file_text(
2159 FileId(1),
2160 "extends Control\nfunc _ready():\n\tvar s := $Enemy/Sprite\n",
2161 Durability::LOW,
2162 );
2163 db.set_file_path(FileId(1), "res://main.gd");
2164 db.set_file_text(
2165 FileId(2),
2166 "[gd_scene format=3]\n\
2167 [node name=\"Enemy\" type=\"Node2D\"]\n\
2168 [node name=\"Sprite\" type=\"Sprite2D\" parent=\".\"]\n",
2169 Durability::LOW,
2170 );
2171 db.set_file_path(FileId(2), "res://enemy.tscn");
2172 db.sync_source_root();
2173 assert!(
2174 binding_labels(&db).iter().any(|l| l == "Sprite2D"),
2175 "an override child under an instance types from the sub-scene (Sprite2D), got: {:?}",
2176 binding_labels(&db),
2177 );
2178 }
2179
2180 #[test]
2181 fn inner_class_value_and_members_type_via_its_item_tree() {
2182 let mut db = RootDatabase::default();
2188 db.set_file_text(
2189 FileId(0),
2190 "class Inner extends Node:\n\
2191 \tvar hp: int = 5\n\
2192 \tfunc ping() -> int:\n\
2193 \t\treturn 1\n\
2194 \tfunc combo() -> int:\n\
2195 \t\tvar s := self.get_class()\n\
2196 \t\treturn hp + ping()\n\
2197 func f():\n\
2198 \tvar x := Inner.new()\n\
2199 \tvar a := x.hp\n\
2200 \tvar b := x.ping()\n\
2201 \tvar c := x.get_class()\n",
2202 Durability::LOW,
2203 );
2204 db.sync_source_root();
2205 let ft = db.file_text(FileId(0)).unwrap();
2206 let fi = analyze_file(&db, ft);
2207 assert!(
2208 fi.diagnostics.is_empty(),
2209 "no hard diagnostics expected: {:?}",
2210 fi.diagnostics
2211 );
2212 let api = db.engine().unwrap();
2213 let labels: Vec<String> = fi
2214 .units
2215 .iter()
2216 .flat_map(|u| &u.result.bindings)
2217 .filter_map(|b| b.ty.label(api))
2218 .collect();
2219 assert!(
2220 labels.iter().filter(|l| *l == "int").count() >= 2,
2221 "x.hp and x.ping() should both type as int: {labels:?}"
2222 );
2223 assert!(
2224 labels.iter().any(|l| l == "String"),
2225 "x.get_class() should resolve via the inner class's `extends Node` chain to String: {labels:?}"
2226 );
2227 }
2228
2229 #[test]
2232 fn unique_name_subpath_resolves_to_the_child_without_warning() {
2233 let db = scene_db(
2237 "[gd_scene format=3]\n\
2238 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
2239 [node name=\"Root\" type=\"Control\"]\n\
2240 script = ExtResource(\"1\")\n\
2241 [node name=\"Box\" type=\"VBoxContainer\" parent=\".\"]\n\
2242 unique_name_in_owner = true\n\
2243 [node name=\"Btn\" type=\"Button\" parent=\"Box\"]\n",
2244 "extends Control\nfunc _ready():\n\tvar b := %Box/Btn\n",
2245 );
2246 assert!(
2247 binding_labels(&db).iter().any(|l| l == "Button"),
2248 "%Box/Btn → Button (and no false INVALID_NODE_PATH)",
2249 );
2250 }
2251
2252 #[test]
2253 fn percent_prefixed_string_paths_resolve_as_unique_without_warning() {
2254 let db = scene_db(
2257 SCENE,
2258 "extends Control\nfunc _ready():\n\tvar a := get_node(\"%Btn\")\n\tvar b := $\"%Btn\"\n",
2259 );
2260 let labels = binding_labels(&db);
2261 assert!(
2262 labels.iter().filter(|l| *l == "Button").count() >= 2,
2263 "both %Btn string forms should resolve to Button: {labels:?}",
2264 );
2265 }
2266}