1use std::sync::Arc;
15
16use gdscript_db::{Db, FileText, ProjectConfig, SourceRoot, parse};
17use rustc_hash::{FxHashMap, FxHashSet};
18use smol_str::SmolStr;
19
20use gdscript_base::FileId;
21use gdscript_scene::{NodeIdx, SceneModel};
22
23use crate::infer::FileInference;
24use crate::item_tree::{ItemTree, Member};
25use crate::ty::{ScriptRefId, Ty};
26use crate::warnings::{SuppressionMap, WarningSettings};
27
28#[salsa::tracked]
31pub fn item_tree(db: &dyn Db, file: FileText) -> Arc<ItemTree> {
32 crate::item_tree::item_tree(&parse(db, file).syntax_node())
33}
34
35#[salsa::tracked]
38pub fn analyze_file(db: &dyn Db, file: FileText) -> Arc<FileInference> {
39 match db.engine() {
40 Some(api) => Arc::new(crate::infer::analyze_file(
41 db,
42 api,
43 &parse(db, file).syntax_node(),
44 file.file_id(db),
45 )),
46 None => Arc::new(FileInference::default()),
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Default)]
52pub struct GlobalRegistry {
53 classes: FxHashMap<SmolStr, FileText>,
54}
55
56impl GlobalRegistry {
57 #[must_use]
59 pub fn resolve(&self, name: &str) -> Option<FileText> {
60 self.classes.get(name).copied()
61 }
62
63 #[must_use]
65 pub fn len(&self) -> usize {
66 self.classes.len()
67 }
68
69 #[must_use]
71 pub fn is_empty(&self) -> bool {
72 self.classes.is_empty()
73 }
74
75 pub fn iter(&self) -> impl Iterator<Item = (&SmolStr, FileText)> + '_ {
77 self.classes.iter().map(|(k, v)| (k, *v))
78 }
79}
80
81#[salsa::tracked]
86pub fn file_class_name(db: &dyn Db, file: FileText) -> Option<SmolStr> {
87 item_tree(db, file).class_name.clone()
88}
89
90#[salsa::tracked]
95pub fn global_registry(db: &dyn Db, root: SourceRoot) -> Arc<GlobalRegistry> {
96 let mut classes = FxHashMap::default();
97 for &file in root.files(db) {
98 if let Some(name) = file_class_name(db, file) {
99 classes.entry(name).or_insert(file);
100 }
101 }
102 Arc::new(GlobalRegistry { classes })
103}
104
105#[salsa::tracked]
111pub fn class_name_collisions(db: &dyn Db, root: SourceRoot) -> Arc<FxHashSet<SmolStr>> {
112 let mut seen: FxHashSet<SmolStr> = FxHashSet::default();
113 let mut dups: FxHashSet<SmolStr> = FxHashSet::default();
114 for &file in root.files(db) {
115 if let Some(name) = file_class_name(db, file)
116 && !seen.insert(name.clone())
117 {
118 dups.insert(name);
119 }
120 }
121 Arc::new(dups)
122}
123
124#[salsa::tracked]
131pub fn res_path_registry(db: &dyn Db, root: SourceRoot) -> Arc<FxHashMap<SmolStr, FileId>> {
132 let mut map = FxHashMap::default();
133 for &file in root.files(db) {
134 if let Some(path) = file.res_path(db) {
135 map.entry(path).or_insert_with(|| file.file_id(db));
136 }
137 }
138 Arc::new(map)
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Default)]
146pub struct AutoloadRegistry {
147 singletons: FxHashMap<SmolStr, SmolStr>,
148}
149
150impl AutoloadRegistry {
151 #[must_use]
153 pub fn resolve_path(&self, name: &str) -> Option<&SmolStr> {
154 self.singletons.get(name)
155 }
156
157 #[must_use]
159 pub fn len(&self) -> usize {
160 self.singletons.len()
161 }
162
163 #[must_use]
165 pub fn is_empty(&self) -> bool {
166 self.singletons.is_empty()
167 }
168}
169
170#[salsa::tracked]
173pub fn autoload_registry(db: &dyn Db, config: ProjectConfig) -> Arc<AutoloadRegistry> {
174 let mut singletons = FxHashMap::default();
175 for e in crate::project::parse_autoloads(config.project_godot_text(db)) {
176 if e.is_singleton {
177 singletons.entry(e.name).or_insert(e.path);
178 }
179 }
180 Arc::new(AutoloadRegistry { singletons })
181}
182
183#[salsa::tracked]
193pub fn engine_version(db: &dyn Db, config: ProjectConfig) -> Option<(u32, u32)> {
194 crate::project::parse_engine_version(config.project_godot_text(db))
195}
196
197#[must_use]
200pub fn project_engine_version(db: &dyn Db) -> Option<(u32, u32)> {
201 engine_version(db, db.project_config()?)
202}
203
204#[salsa::tracked]
210pub fn warning_settings(db: &dyn Db, config: ProjectConfig) -> Arc<WarningSettings> {
211 let text = config.project_godot_text(db);
212 let engine =
213 crate::project::parse_engine_version(text).unwrap_or_else(crate::warnings::bundled_version);
214 Arc::new(crate::project::parse_warning_settings(text, engine))
215}
216
217#[salsa::tracked]
221pub fn suppression_map(db: &dyn Db, file: FileText) -> Arc<SuppressionMap> {
222 Arc::new(crate::warnings::build_suppression_map(
223 &parse(db, file).syntax_node(),
224 file.text(db),
225 ))
226}
227
228#[derive(Debug, Clone, PartialEq, Eq)]
231pub enum MemberSig {
232 Method(Ty),
234 Field(Ty),
236 Signal,
238}
239
240#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct ScriptClass {
247 members: FxHashMap<SmolStr, MemberSig>,
248 base: Ty,
249}
250
251impl ScriptClass {
252 #[must_use]
255 pub fn member(&self, name: &str) -> Option<&MemberSig> {
256 self.members.get(name)
257 }
258
259 #[must_use]
261 pub fn base(&self) -> &Ty {
262 &self.base
263 }
264}
265
266#[must_use]
270pub fn script_ref_name(db: &dyn Db, sref: ScriptRefId) -> Option<SmolStr> {
271 let file = db.file_text(FileId(sref.0))?;
272 file_class_name(db, file)
273}
274
275#[salsa::tracked]
278pub fn script_class(db: &dyn Db, file: FileText) -> Arc<ScriptClass> {
279 let tree = item_tree(db, file);
280 let Some(api) = db.engine() else {
281 return Arc::new(ScriptClass {
282 members: FxHashMap::default(),
283 base: Ty::Unknown,
284 });
285 };
286 let resolve_ann = |ann: Option<&str>| -> Ty {
287 ann.map_or(Ty::Variant, |t| {
288 crate::resolve::resolve_type_name(db, api, t)
289 })
290 };
291 let mut members = FxHashMap::default();
292 for m in &tree.members {
293 let Some(name) = m.name() else { continue };
294 let sig = match m {
295 Member::Func(f) => MemberSig::Method(resolve_ann(f.return_type.as_deref())),
296 Member::Var(v) => MemberSig::Field(resolve_ann(v.type_ref.as_deref())),
297 Member::Const(c) => MemberSig::Field(
302 c.type_ref
303 .is_none()
304 .then_some(c.preload_path.as_deref())
305 .flatten()
306 .and_then(|raw| {
307 crate::resolve::anchor_res_path(file.res_path(db).as_deref(), raw)
308 })
309 .map_or_else(
310 || resolve_ann(c.type_ref.as_deref()),
311 |abs| {
312 crate::resolve::resolve_external(
313 db,
314 &crate::resolve::ExternalRef::Preload(abs),
315 )
316 },
317 ),
318 ),
319 Member::Signal(_) => MemberSig::Signal,
320 Member::Enum(_) | Member::Class(_) => continue,
322 };
323 members.insert(SmolStr::new(name), sig);
324 }
325 let base = crate::resolve::resolve_base(db, api, &tree, file.res_path(db).as_deref());
328 Arc::new(ScriptClass { members, base })
329}
330
331fn is_scene_path(path: &str) -> bool {
336 let ext = path.rsplit('.').next().unwrap_or("");
337 ext.eq_ignore_ascii_case("tscn") || ext.eq_ignore_ascii_case("tres")
338}
339
340#[salsa::tracked]
344pub fn scene_model(db: &dyn Db, file: FileText) -> Arc<SceneModel> {
345 let is_scene = file.res_path(db).as_deref().is_some_and(is_scene_path);
346 if is_scene {
347 Arc::new(gdscript_scene::parse_scene(file.text(db)))
348 } else {
349 Arc::new(gdscript_scene::parse_scene(""))
350 }
351}
352
353#[derive(Debug, Clone, Copy, PartialEq, Eq)]
356pub struct SceneAttach {
357 pub scene: FileId,
359 pub node: NodeIdx,
361 pub ambiguous: bool,
364}
365
366#[salsa::tracked]
373pub fn script_scene_index(db: &dyn Db, root: SourceRoot) -> Arc<FxHashMap<SmolStr, SceneAttach>> {
374 let mut map: FxHashMap<SmolStr, SceneAttach> = FxHashMap::default();
375 for &file in root.files(db) {
376 if !file.res_path(db).as_deref().is_some_and(is_scene_path) {
377 continue;
378 }
379 let model = scene_model(db, file);
380 let scene = file.file_id(db);
381 for (i, node) in model.nodes.iter().enumerate() {
382 let Some(script_id) = node.script.as_ref() else {
383 continue;
384 };
385 let Some(path) = model
386 .ext_resources
387 .get(script_id)
388 .and_then(|e| e.path.clone())
389 else {
390 continue;
391 };
392 let node = NodeIdx(u32::try_from(i).unwrap_or(u32::MAX));
393 match map.get_mut(&path) {
394 Some(existing) => existing.ambiguous = true,
396 None => {
397 map.insert(
398 path,
399 SceneAttach {
400 scene,
401 node,
402 ambiguous: false,
403 },
404 );
405 }
406 }
407 }
408 }
409 Arc::new(map)
410}
411
412#[must_use]
417pub fn scene_context(db: &dyn Db, file: FileText) -> Option<SceneContext> {
418 let res_path = file.res_path(db)?;
419 let root = db.source_root()?;
420 let attach = *script_scene_index(db, root).get(res_path.as_str())?;
421 let scene_file = db.file_text(attach.scene)?;
422 Some(SceneContext {
423 scene: attach.scene,
424 model: scene_model(db, scene_file),
425 attach: attach.node,
426 ambiguous: attach.ambiguous,
427 })
428}
429
430#[derive(Debug, Clone)]
433pub struct SceneContext {
434 pub scene: FileId,
436 pub model: Arc<SceneModel>,
438 pub attach: NodeIdx,
440 pub ambiguous: bool,
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use gdscript_base::FileId;
448 use gdscript_db::RootDatabase;
449 use salsa::Durability;
450
451 fn db_with(src: &str) -> (RootDatabase, FileText) {
452 let mut db = RootDatabase::default();
453 db.set_file_text(FileId(0), src, Durability::LOW);
454 let ft = db.file_text(FileId(0)).unwrap();
455 (db, ft)
456 }
457
458 #[test]
459 fn tracked_item_tree_matches_the_plain_fn() {
460 let (db, ft) = db_with("class_name Foo\nfunc f():\n\tpass\n");
461 let tree = item_tree(&db, ft);
462 assert_eq!(tree.class_name.as_deref(), Some("Foo"));
463 assert_eq!(item_tree(&db, ft), tree);
465 }
466
467 #[test]
468 fn tracked_analyze_file_runs_inference() {
469 let (db, ft) = db_with("func add(a: int, b: int) -> int:\n\treturn a + b\n");
470 let fi = analyze_file(&db, ft);
471 assert!(!fi.units.is_empty());
473 assert!(fi.diagnostics.is_empty());
474 }
475
476 use std::sync::atomic::{AtomicU32, Ordering};
486
487 static WITNESS_RUNS: AtomicU32 = AtomicU32::new(0);
488
489 #[salsa::tracked]
491 fn class_name_witness(db: &dyn gdscript_db::Db, file: FileText) -> Option<smol_str::SmolStr> {
492 WITNESS_RUNS.fetch_add(1, Ordering::SeqCst);
493 item_tree(db, file).class_name.clone()
494 }
495
496 #[test]
497 fn body_edit_does_not_invalidate_signature_queries() {
498 let mut db = RootDatabase::default();
499 db.set_file_text(
500 FileId(0),
501 "class_name Foo\nfunc f():\n\tvar a := 1\n",
502 Durability::LOW,
503 );
504 let ft = db.file_text(FileId(0)).unwrap();
505
506 assert_eq!(class_name_witness(&db, ft).as_deref(), Some("Foo"));
508 let runs_after_warm = WITNESS_RUNS.load(Ordering::SeqCst);
509
510 db.set_file_text(
513 FileId(0),
514 "class_name Foo\nfunc f():\n\tvar a := 2\n",
515 Durability::LOW,
516 );
517 assert_eq!(class_name_witness(&db, ft).as_deref(), Some("Foo"));
518
519 assert_eq!(
520 WITNESS_RUNS.load(Ordering::SeqCst),
521 runs_after_warm,
522 "REGRESSION: a body edit re-ran a signature-only query — the item_tree firewall broke",
523 );
524 }
525
526 #[test]
527 fn global_registry_resolves_class_names_across_files() {
528 let mut db = RootDatabase::default();
529 db.set_file_text(
530 FileId(0),
531 "class_name Player\nfunc f():\n\tpass\n",
532 Durability::LOW,
533 );
534 db.set_file_text(
535 FileId(1),
536 "class_name Enemy\nvar hp := 10\n",
537 Durability::LOW,
538 );
539 db.set_file_text(FileId(2), "func no_class():\n\tpass\n", Durability::LOW);
540 db.sync_source_root();
541 let root = db.source_root().unwrap();
542
543 let reg = global_registry(&db, root);
544 assert_eq!(reg.len(), 2);
545 assert_eq!(reg.resolve("Player"), db.file_text(FileId(0)));
546 assert_eq!(reg.resolve("Enemy"), db.file_text(FileId(1)));
547 assert!(reg.resolve("Nonexistent").is_none());
548 }
549
550 static REGISTRY_OBSERVED: AtomicU32 = AtomicU32::new(0);
557
558 #[salsa::tracked]
560 fn observe_registry(db: &dyn gdscript_db::Db, root: SourceRoot) -> usize {
561 REGISTRY_OBSERVED.fetch_add(1, Ordering::SeqCst);
562 global_registry(db, root).len()
563 }
564
565 #[test]
566 fn body_edit_does_not_invalidate_the_global_registry() {
567 let mut db = RootDatabase::default();
568 db.set_file_text(
569 FileId(0),
570 "class_name Player\nfunc f():\n\tvar a := 1\n",
571 Durability::LOW,
572 );
573 db.set_file_text(FileId(1), "class_name Enemy\n", Durability::LOW);
574 db.sync_source_root();
575 let root = db.source_root().unwrap();
576
577 assert_eq!(observe_registry(&db, root), 2);
578 let runs = REGISTRY_OBSERVED.load(Ordering::SeqCst);
579
580 db.set_file_text(
583 FileId(0),
584 "class_name Player\nfunc f():\n\tvar a := 123456\n",
585 Durability::LOW,
586 );
587
588 assert_eq!(observe_registry(&db, root), 2);
589 assert_eq!(
590 REGISTRY_OBSERVED.load(Ordering::SeqCst),
591 runs,
592 "REGRESSION: a body edit re-ran a global_registry consumer — the cross-file firewall broke",
593 );
594 }
595
596 #[test]
597 fn cross_file_class_name_member_resolves() {
598 let mut db = RootDatabase::default();
599 db.set_file_text(
600 FileId(0),
601 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
602 Durability::LOW,
603 );
604 db.set_file_text(
605 FileId(1),
606 "func use_it():\n\tvar w := Widget.make()\n",
607 Durability::LOW,
608 );
609 db.sync_source_root();
610
611 let file1 = db.file_text(FileId(1)).unwrap();
612 let fi = analyze_file(&db, file1);
613 let api = db.engine().unwrap();
614
615 let unit = fi
618 .units
619 .iter()
620 .find(|u| !u.result.bindings.is_empty())
621 .expect("a unit with a binding");
622 assert_eq!(
623 unit.result.bindings[0].ty.label(api).as_deref(),
624 Some("int")
625 );
626 assert!(
627 fi.diagnostics.is_empty(),
628 "unexpected diagnostics: {:?}",
629 fi.diagnostics
630 );
631 }
632
633 use crate::infer::SHADOWED_GLOBAL_IDENTIFIER;
636
637 fn shadow_codes(fi: &Arc<FileInference>) -> Vec<&str> {
638 fi.diagnostics
639 .iter()
640 .filter(|d| d.code == SHADOWED_GLOBAL_IDENTIFIER)
641 .map(|d| d.code.as_str())
642 .collect()
643 }
644
645 #[test]
646 fn class_name_collisions_names_only_the_duplicates() {
647 let mut db = RootDatabase::default();
648 db.set_file_text(FileId(0), "class_name Dup\n", Durability::LOW);
649 db.set_file_text(FileId(1), "class_name Dup\n", Durability::LOW);
650 db.set_file_text(FileId(2), "class_name Unique\n", Durability::LOW);
651 db.sync_source_root();
652 let root = db.source_root().unwrap();
653
654 let cols = class_name_collisions(&db, root);
655 assert!(cols.contains(&SmolStr::new("Dup")));
656 assert!(
657 !cols.contains(&SmolStr::new("Unique")),
658 "a singly-declared class_name is not a collision",
659 );
660 assert_eq!(cols.len(), 1);
661 }
662
663 #[test]
664 fn duplicate_class_name_warns_at_both_declarations() {
665 let mut db = RootDatabase::default();
666 db.set_file_text(
667 FileId(0),
668 "class_name Dup\nfunc f():\n\tpass\n",
669 Durability::LOW,
670 );
671 db.set_file_text(FileId(1), "class_name Dup\nvar x := 1\n", Durability::LOW);
672 db.sync_source_root();
673
674 for fid in [0, 1] {
675 let fi = analyze_file(&db, db.file_text(FileId(fid)).unwrap());
676 assert!(
677 shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
678 "file {fid} should warn on the duplicate class_name: {:?}",
679 fi.diagnostics
680 );
681 let d = fi
683 .diagnostics
684 .iter()
685 .find(|d| d.code == SHADOWED_GLOBAL_IDENTIFIER)
686 .unwrap();
687 assert_eq!(d.range, gdscript_base::TextRange::new(11, 14));
688 }
689 }
690
691 #[test]
692 fn class_name_shadowing_an_engine_class_warns() {
693 let mut db = RootDatabase::default();
694 db.set_file_text(
696 FileId(0),
697 "class_name Node\nfunc f():\n\tpass\n",
698 Durability::LOW,
699 );
700 db.sync_source_root();
701
702 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
703 assert!(
704 shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
705 "class_name Node must warn (shadows the engine class): {:?}",
706 fi.diagnostics
707 );
708 }
709
710 #[test]
711 fn class_name_shadowing_a_builtin_type_warns() {
712 let mut db = RootDatabase::default();
713 db.set_file_text(FileId(0), "class_name Vector2\n", Durability::LOW);
715 db.sync_source_root();
716
717 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
718 assert!(
719 shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
720 "{:?}",
721 fi.diagnostics
722 );
723 }
724
725 #[test]
726 fn class_name_shadowing_a_star_autoload_warns() {
727 let mut db = RootDatabase::default();
728 db.set_file_text(
729 FileId(0),
730 "class_name Game\nfunc f():\n\tpass\n",
731 Durability::LOW,
732 );
733 db.set_file_path(FileId(0), "res://game.gd");
734 db.set_project_config("[autoload]\nGame=\"*res://other.gd\"\n");
736 db.sync_source_root();
737
738 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
739 assert!(
740 shadow_codes(&fi).contains(&SHADOWED_GLOBAL_IDENTIFIER),
741 "class_name Game must warn (shadows the `*Game` autoload): {:?}",
742 fi.diagnostics
743 );
744 }
745
746 #[test]
747 fn unique_non_shadowing_class_name_does_not_warn() {
748 let mut db = RootDatabase::default();
750 db.set_file_text(
751 FileId(0),
752 "class_name MyVeryOwnUniquePlayer\nfunc f():\n\tpass\n",
753 Durability::LOW,
754 );
755 db.set_file_text(
756 FileId(1),
757 "class_name AnotherUniqueEnemy\n",
758 Durability::LOW,
759 );
760 db.sync_source_root();
761
762 for fid in [0, 1] {
763 let fi = analyze_file(&db, db.file_text(FileId(fid)).unwrap());
764 assert!(
765 shadow_codes(&fi).is_empty(),
766 "file {fid}: a unique class_name must not warn: {:?}",
767 fi.diagnostics
768 );
769 }
770 }
771
772 #[test]
773 fn unknown_member_on_script_ref_is_seam_not_warning() {
774 let mut db = RootDatabase::default();
775 db.set_file_text(
776 FileId(0),
777 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
778 Durability::LOW,
779 );
780 db.set_file_text(
781 FileId(1),
782 "func use_it():\n\tWidget.not_a_member()\n",
783 Durability::LOW,
784 );
785 db.sync_source_root();
786
787 let file1 = db.file_text(FileId(1)).unwrap();
788 let fi = analyze_file(&db, file1);
789 assert!(
791 fi.diagnostics.is_empty(),
792 "a missing member on a ScriptRef must not warn: {:?}",
793 fi.diagnostics
794 );
795 }
796
797 #[test]
798 fn inherited_members_resolve_through_user_and_engine_bases() {
799 let mut db = RootDatabase::default();
800 db.set_file_text(
802 FileId(0),
803 "class_name Base\nextends Node\nfunc base_method() -> int:\n\treturn 1\n",
804 Durability::LOW,
805 );
806 db.set_file_text(
807 FileId(1),
808 "class_name Derived\nextends Base\nfunc own() -> String:\n\treturn \"x\"\n",
809 Durability::LOW,
810 );
811 db.set_file_text(
812 FileId(2),
813 "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",
814 Durability::LOW,
815 );
816 db.sync_source_root();
817 let api = db.engine().unwrap();
818
819 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
820 let unit = fi
821 .units
822 .iter()
823 .find(|u| u.result.bindings.len() >= 4)
824 .expect("use_it unit with 4 bindings");
825 assert_eq!(
827 unit.result.bindings[1].ty.label(api).as_deref(),
828 Some("String")
829 );
830 assert_eq!(
831 unit.result.bindings[2].ty.label(api).as_deref(),
832 Some("int")
833 );
834 assert_eq!(
835 unit.result.bindings[3].ty.label(api).as_deref(),
836 Some("int")
837 );
838 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
839 }
840
841 #[test]
842 fn cyclic_extends_flags_each_cycle_member_and_terminates() {
843 use crate::infer::CYCLIC_INHERITANCE;
844 let mut db = RootDatabase::default();
845 db.set_file_text(FileId(0), "class_name A\nextends B\n", Durability::LOW);
848 db.set_file_text(FileId(1), "class_name B\nextends A\n", Durability::LOW);
849 db.set_file_text(
851 FileId(2),
852 "func use_it():\n\tvar a: A\n\tvar x := a.nope()\n",
853 Durability::LOW,
854 );
855 db.sync_source_root();
856
857 for id in [FileId(0), FileId(1)] {
859 let fi = analyze_file(&db, db.file_text(id).unwrap());
860 let cyclic: Vec<_> = fi
861 .diagnostics
862 .iter()
863 .filter(|d| d.code == CYCLIC_INHERITANCE)
864 .collect();
865 assert_eq!(cyclic.len(), 1, "file {id:?}: {:?}", fi.diagnostics);
866 }
867
868 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
871 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
872 }
873
874 #[test]
875 fn cyclic_extends_via_res_path_two_files_flags_no_hang() {
876 use crate::infer::CYCLIC_INHERITANCE;
877 let mut db = RootDatabase::default();
878 set_with_path(&mut db, 0, "res://a.gd", "extends \"res://b.gd\"\n");
880 set_with_path(&mut db, 1, "res://b.gd", "extends \"res://a.gd\"\n");
881 db.sync_source_root();
882
883 for id in [FileId(0), FileId(1)] {
884 let fi = analyze_file(&db, db.file_text(id).unwrap());
885 assert!(
886 fi.diagnostics.iter().any(|d| d.code == CYCLIC_INHERITANCE),
887 "file {id:?} expected CYCLIC_INHERITANCE: {:?}",
888 fi.diagnostics
889 );
890 }
891 }
892
893 #[test]
894 fn deep_acyclic_extends_chain_does_not_false_fire() {
895 use crate::infer::CYCLIC_INHERITANCE;
896 let mut db = RootDatabase::default();
897 db.set_file_text(FileId(0), "class_name C0\nextends C1\n", Durability::LOW);
900 db.set_file_text(FileId(1), "class_name C1\nextends C2\n", Durability::LOW);
901 db.set_file_text(FileId(2), "class_name C2\nextends C3\n", Durability::LOW);
902 db.set_file_text(FileId(3), "class_name C3\nextends C4\n", Durability::LOW);
903 db.set_file_text(FileId(4), "class_name C4\nextends Node\n", Durability::LOW);
904 db.sync_source_root();
905
906 for id in (0..5).map(FileId) {
907 let fi = analyze_file(&db, db.file_text(id).unwrap());
908 assert!(
909 !fi.diagnostics.iter().any(|d| d.code == CYCLIC_INHERITANCE),
910 "file {id:?} false-fired CYCLIC_INHERITANCE: {:?}",
911 fi.diagnostics
912 );
913 }
914 }
915
916 fn set_with_path(db: &mut RootDatabase, id: u32, path: &str, src: &str) {
920 db.set_file_text(FileId(id), src, Durability::LOW);
921 db.set_file_path(FileId(id), path);
922 }
923
924 #[test]
925 fn res_path_registry_maps_paths_to_files() {
926 let mut db = RootDatabase::default();
927 set_with_path(&mut db, 0, "res://a.gd", "class_name A\n");
928 set_with_path(&mut db, 1, "res://sub/b.gd", "func f():\n\tpass\n");
929 db.set_file_text(FileId(2), "func no_path():\n\tpass\n", Durability::LOW); db.sync_source_root();
931 let root = db.source_root().unwrap();
932
933 let reg = res_path_registry(&db, root);
934 assert_eq!(reg.get("res://a.gd"), Some(&FileId(0)));
935 assert_eq!(reg.get("res://sub/b.gd"), Some(&FileId(1)));
936 assert!(reg.get("res://missing.gd").is_none());
937 assert_eq!(reg.len(), 2);
939 }
940
941 static RES_REGISTRY_OBSERVED: AtomicU32 = AtomicU32::new(0);
946
947 #[salsa::tracked]
948 fn observe_res_registry(db: &dyn gdscript_db::Db, root: SourceRoot) -> usize {
949 RES_REGISTRY_OBSERVED.fetch_add(1, Ordering::SeqCst);
950 res_path_registry(db, root).len()
951 }
952
953 #[test]
954 fn body_edit_does_not_invalidate_the_res_path_registry() {
955 let mut db = RootDatabase::default();
956 set_with_path(&mut db, 0, "res://a.gd", "func f():\n\tvar a := 1\n");
957 db.sync_source_root();
958 let root = db.source_root().unwrap();
959
960 assert_eq!(observe_res_registry(&db, root), 1);
961 let runs = RES_REGISTRY_OBSERVED.load(Ordering::SeqCst);
962
963 db.set_file_text(FileId(0), "func f():\n\tvar a := 123456\n", Durability::LOW);
966
967 assert_eq!(observe_res_registry(&db, root), 1);
968 assert_eq!(
969 RES_REGISTRY_OBSERVED.load(Ordering::SeqCst),
970 runs,
971 "REGRESSION: a body edit re-ran a res_path_registry consumer — the path firewall broke",
972 );
973 }
974
975 #[test]
976 fn preload_const_resolves_to_script_ref_members() {
977 let mut db = RootDatabase::default();
978 set_with_path(
979 &mut db,
980 0,
981 "res://widget.gd",
982 "class_name Widget\nfunc make() -> int:\n\treturn 5\nconst MAX := 10\n",
983 );
984 set_with_path(
985 &mut db,
986 1,
987 "res://main.gd",
988 "const W = preload(\"res://widget.gd\")\nfunc use_it():\n\tvar a := W.make()\n\tvar b := W.new()\n",
989 );
990 db.sync_source_root();
991 let api = db.engine().unwrap();
992
993 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
994 let unit = fi
995 .units
996 .iter()
997 .find(|u| u.result.bindings.len() >= 2)
998 .expect("use_it unit with 2 bindings");
999 assert_eq!(
1001 unit.result.bindings[0].ty.label(api).as_deref(),
1002 Some("int")
1003 );
1004 assert!(
1005 matches!(unit.result.bindings[1].ty, Ty::ScriptRef(_)),
1006 "W.new() should be a script instance, got {:?}",
1007 unit.result.bindings[1].ty
1008 );
1009 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1010 }
1011
1012 #[test]
1013 fn cross_file_preload_const_member_resolves() {
1014 let mut db = RootDatabase::default();
1018 set_with_path(
1019 &mut db,
1020 0,
1021 "res://widget.gd",
1022 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1023 );
1024 set_with_path(
1025 &mut db,
1026 1,
1027 "res://holder.gd",
1028 "class_name Holder\nconst W = preload(\"res://widget.gd\")\n",
1029 );
1030 set_with_path(
1031 &mut db,
1032 2,
1033 "res://user.gd",
1034 "func use_it():\n\tvar a := Holder.W.make()\n",
1035 );
1036 db.sync_source_root();
1037 let api = db.engine().unwrap();
1038
1039 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1040 let unit = fi
1041 .units
1042 .iter()
1043 .find(|u| !u.result.bindings.is_empty())
1044 .expect("use_it unit");
1045 assert_eq!(
1047 unit.result.bindings[0].ty.label(api).as_deref(),
1048 Some("int"),
1049 "Holder.W.make() should resolve cross-file to int, got {:?}",
1050 unit.result.bindings[0].ty
1051 );
1052 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1053 }
1054
1055 #[test]
1056 fn preload_of_script_without_class_name_resolves() {
1057 let mut db = RootDatabase::default();
1060 set_with_path(
1061 &mut db,
1062 0,
1063 "res://helper.gd",
1064 "func help() -> String:\n\treturn \"x\"\n",
1065 );
1066 set_with_path(
1067 &mut db,
1068 1,
1069 "res://main.gd",
1070 "func use_it():\n\tvar h := preload(\"res://helper.gd\")\n\tvar s := h.help()\n",
1071 );
1072 db.sync_source_root();
1073 let api = db.engine().unwrap();
1074
1075 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1076 let unit = fi
1077 .units
1078 .iter()
1079 .find(|u| u.result.bindings.len() >= 2)
1080 .expect("use_it unit");
1081 assert!(
1082 matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
1083 "preload of a class_name-less script must still resolve: {:?}",
1084 unit.result.bindings[0].ty
1085 );
1086 assert_eq!(
1087 unit.result.bindings[1].ty.label(api).as_deref(),
1088 Some("String")
1089 );
1090 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1091 }
1092
1093 #[test]
1094 fn extends_res_path_inherits_members() {
1095 let mut db = RootDatabase::default();
1096 set_with_path(
1098 &mut db,
1099 0,
1100 "res://base.gd",
1101 "extends Node\nfunc base_method() -> int:\n\treturn 1\n",
1102 );
1103 set_with_path(
1104 &mut db,
1105 1,
1106 "res://derived.gd",
1107 "class_name Derived\nextends \"res://base.gd\"\nfunc own() -> String:\n\treturn \"x\"\n",
1108 );
1109 set_with_path(
1110 &mut db,
1111 2,
1112 "res://main.gd",
1113 "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",
1114 );
1115 db.sync_source_root();
1116 let api = db.engine().unwrap();
1117
1118 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1119 let unit = fi
1120 .units
1121 .iter()
1122 .find(|u| u.result.bindings.len() >= 4)
1123 .expect("use_it unit with 4 bindings");
1124 assert_eq!(
1127 unit.result.bindings[1].ty.label(api).as_deref(),
1128 Some("String")
1129 );
1130 assert_eq!(
1131 unit.result.bindings[2].ty.label(api).as_deref(),
1132 Some("int")
1133 );
1134 assert_eq!(
1135 unit.result.bindings[3].ty.label(api).as_deref(),
1136 Some("int")
1137 );
1138 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1139 }
1140
1141 #[test]
1142 fn relative_extends_path_anchors_to_importing_dir() {
1143 let mut db = RootDatabase::default();
1144 set_with_path(
1146 &mut db,
1147 0,
1148 "res://entities/base.gd",
1149 "extends Node\nfunc base_method() -> int:\n\treturn 1\n",
1150 );
1151 set_with_path(
1153 &mut db,
1154 1,
1155 "res://entities/derived.gd",
1156 "class_name Derived\nextends \"base.gd\"\nfunc own() -> String:\n\treturn \"x\"\n",
1157 );
1158 set_with_path(
1159 &mut db,
1160 2,
1161 "res://main.gd",
1162 "func use_it():\n\tvar d: Derived\n\tvar a := d.own()\n\tvar b := d.base_method()\n",
1163 );
1164 db.sync_source_root();
1165 let api = db.engine().unwrap();
1166 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1167 let unit = fi
1168 .units
1169 .iter()
1170 .find(|u| u.result.bindings.len() >= 3)
1171 .expect("use_it unit with 3 bindings (d, a, b)");
1172 assert_eq!(
1174 unit.result.bindings[1].ty.label(api).as_deref(),
1175 Some("String")
1176 );
1177 assert_eq!(
1178 unit.result.bindings[2].ty.label(api).as_deref(),
1179 Some("int"),
1180 "base_method() must resolve through the relative `extends \"base.gd\"`"
1181 );
1182 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1183 }
1184
1185 #[test]
1186 fn dangling_preload_is_seam_not_panic() {
1187 let mut db = RootDatabase::default();
1188 set_with_path(
1189 &mut db,
1190 0,
1191 "res://main.gd",
1192 "func use_it():\n\tvar x := preload(\"res://does_not_exist.gd\")\n\tx.whatever()\n",
1193 );
1194 db.sync_source_root();
1195 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1197 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1198 }
1199
1200 #[test]
1201 fn non_gd_preload_resource_stays_seam() {
1202 let mut db = RootDatabase::default();
1207 set_with_path(&mut db, 0, "res://scene.tscn", "class_name SceneRoot\n");
1208 set_with_path(
1209 &mut db,
1210 1,
1211 "res://main.gd",
1212 "func f():\n\tvar s := preload(\"res://scene.tscn\")\n",
1213 );
1214 db.sync_source_root();
1215
1216 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1217 let unit = fi
1218 .units
1219 .iter()
1220 .find(|u| !u.result.bindings.is_empty())
1221 .expect("f unit");
1222 assert!(
1223 !matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
1224 "a non-.gd preload must stay the seam, got {:?}",
1225 unit.result.bindings[0].ty
1226 );
1227 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1228 }
1229
1230 #[test]
1231 fn load_literal_stays_opaque_not_aliased_to_preload() {
1232 let mut db = RootDatabase::default();
1233 set_with_path(
1234 &mut db,
1235 0,
1236 "res://widget.gd",
1237 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1238 );
1239 set_with_path(
1240 &mut db,
1241 1,
1242 "res://main.gd",
1243 "func use_it():\n\tvar w := load(\"res://widget.gd\")\n",
1244 );
1245 db.sync_source_root();
1246
1247 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1248 let unit = fi
1249 .units
1250 .iter()
1251 .find(|u| !u.result.bindings.is_empty())
1252 .expect("use_it unit");
1253 assert!(
1256 !matches!(unit.result.bindings[0].ty, Ty::ScriptRef(_)),
1257 "load() must stay opaque, not alias preload: {:?}",
1258 unit.result.bindings[0].ty
1259 );
1260 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1261 }
1262
1263 #[test]
1264 fn is_narrows_to_a_user_class_cross_file() {
1265 let mut db = RootDatabase::default();
1270 db.set_file_text(
1271 FileId(0),
1272 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1273 Durability::LOW,
1274 );
1275 db.set_file_text(
1276 FileId(1),
1277 "func use_it(x):\n\tif x is Widget:\n\t\tvar n := x.make()\n",
1278 Durability::LOW,
1279 );
1280 db.sync_source_root();
1281 let api = db.engine().unwrap();
1282
1283 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1284 assert!(
1286 fi.units
1287 .iter()
1288 .flat_map(|u| &u.result.bindings)
1289 .any(|b| b.ty.label(api).as_deref() == Some("int")),
1290 "`x.make()` after `is Widget` should narrow + resolve to int",
1291 );
1292 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1293 }
1294
1295 #[test]
1296 fn as_casts_to_a_user_class_cross_file() {
1297 let mut db = RootDatabase::default();
1299 db.set_file_text(
1300 FileId(0),
1301 "class_name Widget\nfunc make() -> int:\n\treturn 5\n",
1302 Durability::LOW,
1303 );
1304 db.set_file_text(
1305 FileId(1),
1306 "func use_it(x):\n\tvar n := (x as Widget).make()\n",
1307 Durability::LOW,
1308 );
1309 db.sync_source_root();
1310 let api = db.engine().unwrap();
1311
1312 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1313 assert!(
1314 fi.units
1315 .iter()
1316 .flat_map(|u| &u.result.bindings)
1317 .any(|b| b.ty.label(api).as_deref() == Some("int")),
1318 "`(x as Widget).make()` should resolve to int",
1319 );
1320 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1321 }
1322
1323 #[test]
1324 fn renaming_a_files_path_reindexes_the_registry() {
1325 let mut db = RootDatabase::default();
1327 set_with_path(&mut db, 0, "res://old.gd", "class_name A\n");
1328 db.sync_source_root();
1329 let root = db.source_root().unwrap();
1330 assert_eq!(
1331 res_path_registry(&db, root).get("res://old.gd"),
1332 Some(&FileId(0))
1333 );
1334
1335 db.set_file_path(FileId(0), "res://new.gd");
1336 let root = db.source_root().unwrap();
1337 let reg = res_path_registry(&db, root);
1338 assert_eq!(reg.get("res://new.gd"), Some(&FileId(0)));
1339 assert!(reg.get("res://old.gd").is_none());
1340 }
1341
1342 #[test]
1345 fn star_autoload_scene_resolves_via_its_root_script() {
1346 let mut db = RootDatabase::default();
1350 db.set_file_text(
1352 FileId(0),
1353 "func volume() -> int:\n\treturn 5\n",
1354 Durability::LOW,
1355 );
1356 db.set_file_path(FileId(0), "res://music.gd");
1357 db.set_file_text(
1359 FileId(1),
1360 "[gd_scene format=3]\n\
1361 [ext_resource type=\"Script\" path=\"res://music.gd\" id=\"1\"]\n\
1362 [node name=\"Music\" type=\"Node\"]\n\
1363 script = ExtResource(\"1\")\n",
1364 Durability::LOW,
1365 );
1366 db.set_file_path(FileId(1), "res://music.tscn");
1367 db.set_file_text(
1368 FileId(2),
1369 "func f():\n\tvar v := Music.volume()\n",
1370 Durability::LOW,
1371 );
1372 db.set_file_path(FileId(2), "res://main.gd");
1373 db.set_project_config("[autoload]\nMusic=\"*res://music.tscn\"\n");
1374 db.sync_source_root();
1375 let api = db.engine().unwrap();
1376
1377 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1378 let unit = fi
1379 .units
1380 .iter()
1381 .find(|u| !u.result.bindings.is_empty())
1382 .expect("f unit");
1383 assert_eq!(
1384 unit.result.bindings[0].ty.label(api).as_deref(),
1385 Some("int"),
1386 "Music.volume() should resolve via the scene root's script",
1387 );
1388 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1389 }
1390
1391 #[test]
1392 fn star_autoload_scene_resolves_via_script_class_shortcut() {
1393 let mut db = RootDatabase::default();
1398 db.set_file_text(
1399 FileId(0),
1400 "class_name MusicPlayer\nfunc volume() -> int:\n\treturn 5\n",
1401 Durability::LOW,
1402 );
1403 db.set_file_path(FileId(0), "res://music.gd");
1404 db.set_file_text(
1405 FileId(1),
1406 "[gd_scene format=3 script_class=\"MusicPlayer\"]\n[node name=\"Root\" type=\"Node\"]\n",
1407 Durability::LOW,
1408 );
1409 db.set_file_path(FileId(1), "res://music.tscn");
1410 db.set_file_text(
1411 FileId(2),
1412 "func f():\n\tvar v := Audio.volume()\n",
1413 Durability::LOW,
1414 );
1415 db.set_file_path(FileId(2), "res://main.gd");
1416 db.set_project_config("[autoload]\nAudio=\"*res://music.tscn\"\n");
1417 db.sync_source_root();
1418 let api = db.engine().unwrap();
1419
1420 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1421 let unit = fi
1422 .units
1423 .iter()
1424 .find(|u| !u.result.bindings.is_empty())
1425 .expect("f unit");
1426 assert_eq!(
1427 unit.result.bindings[0].ty.label(api).as_deref(),
1428 Some("int"),
1429 "Audio.volume() should resolve via the scene's script_class= shortcut",
1430 );
1431 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1432 }
1433
1434 #[test]
1435 fn engine_version_from_project_config_is_firewalled_against_body_edits() {
1436 let mut db = RootDatabase::default();
1437 db.set_file_text(FileId(0), "func f():\n\tpass\n", Durability::LOW);
1438 db.set_file_path(FileId(0), "res://main.gd");
1439 db.set_project_config("[application]\nconfig/features=PackedStringArray(\"4.6\")\n");
1440 db.sync_source_root();
1441 assert_eq!(project_engine_version(&db), Some((4, 6)));
1442
1443 db.set_file_text(FileId(0), "func f():\n\tvar x := 1\n", Durability::LOW);
1446 db.sync_source_root();
1447 assert_eq!(project_engine_version(&db), Some((4, 6)));
1448
1449 let empty = RootDatabase::default();
1451 assert_eq!(project_engine_version(&empty), None);
1452 }
1453
1454 #[test]
1455 fn star_autoload_gdscript_resolves_as_global_and_members() {
1456 let mut db = RootDatabase::default();
1457 db.set_file_text(
1459 FileId(0),
1460 "func score() -> int:\n\treturn 0\n",
1461 Durability::LOW,
1462 );
1463 db.set_file_path(FileId(0), "res://game.gd");
1464 db.set_file_text(
1465 FileId(1),
1466 "func f():\n\tvar s := Game.score()\n",
1467 Durability::LOW,
1468 );
1469 db.set_file_path(FileId(1), "res://main.gd");
1470 db.set_project_config("[autoload]\nGame=\"*res://game.gd\"\n");
1471 db.sync_source_root();
1472 let api = db.engine().unwrap();
1473
1474 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1475 let unit = fi
1476 .units
1477 .iter()
1478 .find(|u| !u.result.bindings.is_empty())
1479 .expect("f unit");
1480 assert_eq!(
1482 unit.result.bindings[0].ty.label(api).as_deref(),
1483 Some("int")
1484 );
1485 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1486 }
1487
1488 #[test]
1489 fn non_star_autoload_is_not_a_global() {
1490 let mut db = RootDatabase::default();
1491 db.set_file_text(
1492 FileId(0),
1493 "func score() -> int:\n\treturn 0\n",
1494 Durability::LOW,
1495 );
1496 db.set_file_path(FileId(0), "res://game.gd");
1497 db.set_file_text(
1498 FileId(1),
1499 "func f():\n\tvar s := Game.score()\n",
1500 Durability::LOW,
1501 );
1502 db.set_file_path(FileId(1), "res://main.gd");
1503 db.set_project_config("[autoload]\nGame=\"res://game.gd\"\n");
1505 db.sync_source_root();
1506 let api = db.engine().unwrap();
1507
1508 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1509 let unit = fi
1510 .units
1511 .iter()
1512 .find(|u| !u.result.bindings.is_empty())
1513 .expect("f unit");
1514 assert_eq!(unit.result.bindings[0].ty.label(api), None);
1516 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1517 }
1518
1519 #[test]
1520 fn tscn_autoload_is_the_seam_never_false_warns() {
1521 let mut db = RootDatabase::default();
1522 db.set_file_text(FileId(0), "func f():\n\tHud.play_song()\n", Durability::LOW);
1525 db.set_file_path(FileId(0), "res://main.gd");
1526 db.set_project_config("[autoload]\nHud=\"*res://hud.tscn\"\n");
1527 db.sync_source_root();
1528
1529 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1530 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1532 }
1533
1534 static AUTOLOAD_OBSERVED: AtomicU32 = AtomicU32::new(0);
1538
1539 #[salsa::tracked]
1540 fn observe_autoload_registry(db: &dyn gdscript_db::Db, config: ProjectConfig) -> usize {
1541 AUTOLOAD_OBSERVED.fetch_add(1, Ordering::SeqCst);
1542 autoload_registry(db, config).len()
1543 }
1544
1545 #[test]
1546 fn autoload_registry_firewalled_against_body_edits() {
1547 let mut db = RootDatabase::default();
1548 db.set_file_text(FileId(0), "func f():\n\tvar a := 1\n", Durability::LOW);
1549 db.set_file_path(FileId(0), "res://game.gd");
1550 db.set_project_config("[autoload]\nGame=\"*res://game.gd\"\n");
1551 db.sync_source_root();
1552 let config = db.project_config().unwrap();
1553
1554 assert_eq!(observe_autoload_registry(&db, config), 1);
1555 let runs = AUTOLOAD_OBSERVED.load(Ordering::SeqCst);
1556
1557 db.set_file_text(FileId(0), "func f():\n\tvar a := 999999\n", Durability::LOW);
1560
1561 assert_eq!(observe_autoload_registry(&db, config), 1);
1562 assert_eq!(
1563 AUTOLOAD_OBSERVED.load(Ordering::SeqCst),
1564 runs,
1565 "REGRESSION: a body edit re-ran an autoload_registry consumer — the config firewall broke",
1566 );
1567 }
1568
1569 static ANALYZE_OBSERVED: AtomicU32 = AtomicU32::new(0);
1575
1576 #[salsa::tracked]
1577 fn observe_analyze_file(db: &dyn gdscript_db::Db, file: FileText) -> usize {
1578 ANALYZE_OBSERVED.fetch_add(1, Ordering::SeqCst);
1579 analyze_file(db, file).raw_warnings.len()
1580 }
1581
1582 #[test]
1583 fn warning_level_edit_does_not_invalidate_analyze_file() {
1584 use crate::warnings::{WarnLevel, WarningCode};
1585
1586 let mut db = RootDatabase::default();
1587 db.set_file_text(
1588 FileId(0),
1589 "func f():\n\tvar x = 5 / 2\n\treturn x\n",
1590 Durability::LOW,
1591 );
1592 db.set_file_path(FileId(0), "res://game.gd");
1593 db.set_project_config(
1594 "[autoload]\nGame=\"*res://game.gd\"\n[debug]\ngdscript/warnings/integer_division=2\n",
1595 );
1596 db.sync_source_root();
1597 let file = db.file_text(FileId(0)).unwrap();
1598 let config = db.project_config().unwrap();
1599
1600 assert_eq!(observe_analyze_file(&db, file), 1);
1602 let runs = ANALYZE_OBSERVED.load(Ordering::SeqCst);
1603 assert_eq!(
1604 warning_settings(&db, config)
1605 .per_code
1606 .get(&WarningCode::IntegerDivision),
1607 Some(&WarnLevel::Error),
1608 );
1609
1610 db.set_project_config(
1614 "[autoload]\nGame=\"*res://game.gd\"\n[debug]\ngdscript/warnings/integer_division=1\n",
1615 );
1616 assert_eq!(observe_analyze_file(&db, file), 1);
1617 assert_eq!(
1618 ANALYZE_OBSERVED.load(Ordering::SeqCst),
1619 runs,
1620 "REGRESSION: a warning-level edit re-ran analyze_file — the W1 gating firewall broke",
1621 );
1622 assert_eq!(
1624 warning_settings(&db, config)
1625 .per_code
1626 .get(&WarningCode::IntegerDivision),
1627 Some(&WarnLevel::Warn),
1628 );
1629 }
1630
1631 #[test]
1632 fn aliased_self_resolves_own_members_no_false_unsafe() {
1633 let mut db = RootDatabase::default();
1637 db.set_file_text(
1638 FileId(0),
1639 "extends Node\nfunc own() -> int:\n\treturn 1\nfunc use_it():\n\tvar me := self\n\tvar n := me.own()\n",
1640 Durability::LOW,
1641 );
1642 db.sync_source_root();
1643 let api = db.engine().unwrap();
1644
1645 let fi = analyze_file(&db, db.file_text(FileId(0)).unwrap());
1646 assert!(
1648 fi.units
1649 .iter()
1650 .flat_map(|u| &u.result.bindings)
1651 .any(|b| b.ty.label(api).as_deref() == Some("int")),
1652 "aliased self.own() should resolve to int",
1653 );
1654 assert!(
1655 fi.diagnostics.is_empty(),
1656 "no false UNSAFE on aliased self: {:?}",
1657 fi.diagnostics
1658 );
1659 }
1660
1661 #[test]
1662 fn is_userbase_narrows_to_derived_but_not_un_narrowed_to_base() {
1663 let mut db = RootDatabase::default();
1664 db.set_file_text(
1665 FileId(0),
1666 "class_name Base\nfunc base_m() -> int:\n\treturn 1\n",
1667 Durability::LOW,
1668 );
1669 db.set_file_text(
1670 FileId(1),
1671 "class_name Derived\nextends Base\nfunc own_m() -> String:\n\treturn \"x\"\n",
1672 Durability::LOW,
1673 );
1674 db.set_file_text(
1677 FileId(2),
1678 "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",
1679 Durability::LOW,
1680 );
1681 db.sync_source_root();
1682 let api = db.engine().unwrap();
1683
1684 let fi = analyze_file(&db, db.file_text(FileId(2)).unwrap());
1685 let strings = fi
1686 .units
1687 .iter()
1688 .flat_map(|u| &u.result.bindings)
1689 .filter(|b| b.ty.label(api).as_deref() == Some("String"))
1690 .count();
1691 assert!(
1693 strings >= 2,
1694 "expected both own_m() calls to type as String (narrow-down + widen-only), got {strings}",
1695 );
1696 assert!(fi.diagnostics.is_empty(), "diags: {:?}", fi.diagnostics);
1697 }
1698
1699 fn scene_db(scene_text: &str, gd_text: &str) -> RootDatabase {
1703 let mut db = RootDatabase::default();
1704 db.set_file_text(FileId(0), scene_text, Durability::LOW);
1705 db.set_file_path(FileId(0), "res://main.tscn");
1706 db.set_file_text(FileId(1), gd_text, Durability::LOW);
1707 db.set_file_path(FileId(1), "res://main.gd");
1708 db.sync_source_root();
1709 db
1710 }
1711
1712 fn binding_labels(db: &RootDatabase) -> Vec<String> {
1713 let api = db.engine().unwrap();
1714 let fi = analyze_file(db, db.file_text(FileId(1)).unwrap());
1715 assert!(
1716 fi.diagnostics.is_empty(),
1717 "unexpected diags: {:?}",
1718 fi.diagnostics
1719 );
1720 fi.units
1721 .iter()
1722 .flat_map(|u| &u.result.bindings)
1723 .filter_map(|b| b.ty.label(api))
1724 .collect()
1725 }
1726
1727 const SCENE: &str = "[gd_scene format=3]\n\
1728 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1729 [node name=\"Root\" type=\"Control\"]\n\
1730 script = ExtResource(\"1\")\n\
1731 [node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
1732 [node name=\"Box\" type=\"VBoxContainer\" parent=\"Panel\"]\n\
1733 [node name=\"Btn\" type=\"Button\" parent=\"Panel/Box\"]\n\
1734 unique_name_in_owner = true\n";
1735
1736 #[test]
1737 fn dollar_path_types_to_the_concrete_node() {
1738 let db = scene_db(
1740 SCENE,
1741 "extends Control\nfunc _ready():\n\tvar b := $Panel/Box/Btn\n",
1742 );
1743 assert!(
1744 binding_labels(&db).iter().any(|l| l == "Button"),
1745 "$Panel/Box/Btn should type as Button",
1746 );
1747 }
1748
1749 #[test]
1750 fn unique_name_path_types_to_the_concrete_node() {
1751 let db = scene_db(SCENE, "extends Control\nfunc _ready():\n\tvar b := %Btn\n");
1753 assert!(
1754 binding_labels(&db).iter().any(|l| l == "Button"),
1755 "%Btn should type as Button"
1756 );
1757 }
1758
1759 #[test]
1760 fn onready_var_from_a_node_path_is_typed() {
1761 let db = scene_db(
1764 SCENE,
1765 "extends Control\n@onready var btn := $Panel/Box/Btn\n",
1766 );
1767 assert!(
1768 binding_labels(&db).iter().any(|l| l == "Button"),
1769 "@onready var := $Path should type to Button",
1770 );
1771 }
1772
1773 #[test]
1774 fn get_node_string_literal_types_like_dollar() {
1775 let db = scene_db(
1777 SCENE,
1778 "extends Control\nfunc _ready():\n\tvar b := get_node(\"Panel/Box/Btn\")\n",
1779 );
1780 assert!(
1781 binding_labels(&db).iter().any(|l| l == "Button"),
1782 "get_node(\"...\") should type as Button",
1783 );
1784 }
1785
1786 #[test]
1787 fn self_get_node_string_literal_types_like_dollar() {
1788 let db = scene_db(
1791 SCENE,
1792 "extends Control\nfunc _ready():\n\tvar b := self.get_node(\"Panel/Box/Btn\")\n",
1793 );
1794 assert!(
1795 binding_labels(&db).iter().any(|l| l == "Button"),
1796 "self.get_node(\"...\") should type as Button",
1797 );
1798 }
1799
1800 #[test]
1801 fn attached_script_refines_the_node_type() {
1802 let mut db = RootDatabase::default();
1805 db.set_file_text(
1806 FileId(0),
1807 "[gd_scene format=3]\n\
1808 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1809 [ext_resource type=\"Script\" path=\"res://fancy.gd\" id=\"2\"]\n\
1810 [node name=\"Root\" type=\"Control\"]\n\
1811 script = ExtResource(\"1\")\n\
1812 [node name=\"That\" type=\"Button\" parent=\".\"]\n\
1813 script = ExtResource(\"2\")\n",
1814 Durability::LOW,
1815 );
1816 db.set_file_path(FileId(0), "res://main.tscn");
1817 db.set_file_text(
1818 FileId(1),
1819 "extends Control\nfunc _ready():\n\tvar n := $That.fancy()\n",
1820 Durability::LOW,
1821 );
1822 db.set_file_path(FileId(1), "res://main.gd");
1823 db.set_file_text(
1824 FileId(2),
1825 "class_name Fancy\nextends Button\nfunc fancy() -> int:\n\treturn 1\n",
1826 Durability::LOW,
1827 );
1828 db.set_file_path(FileId(2), "res://fancy.gd");
1829 db.sync_source_root();
1830 assert!(
1831 binding_labels(&db).iter().any(|l| l == "int"),
1832 "$That.fancy() should resolve via the attached script Fancy",
1833 );
1834 }
1835
1836 #[test]
1837 fn computed_or_unresolvable_node_path_stays_node_without_warning() {
1838 let mut db = RootDatabase::default();
1841 db.set_file_text(
1842 FileId(1),
1843 "extends Node\nfunc f(p: NodePath):\n\tvar a := get_node(p)\n\tvar b := $Nope\n",
1847 Durability::LOW,
1848 );
1849 db.set_file_path(FileId(1), "res://lone.gd");
1850 db.sync_source_root();
1851 let fi = analyze_file(&db, db.file_text(FileId(1)).unwrap());
1852 assert!(
1853 fi.diagnostics.is_empty(),
1854 "no false node-path warnings: {:?}",
1855 fi.diagnostics
1856 );
1857 }
1858
1859 fn has_invalid_node_path(db: &RootDatabase) -> bool {
1862 let fi = analyze_file(db, db.file_text(FileId(1)).unwrap());
1863 fi.diagnostics
1864 .iter()
1865 .any(|d| d.code == crate::infer::INVALID_NODE_PATH)
1866 }
1867
1868 #[test]
1869 fn invalid_node_path_warns_when_genuinely_absent_in_a_single_owning_scene() {
1870 let db = scene_db(SCENE, "extends Control\nfunc _ready():\n\tvar b := $Nope\n");
1871 assert!(
1872 has_invalid_node_path(&db),
1873 "$Nope is absent in the one owning scene → warn"
1874 );
1875 }
1876
1877 #[test]
1878 fn escape_and_absolute_paths_never_warn() {
1879 let db = scene_db(
1881 SCENE,
1882 "extends Control\nfunc _ready():\n\tvar a := $\"../Sibling\"\n\tvar c := $\"/root/Global\"\n",
1883 );
1884 assert!(!has_invalid_node_path(&db), "escape paths must not warn");
1885 }
1886
1887 #[test]
1888 fn path_descending_into_an_instanced_subscene_never_warns() {
1889 let db = scene_db(
1892 "[gd_scene format=3]\n\
1893 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1894 [ext_resource type=\"PackedScene\" path=\"res://player.tscn\" id=\"2\"]\n\
1895 [node name=\"Root\" type=\"Control\"]\n\
1896 script = ExtResource(\"1\")\n\
1897 [node name=\"Player\" parent=\".\" instance=ExtResource(\"2\")]\n",
1898 "extends Control\nfunc _ready():\n\tvar g := $Player/Gun\n",
1899 );
1900 assert!(
1901 !has_invalid_node_path(&db),
1902 "into-instance miss must not warn"
1903 );
1904 }
1905
1906 #[test]
1907 fn ambiguous_multi_scene_attachment_suppresses_the_invalid_warning() {
1908 let mut db = RootDatabase::default();
1911 db.set_file_text(
1912 FileId(0),
1913 "[gd_scene format=3]\n\
1914 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1915 [node name=\"Root\" type=\"Control\"]\n\
1916 script = ExtResource(\"1\")\n\
1917 [node name=\"Alpha\" type=\"Button\" parent=\".\"]\n",
1918 Durability::LOW,
1919 );
1920 db.set_file_path(FileId(0), "res://a.tscn");
1921 db.set_file_text(
1922 FileId(2),
1923 "[gd_scene format=3]\n\
1924 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1925 [node name=\"Root\" type=\"Control\"]\n\
1926 script = ExtResource(\"1\")\n\
1927 [node name=\"Beta\" type=\"Button\" parent=\".\"]\n",
1928 Durability::LOW,
1929 );
1930 db.set_file_path(FileId(2), "res://b.tscn");
1931 db.set_file_text(
1932 FileId(1),
1933 "extends Control\nfunc _ready():\n\tvar b := $Beta\n",
1934 Durability::LOW,
1935 );
1936 db.set_file_path(FileId(1), "res://main.gd");
1937 db.sync_source_root();
1938 assert!(
1939 !has_invalid_node_path(&db),
1940 "ambiguous multi-scene attachment must not warn"
1941 );
1942 }
1943
1944 #[test]
1947 fn instanced_node_recurses_into_the_subscene_root_script() {
1948 let mut db = RootDatabase::default();
1953 db.set_file_text(
1954 FileId(0),
1955 "[gd_scene format=3]\n\
1956 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
1957 [ext_resource type=\"PackedScene\" path=\"res://enemy.tscn\" id=\"2\"]\n\
1958 [node name=\"Root\" type=\"Control\"]\n\
1959 script = ExtResource(\"1\")\n\
1960 [node name=\"Enemy\" parent=\".\" instance=ExtResource(\"2\")]\n",
1961 Durability::LOW,
1962 );
1963 db.set_file_path(FileId(0), "res://main.tscn");
1964 db.set_file_text(
1965 FileId(1),
1966 "extends Control\nfunc _ready():\n\tvar e := $Enemy.hp()\n",
1967 Durability::LOW,
1968 );
1969 db.set_file_path(FileId(1), "res://main.gd");
1970 db.set_file_text(
1971 FileId(2),
1972 "[gd_scene format=3]\n\
1973 [ext_resource type=\"Script\" path=\"res://enemy.gd\" id=\"1\"]\n\
1974 [node name=\"Enemy\" type=\"Button\"]\n\
1975 script = ExtResource(\"1\")\n",
1976 Durability::LOW,
1977 );
1978 db.set_file_path(FileId(2), "res://enemy.tscn");
1979 db.set_file_text(
1980 FileId(3),
1981 "class_name Enemy\nextends Button\nfunc hp() -> int:\n\treturn 1\n",
1982 Durability::LOW,
1983 );
1984 db.set_file_path(FileId(3), "res://enemy.gd");
1985 db.sync_source_root();
1986 assert!(
1987 binding_labels(&db).iter().any(|l| l == "int"),
1988 "$Enemy.hp() should recurse into the instanced sub-scene root's script Enemy",
1989 );
1990 }
1991
1992 #[test]
1993 fn path_into_an_instanced_subscene_types_the_inner_node() {
1994 let mut db = RootDatabase::default();
2000 db.set_file_text(
2001 FileId(0),
2002 "[gd_scene format=3]\n\
2003 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
2004 [ext_resource type=\"PackedScene\" path=\"res://enemy.tscn\" id=\"2\"]\n\
2005 [node name=\"Root\" type=\"Control\"]\n\
2006 script = ExtResource(\"1\")\n\
2007 [node name=\"Enemy\" parent=\".\" instance=ExtResource(\"2\")]\n",
2008 Durability::LOW,
2009 );
2010 db.set_file_path(FileId(0), "res://main.tscn");
2011 db.set_file_text(
2012 FileId(1),
2013 "extends Control\nfunc _ready():\n\tvar s := $Enemy/Sprite\n\tvar n := $Enemy/Nope\n",
2014 Durability::LOW,
2015 );
2016 db.set_file_path(FileId(1), "res://main.gd");
2017 db.set_file_text(
2018 FileId(2),
2019 "[gd_scene format=3]\n\
2020 [node name=\"Enemy\" type=\"Node2D\"]\n\
2021 [node name=\"Sprite\" type=\"Sprite2D\" parent=\".\"]\n",
2022 Durability::LOW,
2023 );
2024 db.set_file_path(FileId(2), "res://enemy.tscn");
2025 db.sync_source_root();
2026 assert!(
2027 binding_labels(&db).iter().any(|l| l == "Sprite2D"),
2028 "$Enemy/Sprite should type as the sub-scene's Sprite (Sprite2D)",
2029 );
2030 }
2031
2032 #[test]
2035 fn unique_name_subpath_resolves_to_the_child_without_warning() {
2036 let db = scene_db(
2040 "[gd_scene format=3]\n\
2041 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
2042 [node name=\"Root\" type=\"Control\"]\n\
2043 script = ExtResource(\"1\")\n\
2044 [node name=\"Box\" type=\"VBoxContainer\" parent=\".\"]\n\
2045 unique_name_in_owner = true\n\
2046 [node name=\"Btn\" type=\"Button\" parent=\"Box\"]\n",
2047 "extends Control\nfunc _ready():\n\tvar b := %Box/Btn\n",
2048 );
2049 assert!(
2050 binding_labels(&db).iter().any(|l| l == "Button"),
2051 "%Box/Btn → Button (and no false INVALID_NODE_PATH)",
2052 );
2053 }
2054
2055 #[test]
2056 fn percent_prefixed_string_paths_resolve_as_unique_without_warning() {
2057 let db = scene_db(
2060 SCENE,
2061 "extends Control\nfunc _ready():\n\tvar a := get_node(\"%Btn\")\n\tvar b := $\"%Btn\"\n",
2062 );
2063 let labels = binding_labels(&db);
2064 assert!(
2065 labels.iter().filter(|l| *l == "Button").count() >= 2,
2066 "both %Btn string forms should resolve to Button: {labels:?}",
2067 );
2068 }
2069}