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#[salsa::tracked]
192pub fn engine_version(db: &dyn Db, config: ProjectConfig) -> Option<(u32, u32)> {
193 crate::project::parse_engine_version(config.project_godot_text(db))
194}
195
196#[must_use]
199pub fn project_engine_version(db: &dyn Db) -> Option<(u32, u32)> {
200 engine_version(db, db.project_config()?)
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
206pub enum MemberSig {
207 Method(Ty),
209 Field(Ty),
211 Signal,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct ScriptClass {
222 members: FxHashMap<SmolStr, MemberSig>,
223 base: Ty,
224}
225
226impl ScriptClass {
227 #[must_use]
230 pub fn member(&self, name: &str) -> Option<&MemberSig> {
231 self.members.get(name)
232 }
233
234 #[must_use]
236 pub fn base(&self) -> &Ty {
237 &self.base
238 }
239}
240
241#[must_use]
245pub fn script_ref_name(db: &dyn Db, sref: ScriptRefId) -> Option<SmolStr> {
246 let file = db.file_text(FileId(sref.0))?;
247 file_class_name(db, file)
248}
249
250#[salsa::tracked]
253pub fn script_class(db: &dyn Db, file: FileText) -> Arc<ScriptClass> {
254 let tree = item_tree(db, file);
255 let Some(api) = db.engine() else {
256 return Arc::new(ScriptClass {
257 members: FxHashMap::default(),
258 base: Ty::Unknown,
259 });
260 };
261 let resolve_ann = |ann: Option<&str>| -> Ty {
262 ann.map_or(Ty::Variant, |t| {
263 crate::resolve::resolve_type_name(db, api, t)
264 })
265 };
266 let mut members = FxHashMap::default();
267 for m in &tree.members {
268 let Some(name) = m.name() else { continue };
269 let sig = match m {
270 Member::Func(f) => MemberSig::Method(resolve_ann(f.return_type.as_deref())),
271 Member::Var(v) => MemberSig::Field(resolve_ann(v.type_ref.as_deref())),
272 Member::Const(c) => MemberSig::Field(
277 c.type_ref
278 .is_none()
279 .then_some(c.preload_path.as_deref())
280 .flatten()
281 .and_then(|raw| {
282 crate::resolve::anchor_res_path(file.res_path(db).as_deref(), raw)
283 })
284 .map_or_else(
285 || resolve_ann(c.type_ref.as_deref()),
286 |abs| {
287 crate::resolve::resolve_external(
288 db,
289 &crate::resolve::ExternalRef::Preload(abs),
290 )
291 },
292 ),
293 ),
294 Member::Signal(_) => MemberSig::Signal,
295 Member::Enum(_) | Member::Class(_) => continue,
297 };
298 members.insert(SmolStr::new(name), sig);
299 }
300 let base = crate::resolve::resolve_base(db, api, &tree, file.res_path(db).as_deref());
303 Arc::new(ScriptClass { members, base })
304}
305
306fn is_scene_path(path: &str) -> bool {
311 let ext = path.rsplit('.').next().unwrap_or("");
312 ext.eq_ignore_ascii_case("tscn") || ext.eq_ignore_ascii_case("tres")
313}
314
315#[salsa::tracked]
319pub fn scene_model(db: &dyn Db, file: FileText) -> Arc<SceneModel> {
320 let is_scene = file.res_path(db).as_deref().is_some_and(is_scene_path);
321 if is_scene {
322 Arc::new(gdscript_scene::parse_scene(file.text(db)))
323 } else {
324 Arc::new(gdscript_scene::parse_scene(""))
325 }
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
331pub struct SceneAttach {
332 pub scene: FileId,
334 pub node: NodeIdx,
336 pub ambiguous: bool,
339}
340
341#[salsa::tracked]
348pub fn script_scene_index(db: &dyn Db, root: SourceRoot) -> Arc<FxHashMap<SmolStr, SceneAttach>> {
349 let mut map: FxHashMap<SmolStr, SceneAttach> = FxHashMap::default();
350 for &file in root.files(db) {
351 if !file.res_path(db).as_deref().is_some_and(is_scene_path) {
352 continue;
353 }
354 let model = scene_model(db, file);
355 let scene = file.file_id(db);
356 for (i, node) in model.nodes.iter().enumerate() {
357 let Some(script_id) = node.script.as_ref() else {
358 continue;
359 };
360 let Some(path) = model
361 .ext_resources
362 .get(script_id)
363 .and_then(|e| e.path.clone())
364 else {
365 continue;
366 };
367 let node = NodeIdx(u32::try_from(i).unwrap_or(u32::MAX));
368 match map.get_mut(&path) {
369 Some(existing) => existing.ambiguous = true,
371 None => {
372 map.insert(
373 path,
374 SceneAttach {
375 scene,
376 node,
377 ambiguous: false,
378 },
379 );
380 }
381 }
382 }
383 }
384 Arc::new(map)
385}
386
387#[must_use]
392pub fn scene_context(db: &dyn Db, file: FileText) -> Option<SceneContext> {
393 let res_path = file.res_path(db)?;
394 let root = db.source_root()?;
395 let attach = *script_scene_index(db, root).get(res_path.as_str())?;
396 let scene_file = db.file_text(attach.scene)?;
397 Some(SceneContext {
398 scene: attach.scene,
399 model: scene_model(db, scene_file),
400 attach: attach.node,
401 ambiguous: attach.ambiguous,
402 })
403}
404
405#[derive(Debug, Clone)]
408pub struct SceneContext {
409 pub scene: FileId,
411 pub model: Arc<SceneModel>,
413 pub attach: NodeIdx,
415 pub ambiguous: bool,
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422 use gdscript_base::FileId;
423 use gdscript_db::RootDatabase;
424 use salsa::Durability;
425
426 fn db_with(src: &str) -> (RootDatabase, FileText) {
427 let mut db = RootDatabase::default();
428 db.set_file_text(FileId(0), src, Durability::LOW);
429 let ft = db.file_text(FileId(0)).unwrap();
430 (db, ft)
431 }
432
433 #[test]
434 fn tracked_item_tree_matches_the_plain_fn() {
435 let (db, ft) = db_with("class_name Foo\nfunc f():\n\tpass\n");
436 let tree = item_tree(&db, ft);
437 assert_eq!(tree.class_name.as_deref(), Some("Foo"));
438 assert_eq!(item_tree(&db, ft), tree);
440 }
441
442 #[test]
443 fn tracked_analyze_file_runs_inference() {
444 let (db, ft) = db_with("func add(a: int, b: int) -> int:\n\treturn a + b\n");
445 let fi = analyze_file(&db, ft);
446 assert!(!fi.units.is_empty());
448 assert!(fi.diagnostics.is_empty());
449 }
450
451 use std::sync::atomic::{AtomicU32, Ordering};
461
462 static WITNESS_RUNS: AtomicU32 = AtomicU32::new(0);
463
464 #[salsa::tracked]
466 fn class_name_witness(db: &dyn gdscript_db::Db, file: FileText) -> Option<smol_str::SmolStr> {
467 WITNESS_RUNS.fetch_add(1, Ordering::SeqCst);
468 item_tree(db, file).class_name.clone()
469 }
470
471 #[test]
472 fn body_edit_does_not_invalidate_signature_queries() {
473 let mut db = RootDatabase::default();
474 db.set_file_text(
475 FileId(0),
476 "class_name Foo\nfunc f():\n\tvar a := 1\n",
477 Durability::LOW,
478 );
479 let ft = db.file_text(FileId(0)).unwrap();
480
481 assert_eq!(class_name_witness(&db, ft).as_deref(), Some("Foo"));
483 let runs_after_warm = WITNESS_RUNS.load(Ordering::SeqCst);
484
485 db.set_file_text(
488 FileId(0),
489 "class_name Foo\nfunc f():\n\tvar a := 2\n",
490 Durability::LOW,
491 );
492 assert_eq!(class_name_witness(&db, ft).as_deref(), Some("Foo"));
493
494 assert_eq!(
495 WITNESS_RUNS.load(Ordering::SeqCst),
496 runs_after_warm,
497 "REGRESSION: a body edit re-ran a signature-only query — the item_tree firewall broke",
498 );
499 }
500
501 #[test]
502 fn global_registry_resolves_class_names_across_files() {
503 let mut db = RootDatabase::default();
504 db.set_file_text(
505 FileId(0),
506 "class_name Player\nfunc f():\n\tpass\n",
507 Durability::LOW,
508 );
509 db.set_file_text(
510 FileId(1),
511 "class_name Enemy\nvar hp := 10\n",
512 Durability::LOW,
513 );
514 db.set_file_text(FileId(2), "func no_class():\n\tpass\n", Durability::LOW);
515 db.sync_source_root();
516 let root = db.source_root().unwrap();
517
518 let reg = global_registry(&db, root);
519 assert_eq!(reg.len(), 2);
520 assert_eq!(reg.resolve("Player"), db.file_text(FileId(0)));
521 assert_eq!(reg.resolve("Enemy"), db.file_text(FileId(1)));
522 assert!(reg.resolve("Nonexistent").is_none());
523 }
524
525 static REGISTRY_OBSERVED: AtomicU32 = AtomicU32::new(0);
532
533 #[salsa::tracked]
535 fn observe_registry(db: &dyn gdscript_db::Db, root: SourceRoot) -> usize {
536 REGISTRY_OBSERVED.fetch_add(1, Ordering::SeqCst);
537 global_registry(db, root).len()
538 }
539
540 #[test]
541 fn body_edit_does_not_invalidate_the_global_registry() {
542 let mut db = RootDatabase::default();
543 db.set_file_text(
544 FileId(0),
545 "class_name Player\nfunc f():\n\tvar a := 1\n",
546 Durability::LOW,
547 );
548 db.set_file_text(FileId(1), "class_name Enemy\n", Durability::LOW);
549 db.sync_source_root();
550 let root = db.source_root().unwrap();
551
552 assert_eq!(observe_registry(&db, root), 2);
553 let runs = REGISTRY_OBSERVED.load(Ordering::SeqCst);
554
555 db.set_file_text(
558 FileId(0),
559 "class_name Player\nfunc f():\n\tvar a := 123456\n",
560 Durability::LOW,
561 );
562
563 assert_eq!(observe_registry(&db, root), 2);
564 assert_eq!(
565 REGISTRY_OBSERVED.load(Ordering::SeqCst),
566 runs,
567 "REGRESSION: a body edit re-ran a global_registry consumer — the cross-file firewall broke",
568 );
569 }
570
571 #[test]
572 fn cross_file_class_name_member_resolves() {
573 let mut db = RootDatabase::default();
574 db.set_file_text(
575 FileId(0),
576 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
577 Durability::LOW,
578 );
579 db.set_file_text(
580 FileId(1),
581 "func use_it():\n\tvar w := Widget.make()\n",
582 Durability::LOW,
583 );
584 db.sync_source_root();
585
586 let file1 = db.file_text(FileId(1)).unwrap();
587 let fi = analyze_file(&db, file1);
588 let api = db.engine().unwrap();
589
590 let unit = fi
593 .units
594 .iter()
595 .find(|u| !u.result.bindings.is_empty())
596 .expect("a unit with a binding");
597 assert_eq!(
598 unit.result.bindings[0].ty.label(api).as_deref(),
599 Some("int")
600 );
601 assert!(
602 fi.diagnostics.is_empty(),
603 "unexpected diagnostics: {:?}",
604 fi.diagnostics
605 );
606 }
607
608 use crate::infer::SHADOWED_GLOBAL_IDENTIFIER;
611
612 fn shadow_codes(fi: &Arc<FileInference>) -> Vec<&str> {
613 fi.diagnostics
614 .iter()
615 .filter(|d| d.code == SHADOWED_GLOBAL_IDENTIFIER)
616 .map(|d| d.code.as_str())
617 .collect()
618 }
619
620 #[test]
621 fn class_name_collisions_names_only_the_duplicates() {
622 let mut db = RootDatabase::default();
623 db.set_file_text(FileId(0), "class_name Dup\n", Durability::LOW);
624 db.set_file_text(FileId(1), "class_name Dup\n", Durability::LOW);
625 db.set_file_text(FileId(2), "class_name Unique\n", Durability::LOW);
626 db.sync_source_root();
627 let root = db.source_root().unwrap();
628
629 let cols = class_name_collisions(&db, root);
630 assert!(cols.contains(&SmolStr::new("Dup")));
631 assert!(
632 !cols.contains(&SmolStr::new("Unique")),
633 "a singly-declared class_name is not a collision",
634 );
635 assert_eq!(cols.len(), 1);
636 }
637
638 #[test]
639 fn duplicate_class_name_warns_at_both_declarations() {
640 let mut db = RootDatabase::default();
641 db.set_file_text(
642 FileId(0),
643 "class_name Dup\nfunc f():\n\tpass\n",
644 Durability::LOW,
645 );
646 db.set_file_text(FileId(1), "class_name Dup\nvar x := 1\n", Durability::LOW);
647 db.sync_source_root();
648
649 for fid in [0, 1] {
650 let fi = analyze_file(&db, db.file_text(FileId(fid)).unwrap());
651 assert!(
652 shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
653 "file {fid} should warn on the duplicate class_name: {:?}",
654 fi.diagnostics
655 );
656 let d = fi
658 .diagnostics
659 .iter()
660 .find(|d| d.code == SHADOWED_GLOBAL_IDENTIFIER)
661 .unwrap();
662 assert_eq!(d.range, gdscript_base::TextRange::new(11, 14));
663 }
664 }
665
666 #[test]
667 fn class_name_shadowing_an_engine_class_warns() {
668 let mut db = RootDatabase::default();
669 db.set_file_text(
671 FileId(0),
672 "class_name Node\nfunc f():\n\tpass\n",
673 Durability::LOW,
674 );
675 db.sync_source_root();
676
677 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
678 assert!(
679 shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
680 "class_name Node must warn (shadows the engine class): {:?}",
681 fi.diagnostics
682 );
683 }
684
685 #[test]
686 fn class_name_shadowing_a_builtin_type_warns() {
687 let mut db = RootDatabase::default();
688 db.set_file_text(FileId(0), "class_name Vector2\n", Durability::LOW);
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 "{:?}",
696 fi.diagnostics
697 );
698 }
699
700 #[test]
701 fn class_name_shadowing_a_star_autoload_warns() {
702 let mut db = RootDatabase::default();
703 db.set_file_text(
704 FileId(0),
705 "class_name Game\nfunc f():\n\tpass\n",
706 Durability::LOW,
707 );
708 db.set_file_path(FileId(0), "res://game.gd");
709 db.set_project_config("[autoload]\nGame=\"*res://other.gd\"\n");
711 db.sync_source_root();
712
713 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
714 assert!(
715 shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
716 "class_name Game must warn (shadows the `*Game` autoload): {:?}",
717 fi.diagnostics
718 );
719 }
720
721 #[test]
722 fn unique_non_shadowing_class_name_does_not_warn() {
723 let mut db = RootDatabase::default();
725 db.set_file_text(
726 FileId(0),
727 "class_name MyVeryOwnUniquePlayer\nfunc f():\n\tpass\n",
728 Durability::LOW,
729 );
730 db.set_file_text(
731 FileId(1),
732 "class_name AnotherUniqueEnemy\n",
733 Durability::LOW,
734 );
735 db.sync_source_root();
736
737 for fid in [0, 1] {
738 let fi = analyze_file(&db, db.file_text(FileId(fid)).unwrap());
739 assert!(
740 shadow_codes(&fi).is_empty(),
741 "file {fid}: a unique class_name must not warn: {:?}",
742 fi.diagnostics
743 );
744 }
745 }
746
747 #[test]
748 fn unknown_member_on_script_ref_is_seam_not_warning() {
749 let mut db = RootDatabase::default();
750 db.set_file_text(
751 FileId(0),
752 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
753 Durability::LOW,
754 );
755 db.set_file_text(
756 FileId(1),
757 "func use_it():\n\tWidget.not_a_member()\n",
758 Durability::LOW,
759 );
760 db.sync_source_root();
761
762 let file1 = db.file_text(FileId(1)).unwrap();
763 let fi = analyze_file(&db, file1);
764 assert!(
766 fi.diagnostics.is_empty(),
767 "a missing member on a ScriptRef must not warn: {:?}",
768 fi.diagnostics
769 );
770 }
771
772 #[test]
773 fn inherited_members_resolve_through_user_and_engine_bases() {
774 let mut db = RootDatabase::default();
775 db.set_file_text(
777 FileId(0),
778 "class_name Base\nextends Node\nfunc base_method() -> int:\n\treturn 1\n",
779 Durability::LOW,
780 );
781 db.set_file_text(
782 FileId(1),
783 "class_name Derived\nextends Base\nfunc own() -> String:\n\treturn \"x\"\n",
784 Durability::LOW,
785 );
786 db.set_file_text(
787 FileId(2),
788 "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",
789 Durability::LOW,
790 );
791 db.sync_source_root();
792 let api = db.engine().unwrap();
793
794 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
795 let unit = fi
796 .units
797 .iter()
798 .find(|u| u.result.bindings.len() >= 4)
799 .expect("use_it unit with 4 bindings");
800 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 cyclic_extends_flags_each_cycle_member_and_terminates() {
818 use crate::infer::CYCLIC_INHERITANCE;
819 let mut db = RootDatabase::default();
820 db.set_file_text(FileId(0), "class_name A\nextends B\n", Durability::LOW);
823 db.set_file_text(FileId(1), "class_name B\nextends A\n", Durability::LOW);
824 db.set_file_text(
826 FileId(2),
827 "func use_it():\n\tvar a: A\n\tvar x := a.nope()\n",
828 Durability::LOW,
829 );
830 db.sync_source_root();
831
832 for id in [FileId(0), FileId(1)] {
834 let fi = analyze_file(&db, db.file_text(id).unwrap());
835 let cyclic: Vec<_> = fi
836 .diagnostics
837 .iter()
838 .filter(|d| d.code == CYCLIC_INHERITANCE)
839 .collect();
840 assert_eq!(cyclic.len(), 1, "file {id:?}: {:?}", fi.diagnostics);
841 }
842
843 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
846 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
847 }
848
849 #[test]
850 fn cyclic_extends_via_res_path_two_files_flags_no_hang() {
851 use crate::infer::CYCLIC_INHERITANCE;
852 let mut db = RootDatabase::default();
853 set_with_path(&mut db, 0, "res://a.gd", "extends \"res://b.gd\"\n");
855 set_with_path(&mut db, 1, "res://b.gd", "extends \"res://a.gd\"\n");
856 db.sync_source_root();
857
858 for id in [FileId(0), FileId(1)] {
859 let fi = analyze_file(&db, db.file_text(id).unwrap());
860 assert!(
861 fi.diagnostics.iter().any(|d| d.code == CYCLIC_INHERITANCE),
862 "file {id:?} expected CYCLIC_INHERITANCE: {:?}",
863 fi.diagnostics
864 );
865 }
866 }
867
868 #[test]
869 fn deep_acyclic_extends_chain_does_not_false_fire() {
870 use crate::infer::CYCLIC_INHERITANCE;
871 let mut db = RootDatabase::default();
872 db.set_file_text(FileId(0), "class_name C0\nextends C1\n", Durability::LOW);
875 db.set_file_text(FileId(1), "class_name C1\nextends C2\n", Durability::LOW);
876 db.set_file_text(FileId(2), "class_name C2\nextends C3\n", Durability::LOW);
877 db.set_file_text(FileId(3), "class_name C3\nextends C4\n", Durability::LOW);
878 db.set_file_text(FileId(4), "class_name C4\nextends Node\n", Durability::LOW);
879 db.sync_source_root();
880
881 for id in (0..5).map(FileId) {
882 let fi = analyze_file(&db, db.file_text(id).unwrap());
883 assert!(
884 !fi.diagnostics.iter().any(|d| d.code == CYCLIC_INHERITANCE),
885 "file {id:?} false-fired CYCLIC_INHERITANCE: {:?}",
886 fi.diagnostics
887 );
888 }
889 }
890
891 fn set_with_path(db: &mut RootDatabase, id: u32, path: &str, src: &str) {
895 db.set_file_text(FileId(id), src, Durability::LOW);
896 db.set_file_path(FileId(id), path);
897 }
898
899 #[test]
900 fn res_path_registry_maps_paths_to_files() {
901 let mut db = RootDatabase::default();
902 set_with_path(&mut db, 0, "res://a.gd", "class_name A\n");
903 set_with_path(&mut db, 1, "res://sub/b.gd", "func f():\n\tpass\n");
904 db.set_file_text(FileId(2), "func no_path():\n\tpass\n", Durability::LOW); db.sync_source_root();
906 let root = db.source_root().unwrap();
907
908 let reg = res_path_registry(&db, root);
909 assert_eq!(reg.get("res://a.gd"), Some(&FileId(0)));
910 assert_eq!(reg.get("res://sub/b.gd"), Some(&FileId(1)));
911 assert!(reg.get("res://missing.gd").is_none());
912 assert_eq!(reg.len(), 2);
914 }
915
916 static RES_REGISTRY_OBSERVED: AtomicU32 = AtomicU32::new(0);
921
922 #[salsa::tracked]
923 fn observe_res_registry(db: &dyn gdscript_db::Db, root: SourceRoot) -> usize {
924 RES_REGISTRY_OBSERVED.fetch_add(1, Ordering::SeqCst);
925 res_path_registry(db, root).len()
926 }
927
928 #[test]
929 fn body_edit_does_not_invalidate_the_res_path_registry() {
930 let mut db = RootDatabase::default();
931 set_with_path(&mut db, 0, "res://a.gd", "func f():\n\tvar a := 1\n");
932 db.sync_source_root();
933 let root = db.source_root().unwrap();
934
935 assert_eq!(observe_res_registry(&db, root), 1);
936 let runs = RES_REGISTRY_OBSERVED.load(Ordering::SeqCst);
937
938 db.set_file_text(FileId(0), "func f():\n\tvar a := 123456\n", Durability::LOW);
941
942 assert_eq!(observe_res_registry(&db, root), 1);
943 assert_eq!(
944 RES_REGISTRY_OBSERVED.load(Ordering::SeqCst),
945 runs,
946 "REGRESSION: a body edit re-ran a res_path_registry consumer — the path firewall broke",
947 );
948 }
949
950 #[test]
951 fn preload_const_resolves_to_script_ref_members() {
952 let mut db = RootDatabase::default();
953 set_with_path(
954 &mut db,
955 0,
956 "res://widget.gd",
957 "class_name Widget\nfunc make() -> int:\n\treturn 5\nconst MAX := 10\n",
958 );
959 set_with_path(
960 &mut db,
961 1,
962 "res://main.gd",
963 "const W = preload(\"res://widget.gd\")\nfunc use_it():\n\tvar a := W.make()\n\tvar b := W.new()\n",
964 );
965 db.sync_source_root();
966 let api = db.engine().unwrap();
967
968 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
969 let unit = fi
970 .units
971 .iter()
972 .find(|u| u.result.bindings.len() >= 2)
973 .expect("use_it unit with 2 bindings");
974 assert_eq!(
976 unit.result.bindings[0].ty.label(api).as_deref(),
977 Some("int")
978 );
979 assert!(
980 matches!(unit.result.bindings[1].ty, Ty::ScriptRef(_)),
981 "W.new() should be a script instance, got {:?}",
982 unit.result.bindings[1].ty
983 );
984 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
985 }
986
987 #[test]
988 fn cross_file_preload_const_member_resolves() {
989 let mut db = RootDatabase::default();
993 set_with_path(
994 &mut db,
995 0,
996 "res://widget.gd",
997 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
998 );
999 set_with_path(
1000 &mut db,
1001 1,
1002 "res://holder.gd",
1003 "class_name Holder\nconst W = preload(\"res://widget.gd\")\n",
1004 );
1005 set_with_path(
1006 &mut db,
1007 2,
1008 "res://user.gd",
1009 "func use_it():\n\tvar a := Holder.W.make()\n",
1010 );
1011 db.sync_source_root();
1012 let api = db.engine().unwrap();
1013
1014 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1015 let unit = fi
1016 .units
1017 .iter()
1018 .find(|u| !u.result.bindings.is_empty())
1019 .expect("use_it unit");
1020 assert_eq!(
1022 unit.result.bindings[0].ty.label(api).as_deref(),
1023 Some("int"),
1024 "Holder.W.make() should resolve cross-file to int, got {:?}",
1025 unit.result.bindings[0].ty
1026 );
1027 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1028 }
1029
1030 #[test]
1031 fn preload_of_script_without_class_name_resolves() {
1032 let mut db = RootDatabase::default();
1035 set_with_path(
1036 &mut db,
1037 0,
1038 "res://helper.gd",
1039 "func help() -> String:\n\treturn \"x\"\n",
1040 );
1041 set_with_path(
1042 &mut db,
1043 1,
1044 "res://main.gd",
1045 "func use_it():\n\tvar h := preload(\"res://helper.gd\")\n\tvar s := h.help()\n",
1046 );
1047 db.sync_source_root();
1048 let api = db.engine().unwrap();
1049
1050 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1051 let unit = fi
1052 .units
1053 .iter()
1054 .find(|u| u.result.bindings.len() >= 2)
1055 .expect("use_it unit");
1056 assert!(
1057 matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
1058 "preload of a class_name-less script must still resolve: {:?}",
1059 unit.result.bindings[0].ty
1060 );
1061 assert_eq!(
1062 unit.result.bindings[1].ty.label(api).as_deref(),
1063 Some("String")
1064 );
1065 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1066 }
1067
1068 #[test]
1069 fn extends_res_path_inherits_members() {
1070 let mut db = RootDatabase::default();
1071 set_with_path(
1073 &mut db,
1074 0,
1075 "res://base.gd",
1076 "extends Node\nfunc base_method() -> int:\n\treturn 1\n",
1077 );
1078 set_with_path(
1079 &mut db,
1080 1,
1081 "res://derived.gd",
1082 "class_name Derived\nextends \"res://base.gd\"\nfunc own() -> String:\n\treturn \"x\"\n",
1083 );
1084 set_with_path(
1085 &mut db,
1086 2,
1087 "res://main.gd",
1088 "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",
1089 );
1090 db.sync_source_root();
1091 let api = db.engine().unwrap();
1092
1093 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1094 let unit = fi
1095 .units
1096 .iter()
1097 .find(|u| u.result.bindings.len() >= 4)
1098 .expect("use_it unit with 4 bindings");
1099 assert_eq!(
1102 unit.result.bindings[1].ty.label(api).as_deref(),
1103 Some("String")
1104 );
1105 assert_eq!(
1106 unit.result.bindings[2].ty.label(api).as_deref(),
1107 Some("int")
1108 );
1109 assert_eq!(
1110 unit.result.bindings[3].ty.label(api).as_deref(),
1111 Some("int")
1112 );
1113 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1114 }
1115
1116 #[test]
1117 fn relative_extends_path_anchors_to_importing_dir() {
1118 let mut db = RootDatabase::default();
1119 set_with_path(
1121 &mut db,
1122 0,
1123 "res://entities/base.gd",
1124 "extends Node\nfunc base_method() -> int:\n\treturn 1\n",
1125 );
1126 set_with_path(
1128 &mut db,
1129 1,
1130 "res://entities/derived.gd",
1131 "class_name Derived\nextends \"base.gd\"\nfunc own() -> String:\n\treturn \"x\"\n",
1132 );
1133 set_with_path(
1134 &mut db,
1135 2,
1136 "res://main.gd",
1137 "func use_it():\n\tvar d: Derived\n\tvar a := d.own()\n\tvar b := d.base_method()\n",
1138 );
1139 db.sync_source_root();
1140 let api = db.engine().unwrap();
1141 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1142 let unit = fi
1143 .units
1144 .iter()
1145 .find(|u| u.result.bindings.len() >= 3)
1146 .expect("use_it unit with 3 bindings (d, a, b)");
1147 assert_eq!(
1149 unit.result.bindings[1].ty.label(api).as_deref(),
1150 Some("String")
1151 );
1152 assert_eq!(
1153 unit.result.bindings[2].ty.label(api).as_deref(),
1154 Some("int"),
1155 "base_method() must resolve through the relative `extends \"base.gd\"`"
1156 );
1157 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1158 }
1159
1160 #[test]
1161 fn dangling_preload_is_seam_not_panic() {
1162 let mut db = RootDatabase::default();
1163 set_with_path(
1164 &mut db,
1165 0,
1166 "res://main.gd",
1167 "func use_it():\n\tvar x := preload(\"res://does_not_exist.gd\")\n\tx.whatever()\n",
1168 );
1169 db.sync_source_root();
1170 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1172 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1173 }
1174
1175 #[test]
1176 fn non_gd_preload_resource_stays_seam() {
1177 let mut db = RootDatabase::default();
1182 set_with_path(&mut db, 0, "res://scene.tscn", "class_name SceneRoot\n");
1183 set_with_path(
1184 &mut db,
1185 1,
1186 "res://main.gd",
1187 "func f():\n\tvar s := preload(\"res://scene.tscn\")\n",
1188 );
1189 db.sync_source_root();
1190
1191 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1192 let unit = fi
1193 .units
1194 .iter()
1195 .find(|u| !u.result.bindings.is_empty())
1196 .expect("f unit");
1197 assert!(
1198 !matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
1199 "a non-.gd preload must stay the seam, got {:?}",
1200 unit.result.bindings[0].ty
1201 );
1202 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1203 }
1204
1205 #[test]
1206 fn load_literal_stays_opaque_not_aliased_to_preload() {
1207 let mut db = RootDatabase::default();
1208 set_with_path(
1209 &mut db,
1210 0,
1211 "res://widget.gd",
1212 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1213 );
1214 set_with_path(
1215 &mut db,
1216 1,
1217 "res://main.gd",
1218 "func use_it():\n\tvar w := load(\"res://widget.gd\")\n",
1219 );
1220 db.sync_source_root();
1221
1222 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1223 let unit = fi
1224 .units
1225 .iter()
1226 .find(|u| !u.result.bindings.is_empty())
1227 .expect("use_it unit");
1228 assert!(
1231 !matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
1232 "load() must stay opaque, not alias preload: {:?}",
1233 unit.result.bindings[0].ty
1234 );
1235 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1236 }
1237
1238 #[test]
1239 fn is_narrows_to_a_user_class_cross_file() {
1240 let mut db = RootDatabase::default();
1245 db.set_file_text(
1246 FileId(0),
1247 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1248 Durability::LOW,
1249 );
1250 db.set_file_text(
1251 FileId(1),
1252 "func use_it(x):\n\tif x is Widget:\n\t\tvar n := x.make()\n",
1253 Durability::LOW,
1254 );
1255 db.sync_source_root();
1256 let api = db.engine().unwrap();
1257
1258 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1259 assert!(
1261 fi.units
1262 .iter()
1263 .flat_map(|u| &u.result.bindings)
1264 .any(|b| b.ty.label(api).as_deref() == Some("int")),
1265 "`x.make()` after `is Widget` should narrow + resolve to int",
1266 );
1267 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1268 }
1269
1270 #[test]
1271 fn as_casts_to_a_user_class_cross_file() {
1272 let mut db = RootDatabase::default();
1274 db.set_file_text(
1275 FileId(0),
1276 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1277 Durability::LOW,
1278 );
1279 db.set_file_text(
1280 FileId(1),
1281 "func use_it(x):\n\tvar n := (x as Widget).make()\n",
1282 Durability::LOW,
1283 );
1284 db.sync_source_root();
1285 let api = db.engine().unwrap();
1286
1287 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1288 assert!(
1289 fi.units
1290 .iter()
1291 .flat_map(|u| &u.result.bindings)
1292 .any(|b| b.ty.label(api).as_deref() == Some("int")),
1293 "`(x as Widget).make()` should resolve to int",
1294 );
1295 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1296 }
1297
1298 #[test]
1299 fn renaming_a_files_path_reindexes_the_registry() {
1300 let mut db = RootDatabase::default();
1302 set_with_path(&mut db, 0, "res://old.gd", "class_name A\n");
1303 db.sync_source_root();
1304 let root = db.source_root().unwrap();
1305 assert_eq!(
1306 res_path_registry(&db, root).get("res://old.gd"),
1307 Some(&FileId(0))
1308 );
1309
1310 db.set_file_path(FileId(0), "res://new.gd");
1311 let root = db.source_root().unwrap();
1312 let reg = res_path_registry(&db, root);
1313 assert_eq!(reg.get("res://new.gd"), Some(&FileId(0)));
1314 assert!(reg.get("res://old.gd").is_none());
1315 }
1316
1317 #[test]
1320 fn star_autoload_scene_resolves_via_its_root_script() {
1321 let mut db = RootDatabase::default();
1325 db.set_file_text(
1327 FileId(0),
1328 "func volume() -> int:\n\treturn 5\n",
1329 Durability::LOW,
1330 );
1331 db.set_file_path(FileId(0), "res://music.gd");
1332 db.set_file_text(
1334 FileId(1),
1335 "[gd_scene format=3]\n\
1336 [ext_resource type=\"Script\" path=\"res://music.gd\" id=\"1\"]\n\
1337 [node name=\"Music\" type=\"Node\"]\n\
1338 script = ExtResource(\"1\")\n",
1339 Durability::LOW,
1340 );
1341 db.set_file_path(FileId(1), "res://music.tscn");
1342 db.set_file_text(
1343 FileId(2),
1344 "func f():\n\tvar v := Music.volume()\n",
1345 Durability::LOW,
1346 );
1347 db.set_file_path(FileId(2), "res://main.gd");
1348 db.set_project_config("[autoload]\nMusic=\"*res://music.tscn\"\n");
1349 db.sync_source_root();
1350 let api = db.engine().unwrap();
1351
1352 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1353 let unit = fi
1354 .units
1355 .iter()
1356 .find(|u| !u.result.bindings.is_empty())
1357 .expect("f unit");
1358 assert_eq!(
1359 unit.result.bindings[0].ty.label(api).as_deref(),
1360 Some("int"),
1361 "Music.volume() should resolve via the scene root's script",
1362 );
1363 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1364 }
1365
1366 #[test]
1367 fn star_autoload_scene_resolves_via_script_class_shortcut() {
1368 let mut db = RootDatabase::default();
1373 db.set_file_text(
1374 FileId(0),
1375 "class_name MusicPlayer\nfunc volume() -> int:\n\treturn 5\n",
1376 Durability::LOW,
1377 );
1378 db.set_file_path(FileId(0), "res://music.gd");
1379 db.set_file_text(
1380 FileId(1),
1381 "[gd_scene format=3 script_class=\"MusicPlayer\"]\n[node name=\"Root\" type=\"Node\"]\n",
1382 Durability::LOW,
1383 );
1384 db.set_file_path(FileId(1), "res://music.tscn");
1385 db.set_file_text(
1386 FileId(2),
1387 "func f():\n\tvar v := Audio.volume()\n",
1388 Durability::LOW,
1389 );
1390 db.set_file_path(FileId(2), "res://main.gd");
1391 db.set_project_config("[autoload]\nAudio=\"*res://music.tscn\"\n");
1392 db.sync_source_root();
1393 let api = db.engine().unwrap();
1394
1395 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1396 let unit = fi
1397 .units
1398 .iter()
1399 .find(|u| !u.result.bindings.is_empty())
1400 .expect("f unit");
1401 assert_eq!(
1402 unit.result.bindings[0].ty.label(api).as_deref(),
1403 Some("int"),
1404 "Audio.volume() should resolve via the scene's script_class= shortcut",
1405 );
1406 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1407 }
1408
1409 #[test]
1410 fn engine_version_from_project_config_is_firewalled_against_body_edits() {
1411 let mut db = RootDatabase::default();
1412 db.set_file_text(FileId(0), "func f():\n\tpass\n", Durability::LOW);
1413 db.set_file_path(FileId(0), "res://main.gd");
1414 db.set_project_config("[application]\nconfig/features=PackedStringArray(\"4.6\")\n");
1415 db.sync_source_root();
1416 assert_eq!(project_engine_version(&db), Some((4, 6)));
1417
1418 db.set_file_text(FileId(0), "func f():\n\tvar x := 1\n", Durability::LOW);
1421 db.sync_source_root();
1422 assert_eq!(project_engine_version(&db), Some((4, 6)));
1423
1424 let empty = RootDatabase::default();
1426 assert_eq!(project_engine_version(&empty), None);
1427 }
1428
1429 #[test]
1430 fn star_autoload_gdscript_resolves_as_global_and_members() {
1431 let mut db = RootDatabase::default();
1432 db.set_file_text(
1434 FileId(0),
1435 "func score() -> int:\n\treturn 0\n",
1436 Durability::LOW,
1437 );
1438 db.set_file_path(FileId(0), "res://game.gd");
1439 db.set_file_text(
1440 FileId(1),
1441 "func f():\n\tvar s := Game.score()\n",
1442 Durability::LOW,
1443 );
1444 db.set_file_path(FileId(1), "res://main.gd");
1445 db.set_project_config("[autoload]\nGame=\"*res://game.gd\"\n");
1446 db.sync_source_root();
1447 let api = db.engine().unwrap();
1448
1449 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1450 let unit = fi
1451 .units
1452 .iter()
1453 .find(|u| !u.result.bindings.is_empty())
1454 .expect("f unit");
1455 assert_eq!(
1457 unit.result.bindings[0].ty.label(api).as_deref(),
1458 Some("int")
1459 );
1460 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1461 }
1462
1463 #[test]
1464 fn non_star_autoload_is_not_a_global() {
1465 let mut db = RootDatabase::default();
1466 db.set_file_text(
1467 FileId(0),
1468 "func score() -> int:\n\treturn 0\n",
1469 Durability::LOW,
1470 );
1471 db.set_file_path(FileId(0), "res://game.gd");
1472 db.set_file_text(
1473 FileId(1),
1474 "func f():\n\tvar s := Game.score()\n",
1475 Durability::LOW,
1476 );
1477 db.set_file_path(FileId(1), "res://main.gd");
1478 db.set_project_config("[autoload]\nGame=\"res://game.gd\"\n");
1480 db.sync_source_root();
1481 let api = db.engine().unwrap();
1482
1483 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1484 let unit = fi
1485 .units
1486 .iter()
1487 .find(|u| !u.result.bindings.is_empty())
1488 .expect("f unit");
1489 assert_eq!(unit.result.bindings[0].ty.label(api), None);
1491 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1492 }
1493
1494 #[test]
1495 fn tscn_autoload_is_the_seam_never_false_warns() {
1496 let mut db = RootDatabase::default();
1497 db.set_file_text(FileId(0), "func f():\n\tHud.play_song()\n", Durability::LOW);
1500 db.set_file_path(FileId(0), "res://main.gd");
1501 db.set_project_config("[autoload]\nHud=\"*res://hud.tscn\"\n");
1502 db.sync_source_root();
1503
1504 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1505 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1507 }
1508
1509 static AUTOLOAD_OBSERVED: AtomicU32 = AtomicU32::new(0);
1513
1514 #[salsa::tracked]
1515 fn observe_autoload_registry(db: &dyn gdscript_db::Db, config: ProjectConfig) -> usize {
1516 AUTOLOAD_OBSERVED.fetch_add(1, Ordering::SeqCst);
1517 autoload_registry(db, config).len()
1518 }
1519
1520 #[test]
1521 fn autoload_registry_firewalled_against_body_edits() {
1522 let mut db = RootDatabase::default();
1523 db.set_file_text(FileId(0), "func f():\n\tvar a := 1\n", Durability::LOW);
1524 db.set_file_path(FileId(0), "res://game.gd");
1525 db.set_project_config("[autoload]\nGame=\"*res://game.gd\"\n");
1526 db.sync_source_root();
1527 let config = db.project_config().unwrap();
1528
1529 assert_eq!(observe_autoload_registry(&db, config), 1);
1530 let runs = AUTOLOAD_OBSERVED.load(Ordering::SeqCst);
1531
1532 db.set_file_text(FileId(0), "func f():\n\tvar a := 999999\n", Durability::LOW);
1535
1536 assert_eq!(observe_autoload_registry(&db, config), 1);
1537 assert_eq!(
1538 AUTOLOAD_OBSERVED.load(Ordering::SeqCst),
1539 runs,
1540 "REGRESSION: a body edit re-ran an autoload_registry consumer — the config firewall broke",
1541 );
1542 }
1543
1544 #[test]
1545 fn aliased_self_resolves_own_members_no_false_unsafe() {
1546 let mut db = RootDatabase::default();
1550 db.set_file_text(
1551 FileId(0),
1552 "extends Node\nfunc own() -> int:\n\treturn 1\nfunc use_it():\n\tvar me := self\n\tvar n := me.own()\n",
1553 Durability::LOW,
1554 );
1555 db.sync_source_root();
1556 let api = db.engine().unwrap();
1557
1558 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1559 assert!(
1561 fi.units
1562 .iter()
1563 .flat_map(|u| &u.result.bindings)
1564 .any(|b| b.ty.label(api).as_deref() == Some("int")),
1565 "aliased self.own() should resolve to int",
1566 );
1567 assert!(
1568 fi.diagnostics.is_empty(),
1569 "no false UNSAFE on aliased self: {:?}",
1570 fi.diagnostics
1571 );
1572 }
1573
1574 #[test]
1575 fn is_userbase_narrows_to_derived_but_not_un_narrowed_to_base() {
1576 let mut db = RootDatabase::default();
1577 db.set_file_text(
1578 FileId(0),
1579 "class_name Base\nfunc base_m() -> int:\n\treturn 1\n",
1580 Durability::LOW,
1581 );
1582 db.set_file_text(
1583 FileId(1),
1584 "class_name Derived\nextends Base\nfunc own_m() -> String:\n\treturn \"x\"\n",
1585 Durability::LOW,
1586 );
1587 db.set_file_text(
1590 FileId(2),
1591 "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",
1592 Durability::LOW,
1593 );
1594 db.sync_source_root();
1595 let api = db.engine().unwrap();
1596
1597 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1598 let strings = fi
1599 .units
1600 .iter()
1601 .flat_map(|u| &u.result.bindings)
1602 .filter(|b| b.ty.label(api).as_deref() == Some("String"))
1603 .count();
1604 assert!(
1606 strings >= 2,
1607 "expected both own_m() calls to type as String (narrow-down + widen-only), got {strings}",
1608 );
1609 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1610 }
1611
1612 fn scene_db(scene_text: &str, gd_text: &str) -> RootDatabase {
1616 let mut db = RootDatabase::default();
1617 db.set_file_text(FileId(0), scene_text, Durability::LOW);
1618 db.set_file_path(FileId(0), "res://main.tscn");
1619 db.set_file_text(FileId(1), gd_text, Durability::LOW);
1620 db.set_file_path(FileId(1), "res://main.gd");
1621 db.sync_source_root();
1622 db
1623 }
1624
1625 fn binding_labels(db: &RootDatabase) -> Vec<String> {
1626 let api = db.engine().unwrap();
1627 let fi = analyze_file(db, db.file_text(FileId(1)).unwrap());
1628 assert!(
1629 fi.diagnostics.is_empty(),
1630 "unexpected diags: {:?}",
1631 fi.diagnostics
1632 );
1633 fi.units
1634 .iter()
1635 .flat_map(|u| &u.result.bindings)
1636 .filter_map(|b| b.ty.label(api))
1637 .collect()
1638 }
1639
1640 const SCENE: &str = "[gd_scene format=3]\n\
1641 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1642 [node name=\"Root\" type=\"Control\"]\n\
1643 script = ExtResource(\"1\")\n\
1644 [node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
1645 [node name=\"Box\" type=\"VBoxContainer\" parent=\"Panel\"]\n\
1646 [node name=\"Btn\" type=\"Button\" parent=\"Panel/Box\"]\n\
1647 unique_name_in_owner = true\n";
1648
1649 #[test]
1650 fn dollar_path_types_to_the_concrete_node() {
1651 let db = scene_db(
1653 SCENE,
1654 "extends Control\nfunc _ready():\n\tvar b := $Panel/Box/Btn\n",
1655 );
1656 assert!(
1657 binding_labels(&db).iter().any(|l| l == "Button"),
1658 "$Panel/Box/Btn should type as Button",
1659 );
1660 }
1661
1662 #[test]
1663 fn unique_name_path_types_to_the_concrete_node() {
1664 let db = scene_db(SCENE, "extends Control\nfunc _ready():\n\tvar b := %Btn\n");
1666 assert!(
1667 binding_labels(&db).iter().any(|l| l == "Button"),
1668 "%Btn should type as Button"
1669 );
1670 }
1671
1672 #[test]
1673 fn onready_var_from_a_node_path_is_typed() {
1674 let db = scene_db(
1677 SCENE,
1678 "extends Control\n@onready var btn := $Panel/Box/Btn\n",
1679 );
1680 assert!(
1681 binding_labels(&db).iter().any(|l| l == "Button"),
1682 "@onready var := $Path should type to Button",
1683 );
1684 }
1685
1686 #[test]
1687 fn get_node_string_literal_types_like_dollar() {
1688 let db = scene_db(
1690 SCENE,
1691 "extends Control\nfunc _ready():\n\tvar b := get_node(\"Panel/Box/Btn\")\n",
1692 );
1693 assert!(
1694 binding_labels(&db).iter().any(|l| l == "Button"),
1695 "get_node(\"...\") should type as Button",
1696 );
1697 }
1698
1699 #[test]
1700 fn self_get_node_string_literal_types_like_dollar() {
1701 let db = scene_db(
1704 SCENE,
1705 "extends Control\nfunc _ready():\n\tvar b := self.get_node(\"Panel/Box/Btn\")\n",
1706 );
1707 assert!(
1708 binding_labels(&db).iter().any(|l| l == "Button"),
1709 "self.get_node(\"...\") should type as Button",
1710 );
1711 }
1712
1713 #[test]
1714 fn attached_script_refines_the_node_type() {
1715 let mut db = RootDatabase::default();
1718 db.set_file_text(
1719 FileId(0),
1720 "[gd_scene format=3]\n\
1721 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1722 [ext_resource type=\"Script\" path=\"res://fancy.gd\" id=\"2\"]\n\
1723 [node name=\"Root\" type=\"Control\"]\n\
1724 script = ExtResource(\"1\")\n\
1725 [node name=\"That\" type=\"Button\" parent=\".\"]\n\
1726 script = ExtResource(\"2\")\n",
1727 Durability::LOW,
1728 );
1729 db.set_file_path(FileId(0), "res://main.tscn");
1730 db.set_file_text(
1731 FileId(1),
1732 "extends Control\nfunc _ready():\n\tvar n := $That.fancy()\n",
1733 Durability::LOW,
1734 );
1735 db.set_file_path(FileId(1), "res://main.gd");
1736 db.set_file_text(
1737 FileId(2),
1738 "class_name Fancy\nextends Button\nfunc fancy() -> int:\n\treturn 1\n",
1739 Durability::LOW,
1740 );
1741 db.set_file_path(FileId(2), "res://fancy.gd");
1742 db.sync_source_root();
1743 assert!(
1744 binding_labels(&db).iter().any(|l| l == "int"),
1745 "$That.fancy() should resolve via the attached script Fancy",
1746 );
1747 }
1748
1749 #[test]
1750 fn computed_or_unresolvable_node_path_stays_node_without_warning() {
1751 let mut db = RootDatabase::default();
1754 db.set_file_text(
1755 FileId(1),
1756 "extends Node\nfunc f(p: NodePath):\n\tvar a := get_node(p)\n\tvar b := $Nope\n",
1760 Durability::LOW,
1761 );
1762 db.set_file_path(FileId(1), "res://lone.gd");
1763 db.sync_source_root();
1764 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1765 assert!(
1766 fi.diagnostics.is_empty(),
1767 "no false node-path warnings: {:?}",
1768 fi.diagnostics
1769 );
1770 }
1771
1772 fn has_invalid_node_path(db: &RootDatabase) -> bool {
1775 let fi = analyze_file(db, db.file_text(FileId(1)).unwrap());
1776 fi.diagnostics
1777 .iter()
1778 .any(|d| d.code == crate::infer::INVALID_NODE_PATH)
1779 }
1780
1781 #[test]
1782 fn invalid_node_path_warns_when_genuinely_absent_in_a_single_owning_scene() {
1783 let db = scene_db(SCENE, "extends Control\nfunc _ready():\n\tvar b := $Nope\n");
1784 assert!(
1785 has_invalid_node_path(&db),
1786 "$Nope is absent in the one owning scene → warn"
1787 );
1788 }
1789
1790 #[test]
1791 fn escape_and_absolute_paths_never_warn() {
1792 let db = scene_db(
1794 SCENE,
1795 "extends Control\nfunc _ready():\n\tvar a := $\"../Sibling\"\n\tvar c := $\"/root/Global\"\n",
1796 );
1797 assert!(!has_invalid_node_path(&db), "escape paths must not warn");
1798 }
1799
1800 #[test]
1801 fn path_descending_into_an_instanced_subscene_never_warns() {
1802 let db = scene_db(
1805 "[gd_scene format=3]\n\
1806 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1807 [ext_resource type=\"PackedScene\" path=\"res://player.tscn\" id=\"2\"]\n\
1808 [node name=\"Root\" type=\"Control\"]\n\
1809 script = ExtResource(\"1\")\n\
1810 [node name=\"Player\" parent=\".\" instance=ExtResource(\"2\")]\n",
1811 "extends Control\nfunc _ready():\n\tvar g := $Player/Gun\n",
1812 );
1813 assert!(
1814 !has_invalid_node_path(&db),
1815 "into-instance miss must not warn"
1816 );
1817 }
1818
1819 #[test]
1820 fn ambiguous_multi_scene_attachment_suppresses_the_invalid_warning() {
1821 let mut db = RootDatabase::default();
1824 db.set_file_text(
1825 FileId(0),
1826 "[gd_scene format=3]\n\
1827 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1828 [node name=\"Root\" type=\"Control\"]\n\
1829 script = ExtResource(\"1\")\n\
1830 [node name=\"Alpha\" type=\"Button\" parent=\".\"]\n",
1831 Durability::LOW,
1832 );
1833 db.set_file_path(FileId(0), "res://a.tscn");
1834 db.set_file_text(
1835 FileId(2),
1836 "[gd_scene format=3]\n\
1837 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1838 [node name=\"Root\" type=\"Control\"]\n\
1839 script = ExtResource(\"1\")\n\
1840 [node name=\"Beta\" type=\"Button\" parent=\".\"]\n",
1841 Durability::LOW,
1842 );
1843 db.set_file_path(FileId(2), "res://b.tscn");
1844 db.set_file_text(
1845 FileId(1),
1846 "extends Control\nfunc _ready():\n\tvar b := $Beta\n",
1847 Durability::LOW,
1848 );
1849 db.set_file_path(FileId(1), "res://main.gd");
1850 db.sync_source_root();
1851 assert!(
1852 !has_invalid_node_path(&db),
1853 "ambiguous multi-scene attachment must not warn"
1854 );
1855 }
1856
1857 #[test]
1860 fn instanced_node_recurses_into_the_subscene_root_script() {
1861 let mut db = RootDatabase::default();
1866 db.set_file_text(
1867 FileId(0),
1868 "[gd_scene format=3]\n\
1869 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1870 [ext_resource type=\"PackedScene\" path=\"res://enemy.tscn\" id=\"2\"]\n\
1871 [node name=\"Root\" type=\"Control\"]\n\
1872 script = ExtResource(\"1\")\n\
1873 [node name=\"Enemy\" parent=\".\" instance=ExtResource(\"2\")]\n",
1874 Durability::LOW,
1875 );
1876 db.set_file_path(FileId(0), "res://main.tscn");
1877 db.set_file_text(
1878 FileId(1),
1879 "extends Control\nfunc _ready():\n\tvar e := $Enemy.hp()\n",
1880 Durability::LOW,
1881 );
1882 db.set_file_path(FileId(1), "res://main.gd");
1883 db.set_file_text(
1884 FileId(2),
1885 "[gd_scene format=3]\n\
1886 [ext_resource type=\"Script\" path=\"res://enemy.gd\" id=\"1\"]\n\
1887 [node name=\"Enemy\" type=\"Button\"]\n\
1888 script = ExtResource(\"1\")\n",
1889 Durability::LOW,
1890 );
1891 db.set_file_path(FileId(2), "res://enemy.tscn");
1892 db.set_file_text(
1893 FileId(3),
1894 "class_name Enemy\nextends Button\nfunc hp() -> int:\n\treturn 1\n",
1895 Durability::LOW,
1896 );
1897 db.set_file_path(FileId(3), "res://enemy.gd");
1898 db.sync_source_root();
1899 assert!(
1900 binding_labels(&db).iter().any(|l| l == "int"),
1901 "$Enemy.hp() should recurse into the instanced sub-scene root's script Enemy",
1902 );
1903 }
1904
1905 #[test]
1906 fn path_into_an_instanced_subscene_types_the_inner_node() {
1907 let mut db = RootDatabase::default();
1913 db.set_file_text(
1914 FileId(0),
1915 "[gd_scene format=3]\n\
1916 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1917 [ext_resource type=\"PackedScene\" path=\"res://enemy.tscn\" id=\"2\"]\n\
1918 [node name=\"Root\" type=\"Control\"]\n\
1919 script = ExtResource(\"1\")\n\
1920 [node name=\"Enemy\" parent=\".\" instance=ExtResource(\"2\")]\n",
1921 Durability::LOW,
1922 );
1923 db.set_file_path(FileId(0), "res://main.tscn");
1924 db.set_file_text(
1925 FileId(1),
1926 "extends Control\nfunc _ready():\n\tvar s := $Enemy/Sprite\n\tvar n := $Enemy/Nope\n",
1927 Durability::LOW,
1928 );
1929 db.set_file_path(FileId(1), "res://main.gd");
1930 db.set_file_text(
1931 FileId(2),
1932 "[gd_scene format=3]\n\
1933 [node name=\"Enemy\" type=\"Node2D\"]\n\
1934 [node name=\"Sprite\" type=\"Sprite2D\" parent=\".\"]\n",
1935 Durability::LOW,
1936 );
1937 db.set_file_path(FileId(2), "res://enemy.tscn");
1938 db.sync_source_root();
1939 assert!(
1940 binding_labels(&db).iter().any(|l| l == "Sprite2D"),
1941 "$Enemy/Sprite should type as the sub-scene's Sprite (Sprite2D)",
1942 );
1943 }
1944
1945 #[test]
1948 fn unique_name_subpath_resolves_to_the_child_without_warning() {
1949 let db = scene_db(
1953 "[gd_scene format=3]\n\
1954 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1955 [node name=\"Root\" type=\"Control\"]\n\
1956 script = ExtResource(\"1\")\n\
1957 [node name=\"Box\" type=\"VBoxContainer\" parent=\".\"]\n\
1958 unique_name_in_owner = true\n\
1959 [node name=\"Btn\" type=\"Button\" parent=\"Box\"]\n",
1960 "extends Control\nfunc _ready():\n\tvar b := %Box/Btn\n",
1961 );
1962 assert!(
1963 binding_labels(&db).iter().any(|l| l == "Button"),
1964 "%Box/Btn → Button (and no false INVALID_NODE_PATH)",
1965 );
1966 }
1967
1968 #[test]
1969 fn percent_prefixed_string_paths_resolve_as_unique_without_warning() {
1970 let db = scene_db(
1973 SCENE,
1974 "extends Control\nfunc _ready():\n\tvar a := get_node(\"%Btn\")\n\tvar b := $\"%Btn\"\n",
1975 );
1976 let labels = binding_labels(&db);
1977 assert!(
1978 labels.iter().filter(|l| *l == "Button").count() >= 2,
1979 "both %Btn string forms should resolve to Button: {labels:?}",
1980 );
1981 }
1982}