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};
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]
110pub fn class_name_collisions(db: &dyn Db, root: SourceRoot) -> Arc<FxHashSet<SmolStr>> {
111 let mut seen: FxHashSet<SmolStr> = FxHashSet::default();
112 let mut dups: FxHashSet<SmolStr> = FxHashSet::default();
113 for &file in root.files(db) {
114 if let Some(name) = file_class_name(db, file)
115 && !seen.insert(name.clone())
116 {
117 dups.insert(name);
118 }
119 }
120 Arc::new(dups)
121}
122
123#[salsa::tracked]
130pub fn res_path_registry(db: &dyn Db, root: SourceRoot) -> Arc<FxHashMap<SmolStr, FileId>> {
131 let mut map = FxHashMap::default();
132 for &file in root.files(db) {
133 if let Some(path) = file.res_path(db) {
134 map.entry(path).or_insert_with(|| file.file_id(db));
135 }
136 }
137 Arc::new(map)
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Default)]
145pub struct AutoloadRegistry {
146 singletons: FxHashMap<SmolStr, SmolStr>,
147}
148
149impl AutoloadRegistry {
150 #[must_use]
152 pub fn resolve_path(&self, name: &str) -> Option<&SmolStr> {
153 self.singletons.get(name)
154 }
155
156 #[must_use]
158 pub fn len(&self) -> usize {
159 self.singletons.len()
160 }
161
162 #[must_use]
164 pub fn is_empty(&self) -> bool {
165 self.singletons.is_empty()
166 }
167}
168
169#[salsa::tracked]
172pub fn autoload_registry(db: &dyn Db, config: ProjectConfig) -> Arc<AutoloadRegistry> {
173 let mut singletons = FxHashMap::default();
174 for e in crate::project::parse_autoloads(config.project_godot_text(db)) {
175 if e.is_singleton {
176 singletons.entry(e.name).or_insert(e.path);
177 }
178 }
179 Arc::new(AutoloadRegistry { singletons })
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
185pub enum MemberSig {
186 Method(Ty),
188 Field(Ty),
190 Signal,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct ScriptClass {
201 members: FxHashMap<SmolStr, MemberSig>,
202 base: Ty,
203}
204
205impl ScriptClass {
206 #[must_use]
209 pub fn member(&self, name: &str) -> Option<&MemberSig> {
210 self.members.get(name)
211 }
212
213 #[must_use]
215 pub fn base(&self) -> &Ty {
216 &self.base
217 }
218}
219
220#[must_use]
224pub fn script_ref_name(db: &dyn Db, sref: ScriptRefId) -> Option<SmolStr> {
225 let file = db.file_text(FileId(sref.0))?;
226 file_class_name(db, file)
227}
228
229#[salsa::tracked]
232pub fn script_class(db: &dyn Db, file: FileText) -> Arc<ScriptClass> {
233 let tree = item_tree(db, file);
234 let Some(api) = db.engine() else {
235 return Arc::new(ScriptClass {
236 members: FxHashMap::default(),
237 base: Ty::Unknown,
238 });
239 };
240 let resolve_ann = |ann: Option<&str>| -> Ty {
241 ann.map_or(Ty::Variant, |t| {
242 crate::resolve::resolve_type_name(db, api, t)
243 })
244 };
245 let mut members = FxHashMap::default();
246 for m in &tree.members {
247 let Some(name) = m.name() else { continue };
248 let sig = match m {
249 Member::Func(f) => MemberSig::Method(resolve_ann(f.return_type.as_deref())),
250 Member::Var(v) => MemberSig::Field(resolve_ann(v.type_ref.as_deref())),
251 Member::Const(c) => MemberSig::Field(
256 c.type_ref
257 .is_none()
258 .then_some(c.preload_path.as_deref())
259 .flatten()
260 .and_then(|raw| {
261 crate::resolve::anchor_res_path(file.res_path(db).as_deref(), raw)
262 })
263 .map_or_else(
264 || resolve_ann(c.type_ref.as_deref()),
265 |abs| {
266 crate::resolve::resolve_external(
267 db,
268 &crate::resolve::ExternalRef::Preload(abs),
269 )
270 },
271 ),
272 ),
273 Member::Signal(_) => MemberSig::Signal,
274 Member::Enum(_) | Member::Class(_) => continue,
276 };
277 members.insert(SmolStr::new(name), sig);
278 }
279 let base = crate::resolve::resolve_base(db, api, &tree, file.res_path(db).as_deref());
282 Arc::new(ScriptClass { members, base })
283}
284
285fn is_scene_path(path: &str) -> bool {
290 let ext = path.rsplit('.').next().unwrap_or("");
291 ext.eq_ignore_ascii_case("tscn") || ext.eq_ignore_ascii_case("tres")
292}
293
294#[salsa::tracked]
298pub fn scene_model(db: &dyn Db, file: FileText) -> Arc<SceneModel> {
299 let is_scene = file.res_path(db).as_deref().is_some_and(is_scene_path);
300 if is_scene {
301 Arc::new(gdscript_scene::parse_scene(file.text(db)))
302 } else {
303 Arc::new(gdscript_scene::parse_scene(""))
304 }
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310pub struct SceneAttach {
311 pub scene: FileId,
313 pub node: NodeIdx,
315 pub ambiguous: bool,
318}
319
320#[salsa::tracked]
327pub fn script_scene_index(db: &dyn Db, root: SourceRoot) -> Arc<FxHashMap<SmolStr, SceneAttach>> {
328 let mut map: FxHashMap<SmolStr, SceneAttach> = FxHashMap::default();
329 for &file in root.files(db) {
330 if !file.res_path(db).as_deref().is_some_and(is_scene_path) {
331 continue;
332 }
333 let model = scene_model(db, file);
334 let scene = file.file_id(db);
335 for (i, node) in model.nodes.iter().enumerate() {
336 let Some(script_id) = node.script.as_ref() else {
337 continue;
338 };
339 let Some(path) = model
340 .ext_resources
341 .get(script_id)
342 .and_then(|e| e.path.clone())
343 else {
344 continue;
345 };
346 let node = NodeIdx(u32::try_from(i).unwrap_or(u32::MAX));
347 match map.get_mut(&path) {
348 Some(existing) => existing.ambiguous = true,
350 None => {
351 map.insert(
352 path,
353 SceneAttach {
354 scene,
355 node,
356 ambiguous: false,
357 },
358 );
359 }
360 }
361 }
362 }
363 Arc::new(map)
364}
365
366#[must_use]
371pub fn scene_context(db: &dyn Db, file: FileText) -> Option<SceneContext> {
372 let res_path = file.res_path(db)?;
373 let root = db.source_root()?;
374 let attach = *script_scene_index(db, root).get(res_path.as_str())?;
375 let scene_file = db.file_text(attach.scene)?;
376 Some(SceneContext {
377 scene: attach.scene,
378 model: scene_model(db, scene_file),
379 attach: attach.node,
380 ambiguous: attach.ambiguous,
381 })
382}
383
384#[derive(Debug, Clone)]
387pub struct SceneContext {
388 pub scene: FileId,
390 pub model: Arc<SceneModel>,
392 pub attach: NodeIdx,
394 pub ambiguous: bool,
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use gdscript_base::FileId;
402 use gdscript_db::RootDatabase;
403 use salsa::Durability;
404
405 fn db_with(src: &str) -> (RootDatabase, FileText) {
406 let mut db = RootDatabase::default();
407 db.set_file_text(FileId(0), src, Durability::LOW);
408 let ft = db.file_text(FileId(0)).unwrap();
409 (db, ft)
410 }
411
412 #[test]
413 fn tracked_item_tree_matches_the_plain_fn() {
414 let (db, ft) = db_with("class_name Foo\nfunc f():\n\tpass\n");
415 let tree = item_tree(&db, ft);
416 assert_eq!(tree.class_name.as_deref(), Some("Foo"));
417 assert_eq!(item_tree(&db, ft), tree);
419 }
420
421 #[test]
422 fn tracked_analyze_file_runs_inference() {
423 let (db, ft) = db_with("func add(a: int, b: int) -> int:\n\treturn a + b\n");
424 let fi = analyze_file(&db, ft);
425 assert!(!fi.units.is_empty());
427 assert!(fi.diagnostics.is_empty());
428 }
429
430 use std::sync::atomic::{AtomicU32, Ordering};
440
441 static WITNESS_RUNS: AtomicU32 = AtomicU32::new(0);
442
443 #[salsa::tracked]
445 fn class_name_witness(db: &dyn gdscript_db::Db, file: FileText) -> Option<smol_str::SmolStr> {
446 WITNESS_RUNS.fetch_add(1, Ordering::SeqCst);
447 item_tree(db, file).class_name.clone()
448 }
449
450 #[test]
451 fn body_edit_does_not_invalidate_signature_queries() {
452 let mut db = RootDatabase::default();
453 db.set_file_text(
454 FileId(0),
455 "class_name Foo\nfunc f():\n\tvar a := 1\n",
456 Durability::LOW,
457 );
458 let ft = db.file_text(FileId(0)).unwrap();
459
460 assert_eq!(class_name_witness(&db, ft).as_deref(), Some("Foo"));
462 let runs_after_warm = WITNESS_RUNS.load(Ordering::SeqCst);
463
464 db.set_file_text(
467 FileId(0),
468 "class_name Foo\nfunc f():\n\tvar a := 2\n",
469 Durability::LOW,
470 );
471 assert_eq!(class_name_witness(&db, ft).as_deref(), Some("Foo"));
472
473 assert_eq!(
474 WITNESS_RUNS.load(Ordering::SeqCst),
475 runs_after_warm,
476 "REGRESSION: a body edit re-ran a signature-only query — the item_tree firewall broke",
477 );
478 }
479
480 #[test]
481 fn global_registry_resolves_class_names_across_files() {
482 let mut db = RootDatabase::default();
483 db.set_file_text(
484 FileId(0),
485 "class_name Player\nfunc f():\n\tpass\n",
486 Durability::LOW,
487 );
488 db.set_file_text(
489 FileId(1),
490 "class_name Enemy\nvar hp := 10\n",
491 Durability::LOW,
492 );
493 db.set_file_text(FileId(2), "func no_class():\n\tpass\n", Durability::LOW);
494 db.sync_source_root();
495 let root = db.source_root().unwrap();
496
497 let reg = global_registry(&db, root);
498 assert_eq!(reg.len(), 2);
499 assert_eq!(reg.resolve("Player"), db.file_text(FileId(0)));
500 assert_eq!(reg.resolve("Enemy"), db.file_text(FileId(1)));
501 assert!(reg.resolve("Nonexistent").is_none());
502 }
503
504 static REGISTRY_OBSERVED: AtomicU32 = AtomicU32::new(0);
511
512 #[salsa::tracked]
514 fn observe_registry(db: &dyn gdscript_db::Db, root: SourceRoot) -> usize {
515 REGISTRY_OBSERVED.fetch_add(1, Ordering::SeqCst);
516 global_registry(db, root).len()
517 }
518
519 #[test]
520 fn body_edit_does_not_invalidate_the_global_registry() {
521 let mut db = RootDatabase::default();
522 db.set_file_text(
523 FileId(0),
524 "class_name Player\nfunc f():\n\tvar a := 1\n",
525 Durability::LOW,
526 );
527 db.set_file_text(FileId(1), "class_name Enemy\n", Durability::LOW);
528 db.sync_source_root();
529 let root = db.source_root().unwrap();
530
531 assert_eq!(observe_registry(&db, root), 2);
532 let runs = REGISTRY_OBSERVED.load(Ordering::SeqCst);
533
534 db.set_file_text(
537 FileId(0),
538 "class_name Player\nfunc f():\n\tvar a := 123456\n",
539 Durability::LOW,
540 );
541
542 assert_eq!(observe_registry(&db, root), 2);
543 assert_eq!(
544 REGISTRY_OBSERVED.load(Ordering::SeqCst),
545 runs,
546 "REGRESSION: a body edit re-ran a global_registry consumer — the cross-file firewall broke",
547 );
548 }
549
550 #[test]
551 fn cross_file_class_name_member_resolves() {
552 let mut db = RootDatabase::default();
553 db.set_file_text(
554 FileId(0),
555 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
556 Durability::LOW,
557 );
558 db.set_file_text(
559 FileId(1),
560 "func use_it():\n\tvar w := Widget.make()\n",
561 Durability::LOW,
562 );
563 db.sync_source_root();
564
565 let file1 = db.file_text(FileId(1)).unwrap();
566 let fi = analyze_file(&db, file1);
567 let api = db.engine().unwrap();
568
569 let unit = fi
572 .units
573 .iter()
574 .find(|u| !u.result.bindings.is_empty())
575 .expect("a unit with a binding");
576 assert_eq!(
577 unit.result.bindings[0].ty.label(api).as_deref(),
578 Some("int")
579 );
580 assert!(
581 fi.diagnostics.is_empty(),
582 "unexpected diagnostics: {:?}",
583 fi.diagnostics
584 );
585 }
586
587 use crate::infer::SHADOWED_GLOBAL_IDENTIFIER;
590
591 fn shadow_codes(fi: &Arc<FileInference>) -> Vec<&str> {
592 fi.diagnostics
593 .iter()
594 .filter(|d| d.code == SHADOWED_GLOBAL_IDENTIFIER)
595 .map(|d| d.code.as_str())
596 .collect()
597 }
598
599 #[test]
600 fn class_name_collisions_names_only_the_duplicates() {
601 let mut db = RootDatabase::default();
602 db.set_file_text(FileId(0), "class_name Dup\n", Durability::LOW);
603 db.set_file_text(FileId(1), "class_name Dup\n", Durability::LOW);
604 db.set_file_text(FileId(2), "class_name Unique\n", Durability::LOW);
605 db.sync_source_root();
606 let root = db.source_root().unwrap();
607
608 let cols = class_name_collisions(&db, root);
609 assert!(cols.contains(&SmolStr::new("Dup")));
610 assert!(
611 !cols.contains(&SmolStr::new("Unique")),
612 "a singly-declared class_name is not a collision",
613 );
614 assert_eq!(cols.len(), 1);
615 }
616
617 #[test]
618 fn duplicate_class_name_warns_at_both_declarations() {
619 let mut db = RootDatabase::default();
620 db.set_file_text(
621 FileId(0),
622 "class_name Dup\nfunc f():\n\tpass\n",
623 Durability::LOW,
624 );
625 db.set_file_text(FileId(1), "class_name Dup\nvar x := 1\n", Durability::LOW);
626 db.sync_source_root();
627
628 for fid in [0, 1] {
629 let fi = analyze_file(&db, db.file_text(FileId(fid)).unwrap());
630 assert!(
631 shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
632 "file {fid} should warn on the duplicate class_name: {:?}",
633 fi.diagnostics
634 );
635 let d = fi
637 .diagnostics
638 .iter()
639 .find(|d| d.code == SHADOWED_GLOBAL_IDENTIFIER)
640 .unwrap();
641 assert_eq!(d.range, gdscript_base::TextRange::new(11, 14));
642 }
643 }
644
645 #[test]
646 fn class_name_shadowing_an_engine_class_warns() {
647 let mut db = RootDatabase::default();
648 db.set_file_text(
650 FileId(0),
651 "class_name Node\nfunc f():\n\tpass\n",
652 Durability::LOW,
653 );
654 db.sync_source_root();
655
656 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
657 assert!(
658 shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
659 "class_name Node must warn (shadows the engine class): {:?}",
660 fi.diagnostics
661 );
662 }
663
664 #[test]
665 fn class_name_shadowing_a_builtin_type_warns() {
666 let mut db = RootDatabase::default();
667 db.set_file_text(FileId(0), "class_name Vector2\n", Durability::LOW);
669 db.sync_source_root();
670
671 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
672 assert!(
673 shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
674 "{:?}",
675 fi.diagnostics
676 );
677 }
678
679 #[test]
680 fn class_name_shadowing_a_star_autoload_warns() {
681 let mut db = RootDatabase::default();
682 db.set_file_text(
683 FileId(0),
684 "class_name Game\nfunc f():\n\tpass\n",
685 Durability::LOW,
686 );
687 db.set_file_path(FileId(0), "res://game.gd");
688 db.set_project_config("[autoload]\nGame=\"*res://other.gd\"\n");
690 db.sync_source_root();
691
692 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
693 assert!(
694 shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
695 "class_name Game must warn (shadows the `*Game` autoload): {:?}",
696 fi.diagnostics
697 );
698 }
699
700 #[test]
701 fn unique_non_shadowing_class_name_does_not_warn() {
702 let mut db = RootDatabase::default();
704 db.set_file_text(
705 FileId(0),
706 "class_name MyVeryOwnUniquePlayer\nfunc f():\n\tpass\n",
707 Durability::LOW,
708 );
709 db.set_file_text(
710 FileId(1),
711 "class_name AnotherUniqueEnemy\n",
712 Durability::LOW,
713 );
714 db.sync_source_root();
715
716 for fid in [0, 1] {
717 let fi = analyze_file(&db, db.file_text(FileId(fid)).unwrap());
718 assert!(
719 shadow_codes(&fi).is_empty(),
720 "file {fid}: a unique class_name must not warn: {:?}",
721 fi.diagnostics
722 );
723 }
724 }
725
726 #[test]
727 fn unknown_member_on_script_ref_is_seam_not_warning() {
728 let mut db = RootDatabase::default();
729 db.set_file_text(
730 FileId(0),
731 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
732 Durability::LOW,
733 );
734 db.set_file_text(
735 FileId(1),
736 "func use_it():\n\tWidget.not_a_member()\n",
737 Durability::LOW,
738 );
739 db.sync_source_root();
740
741 let file1 = db.file_text(FileId(1)).unwrap();
742 let fi = analyze_file(&db, file1);
743 assert!(
745 fi.diagnostics.is_empty(),
746 "a missing member on a ScriptRef must not warn: {:?}",
747 fi.diagnostics
748 );
749 }
750
751 #[test]
752 fn inherited_members_resolve_through_user_and_engine_bases() {
753 let mut db = RootDatabase::default();
754 db.set_file_text(
756 FileId(0),
757 "class_name Base\nextends Node\nfunc base_method() -> int:\n\treturn 1\n",
758 Durability::LOW,
759 );
760 db.set_file_text(
761 FileId(1),
762 "class_name Derived\nextends Base\nfunc own() -> String:\n\treturn \"x\"\n",
763 Durability::LOW,
764 );
765 db.set_file_text(
766 FileId(2),
767 "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",
768 Durability::LOW,
769 );
770 db.sync_source_root();
771 let api = db.engine().unwrap();
772
773 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
774 let unit = fi
775 .units
776 .iter()
777 .find(|u| u.result.bindings.len() >= 4)
778 .expect("use_it unit with 4 bindings");
779 assert_eq!(
781 unit.result.bindings[1].ty.label(api).as_deref(),
782 Some("String")
783 );
784 assert_eq!(
785 unit.result.bindings[2].ty.label(api).as_deref(),
786 Some("int")
787 );
788 assert_eq!(
789 unit.result.bindings[3].ty.label(api).as_deref(),
790 Some("int")
791 );
792 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
793 }
794
795 #[test]
796 fn cyclic_extends_flags_each_cycle_member_and_terminates() {
797 use crate::infer::CYCLIC_INHERITANCE;
798 let mut db = RootDatabase::default();
799 db.set_file_text(FileId(0), "class_name A\nextends B\n", Durability::LOW);
802 db.set_file_text(FileId(1), "class_name B\nextends A\n", Durability::LOW);
803 db.set_file_text(
805 FileId(2),
806 "func use_it():\n\tvar a: A\n\tvar x := a.nope()\n",
807 Durability::LOW,
808 );
809 db.sync_source_root();
810
811 for id in [FileId(0), FileId(1)] {
813 let fi = analyze_file(&db, db.file_text(id).unwrap());
814 let cyclic: Vec<_> = fi
815 .diagnostics
816 .iter()
817 .filter(|d| d.code == CYCLIC_INHERITANCE)
818 .collect();
819 assert_eq!(cyclic.len(), 1, "file {id:?}: {:?}", fi.diagnostics);
820 }
821
822 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
825 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
826 }
827
828 #[test]
829 fn cyclic_extends_via_res_path_two_files_flags_no_hang() {
830 use crate::infer::CYCLIC_INHERITANCE;
831 let mut db = RootDatabase::default();
832 set_with_path(&mut db, 0, "res://a.gd", "extends \"res://b.gd\"\n");
834 set_with_path(&mut db, 1, "res://b.gd", "extends \"res://a.gd\"\n");
835 db.sync_source_root();
836
837 for id in [FileId(0), FileId(1)] {
838 let fi = analyze_file(&db, db.file_text(id).unwrap());
839 assert!(
840 fi.diagnostics.iter().any(|d| d.code == CYCLIC_INHERITANCE),
841 "file {id:?} expected CYCLIC_INHERITANCE: {:?}",
842 fi.diagnostics
843 );
844 }
845 }
846
847 #[test]
848 fn deep_acyclic_extends_chain_does_not_false_fire() {
849 use crate::infer::CYCLIC_INHERITANCE;
850 let mut db = RootDatabase::default();
851 db.set_file_text(FileId(0), "class_name C0\nextends C1\n", Durability::LOW);
854 db.set_file_text(FileId(1), "class_name C1\nextends C2\n", Durability::LOW);
855 db.set_file_text(FileId(2), "class_name C2\nextends C3\n", Durability::LOW);
856 db.set_file_text(FileId(3), "class_name C3\nextends C4\n", Durability::LOW);
857 db.set_file_text(FileId(4), "class_name C4\nextends Node\n", Durability::LOW);
858 db.sync_source_root();
859
860 for id in (0..5).map(FileId) {
861 let fi = analyze_file(&db, db.file_text(id).unwrap());
862 assert!(
863 !fi.diagnostics.iter().any(|d| d.code == CYCLIC_INHERITANCE),
864 "file {id:?} false-fired CYCLIC_INHERITANCE: {:?}",
865 fi.diagnostics
866 );
867 }
868 }
869
870 fn set_with_path(db: &mut RootDatabase, id: u32, path: &str, src: &str) {
874 db.set_file_text(FileId(id), src, Durability::LOW);
875 db.set_file_path(FileId(id), path);
876 }
877
878 #[test]
879 fn res_path_registry_maps_paths_to_files() {
880 let mut db = RootDatabase::default();
881 set_with_path(&mut db, 0, "res://a.gd", "class_name A\n");
882 set_with_path(&mut db, 1, "res://sub/b.gd", "func f():\n\tpass\n");
883 db.set_file_text(FileId(2), "func no_path():\n\tpass\n", Durability::LOW); db.sync_source_root();
885 let root = db.source_root().unwrap();
886
887 let reg = res_path_registry(&db, root);
888 assert_eq!(reg.get("res://a.gd"), Some(&FileId(0)));
889 assert_eq!(reg.get("res://sub/b.gd"), Some(&FileId(1)));
890 assert!(reg.get("res://missing.gd").is_none());
891 assert_eq!(reg.len(), 2);
893 }
894
895 static RES_REGISTRY_OBSERVED: AtomicU32 = AtomicU32::new(0);
900
901 #[salsa::tracked]
902 fn observe_res_registry(db: &dyn gdscript_db::Db, root: SourceRoot) -> usize {
903 RES_REGISTRY_OBSERVED.fetch_add(1, Ordering::SeqCst);
904 res_path_registry(db, root).len()
905 }
906
907 #[test]
908 fn body_edit_does_not_invalidate_the_res_path_registry() {
909 let mut db = RootDatabase::default();
910 set_with_path(&mut db, 0, "res://a.gd", "func f():\n\tvar a := 1\n");
911 db.sync_source_root();
912 let root = db.source_root().unwrap();
913
914 assert_eq!(observe_res_registry(&db, root), 1);
915 let runs = RES_REGISTRY_OBSERVED.load(Ordering::SeqCst);
916
917 db.set_file_text(FileId(0), "func f():\n\tvar a := 123456\n", Durability::LOW);
920
921 assert_eq!(observe_res_registry(&db, root), 1);
922 assert_eq!(
923 RES_REGISTRY_OBSERVED.load(Ordering::SeqCst),
924 runs,
925 "REGRESSION: a body edit re-ran a res_path_registry consumer — the path firewall broke",
926 );
927 }
928
929 #[test]
930 fn preload_const_resolves_to_script_ref_members() {
931 let mut db = RootDatabase::default();
932 set_with_path(
933 &mut db,
934 0,
935 "res://widget.gd",
936 "class_name Widget\nfunc make() -> int:\n\treturn 5\nconst MAX := 10\n",
937 );
938 set_with_path(
939 &mut db,
940 1,
941 "res://main.gd",
942 "const W = preload(\"res://widget.gd\")\nfunc use_it():\n\tvar a := W.make()\n\tvar b := W.new()\n",
943 );
944 db.sync_source_root();
945 let api = db.engine().unwrap();
946
947 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
948 let unit = fi
949 .units
950 .iter()
951 .find(|u| u.result.bindings.len() >= 2)
952 .expect("use_it unit with 2 bindings");
953 assert_eq!(
955 unit.result.bindings[0].ty.label(api).as_deref(),
956 Some("int")
957 );
958 assert!(
959 matches!(unit.result.bindings[1].ty, Ty::ScriptRef(_)),
960 "W.new() should be a script instance, got {:?}",
961 unit.result.bindings[1].ty
962 );
963 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
964 }
965
966 #[test]
967 fn cross_file_preload_const_member_resolves() {
968 let mut db = RootDatabase::default();
972 set_with_path(
973 &mut db,
974 0,
975 "res://widget.gd",
976 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
977 );
978 set_with_path(
979 &mut db,
980 1,
981 "res://holder.gd",
982 "class_name Holder\nconst W = preload(\"res://widget.gd\")\n",
983 );
984 set_with_path(
985 &mut db,
986 2,
987 "res://user.gd",
988 "func use_it():\n\tvar a := Holder.W.make()\n",
989 );
990 db.sync_source_root();
991 let api = db.engine().unwrap();
992
993 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
994 let unit = fi
995 .units
996 .iter()
997 .find(|u| !u.result.bindings.is_empty())
998 .expect("use_it unit");
999 assert_eq!(
1001 unit.result.bindings[0].ty.label(api).as_deref(),
1002 Some("int"),
1003 "Holder.W.make() should resolve cross-file to int, got {:?}",
1004 unit.result.bindings[0].ty
1005 );
1006 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1007 }
1008
1009 #[test]
1010 fn preload_of_script_without_class_name_resolves() {
1011 let mut db = RootDatabase::default();
1014 set_with_path(
1015 &mut db,
1016 0,
1017 "res://helper.gd",
1018 "func help() -> String:\n\treturn \"x\"\n",
1019 );
1020 set_with_path(
1021 &mut db,
1022 1,
1023 "res://main.gd",
1024 "func use_it():\n\tvar h := preload(\"res://helper.gd\")\n\tvar s := h.help()\n",
1025 );
1026 db.sync_source_root();
1027 let api = db.engine().unwrap();
1028
1029 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1030 let unit = fi
1031 .units
1032 .iter()
1033 .find(|u| u.result.bindings.len() >= 2)
1034 .expect("use_it unit");
1035 assert!(
1036 matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
1037 "preload of a class_name-less script must still resolve: {:?}",
1038 unit.result.bindings[0].ty
1039 );
1040 assert_eq!(
1041 unit.result.bindings[1].ty.label(api).as_deref(),
1042 Some("String")
1043 );
1044 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1045 }
1046
1047 #[test]
1048 fn extends_res_path_inherits_members() {
1049 let mut db = RootDatabase::default();
1050 set_with_path(
1052 &mut db,
1053 0,
1054 "res://base.gd",
1055 "extends Node\nfunc base_method() -> int:\n\treturn 1\n",
1056 );
1057 set_with_path(
1058 &mut db,
1059 1,
1060 "res://derived.gd",
1061 "class_name Derived\nextends \"res://base.gd\"\nfunc own() -> String:\n\treturn \"x\"\n",
1062 );
1063 set_with_path(
1064 &mut db,
1065 2,
1066 "res://main.gd",
1067 "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",
1068 );
1069 db.sync_source_root();
1070 let api = db.engine().unwrap();
1071
1072 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1073 let unit = fi
1074 .units
1075 .iter()
1076 .find(|u| u.result.bindings.len() >= 4)
1077 .expect("use_it unit with 4 bindings");
1078 assert_eq!(
1081 unit.result.bindings[1].ty.label(api).as_deref(),
1082 Some("String")
1083 );
1084 assert_eq!(
1085 unit.result.bindings[2].ty.label(api).as_deref(),
1086 Some("int")
1087 );
1088 assert_eq!(
1089 unit.result.bindings[3].ty.label(api).as_deref(),
1090 Some("int")
1091 );
1092 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1093 }
1094
1095 #[test]
1096 fn relative_extends_path_anchors_to_importing_dir() {
1097 let mut db = RootDatabase::default();
1098 set_with_path(
1100 &mut db,
1101 0,
1102 "res://entities/base.gd",
1103 "extends Node\nfunc base_method() -> int:\n\treturn 1\n",
1104 );
1105 set_with_path(
1107 &mut db,
1108 1,
1109 "res://entities/derived.gd",
1110 "class_name Derived\nextends \"base.gd\"\nfunc own() -> String:\n\treturn \"x\"\n",
1111 );
1112 set_with_path(
1113 &mut db,
1114 2,
1115 "res://main.gd",
1116 "func use_it():\n\tvar d: Derived\n\tvar a := d.own()\n\tvar b := d.base_method()\n",
1117 );
1118 db.sync_source_root();
1119 let api = db.engine().unwrap();
1120 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1121 let unit = fi
1122 .units
1123 .iter()
1124 .find(|u| u.result.bindings.len() >= 3)
1125 .expect("use_it unit with 3 bindings (d, a, b)");
1126 assert_eq!(
1128 unit.result.bindings[1].ty.label(api).as_deref(),
1129 Some("String")
1130 );
1131 assert_eq!(
1132 unit.result.bindings[2].ty.label(api).as_deref(),
1133 Some("int"),
1134 "base_method() must resolve through the relative `extends \"base.gd\"`"
1135 );
1136 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1137 }
1138
1139 #[test]
1140 fn dangling_preload_is_seam_not_panic() {
1141 let mut db = RootDatabase::default();
1142 set_with_path(
1143 &mut db,
1144 0,
1145 "res://main.gd",
1146 "func use_it():\n\tvar x := preload(\"res://does_not_exist.gd\")\n\tx.whatever()\n",
1147 );
1148 db.sync_source_root();
1149 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1151 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1152 }
1153
1154 #[test]
1155 fn non_gd_preload_resource_stays_seam() {
1156 let mut db = RootDatabase::default();
1161 set_with_path(&mut db, 0, "res://scene.tscn", "class_name SceneRoot\n");
1162 set_with_path(
1163 &mut db,
1164 1,
1165 "res://main.gd",
1166 "func f():\n\tvar s := preload(\"res://scene.tscn\")\n",
1167 );
1168 db.sync_source_root();
1169
1170 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1171 let unit = fi
1172 .units
1173 .iter()
1174 .find(|u| !u.result.bindings.is_empty())
1175 .expect("f unit");
1176 assert!(
1177 !matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
1178 "a non-.gd preload must stay the seam, got {:?}",
1179 unit.result.bindings[0].ty
1180 );
1181 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1182 }
1183
1184 #[test]
1185 fn load_literal_stays_opaque_not_aliased_to_preload() {
1186 let mut db = RootDatabase::default();
1187 set_with_path(
1188 &mut db,
1189 0,
1190 "res://widget.gd",
1191 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1192 );
1193 set_with_path(
1194 &mut db,
1195 1,
1196 "res://main.gd",
1197 "func use_it():\n\tvar w := load(\"res://widget.gd\")\n",
1198 );
1199 db.sync_source_root();
1200
1201 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1202 let unit = fi
1203 .units
1204 .iter()
1205 .find(|u| !u.result.bindings.is_empty())
1206 .expect("use_it unit");
1207 assert!(
1210 !matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
1211 "load() must stay opaque, not alias preload: {:?}",
1212 unit.result.bindings[0].ty
1213 );
1214 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1215 }
1216
1217 #[test]
1218 fn is_narrows_to_a_user_class_cross_file() {
1219 let mut db = RootDatabase::default();
1224 db.set_file_text(
1225 FileId(0),
1226 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1227 Durability::LOW,
1228 );
1229 db.set_file_text(
1230 FileId(1),
1231 "func use_it(x):\n\tif x is Widget:\n\t\tvar n := x.make()\n",
1232 Durability::LOW,
1233 );
1234 db.sync_source_root();
1235 let api = db.engine().unwrap();
1236
1237 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1238 assert!(
1240 fi.units
1241 .iter()
1242 .flat_map(|u| &u.result.bindings)
1243 .any(|b| b.ty.label(api).as_deref() == Some("int")),
1244 "`x.make()` after `is Widget` should narrow + resolve to int",
1245 );
1246 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1247 }
1248
1249 #[test]
1250 fn as_casts_to_a_user_class_cross_file() {
1251 let mut db = RootDatabase::default();
1253 db.set_file_text(
1254 FileId(0),
1255 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1256 Durability::LOW,
1257 );
1258 db.set_file_text(
1259 FileId(1),
1260 "func use_it(x):\n\tvar n := (x as Widget).make()\n",
1261 Durability::LOW,
1262 );
1263 db.sync_source_root();
1264 let api = db.engine().unwrap();
1265
1266 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1267 assert!(
1268 fi.units
1269 .iter()
1270 .flat_map(|u| &u.result.bindings)
1271 .any(|b| b.ty.label(api).as_deref() == Some("int")),
1272 "`(x as Widget).make()` should resolve to int",
1273 );
1274 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1275 }
1276
1277 #[test]
1278 fn renaming_a_files_path_reindexes_the_registry() {
1279 let mut db = RootDatabase::default();
1281 set_with_path(&mut db, 0, "res://old.gd", "class_name A\n");
1282 db.sync_source_root();
1283 let root = db.source_root().unwrap();
1284 assert_eq!(
1285 res_path_registry(&db, root).get("res://old.gd"),
1286 Some(&FileId(0))
1287 );
1288
1289 db.set_file_path(FileId(0), "res://new.gd");
1290 let root = db.source_root().unwrap();
1291 let reg = res_path_registry(&db, root);
1292 assert_eq!(reg.get("res://new.gd"), Some(&FileId(0)));
1293 assert!(reg.get("res://old.gd").is_none());
1294 }
1295
1296 #[test]
1299 fn star_autoload_scene_resolves_via_its_root_script() {
1300 let mut db = RootDatabase::default();
1304 db.set_file_text(
1306 FileId(0),
1307 "func volume() -> int:\n\treturn 5\n",
1308 Durability::LOW,
1309 );
1310 db.set_file_path(FileId(0), "res://music.gd");
1311 db.set_file_text(
1313 FileId(1),
1314 "[gd_scene format=3]\n\
1315 [ext_resource type=\"Script\" path=\"res://music.gd\" id=\"1\"]\n\
1316 [node name=\"Music\" type=\"Node\"]\n\
1317 script = ExtResource(\"1\")\n",
1318 Durability::LOW,
1319 );
1320 db.set_file_path(FileId(1), "res://music.tscn");
1321 db.set_file_text(
1322 FileId(2),
1323 "func f():\n\tvar v := Music.volume()\n",
1324 Durability::LOW,
1325 );
1326 db.set_file_path(FileId(2), "res://main.gd");
1327 db.set_project_config("[autoload]\nMusic=\"*res://music.tscn\"\n");
1328 db.sync_source_root();
1329 let api = db.engine().unwrap();
1330
1331 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1332 let unit = fi
1333 .units
1334 .iter()
1335 .find(|u| !u.result.bindings.is_empty())
1336 .expect("f unit");
1337 assert_eq!(
1338 unit.result.bindings[0].ty.label(api).as_deref(),
1339 Some("int"),
1340 "Music.volume() should resolve via the scene root's script",
1341 );
1342 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1343 }
1344
1345 #[test]
1346 fn star_autoload_scene_resolves_via_script_class_shortcut() {
1347 let mut db = RootDatabase::default();
1352 db.set_file_text(
1353 FileId(0),
1354 "class_name MusicPlayer\nfunc volume() -> int:\n\treturn 5\n",
1355 Durability::LOW,
1356 );
1357 db.set_file_path(FileId(0), "res://music.gd");
1358 db.set_file_text(
1359 FileId(1),
1360 "[gd_scene format=3 script_class=\"MusicPlayer\"]\n[node name=\"Root\" type=\"Node\"]\n",
1361 Durability::LOW,
1362 );
1363 db.set_file_path(FileId(1), "res://music.tscn");
1364 db.set_file_text(
1365 FileId(2),
1366 "func f():\n\tvar v := Audio.volume()\n",
1367 Durability::LOW,
1368 );
1369 db.set_file_path(FileId(2), "res://main.gd");
1370 db.set_project_config("[autoload]\nAudio=\"*res://music.tscn\"\n");
1371 db.sync_source_root();
1372 let api = db.engine().unwrap();
1373
1374 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1375 let unit = fi
1376 .units
1377 .iter()
1378 .find(|u| !u.result.bindings.is_empty())
1379 .expect("f unit");
1380 assert_eq!(
1381 unit.result.bindings[0].ty.label(api).as_deref(),
1382 Some("int"),
1383 "Audio.volume() should resolve via the scene's script_class= shortcut",
1384 );
1385 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1386 }
1387
1388 #[test]
1389 fn star_autoload_gdscript_resolves_as_global_and_members() {
1390 let mut db = RootDatabase::default();
1391 db.set_file_text(
1393 FileId(0),
1394 "func score() -> int:\n\treturn 0\n",
1395 Durability::LOW,
1396 );
1397 db.set_file_path(FileId(0), "res://game.gd");
1398 db.set_file_text(
1399 FileId(1),
1400 "func f():\n\tvar s := Game.score()\n",
1401 Durability::LOW,
1402 );
1403 db.set_file_path(FileId(1), "res://main.gd");
1404 db.set_project_config("[autoload]\nGame=\"*res://game.gd\"\n");
1405 db.sync_source_root();
1406 let api = db.engine().unwrap();
1407
1408 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1409 let unit = fi
1410 .units
1411 .iter()
1412 .find(|u| !u.result.bindings.is_empty())
1413 .expect("f unit");
1414 assert_eq!(
1416 unit.result.bindings[0].ty.label(api).as_deref(),
1417 Some("int")
1418 );
1419 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1420 }
1421
1422 #[test]
1423 fn non_star_autoload_is_not_a_global() {
1424 let mut db = RootDatabase::default();
1425 db.set_file_text(
1426 FileId(0),
1427 "func score() -> int:\n\treturn 0\n",
1428 Durability::LOW,
1429 );
1430 db.set_file_path(FileId(0), "res://game.gd");
1431 db.set_file_text(
1432 FileId(1),
1433 "func f():\n\tvar s := Game.score()\n",
1434 Durability::LOW,
1435 );
1436 db.set_file_path(FileId(1), "res://main.gd");
1437 db.set_project_config("[autoload]\nGame=\"res://game.gd\"\n");
1439 db.sync_source_root();
1440 let api = db.engine().unwrap();
1441
1442 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1443 let unit = fi
1444 .units
1445 .iter()
1446 .find(|u| !u.result.bindings.is_empty())
1447 .expect("f unit");
1448 assert_eq!(unit.result.bindings[0].ty.label(api), None);
1450 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1451 }
1452
1453 #[test]
1454 fn tscn_autoload_is_the_seam_never_false_warns() {
1455 let mut db = RootDatabase::default();
1456 db.set_file_text(FileId(0), "func f():\n\tHud.play_song()\n", Durability::LOW);
1459 db.set_file_path(FileId(0), "res://main.gd");
1460 db.set_project_config("[autoload]\nHud=\"*res://hud.tscn\"\n");
1461 db.sync_source_root();
1462
1463 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1464 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1466 }
1467
1468 static AUTOLOAD_OBSERVED: AtomicU32 = AtomicU32::new(0);
1472
1473 #[salsa::tracked]
1474 fn observe_autoload_registry(db: &dyn gdscript_db::Db, config: ProjectConfig) -> usize {
1475 AUTOLOAD_OBSERVED.fetch_add(1, Ordering::SeqCst);
1476 autoload_registry(db, config).len()
1477 }
1478
1479 #[test]
1480 fn autoload_registry_firewalled_against_body_edits() {
1481 let mut db = RootDatabase::default();
1482 db.set_file_text(FileId(0), "func f():\n\tvar a := 1\n", Durability::LOW);
1483 db.set_file_path(FileId(0), "res://game.gd");
1484 db.set_project_config("[autoload]\nGame=\"*res://game.gd\"\n");
1485 db.sync_source_root();
1486 let config = db.project_config().unwrap();
1487
1488 assert_eq!(observe_autoload_registry(&db, config), 1);
1489 let runs = AUTOLOAD_OBSERVED.load(Ordering::SeqCst);
1490
1491 db.set_file_text(FileId(0), "func f():\n\tvar a := 999999\n", Durability::LOW);
1494
1495 assert_eq!(observe_autoload_registry(&db, config), 1);
1496 assert_eq!(
1497 AUTOLOAD_OBSERVED.load(Ordering::SeqCst),
1498 runs,
1499 "REGRESSION: a body edit re-ran an autoload_registry consumer — the config firewall broke",
1500 );
1501 }
1502
1503 #[test]
1504 fn aliased_self_resolves_own_members_no_false_unsafe() {
1505 let mut db = RootDatabase::default();
1509 db.set_file_text(
1510 FileId(0),
1511 "extends Node\nfunc own() -> int:\n\treturn 1\nfunc use_it():\n\tvar me := self\n\tvar n := me.own()\n",
1512 Durability::LOW,
1513 );
1514 db.sync_source_root();
1515 let api = db.engine().unwrap();
1516
1517 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1518 assert!(
1520 fi.units
1521 .iter()
1522 .flat_map(|u| &u.result.bindings)
1523 .any(|b| b.ty.label(api).as_deref() == Some("int")),
1524 "aliased self.own() should resolve to int",
1525 );
1526 assert!(
1527 fi.diagnostics.is_empty(),
1528 "no false UNSAFE on aliased self: {:?}",
1529 fi.diagnostics
1530 );
1531 }
1532
1533 #[test]
1534 fn is_userbase_narrows_to_derived_but_not_un_narrowed_to_base() {
1535 let mut db = RootDatabase::default();
1536 db.set_file_text(
1537 FileId(0),
1538 "class_name Base\nfunc base_m() -> int:\n\treturn 1\n",
1539 Durability::LOW,
1540 );
1541 db.set_file_text(
1542 FileId(1),
1543 "class_name Derived\nextends Base\nfunc own_m() -> String:\n\treturn \"x\"\n",
1544 Durability::LOW,
1545 );
1546 db.set_file_text(
1549 FileId(2),
1550 "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",
1551 Durability::LOW,
1552 );
1553 db.sync_source_root();
1554 let api = db.engine().unwrap();
1555
1556 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1557 let strings = fi
1558 .units
1559 .iter()
1560 .flat_map(|u| &u.result.bindings)
1561 .filter(|b| b.ty.label(api).as_deref() == Some("String"))
1562 .count();
1563 assert!(
1565 strings >= 2,
1566 "expected both own_m() calls to type as String (narrow-down + widen-only), got {strings}",
1567 );
1568 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1569 }
1570
1571 fn scene_db(scene_text: &str, gd_text: &str) -> RootDatabase {
1575 let mut db = RootDatabase::default();
1576 db.set_file_text(FileId(0), scene_text, Durability::LOW);
1577 db.set_file_path(FileId(0), "res://main.tscn");
1578 db.set_file_text(FileId(1), gd_text, Durability::LOW);
1579 db.set_file_path(FileId(1), "res://main.gd");
1580 db.sync_source_root();
1581 db
1582 }
1583
1584 fn binding_labels(db: &RootDatabase) -> Vec<String> {
1585 let api = db.engine().unwrap();
1586 let fi = analyze_file(db, db.file_text(FileId(1)).unwrap());
1587 assert!(
1588 fi.diagnostics.is_empty(),
1589 "unexpected diags: {:?}",
1590 fi.diagnostics
1591 );
1592 fi.units
1593 .iter()
1594 .flat_map(|u| &u.result.bindings)
1595 .filter_map(|b| b.ty.label(api))
1596 .collect()
1597 }
1598
1599 const SCENE: &str = "[gd_scene format=3]\n\
1600 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1601 [node name=\"Root\" type=\"Control\"]\n\
1602 script = ExtResource(\"1\")\n\
1603 [node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
1604 [node name=\"Box\" type=\"VBoxContainer\" parent=\"Panel\"]\n\
1605 [node name=\"Btn\" type=\"Button\" parent=\"Panel/Box\"]\n\
1606 unique_name_in_owner = true\n";
1607
1608 #[test]
1609 fn dollar_path_types_to_the_concrete_node() {
1610 let db = scene_db(
1612 SCENE,
1613 "extends Control\nfunc _ready():\n\tvar b := $Panel/Box/Btn\n",
1614 );
1615 assert!(
1616 binding_labels(&db).iter().any(|l| l == "Button"),
1617 "$Panel/Box/Btn should type as Button",
1618 );
1619 }
1620
1621 #[test]
1622 fn unique_name_path_types_to_the_concrete_node() {
1623 let db = scene_db(SCENE, "extends Control\nfunc _ready():\n\tvar b := %Btn\n");
1625 assert!(
1626 binding_labels(&db).iter().any(|l| l == "Button"),
1627 "%Btn should type as Button"
1628 );
1629 }
1630
1631 #[test]
1632 fn onready_var_from_a_node_path_is_typed() {
1633 let db = scene_db(
1636 SCENE,
1637 "extends Control\n@onready var btn := $Panel/Box/Btn\n",
1638 );
1639 assert!(
1640 binding_labels(&db).iter().any(|l| l == "Button"),
1641 "@onready var := $Path should type to Button",
1642 );
1643 }
1644
1645 #[test]
1646 fn get_node_string_literal_types_like_dollar() {
1647 let db = scene_db(
1649 SCENE,
1650 "extends Control\nfunc _ready():\n\tvar b := get_node(\"Panel/Box/Btn\")\n",
1651 );
1652 assert!(
1653 binding_labels(&db).iter().any(|l| l == "Button"),
1654 "get_node(\"...\") should type as Button",
1655 );
1656 }
1657
1658 #[test]
1659 fn self_get_node_string_literal_types_like_dollar() {
1660 let db = scene_db(
1663 SCENE,
1664 "extends Control\nfunc _ready():\n\tvar b := self.get_node(\"Panel/Box/Btn\")\n",
1665 );
1666 assert!(
1667 binding_labels(&db).iter().any(|l| l == "Button"),
1668 "self.get_node(\"...\") should type as Button",
1669 );
1670 }
1671
1672 #[test]
1673 fn attached_script_refines_the_node_type() {
1674 let mut db = RootDatabase::default();
1677 db.set_file_text(
1678 FileId(0),
1679 "[gd_scene format=3]\n\
1680 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1681 [ext_resource type=\"Script\" path=\"res://fancy.gd\" id=\"2\"]\n\
1682 [node name=\"Root\" type=\"Control\"]\n\
1683 script = ExtResource(\"1\")\n\
1684 [node name=\"That\" type=\"Button\" parent=\".\"]\n\
1685 script = ExtResource(\"2\")\n",
1686 Durability::LOW,
1687 );
1688 db.set_file_path(FileId(0), "res://main.tscn");
1689 db.set_file_text(
1690 FileId(1),
1691 "extends Control\nfunc _ready():\n\tvar n := $That.fancy()\n",
1692 Durability::LOW,
1693 );
1694 db.set_file_path(FileId(1), "res://main.gd");
1695 db.set_file_text(
1696 FileId(2),
1697 "class_name Fancy\nextends Button\nfunc fancy() -> int:\n\treturn 1\n",
1698 Durability::LOW,
1699 );
1700 db.set_file_path(FileId(2), "res://fancy.gd");
1701 db.sync_source_root();
1702 assert!(
1703 binding_labels(&db).iter().any(|l| l == "int"),
1704 "$That.fancy() should resolve via the attached script Fancy",
1705 );
1706 }
1707
1708 #[test]
1709 fn computed_or_unresolvable_node_path_stays_node_without_warning() {
1710 let mut db = RootDatabase::default();
1713 db.set_file_text(
1714 FileId(1),
1715 "extends Node\nfunc f(p: NodePath):\n\tvar a := get_node(p)\n\tvar b := $Nope\n",
1719 Durability::LOW,
1720 );
1721 db.set_file_path(FileId(1), "res://lone.gd");
1722 db.sync_source_root();
1723 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1724 assert!(
1725 fi.diagnostics.is_empty(),
1726 "no false node-path warnings: {:?}",
1727 fi.diagnostics
1728 );
1729 }
1730
1731 fn has_invalid_node_path(db: &RootDatabase) -> bool {
1734 let fi = analyze_file(db, db.file_text(FileId(1)).unwrap());
1735 fi.diagnostics
1736 .iter()
1737 .any(|d| d.code == crate::infer::INVALID_NODE_PATH)
1738 }
1739
1740 #[test]
1741 fn invalid_node_path_warns_when_genuinely_absent_in_a_single_owning_scene() {
1742 let db = scene_db(SCENE, "extends Control\nfunc _ready():\n\tvar b := $Nope\n");
1743 assert!(
1744 has_invalid_node_path(&db),
1745 "$Nope is absent in the one owning scene → warn"
1746 );
1747 }
1748
1749 #[test]
1750 fn escape_and_absolute_paths_never_warn() {
1751 let db = scene_db(
1753 SCENE,
1754 "extends Control\nfunc _ready():\n\tvar a := $\"../Sibling\"\n\tvar c := $\"/root/Global\"\n",
1755 );
1756 assert!(!has_invalid_node_path(&db), "escape paths must not warn");
1757 }
1758
1759 #[test]
1760 fn path_descending_into_an_instanced_subscene_never_warns() {
1761 let db = scene_db(
1764 "[gd_scene format=3]\n\
1765 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1766 [ext_resource type=\"PackedScene\" path=\"res://player.tscn\" id=\"2\"]\n\
1767 [node name=\"Root\" type=\"Control\"]\n\
1768 script = ExtResource(\"1\")\n\
1769 [node name=\"Player\" parent=\".\" instance=ExtResource(\"2\")]\n",
1770 "extends Control\nfunc _ready():\n\tvar g := $Player/Gun\n",
1771 );
1772 assert!(
1773 !has_invalid_node_path(&db),
1774 "into-instance miss must not warn"
1775 );
1776 }
1777
1778 #[test]
1779 fn ambiguous_multi_scene_attachment_suppresses_the_invalid_warning() {
1780 let mut db = RootDatabase::default();
1783 db.set_file_text(
1784 FileId(0),
1785 "[gd_scene format=3]\n\
1786 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1787 [node name=\"Root\" type=\"Control\"]\n\
1788 script = ExtResource(\"1\")\n\
1789 [node name=\"Alpha\" type=\"Button\" parent=\".\"]\n",
1790 Durability::LOW,
1791 );
1792 db.set_file_path(FileId(0), "res://a.tscn");
1793 db.set_file_text(
1794 FileId(2),
1795 "[gd_scene format=3]\n\
1796 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1797 [node name=\"Root\" type=\"Control\"]\n\
1798 script = ExtResource(\"1\")\n\
1799 [node name=\"Beta\" type=\"Button\" parent=\".\"]\n",
1800 Durability::LOW,
1801 );
1802 db.set_file_path(FileId(2), "res://b.tscn");
1803 db.set_file_text(
1804 FileId(1),
1805 "extends Control\nfunc _ready():\n\tvar b := $Beta\n",
1806 Durability::LOW,
1807 );
1808 db.set_file_path(FileId(1), "res://main.gd");
1809 db.sync_source_root();
1810 assert!(
1811 !has_invalid_node_path(&db),
1812 "ambiguous multi-scene attachment must not warn"
1813 );
1814 }
1815
1816 #[test]
1819 fn instanced_node_recurses_into_the_subscene_root_script() {
1820 let mut db = RootDatabase::default();
1825 db.set_file_text(
1826 FileId(0),
1827 "[gd_scene format=3]\n\
1828 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1829 [ext_resource type=\"PackedScene\" path=\"res://enemy.tscn\" id=\"2\"]\n\
1830 [node name=\"Root\" type=\"Control\"]\n\
1831 script = ExtResource(\"1\")\n\
1832 [node name=\"Enemy\" parent=\".\" instance=ExtResource(\"2\")]\n",
1833 Durability::LOW,
1834 );
1835 db.set_file_path(FileId(0), "res://main.tscn");
1836 db.set_file_text(
1837 FileId(1),
1838 "extends Control\nfunc _ready():\n\tvar e := $Enemy.hp()\n",
1839 Durability::LOW,
1840 );
1841 db.set_file_path(FileId(1), "res://main.gd");
1842 db.set_file_text(
1843 FileId(2),
1844 "[gd_scene format=3]\n\
1845 [ext_resource type=\"Script\" path=\"res://enemy.gd\" id=\"1\"]\n\
1846 [node name=\"Enemy\" type=\"Button\"]\n\
1847 script = ExtResource(\"1\")\n",
1848 Durability::LOW,
1849 );
1850 db.set_file_path(FileId(2), "res://enemy.tscn");
1851 db.set_file_text(
1852 FileId(3),
1853 "class_name Enemy\nextends Button\nfunc hp() -> int:\n\treturn 1\n",
1854 Durability::LOW,
1855 );
1856 db.set_file_path(FileId(3), "res://enemy.gd");
1857 db.sync_source_root();
1858 assert!(
1859 binding_labels(&db).iter().any(|l| l == "int"),
1860 "$Enemy.hp() should recurse into the instanced sub-scene root's script Enemy",
1861 );
1862 }
1863
1864 #[test]
1867 fn unique_name_subpath_resolves_to_the_child_without_warning() {
1868 let db = scene_db(
1872 "[gd_scene format=3]\n\
1873 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1874 [node name=\"Root\" type=\"Control\"]\n\
1875 script = ExtResource(\"1\")\n\
1876 [node name=\"Box\" type=\"VBoxContainer\" parent=\".\"]\n\
1877 unique_name_in_owner = true\n\
1878 [node name=\"Btn\" type=\"Button\" parent=\"Box\"]\n",
1879 "extends Control\nfunc _ready():\n\tvar b := %Box/Btn\n",
1880 );
1881 assert!(
1882 binding_labels(&db).iter().any(|l| l == "Button"),
1883 "%Box/Btn → Button (and no false INVALID_NODE_PATH)",
1884 );
1885 }
1886
1887 #[test]
1888 fn percent_prefixed_string_paths_resolve_as_unique_without_warning() {
1889 let db = scene_db(
1892 SCENE,
1893 "extends Control\nfunc _ready():\n\tvar a := get_node(\"%Btn\")\n\tvar b := $\"%Btn\"\n",
1894 );
1895 let labels = binding_labels(&db);
1896 assert!(
1897 labels.iter().filter(|l| *l == "Button").count() >= 2,
1898 "both %Btn string forms should resolve to Button: {labels:?}",
1899 );
1900 }
1901}