1use std::sync::Arc;
15
16use gdscript_db::{Db, FileText, ProjectConfig, SourceRoot, parse};
17use rustc_hash::FxHashMap;
18use smol_str::SmolStr;
19
20use gdscript_base::FileId;
21use gdscript_scene::{NodeIdx, SceneModel};
22
23use crate::infer::FileInference;
24use crate::item_tree::{ItemTree, Member};
25use crate::ty::{ScriptRefId, Ty};
26
27#[salsa::tracked]
30pub fn item_tree(db: &dyn Db, file: FileText) -> Arc<ItemTree> {
31 crate::item_tree::item_tree(&parse(db, file).syntax_node())
32}
33
34#[salsa::tracked]
37pub fn analyze_file(db: &dyn Db, file: FileText) -> Arc<FileInference> {
38 match db.engine() {
39 Some(api) => Arc::new(crate::infer::analyze_file(
40 db,
41 api,
42 &parse(db, file).syntax_node(),
43 file.file_id(db),
44 )),
45 None => Arc::new(FileInference::default()),
46 }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Default)]
51pub struct GlobalRegistry {
52 classes: FxHashMap<SmolStr, FileText>,
53}
54
55impl GlobalRegistry {
56 #[must_use]
58 pub fn resolve(&self, name: &str) -> Option<FileText> {
59 self.classes.get(name).copied()
60 }
61
62 #[must_use]
64 pub fn len(&self) -> usize {
65 self.classes.len()
66 }
67
68 #[must_use]
70 pub fn is_empty(&self) -> bool {
71 self.classes.is_empty()
72 }
73
74 pub fn iter(&self) -> impl Iterator<Item = (&SmolStr, FileText)> + '_ {
76 self.classes.iter().map(|(k, v)| (k, *v))
77 }
78}
79
80#[salsa::tracked]
85pub fn file_class_name(db: &dyn Db, file: FileText) -> Option<SmolStr> {
86 item_tree(db, file).class_name.clone()
87}
88
89#[salsa::tracked]
94pub fn global_registry(db: &dyn Db, root: SourceRoot) -> Arc<GlobalRegistry> {
95 let mut classes = FxHashMap::default();
96 for &file in root.files(db) {
97 if let Some(name) = file_class_name(db, file) {
98 classes.entry(name).or_insert(file);
99 }
100 }
101 Arc::new(GlobalRegistry { classes })
102}
103
104#[salsa::tracked]
111pub fn res_path_registry(db: &dyn Db, root: SourceRoot) -> Arc<FxHashMap<SmolStr, FileId>> {
112 let mut map = FxHashMap::default();
113 for &file in root.files(db) {
114 if let Some(path) = file.res_path(db) {
115 map.entry(path).or_insert_with(|| file.file_id(db));
116 }
117 }
118 Arc::new(map)
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Default)]
126pub struct AutoloadRegistry {
127 singletons: FxHashMap<SmolStr, SmolStr>,
128}
129
130impl AutoloadRegistry {
131 #[must_use]
133 pub fn resolve_path(&self, name: &str) -> Option<&SmolStr> {
134 self.singletons.get(name)
135 }
136
137 #[must_use]
139 pub fn len(&self) -> usize {
140 self.singletons.len()
141 }
142
143 #[must_use]
145 pub fn is_empty(&self) -> bool {
146 self.singletons.is_empty()
147 }
148}
149
150#[salsa::tracked]
153pub fn autoload_registry(db: &dyn Db, config: ProjectConfig) -> Arc<AutoloadRegistry> {
154 let mut singletons = FxHashMap::default();
155 for e in crate::project::parse_autoloads(config.project_godot_text(db)) {
156 if e.is_singleton {
157 singletons.entry(e.name).or_insert(e.path);
158 }
159 }
160 Arc::new(AutoloadRegistry { singletons })
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum MemberSig {
167 Method(Ty),
169 Field(Ty),
171 Signal,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct ScriptClass {
182 members: FxHashMap<SmolStr, MemberSig>,
183 base: Ty,
184}
185
186impl ScriptClass {
187 #[must_use]
190 pub fn member(&self, name: &str) -> Option<&MemberSig> {
191 self.members.get(name)
192 }
193
194 #[must_use]
196 pub fn base(&self) -> &Ty {
197 &self.base
198 }
199}
200
201#[must_use]
205pub fn script_ref_name(db: &dyn Db, sref: ScriptRefId) -> Option<SmolStr> {
206 let file = db.file_text(FileId(sref.0))?;
207 file_class_name(db, file)
208}
209
210#[salsa::tracked]
213pub fn script_class(db: &dyn Db, file: FileText) -> Arc<ScriptClass> {
214 let tree = item_tree(db, file);
215 let Some(api) = db.engine() else {
216 return Arc::new(ScriptClass {
217 members: FxHashMap::default(),
218 base: Ty::Unknown,
219 });
220 };
221 let resolve_ann = |ann: Option<&str>| -> Ty {
222 ann.map_or(Ty::Variant, |t| {
223 crate::resolve::resolve_type_name(db, api, t)
224 })
225 };
226 let mut members = FxHashMap::default();
227 for m in &tree.members {
228 let Some(name) = m.name() else { continue };
229 let sig = match m {
230 Member::Func(f) => MemberSig::Method(resolve_ann(f.return_type.as_deref())),
231 Member::Var(v) => MemberSig::Field(resolve_ann(v.type_ref.as_deref())),
232 Member::Const(c) => MemberSig::Field(resolve_ann(c.type_ref.as_deref())),
233 Member::Signal(_) => MemberSig::Signal,
234 Member::Enum(_) | Member::Class(_) => continue,
236 };
237 members.insert(SmolStr::new(name), sig);
238 }
239 let base = crate::resolve::resolve_base(db, api, &tree);
242 Arc::new(ScriptClass { members, base })
243}
244
245fn is_scene_path(path: &str) -> bool {
250 let ext = path.rsplit('.').next().unwrap_or("");
251 ext.eq_ignore_ascii_case("tscn") || ext.eq_ignore_ascii_case("tres")
252}
253
254#[salsa::tracked]
258pub fn scene_model(db: &dyn Db, file: FileText) -> Arc<SceneModel> {
259 let is_scene = file.res_path(db).as_deref().is_some_and(is_scene_path);
260 if is_scene {
261 Arc::new(gdscript_scene::parse_scene(file.text(db)))
262 } else {
263 Arc::new(gdscript_scene::parse_scene(""))
264 }
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
270pub struct SceneAttach {
271 pub scene: FileId,
273 pub node: NodeIdx,
275 pub ambiguous: bool,
278}
279
280#[salsa::tracked]
287pub fn script_scene_index(db: &dyn Db, root: SourceRoot) -> Arc<FxHashMap<SmolStr, SceneAttach>> {
288 let mut map: FxHashMap<SmolStr, SceneAttach> = FxHashMap::default();
289 for &file in root.files(db) {
290 if !file.res_path(db).as_deref().is_some_and(is_scene_path) {
291 continue;
292 }
293 let model = scene_model(db, file);
294 let scene = file.file_id(db);
295 for (i, node) in model.nodes.iter().enumerate() {
296 let Some(script_id) = node.script.as_ref() else {
297 continue;
298 };
299 let Some(path) = model
300 .ext_resources
301 .get(script_id)
302 .and_then(|e| e.path.clone())
303 else {
304 continue;
305 };
306 let node = NodeIdx(u32::try_from(i).unwrap_or(u32::MAX));
307 match map.get_mut(&path) {
308 Some(existing) => existing.ambiguous = true,
310 None => {
311 map.insert(
312 path,
313 SceneAttach {
314 scene,
315 node,
316 ambiguous: false,
317 },
318 );
319 }
320 }
321 }
322 }
323 Arc::new(map)
324}
325
326#[must_use]
331pub fn scene_context(db: &dyn Db, file: FileText) -> Option<SceneContext> {
332 let res_path = file.res_path(db)?;
333 let root = db.source_root()?;
334 let attach = *script_scene_index(db, root).get(res_path.as_str())?;
335 let scene_file = db.file_text(attach.scene)?;
336 Some(SceneContext {
337 scene: attach.scene,
338 model: scene_model(db, scene_file),
339 attach: attach.node,
340 ambiguous: attach.ambiguous,
341 })
342}
343
344#[derive(Debug, Clone)]
347pub struct SceneContext {
348 pub scene: FileId,
350 pub model: Arc<SceneModel>,
352 pub attach: NodeIdx,
354 pub ambiguous: bool,
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use gdscript_base::FileId;
362 use gdscript_db::RootDatabase;
363 use salsa::Durability;
364
365 fn db_with(src: &str) -> (RootDatabase, FileText) {
366 let mut db = RootDatabase::default();
367 db.set_file_text(FileId(0), src, Durability::LOW);
368 let ft = db.file_text(FileId(0)).unwrap();
369 (db, ft)
370 }
371
372 #[test]
373 fn tracked_item_tree_matches_the_plain_fn() {
374 let (db, ft) = db_with("class_name Foo\nfunc f():\n\tpass\n");
375 let tree = item_tree(&db, ft);
376 assert_eq!(tree.class_name.as_deref(), Some("Foo"));
377 assert_eq!(item_tree(&db, ft), tree);
379 }
380
381 #[test]
382 fn tracked_analyze_file_runs_inference() {
383 let (db, ft) = db_with("func add(a: int, b: int) -> int:\n\treturn a + b\n");
384 let fi = analyze_file(&db, ft);
385 assert!(!fi.units.is_empty());
387 assert!(fi.diagnostics.is_empty());
388 }
389
390 use std::sync::atomic::{AtomicU32, Ordering};
400
401 static WITNESS_RUNS: AtomicU32 = AtomicU32::new(0);
402
403 #[salsa::tracked]
405 fn class_name_witness(db: &dyn gdscript_db::Db, file: FileText) -> Option<smol_str::SmolStr> {
406 WITNESS_RUNS.fetch_add(1, Ordering::SeqCst);
407 item_tree(db, file).class_name.clone()
408 }
409
410 #[test]
411 fn body_edit_does_not_invalidate_signature_queries() {
412 let mut db = RootDatabase::default();
413 db.set_file_text(
414 FileId(0),
415 "class_name Foo\nfunc f():\n\tvar a := 1\n",
416 Durability::LOW,
417 );
418 let ft = db.file_text(FileId(0)).unwrap();
419
420 assert_eq!(class_name_witness(&db, ft).as_deref(), Some("Foo"));
422 let runs_after_warm = WITNESS_RUNS.load(Ordering::SeqCst);
423
424 db.set_file_text(
427 FileId(0),
428 "class_name Foo\nfunc f():\n\tvar a := 2\n",
429 Durability::LOW,
430 );
431 assert_eq!(class_name_witness(&db, ft).as_deref(), Some("Foo"));
432
433 assert_eq!(
434 WITNESS_RUNS.load(Ordering::SeqCst),
435 runs_after_warm,
436 "REGRESSION: a body edit re-ran a signature-only query — the item_tree firewall broke",
437 );
438 }
439
440 #[test]
441 fn global_registry_resolves_class_names_across_files() {
442 let mut db = RootDatabase::default();
443 db.set_file_text(
444 FileId(0),
445 "class_name Player\nfunc f():\n\tpass\n",
446 Durability::LOW,
447 );
448 db.set_file_text(
449 FileId(1),
450 "class_name Enemy\nvar hp := 10\n",
451 Durability::LOW,
452 );
453 db.set_file_text(FileId(2), "func no_class():\n\tpass\n", Durability::LOW);
454 db.sync_source_root();
455 let root = db.source_root().unwrap();
456
457 let reg = global_registry(&db, root);
458 assert_eq!(reg.len(), 2);
459 assert_eq!(reg.resolve("Player"), db.file_text(FileId(0)));
460 assert_eq!(reg.resolve("Enemy"), db.file_text(FileId(1)));
461 assert!(reg.resolve("Nonexistent").is_none());
462 }
463
464 static REGISTRY_OBSERVED: AtomicU32 = AtomicU32::new(0);
471
472 #[salsa::tracked]
474 fn observe_registry(db: &dyn gdscript_db::Db, root: SourceRoot) -> usize {
475 REGISTRY_OBSERVED.fetch_add(1, Ordering::SeqCst);
476 global_registry(db, root).len()
477 }
478
479 #[test]
480 fn body_edit_does_not_invalidate_the_global_registry() {
481 let mut db = RootDatabase::default();
482 db.set_file_text(
483 FileId(0),
484 "class_name Player\nfunc f():\n\tvar a := 1\n",
485 Durability::LOW,
486 );
487 db.set_file_text(FileId(1), "class_name Enemy\n", Durability::LOW);
488 db.sync_source_root();
489 let root = db.source_root().unwrap();
490
491 assert_eq!(observe_registry(&db, root), 2);
492 let runs = REGISTRY_OBSERVED.load(Ordering::SeqCst);
493
494 db.set_file_text(
497 FileId(0),
498 "class_name Player\nfunc f():\n\tvar a := 123456\n",
499 Durability::LOW,
500 );
501
502 assert_eq!(observe_registry(&db, root), 2);
503 assert_eq!(
504 REGISTRY_OBSERVED.load(Ordering::SeqCst),
505 runs,
506 "REGRESSION: a body edit re-ran a global_registry consumer — the cross-file firewall broke",
507 );
508 }
509
510 #[test]
511 fn cross_file_class_name_member_resolves() {
512 let mut db = RootDatabase::default();
513 db.set_file_text(
514 FileId(0),
515 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
516 Durability::LOW,
517 );
518 db.set_file_text(
519 FileId(1),
520 "func use_it():\n\tvar w := Widget.make()\n",
521 Durability::LOW,
522 );
523 db.sync_source_root();
524
525 let file1 = db.file_text(FileId(1)).unwrap();
526 let fi = analyze_file(&db, file1);
527 let api = db.engine().unwrap();
528
529 let unit = fi
532 .units
533 .iter()
534 .find(|u| !u.result.bindings.is_empty())
535 .expect("a unit with a binding");
536 assert_eq!(
537 unit.result.bindings[0].ty.label(api).as_deref(),
538 Some("int")
539 );
540 assert!(
541 fi.diagnostics.is_empty(),
542 "unexpected diagnostics: {:?}",
543 fi.diagnostics
544 );
545 }
546
547 #[test]
548 fn unknown_member_on_script_ref_is_seam_not_warning() {
549 let mut db = RootDatabase::default();
550 db.set_file_text(
551 FileId(0),
552 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
553 Durability::LOW,
554 );
555 db.set_file_text(
556 FileId(1),
557 "func use_it():\n\tWidget.not_a_member()\n",
558 Durability::LOW,
559 );
560 db.sync_source_root();
561
562 let file1 = db.file_text(FileId(1)).unwrap();
563 let fi = analyze_file(&db, file1);
564 assert!(
566 fi.diagnostics.is_empty(),
567 "a missing member on a ScriptRef must not warn: {:?}",
568 fi.diagnostics
569 );
570 }
571
572 #[test]
573 fn inherited_members_resolve_through_user_and_engine_bases() {
574 let mut db = RootDatabase::default();
575 db.set_file_text(
577 FileId(0),
578 "class_name Base\nextends Node\nfunc base_method() -> int:\n\treturn 1\n",
579 Durability::LOW,
580 );
581 db.set_file_text(
582 FileId(1),
583 "class_name Derived\nextends Base\nfunc own() -> String:\n\treturn \"x\"\n",
584 Durability::LOW,
585 );
586 db.set_file_text(
587 FileId(2),
588 "func use_it():\n\tvar d: Derived\n\tvar own := d.own()\n\tvar from_base := d.base_method()\n\tvar from_engine := d.get_instance_id()\n",
589 Durability::LOW,
590 );
591 db.sync_source_root();
592 let api = db.engine().unwrap();
593
594 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
595 let unit = fi
596 .units
597 .iter()
598 .find(|u| u.result.bindings.len() >= 4)
599 .expect("use_it unit with 4 bindings");
600 assert_eq!(
602 unit.result.bindings[1].ty.label(api).as_deref(),
603 Some("String")
604 );
605 assert_eq!(
606 unit.result.bindings[2].ty.label(api).as_deref(),
607 Some("int")
608 );
609 assert_eq!(
610 unit.result.bindings[3].ty.label(api).as_deref(),
611 Some("int")
612 );
613 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
614 }
615
616 #[test]
617 fn cyclic_extends_terminates() {
618 let mut db = RootDatabase::default();
619 db.set_file_text(FileId(0), "class_name A\nextends B\n", Durability::LOW);
621 db.set_file_text(FileId(1), "class_name B\nextends A\n", Durability::LOW);
622 db.set_file_text(
623 FileId(2),
624 "func use_it():\n\tvar a: A\n\tvar x := a.nope()\n",
625 Durability::LOW,
626 );
627 db.sync_source_root();
628
629 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
631 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
632 }
633
634 fn set_with_path(db: &mut RootDatabase, id: u32, path: &str, src: &str) {
638 db.set_file_text(FileId(id), src, Durability::LOW);
639 db.set_file_path(FileId(id), path);
640 }
641
642 #[test]
643 fn res_path_registry_maps_paths_to_files() {
644 let mut db = RootDatabase::default();
645 set_with_path(&mut db, 0, "res://a.gd", "class_name A\n");
646 set_with_path(&mut db, 1, "res://sub/b.gd", "func f():\n\tpass\n");
647 db.set_file_text(FileId(2), "func no_path():\n\tpass\n", Durability::LOW); db.sync_source_root();
649 let root = db.source_root().unwrap();
650
651 let reg = res_path_registry(&db, root);
652 assert_eq!(reg.get("res://a.gd"), Some(&FileId(0)));
653 assert_eq!(reg.get("res://sub/b.gd"), Some(&FileId(1)));
654 assert!(reg.get("res://missing.gd").is_none());
655 assert_eq!(reg.len(), 2);
657 }
658
659 static RES_REGISTRY_OBSERVED: AtomicU32 = AtomicU32::new(0);
664
665 #[salsa::tracked]
666 fn observe_res_registry(db: &dyn gdscript_db::Db, root: SourceRoot) -> usize {
667 RES_REGISTRY_OBSERVED.fetch_add(1, Ordering::SeqCst);
668 res_path_registry(db, root).len()
669 }
670
671 #[test]
672 fn body_edit_does_not_invalidate_the_res_path_registry() {
673 let mut db = RootDatabase::default();
674 set_with_path(&mut db, 0, "res://a.gd", "func f():\n\tvar a := 1\n");
675 db.sync_source_root();
676 let root = db.source_root().unwrap();
677
678 assert_eq!(observe_res_registry(&db, root), 1);
679 let runs = RES_REGISTRY_OBSERVED.load(Ordering::SeqCst);
680
681 db.set_file_text(FileId(0), "func f():\n\tvar a := 123456\n", Durability::LOW);
684
685 assert_eq!(observe_res_registry(&db, root), 1);
686 assert_eq!(
687 RES_REGISTRY_OBSERVED.load(Ordering::SeqCst),
688 runs,
689 "REGRESSION: a body edit re-ran a res_path_registry consumer — the path firewall broke",
690 );
691 }
692
693 #[test]
694 fn preload_const_resolves_to_script_ref_members() {
695 let mut db = RootDatabase::default();
696 set_with_path(
697 &mut db,
698 0,
699 "res://widget.gd",
700 "class_name Widget\nfunc make() -> int:\n\treturn 5\nconst MAX := 10\n",
701 );
702 set_with_path(
703 &mut db,
704 1,
705 "res://main.gd",
706 "const W = preload(\"res://widget.gd\")\nfunc use_it():\n\tvar a := W.make()\n\tvar b := W.new()\n",
707 );
708 db.sync_source_root();
709 let api = db.engine().unwrap();
710
711 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
712 let unit = fi
713 .units
714 .iter()
715 .find(|u| u.result.bindings.len() >= 2)
716 .expect("use_it unit with 2 bindings");
717 assert_eq!(
719 unit.result.bindings[0].ty.label(api).as_deref(),
720 Some("int")
721 );
722 assert!(
723 matches!(unit.result.bindings[1].ty, Ty::ScriptRef(_)),
724 "W.new() should be a script instance, got {:?}",
725 unit.result.bindings[1].ty
726 );
727 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
728 }
729
730 #[test]
731 fn preload_of_script_without_class_name_resolves() {
732 let mut db = RootDatabase::default();
735 set_with_path(
736 &mut db,
737 0,
738 "res://helper.gd",
739 "func help() -> String:\n\treturn \"x\"\n",
740 );
741 set_with_path(
742 &mut db,
743 1,
744 "res://main.gd",
745 "func use_it():\n\tvar h := preload(\"res://helper.gd\")\n\tvar s := h.help()\n",
746 );
747 db.sync_source_root();
748 let api = db.engine().unwrap();
749
750 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
751 let unit = fi
752 .units
753 .iter()
754 .find(|u| u.result.bindings.len() >= 2)
755 .expect("use_it unit");
756 assert!(
757 matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
758 "preload of a class_name-less script must still resolve: {:?}",
759 unit.result.bindings[0].ty
760 );
761 assert_eq!(
762 unit.result.bindings[1].ty.label(api).as_deref(),
763 Some("String")
764 );
765 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
766 }
767
768 #[test]
769 fn extends_res_path_inherits_members() {
770 let mut db = RootDatabase::default();
771 set_with_path(
773 &mut db,
774 0,
775 "res://base.gd",
776 "extends Node\nfunc base_method() -> int:\n\treturn 1\n",
777 );
778 set_with_path(
779 &mut db,
780 1,
781 "res://derived.gd",
782 "class_name Derived\nextends \"res://base.gd\"\nfunc own() -> String:\n\treturn \"x\"\n",
783 );
784 set_with_path(
785 &mut db,
786 2,
787 "res://main.gd",
788 "func use_it():\n\tvar d: Derived\n\tvar a := d.own()\n\tvar b := d.base_method()\n\tvar c := d.get_instance_id()\n",
789 );
790 db.sync_source_root();
791 let api = db.engine().unwrap();
792
793 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
794 let unit = fi
795 .units
796 .iter()
797 .find(|u| u.result.bindings.len() >= 4)
798 .expect("use_it unit with 4 bindings");
799 assert_eq!(
802 unit.result.bindings[1].ty.label(api).as_deref(),
803 Some("String")
804 );
805 assert_eq!(
806 unit.result.bindings[2].ty.label(api).as_deref(),
807 Some("int")
808 );
809 assert_eq!(
810 unit.result.bindings[3].ty.label(api).as_deref(),
811 Some("int")
812 );
813 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
814 }
815
816 #[test]
817 fn dangling_preload_is_seam_not_panic() {
818 let mut db = RootDatabase::default();
819 set_with_path(
820 &mut db,
821 0,
822 "res://main.gd",
823 "func use_it():\n\tvar x := preload(\"res://does_not_exist.gd\")\n\tx.whatever()\n",
824 );
825 db.sync_source_root();
826 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
828 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
829 }
830
831 #[test]
832 fn non_gd_preload_resource_stays_seam() {
833 let mut db = RootDatabase::default();
838 set_with_path(&mut db, 0, "res://scene.tscn", "class_name SceneRoot\n");
839 set_with_path(
840 &mut db,
841 1,
842 "res://main.gd",
843 "func f():\n\tvar s := preload(\"res://scene.tscn\")\n",
844 );
845 db.sync_source_root();
846
847 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
848 let unit = fi
849 .units
850 .iter()
851 .find(|u| !u.result.bindings.is_empty())
852 .expect("f unit");
853 assert!(
854 !matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
855 "a non-.gd preload must stay the seam, got {:?}",
856 unit.result.bindings[0].ty
857 );
858 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
859 }
860
861 #[test]
862 fn load_literal_stays_opaque_not_aliased_to_preload() {
863 let mut db = RootDatabase::default();
864 set_with_path(
865 &mut db,
866 0,
867 "res://widget.gd",
868 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
869 );
870 set_with_path(
871 &mut db,
872 1,
873 "res://main.gd",
874 "func use_it():\n\tvar w := load(\"res://widget.gd\")\n",
875 );
876 db.sync_source_root();
877
878 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
879 let unit = fi
880 .units
881 .iter()
882 .find(|u| !u.result.bindings.is_empty())
883 .expect("use_it unit");
884 assert!(
887 !matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
888 "load() must stay opaque, not alias preload: {:?}",
889 unit.result.bindings[0].ty
890 );
891 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
892 }
893
894 #[test]
895 fn is_narrows_to_a_user_class_cross_file() {
896 let mut db = RootDatabase::default();
901 db.set_file_text(
902 FileId(0),
903 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
904 Durability::LOW,
905 );
906 db.set_file_text(
907 FileId(1),
908 "func use_it(x):\n\tif x is Widget:\n\t\tvar n := x.make()\n",
909 Durability::LOW,
910 );
911 db.sync_source_root();
912 let api = db.engine().unwrap();
913
914 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
915 assert!(
917 fi.units
918 .iter()
919 .flat_map(|u| &u.result.bindings)
920 .any(|b| b.ty.label(api).as_deref() == Some("int")),
921 "`x.make()` after `is Widget` should narrow + resolve to int",
922 );
923 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
924 }
925
926 #[test]
927 fn as_casts_to_a_user_class_cross_file() {
928 let mut db = RootDatabase::default();
930 db.set_file_text(
931 FileId(0),
932 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
933 Durability::LOW,
934 );
935 db.set_file_text(
936 FileId(1),
937 "func use_it(x):\n\tvar n := (x as Widget).make()\n",
938 Durability::LOW,
939 );
940 db.sync_source_root();
941 let api = db.engine().unwrap();
942
943 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
944 assert!(
945 fi.units
946 .iter()
947 .flat_map(|u| &u.result.bindings)
948 .any(|b| b.ty.label(api).as_deref() == Some("int")),
949 "`(x as Widget).make()` should resolve to int",
950 );
951 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
952 }
953
954 #[test]
955 fn renaming_a_files_path_reindexes_the_registry() {
956 let mut db = RootDatabase::default();
958 set_with_path(&mut db, 0, "res://old.gd", "class_name A\n");
959 db.sync_source_root();
960 let root = db.source_root().unwrap();
961 assert_eq!(
962 res_path_registry(&db, root).get("res://old.gd"),
963 Some(&FileId(0))
964 );
965
966 db.set_file_path(FileId(0), "res://new.gd");
967 let root = db.source_root().unwrap();
968 let reg = res_path_registry(&db, root);
969 assert_eq!(reg.get("res://new.gd"), Some(&FileId(0)));
970 assert!(reg.get("res://old.gd").is_none());
971 }
972
973 #[test]
976 fn star_autoload_scene_resolves_via_its_root_script() {
977 let mut db = RootDatabase::default();
981 db.set_file_text(
983 FileId(0),
984 "func volume() -> int:\n\treturn 5\n",
985 Durability::LOW,
986 );
987 db.set_file_path(FileId(0), "res://music.gd");
988 db.set_file_text(
990 FileId(1),
991 "[gd_scene format=3]\n\
992 [ext_resource type=\"Script\" path=\"res://music.gd\" id=\"1\"]\n\
993 [node name=\"Music\" type=\"Node\"]\n\
994 script = ExtResource(\"1\")\n",
995 Durability::LOW,
996 );
997 db.set_file_path(FileId(1), "res://music.tscn");
998 db.set_file_text(
999 FileId(2),
1000 "func f():\n\tvar v := Music.volume()\n",
1001 Durability::LOW,
1002 );
1003 db.set_file_path(FileId(2), "res://main.gd");
1004 db.set_project_config("[autoload]\nMusic=\"*res://music.tscn\"\n");
1005 db.sync_source_root();
1006 let api = db.engine().unwrap();
1007
1008 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1009 let unit = fi
1010 .units
1011 .iter()
1012 .find(|u| !u.result.bindings.is_empty())
1013 .expect("f unit");
1014 assert_eq!(
1015 unit.result.bindings[0].ty.label(api).as_deref(),
1016 Some("int"),
1017 "Music.volume() should resolve via the scene root's script",
1018 );
1019 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1020 }
1021
1022 #[test]
1023 fn star_autoload_gdscript_resolves_as_global_and_members() {
1024 let mut db = RootDatabase::default();
1025 db.set_file_text(
1027 FileId(0),
1028 "func score() -> int:\n\treturn 0\n",
1029 Durability::LOW,
1030 );
1031 db.set_file_path(FileId(0), "res://game.gd");
1032 db.set_file_text(
1033 FileId(1),
1034 "func f():\n\tvar s := Game.score()\n",
1035 Durability::LOW,
1036 );
1037 db.set_file_path(FileId(1), "res://main.gd");
1038 db.set_project_config("[autoload]\nGame=\"*res://game.gd\"\n");
1039 db.sync_source_root();
1040 let api = db.engine().unwrap();
1041
1042 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1043 let unit = fi
1044 .units
1045 .iter()
1046 .find(|u| !u.result.bindings.is_empty())
1047 .expect("f unit");
1048 assert_eq!(
1050 unit.result.bindings[0].ty.label(api).as_deref(),
1051 Some("int")
1052 );
1053 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1054 }
1055
1056 #[test]
1057 fn non_star_autoload_is_not_a_global() {
1058 let mut db = RootDatabase::default();
1059 db.set_file_text(
1060 FileId(0),
1061 "func score() -> int:\n\treturn 0\n",
1062 Durability::LOW,
1063 );
1064 db.set_file_path(FileId(0), "res://game.gd");
1065 db.set_file_text(
1066 FileId(1),
1067 "func f():\n\tvar s := Game.score()\n",
1068 Durability::LOW,
1069 );
1070 db.set_file_path(FileId(1), "res://main.gd");
1071 db.set_project_config("[autoload]\nGame=\"res://game.gd\"\n");
1073 db.sync_source_root();
1074 let api = db.engine().unwrap();
1075
1076 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1077 let unit = fi
1078 .units
1079 .iter()
1080 .find(|u| !u.result.bindings.is_empty())
1081 .expect("f unit");
1082 assert_eq!(unit.result.bindings[0].ty.label(api), None);
1084 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1085 }
1086
1087 #[test]
1088 fn tscn_autoload_is_the_seam_never_false_warns() {
1089 let mut db = RootDatabase::default();
1090 db.set_file_text(FileId(0), "func f():\n\tHud.play_song()\n", Durability::LOW);
1093 db.set_file_path(FileId(0), "res://main.gd");
1094 db.set_project_config("[autoload]\nHud=\"*res://hud.tscn\"\n");
1095 db.sync_source_root();
1096
1097 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1098 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1100 }
1101
1102 static AUTOLOAD_OBSERVED: AtomicU32 = AtomicU32::new(0);
1106
1107 #[salsa::tracked]
1108 fn observe_autoload_registry(db: &dyn gdscript_db::Db, config: ProjectConfig) -> usize {
1109 AUTOLOAD_OBSERVED.fetch_add(1, Ordering::SeqCst);
1110 autoload_registry(db, config).len()
1111 }
1112
1113 #[test]
1114 fn autoload_registry_firewalled_against_body_edits() {
1115 let mut db = RootDatabase::default();
1116 db.set_file_text(FileId(0), "func f():\n\tvar a := 1\n", Durability::LOW);
1117 db.set_file_path(FileId(0), "res://game.gd");
1118 db.set_project_config("[autoload]\nGame=\"*res://game.gd\"\n");
1119 db.sync_source_root();
1120 let config = db.project_config().unwrap();
1121
1122 assert_eq!(observe_autoload_registry(&db, config), 1);
1123 let runs = AUTOLOAD_OBSERVED.load(Ordering::SeqCst);
1124
1125 db.set_file_text(FileId(0), "func f():\n\tvar a := 999999\n", Durability::LOW);
1128
1129 assert_eq!(observe_autoload_registry(&db, config), 1);
1130 assert_eq!(
1131 AUTOLOAD_OBSERVED.load(Ordering::SeqCst),
1132 runs,
1133 "REGRESSION: a body edit re-ran an autoload_registry consumer — the config firewall broke",
1134 );
1135 }
1136
1137 #[test]
1138 fn aliased_self_resolves_own_members_no_false_unsafe() {
1139 let mut db = RootDatabase::default();
1143 db.set_file_text(
1144 FileId(0),
1145 "extends Node\nfunc own() -> int:\n\treturn 1\nfunc use_it():\n\tvar me := self\n\tvar n := me.own()\n",
1146 Durability::LOW,
1147 );
1148 db.sync_source_root();
1149 let api = db.engine().unwrap();
1150
1151 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1152 assert!(
1154 fi.units
1155 .iter()
1156 .flat_map(|u| &u.result.bindings)
1157 .any(|b| b.ty.label(api).as_deref() == Some("int")),
1158 "aliased self.own() should resolve to int",
1159 );
1160 assert!(
1161 fi.diagnostics.is_empty(),
1162 "no false UNSAFE on aliased self: {:?}",
1163 fi.diagnostics
1164 );
1165 }
1166
1167 #[test]
1168 fn is_userbase_narrows_to_derived_but_not_un_narrowed_to_base() {
1169 let mut db = RootDatabase::default();
1170 db.set_file_text(
1171 FileId(0),
1172 "class_name Base\nfunc base_m() -> int:\n\treturn 1\n",
1173 Durability::LOW,
1174 );
1175 db.set_file_text(
1176 FileId(1),
1177 "class_name Derived\nextends Base\nfunc own_m() -> String:\n\treturn \"x\"\n",
1178 Durability::LOW,
1179 );
1180 db.set_file_text(
1183 FileId(2),
1184 "func use_it(x):\n\tif x is Derived:\n\t\tvar a := x.own_m()\n\tvar d: Derived\n\tif d is Base:\n\t\tvar b := d.own_m()\n",
1185 Durability::LOW,
1186 );
1187 db.sync_source_root();
1188 let api = db.engine().unwrap();
1189
1190 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1191 let strings = fi
1192 .units
1193 .iter()
1194 .flat_map(|u| &u.result.bindings)
1195 .filter(|b| b.ty.label(api).as_deref() == Some("String"))
1196 .count();
1197 assert!(
1199 strings >= 2,
1200 "expected both own_m() calls to type as String (narrow-down + widen-only), got {strings}",
1201 );
1202 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1203 }
1204
1205 fn scene_db(scene_text: &str, gd_text: &str) -> RootDatabase {
1209 let mut db = RootDatabase::default();
1210 db.set_file_text(FileId(0), scene_text, Durability::LOW);
1211 db.set_file_path(FileId(0), "res://main.tscn");
1212 db.set_file_text(FileId(1), gd_text, Durability::LOW);
1213 db.set_file_path(FileId(1), "res://main.gd");
1214 db.sync_source_root();
1215 db
1216 }
1217
1218 fn binding_labels(db: &RootDatabase) -> Vec<String> {
1219 let api = db.engine().unwrap();
1220 let fi = analyze_file(db, db.file_text(FileId(1)).unwrap());
1221 assert!(
1222 fi.diagnostics.is_empty(),
1223 "unexpected diags: {:?}",
1224 fi.diagnostics
1225 );
1226 fi.units
1227 .iter()
1228 .flat_map(|u| &u.result.bindings)
1229 .filter_map(|b| b.ty.label(api))
1230 .collect()
1231 }
1232
1233 const SCENE: &str = "[gd_scene format=3]\n\
1234 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1235 [node name=\"Root\" type=\"Control\"]\n\
1236 script = ExtResource(\"1\")\n\
1237 [node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
1238 [node name=\"Box\" type=\"VBoxContainer\" parent=\"Panel\"]\n\
1239 [node name=\"Btn\" type=\"Button\" parent=\"Panel/Box\"]\n\
1240 unique_name_in_owner = true\n";
1241
1242 #[test]
1243 fn dollar_path_types_to_the_concrete_node() {
1244 let db = scene_db(
1246 SCENE,
1247 "extends Control\nfunc _ready():\n\tvar b := $Panel/Box/Btn\n",
1248 );
1249 assert!(
1250 binding_labels(&db).iter().any(|l| l == "Button"),
1251 "$Panel/Box/Btn should type as Button",
1252 );
1253 }
1254
1255 #[test]
1256 fn unique_name_path_types_to_the_concrete_node() {
1257 let db = scene_db(SCENE, "extends Control\nfunc _ready():\n\tvar b := %Btn\n");
1259 assert!(
1260 binding_labels(&db).iter().any(|l| l == "Button"),
1261 "%Btn should type as Button"
1262 );
1263 }
1264
1265 #[test]
1266 fn onready_var_from_a_node_path_is_typed() {
1267 let db = scene_db(
1270 SCENE,
1271 "extends Control\n@onready var btn := $Panel/Box/Btn\n",
1272 );
1273 assert!(
1274 binding_labels(&db).iter().any(|l| l == "Button"),
1275 "@onready var := $Path should type to Button",
1276 );
1277 }
1278
1279 #[test]
1280 fn get_node_string_literal_types_like_dollar() {
1281 let db = scene_db(
1283 SCENE,
1284 "extends Control\nfunc _ready():\n\tvar b := get_node(\"Panel/Box/Btn\")\n",
1285 );
1286 assert!(
1287 binding_labels(&db).iter().any(|l| l == "Button"),
1288 "get_node(\"...\") should type as Button",
1289 );
1290 }
1291
1292 #[test]
1293 fn self_get_node_string_literal_types_like_dollar() {
1294 let db = scene_db(
1297 SCENE,
1298 "extends Control\nfunc _ready():\n\tvar b := self.get_node(\"Panel/Box/Btn\")\n",
1299 );
1300 assert!(
1301 binding_labels(&db).iter().any(|l| l == "Button"),
1302 "self.get_node(\"...\") should type as Button",
1303 );
1304 }
1305
1306 #[test]
1307 fn attached_script_refines_the_node_type() {
1308 let mut db = RootDatabase::default();
1311 db.set_file_text(
1312 FileId(0),
1313 "[gd_scene format=3]\n\
1314 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1315 [ext_resource type=\"Script\" path=\"res://fancy.gd\" id=\"2\"]\n\
1316 [node name=\"Root\" type=\"Control\"]\n\
1317 script = ExtResource(\"1\")\n\
1318 [node name=\"That\" type=\"Button\" parent=\".\"]\n\
1319 script = ExtResource(\"2\")\n",
1320 Durability::LOW,
1321 );
1322 db.set_file_path(FileId(0), "res://main.tscn");
1323 db.set_file_text(
1324 FileId(1),
1325 "extends Control\nfunc _ready():\n\tvar n := $That.fancy()\n",
1326 Durability::LOW,
1327 );
1328 db.set_file_path(FileId(1), "res://main.gd");
1329 db.set_file_text(
1330 FileId(2),
1331 "class_name Fancy\nextends Button\nfunc fancy() -> int:\n\treturn 1\n",
1332 Durability::LOW,
1333 );
1334 db.set_file_path(FileId(2), "res://fancy.gd");
1335 db.sync_source_root();
1336 assert!(
1337 binding_labels(&db).iter().any(|l| l == "int"),
1338 "$That.fancy() should resolve via the attached script Fancy",
1339 );
1340 }
1341
1342 #[test]
1343 fn computed_or_unresolvable_node_path_stays_node_without_warning() {
1344 let mut db = RootDatabase::default();
1347 db.set_file_text(
1348 FileId(1),
1349 "extends Node\nfunc f(p):\n\tvar a := get_node(p)\n\tvar b := $Nope\n",
1350 Durability::LOW,
1351 );
1352 db.set_file_path(FileId(1), "res://lone.gd");
1353 db.sync_source_root();
1354 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1355 assert!(
1356 fi.diagnostics.is_empty(),
1357 "no false node-path warnings: {:?}",
1358 fi.diagnostics
1359 );
1360 }
1361
1362 fn has_invalid_node_path(db: &RootDatabase) -> bool {
1365 let fi = analyze_file(db, db.file_text(FileId(1)).unwrap());
1366 fi.diagnostics
1367 .iter()
1368 .any(|d| d.code == crate::infer::INVALID_NODE_PATH)
1369 }
1370
1371 #[test]
1372 fn invalid_node_path_warns_when_genuinely_absent_in_a_single_owning_scene() {
1373 let db = scene_db(SCENE, "extends Control\nfunc _ready():\n\tvar b := $Nope\n");
1374 assert!(
1375 has_invalid_node_path(&db),
1376 "$Nope is absent in the one owning scene → warn"
1377 );
1378 }
1379
1380 #[test]
1381 fn escape_and_absolute_paths_never_warn() {
1382 let db = scene_db(
1384 SCENE,
1385 "extends Control\nfunc _ready():\n\tvar a := $\"../Sibling\"\n\tvar c := $\"/root/Global\"\n",
1386 );
1387 assert!(!has_invalid_node_path(&db), "escape paths must not warn");
1388 }
1389
1390 #[test]
1391 fn path_descending_into_an_instanced_subscene_never_warns() {
1392 let db = scene_db(
1395 "[gd_scene format=3]\n\
1396 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1397 [ext_resource type=\"PackedScene\" path=\"res://player.tscn\" id=\"2\"]\n\
1398 [node name=\"Root\" type=\"Control\"]\n\
1399 script = ExtResource(\"1\")\n\
1400 [node name=\"Player\" parent=\".\" instance=ExtResource(\"2\")]\n",
1401 "extends Control\nfunc _ready():\n\tvar g := $Player/Gun\n",
1402 );
1403 assert!(
1404 !has_invalid_node_path(&db),
1405 "into-instance miss must not warn"
1406 );
1407 }
1408
1409 #[test]
1410 fn ambiguous_multi_scene_attachment_suppresses_the_invalid_warning() {
1411 let mut db = RootDatabase::default();
1414 db.set_file_text(
1415 FileId(0),
1416 "[gd_scene format=3]\n\
1417 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1418 [node name=\"Root\" type=\"Control\"]\n\
1419 script = ExtResource(\"1\")\n\
1420 [node name=\"Alpha\" type=\"Button\" parent=\".\"]\n",
1421 Durability::LOW,
1422 );
1423 db.set_file_path(FileId(0), "res://a.tscn");
1424 db.set_file_text(
1425 FileId(2),
1426 "[gd_scene format=3]\n\
1427 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1428 [node name=\"Root\" type=\"Control\"]\n\
1429 script = ExtResource(\"1\")\n\
1430 [node name=\"Beta\" type=\"Button\" parent=\".\"]\n",
1431 Durability::LOW,
1432 );
1433 db.set_file_path(FileId(2), "res://b.tscn");
1434 db.set_file_text(
1435 FileId(1),
1436 "extends Control\nfunc _ready():\n\tvar b := $Beta\n",
1437 Durability::LOW,
1438 );
1439 db.set_file_path(FileId(1), "res://main.gd");
1440 db.sync_source_root();
1441 assert!(
1442 !has_invalid_node_path(&db),
1443 "ambiguous multi-scene attachment must not warn"
1444 );
1445 }
1446
1447 #[test]
1450 fn instanced_node_recurses_into_the_subscene_root_script() {
1451 let mut db = RootDatabase::default();
1456 db.set_file_text(
1457 FileId(0),
1458 "[gd_scene format=3]\n\
1459 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1460 [ext_resource type=\"PackedScene\" path=\"res://enemy.tscn\" id=\"2\"]\n\
1461 [node name=\"Root\" type=\"Control\"]\n\
1462 script = ExtResource(\"1\")\n\
1463 [node name=\"Enemy\" parent=\".\" instance=ExtResource(\"2\")]\n",
1464 Durability::LOW,
1465 );
1466 db.set_file_path(FileId(0), "res://main.tscn");
1467 db.set_file_text(
1468 FileId(1),
1469 "extends Control\nfunc _ready():\n\tvar e := $Enemy.hp()\n",
1470 Durability::LOW,
1471 );
1472 db.set_file_path(FileId(1), "res://main.gd");
1473 db.set_file_text(
1474 FileId(2),
1475 "[gd_scene format=3]\n\
1476 [ext_resource type=\"Script\" path=\"res://enemy.gd\" id=\"1\"]\n\
1477 [node name=\"Enemy\" type=\"Button\"]\n\
1478 script = ExtResource(\"1\")\n",
1479 Durability::LOW,
1480 );
1481 db.set_file_path(FileId(2), "res://enemy.tscn");
1482 db.set_file_text(
1483 FileId(3),
1484 "class_name Enemy\nextends Button\nfunc hp() -> int:\n\treturn 1\n",
1485 Durability::LOW,
1486 );
1487 db.set_file_path(FileId(3), "res://enemy.gd");
1488 db.sync_source_root();
1489 assert!(
1490 binding_labels(&db).iter().any(|l| l == "int"),
1491 "$Enemy.hp() should recurse into the instanced sub-scene root's script Enemy",
1492 );
1493 }
1494
1495 #[test]
1498 fn unique_name_subpath_resolves_to_the_child_without_warning() {
1499 let db = scene_db(
1503 "[gd_scene format=3]\n\
1504 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1505 [node name=\"Root\" type=\"Control\"]\n\
1506 script = ExtResource(\"1\")\n\
1507 [node name=\"Box\" type=\"VBoxContainer\" parent=\".\"]\n\
1508 unique_name_in_owner = true\n\
1509 [node name=\"Btn\" type=\"Button\" parent=\"Box\"]\n",
1510 "extends Control\nfunc _ready():\n\tvar b := %Box/Btn\n",
1511 );
1512 assert!(
1513 binding_labels(&db).iter().any(|l| l == "Button"),
1514 "%Box/Btn → Button (and no false INVALID_NODE_PATH)",
1515 );
1516 }
1517
1518 #[test]
1519 fn percent_prefixed_string_paths_resolve_as_unique_without_warning() {
1520 let db = scene_db(
1523 SCENE,
1524 "extends Control\nfunc _ready():\n\tvar a := get_node(\"%Btn\")\n\tvar b := $\"%Btn\"\n",
1525 );
1526 let labels = binding_labels(&db);
1527 assert!(
1528 labels.iter().filter(|l| *l == "Button").count() >= 2,
1529 "both %Btn string forms should resolve to Button: {labels:?}",
1530 );
1531 }
1532}