1#![cfg_attr(docsrs, feature(doc_cfg))]
21#![deny(missing_docs)]
22
23use std::sync::Arc;
24
25use gdscript_base::{
26 Cancellable, CodeAction, CompletionItem, Diagnostic, DocumentSymbol, FileId, FilePosition,
27 FoldRange, HoverResult, InlayHint, SignatureHelp,
28};
29use gdscript_db::{Db, RootDatabase};
30use salsa::Durability;
31
32pub use gdscript_db::WarningOverride;
35
36mod features;
37mod navigation;
38mod semantic;
39mod semantic_tokens;
40
41fn catch<T>(f: impl FnOnce() -> T) -> Cancellable<T> {
46 salsa::Cancelled::catch(std::panic::AssertUnwindSafe(f)).map_err(|_| gdscript_base::Cancelled)
47}
48
49#[derive(Debug, Clone, Default)]
54pub struct AnalysisHost {
55 db: RootDatabase,
56}
57
58#[derive(Debug, Default)]
60pub struct Change {
61 pub files: Vec<(FileId, Option<Arc<str>>)>,
63 pub paths: Vec<(FileId, String)>,
68 pub project_config: Option<Arc<str>>,
71}
72
73impl Change {
74 #[must_use]
76 pub fn new() -> Self {
77 Self::default()
78 }
79
80 pub fn change_file(&mut self, file: FileId, text: impl Into<Arc<str>>) {
82 self.files.push((file, Some(text.into())));
83 }
84
85 pub fn remove_file(&mut self, file: FileId) {
87 self.files.push((file, None));
88 }
89
90 pub fn set_file_path(&mut self, file: FileId, path: impl Into<String>) {
93 self.paths.push((file, path.into()));
94 }
95
96 pub fn set_project_config(&mut self, text: impl Into<Arc<str>>) {
99 self.project_config = Some(text.into());
100 }
101}
102
103impl AnalysisHost {
104 #[must_use]
106 pub fn new() -> Self {
107 Self::default()
108 }
109
110 pub fn apply_change(&mut self, change: Change) {
114 let mut structure_changed = false;
115 for (id, text) in change.files {
116 if let Some(t) = text {
117 structure_changed |= self.db.file_text(id).is_none();
119 self.db.set_file_text(id, &t, Durability::LOW);
120 } else {
121 structure_changed |= self.db.file_text(id).is_some();
122 self.db.remove_file(id);
123 }
124 }
125 for (id, path) in change.paths {
129 self.db.set_file_path(id, &path);
130 }
131 if let Some(text) = change.project_config {
134 self.db.set_project_config(&text);
135 }
136 if structure_changed {
139 self.db.sync_source_root();
140 }
141 }
142
143 pub fn set_engine_api(&mut self, bytes: &[u8]) -> bool {
151 match gdscript_api::EngineApi::from_bytes(bytes) {
152 Ok(api) => {
153 self.db.set_engine_api(api);
154 true
155 }
156 Err(_) => false,
157 }
158 }
159
160 pub fn set_warning_override(&mut self, ov: gdscript_db::WarningOverride) {
164 self.db.set_warning_override(ov);
165 }
166
167 #[must_use]
169 pub fn analysis(&self) -> Analysis {
170 Analysis {
171 db: self.db.clone(),
172 }
173 }
174}
175
176#[derive(Debug, Clone)]
180pub struct Analysis {
181 db: RootDatabase,
182}
183
184impl Analysis {
185 pub fn syntax_tree(&self, file: FileId) -> Cancellable<Option<String>> {
192 catch(|| {
193 self.db
194 .file_text(file)
195 .map(|ft| gdscript_db::parse(&self.db, ft).debug_tree())
196 })
197 }
198
199 pub fn diagnostics(&self, file: FileId) -> Cancellable<Vec<Diagnostic>> {
204 catch(|| {
205 self.db
206 .file_text(file)
207 .map(|ft| {
208 let mut diags = features::diagnostics(&self.db, ft);
209 diags.extend(semantic::type_diagnostics(&self.db, ft));
210 diags
211 })
212 .unwrap_or_default()
213 })
214 }
215
216 pub fn format(&self, file: FileId) -> Cancellable<Option<String>> {
223 catch(|| {
224 self.db.file_text(file).map(|ft| {
225 gdscript_fmt::format(ft.text(&self.db), &gdscript_fmt::FmtConfig::default())
226 })
227 })
228 }
229
230 pub fn format_range(
237 &self,
238 file: FileId,
239 start: u32,
240 end: u32,
241 ) -> Cancellable<Option<(u32, u32, String)>> {
242 catch(|| {
243 self.db.file_text(file).and_then(|ft| {
244 let sel = (start as usize)..(end as usize);
245 gdscript_fmt::format_range(
246 ft.text(&self.db),
247 &gdscript_fmt::FmtConfig::default(),
248 sel,
249 )
250 .map(|e| {
251 (
252 u32::try_from(e.range.start).unwrap_or(u32::MAX),
253 u32::try_from(e.range.end).unwrap_or(u32::MAX),
254 e.new_text,
255 )
256 })
257 })
258 })
259 }
260
261 pub fn document_symbols(&self, file: FileId) -> Cancellable<Vec<DocumentSymbol>> {
266 catch(|| {
267 self.db
268 .file_text(file)
269 .map(|ft| features::document_symbols(&self.db, ft))
270 .unwrap_or_default()
271 })
272 }
273
274 pub fn semantic_tokens(&self, file: FileId) -> Cancellable<Vec<gdscript_base::SemanticToken>> {
281 catch(|| {
282 self.db
283 .file_text(file)
284 .map(|ft| semantic_tokens::semantic_tokens(&self.db, ft))
285 .unwrap_or_default()
286 })
287 }
288
289 pub fn folding_ranges(&self, file: FileId) -> Cancellable<Vec<FoldRange>> {
294 catch(|| {
295 self.db
296 .file_text(file)
297 .map(|ft| features::folding_ranges(&self.db, ft))
298 .unwrap_or_default()
299 })
300 }
301
302 pub fn completions(&self, pos: FilePosition) -> Cancellable<Vec<CompletionItem>> {
309 catch(|| {
310 self.db
311 .file_text(pos.file)
312 .map(|ft| {
313 semantic::node_path_completions(&self.db, ft, pos.offset)
314 .or_else(|| semantic::member_completions(&self.db, ft, pos.offset))
315 .unwrap_or_else(|| features::completions(&self.db, ft, pos.offset))
316 })
317 .unwrap_or_default()
318 })
319 }
320
321 pub fn hover(&self, pos: FilePosition) -> Cancellable<Option<HoverResult>> {
327 catch(|| {
328 self.db
329 .file_text(pos.file)
330 .and_then(|ft| semantic::hover(&self.db, ft, pos.offset))
331 })
332 }
333
334 pub fn inlay_hints(&self, file: FileId) -> Cancellable<Vec<InlayHint>> {
340 catch(|| {
341 self.db
342 .file_text(file)
343 .map(|ft| semantic::inlay_hints(&self.db, ft))
344 .unwrap_or_default()
345 })
346 }
347
348 pub fn signature_help(&self, pos: FilePosition) -> Cancellable<Option<SignatureHelp>> {
353 catch(|| {
354 self.db
355 .file_text(pos.file)
356 .and_then(|ft| semantic::signature_help(&self.db, ft, pos.offset))
357 })
358 }
359
360 pub fn code_actions(&self, pos: FilePosition) -> Cancellable<Vec<CodeAction>> {
365 catch(|| {
366 self.db
367 .file_text(pos.file)
368 .map(|ft| semantic::code_actions(&self.db, ft, pos.offset))
369 .unwrap_or_default()
370 })
371 }
372
373 pub fn goto_definition(&self, pos: FilePosition) -> Cancellable<Vec<gdscript_base::NavTarget>> {
378 catch(|| navigation::goto_definition(&self.db, pos))
379 }
380
381 pub fn find_references(&self, pos: FilePosition) -> Cancellable<Vec<gdscript_base::Reference>> {
386 catch(|| navigation::find_references(&self.db, pos))
387 }
388
389 pub fn rename(
396 &self,
397 pos: FilePosition,
398 new_name: &str,
399 ) -> Cancellable<Result<gdscript_base::SourceChange, gdscript_base::RenameError>> {
400 catch(|| navigation::rename(&self.db, pos, new_name))
401 }
402
403 pub fn workspace_symbols(&self, query: &str) -> Cancellable<Vec<gdscript_base::NavTarget>> {
408 catch(|| navigation::workspace_symbols(&self.db, query))
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 fn host_with(src: &str) -> (AnalysisHost, FileId) {
417 let mut host = AnalysisHost::new();
418 let file = FileId(0);
419 let mut change = Change::new();
420 change.change_file(file, src);
421 host.apply_change(change);
422 (host, file)
423 }
424
425 #[test]
426 fn snapshot_reads_applied_files() {
427 let (host, file) = host_with("func f():\n\tpass\n");
428 let analysis = host.analysis();
429 let symbols = analysis.document_symbols(file).unwrap();
430 assert_eq!(symbols.len(), 1);
431 assert_eq!(symbols[0].name, "f");
432 }
433
434 #[test]
435 fn preload_resolves_cross_file_through_the_public_api() {
436 let mut host = AnalysisHost::new();
439 let mut change = Change::new();
440 change.change_file(
441 FileId(0),
442 "class_name Markup\nfunc parse() -> int:\n\treturn 1\n",
443 );
444 change.set_file_path(FileId(0), "res://markup.gd");
445 change.change_file(
446 FileId(1),
447 "const M = preload(\"res://markup.gd\")\nfunc go():\n\tvar n := M.new().parse()\n\treturn n\n",
448 );
449 change.set_file_path(FileId(1), "res://main.gd");
450 host.apply_change(change);
451 let analysis = host.analysis();
452
453 assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
455 let hints = analysis.inlay_hints(FileId(1)).unwrap();
458 assert!(
459 hints.iter().any(|h| h.label.contains("int")),
460 "expected an `: int` inlay on the preload-resolved binding, got {hints:?}",
461 );
462 }
463
464 #[test]
465 fn autoload_resolves_cross_file_through_the_public_api() {
466 let mut host = AnalysisHost::new();
469 let mut change = Change::new();
470 change.change_file(FileId(0), "func volume() -> int:\n\treturn 50\n");
471 change.set_file_path(FileId(0), "res://audio.gd");
472 change.change_file(
473 FileId(1),
474 "func go():\n\tvar v := Audio.volume()\n\treturn v\n",
475 );
476 change.set_file_path(FileId(1), "res://main.gd");
477 change.set_project_config("[autoload]\nAudio=\"*res://audio.gd\"\n");
478 host.apply_change(change);
479 let analysis = host.analysis();
480
481 assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
482 let hints = analysis.inlay_hints(FileId(1)).unwrap();
484 assert!(
485 hints.iter().any(|h| h.label.contains("int")),
486 "expected an `: int` inlay on the autoload-resolved binding, got {hints:?}",
487 );
488 }
489
490 #[test]
491 fn multi_scene_node_path_unions_to_the_common_base() {
492 let mut host = AnalysisHost::new();
495 let mut change = Change::new();
496 change.change_file(
497 FileId(0),
498 "[gd_scene format=3]\n\
499 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
500 [node name=\"Root\" type=\"Control\"]\n\
501 script = ExtResource(\"1\")\n\
502 [node name=\"Btn\" type=\"HBoxContainer\" parent=\".\"]\n",
503 );
504 change.set_file_path(FileId(0), "res://a.tscn");
505 change.change_file(
506 FileId(2),
507 "[gd_scene format=3]\n\
508 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
509 [node name=\"Root\" type=\"Control\"]\n\
510 script = ExtResource(\"1\")\n\
511 [node name=\"Btn\" type=\"VBoxContainer\" parent=\".\"]\n",
512 );
513 change.set_file_path(FileId(2), "res://b.tscn");
514 change.change_file(
515 FileId(1),
516 "extends Control\nfunc _ready():\n\tvar b := $Btn\n\tb.queue_free()\n",
517 );
518 change.set_file_path(FileId(1), "res://main.gd");
519 host.apply_change(change);
520 let analysis = host.analysis();
521
522 let hints = analysis.inlay_hints(FileId(1)).unwrap();
523 assert!(
524 hints.iter().any(|h| h.label.contains("BoxContainer")),
525 "expected the common base `: BoxContainer` of HBox/VBoxContainer, got {hints:?}",
526 );
527 }
528
529 #[test]
530 fn non_singleton_autoload_resolves_via_root_path() {
531 let mut host = AnalysisHost::new();
534 let mut change = Change::new();
535 change.change_file(FileId(0), "func volume() -> int:\n\treturn 50\n");
536 change.set_file_path(FileId(0), "res://audio.gd");
537 change.change_file(
538 FileId(1),
539 "func go():\n\tvar v := get_node(\"/root/Audio\").volume()\n\treturn v\n",
540 );
541 change.set_file_path(FileId(1), "res://main.gd");
542 change.set_project_config("[autoload]\nAudio=\"res://audio.gd\"\n");
544 host.apply_change(change);
545 let analysis = host.analysis();
546
547 assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
548 let hints = analysis.inlay_hints(FileId(1)).unwrap();
549 assert!(
550 hints.iter().any(|h| h.label.contains("int")),
551 "expected an `: int` inlay on the /root/-autoload-resolved binding, got {hints:?}",
552 );
553 }
554
555 #[test]
556 fn scene_node_path_typing_through_the_public_api() {
557 let mut host = AnalysisHost::new();
560 let mut change = Change::new();
561 change.change_file(
562 FileId(0),
563 "[gd_scene format=3]\n\
564 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
565 [node name=\"Root\" type=\"Control\"]\n\
566 script = ExtResource(\"1\")\n\
567 [node name=\"Btn\" type=\"Button\" parent=\".\"]\n",
568 );
569 change.set_file_path(FileId(0), "res://main.tscn");
570 change.change_file(
571 FileId(1),
572 "extends Control\nfunc _ready():\n\tvar b := $Btn\n\tb.show()\n",
573 );
574 change.set_file_path(FileId(1), "res://main.gd");
575 host.apply_change(change);
576 let analysis = host.analysis();
577
578 assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
579 let hints = analysis.inlay_hints(FileId(1)).unwrap();
580 assert!(
581 hints.iter().any(|h| h.label.contains("Button")),
582 "expected a `: Button` inlay on `var b := $Btn`, got {hints:?}",
583 );
584 }
585
586 #[test]
587 fn node_path_completion_offers_scene_children() {
588 let mut host = AnalysisHost::new();
590 let mut change = Change::new();
591 change.change_file(
592 FileId(0),
593 "[gd_scene format=3]\n\
594 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
595 [node name=\"Root\" type=\"Control\"]\n\
596 script = ExtResource(\"1\")\n\
597 [node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
598 [node name=\"Ok\" type=\"Button\" parent=\"Panel\"]\n\
599 [node name=\"Cancel\" type=\"Button\" parent=\"Panel\"]\n",
600 );
601 change.set_file_path(FileId(0), "res://main.tscn");
602 let gd = "extends Control\nfunc _ready():\n\tvar b := $Panel/\n";
603 change.change_file(FileId(1), gd);
604 change.set_file_path(FileId(1), "res://main.gd");
605 host.apply_change(change);
606 let analysis = host.analysis();
607
608 let offset = u32::try_from(gd.find("$Panel/").unwrap() + "$Panel/".len()).unwrap();
609 let items = analysis
610 .completions(FilePosition {
611 file: FileId(1),
612 offset,
613 })
614 .unwrap();
615 let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
616 assert!(
617 labels.contains(&"Ok") && labels.contains(&"Cancel"),
618 "{labels:?}"
619 );
620 assert!(
622 items
623 .iter()
624 .find(|i| i.label == "Ok")
625 .is_some_and(|i| i.detail.as_deref() == Some("Button")),
626 "{items:?}",
627 );
628 assert!(
629 !labels.contains(&"func"),
630 "should be node-path, not keyword, completion"
631 );
632 }
633
634 #[test]
635 fn node_path_completion_does_not_hijack_inside_a_string_literal() {
636 let mut host = AnalysisHost::new();
639 let mut change = Change::new();
640 change.change_file(
641 FileId(0),
642 "[gd_scene format=3]\n\
643 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
644 [node name=\"Root\" type=\"Control\"]\n\
645 script = ExtResource(\"1\")\n\
646 [node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
647 [node name=\"Ok\" type=\"Button\" parent=\"Panel\"]\n",
648 );
649 change.set_file_path(FileId(0), "res://main.tscn");
650 let gd = "extends Control\nfunc _ready():\n\tvar s := \"$Panel/\"\n";
651 change.change_file(FileId(1), gd);
652 change.set_file_path(FileId(1), "res://main.gd");
653 host.apply_change(change);
654 let analysis = host.analysis();
655
656 let offset = u32::try_from(gd.find("$Panel/").unwrap() + "$Panel/".len()).unwrap();
658 let items = analysis
659 .completions(FilePosition {
660 file: FileId(1),
661 offset,
662 })
663 .unwrap();
664 assert!(
665 !items.iter().any(|i| i.label == "Ok"),
666 "node names must not leak into a string literal: {items:?}",
667 );
668 }
669
670 #[test]
671 fn unique_node_path_completion_offers_children() {
672 let mut host = AnalysisHost::new();
674 let mut change = Change::new();
675 let scene = "[gd_scene format=3]\n\
676 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
677 [node name=\"Root\" type=\"Control\"]\n\
678 script = ExtResource(\"1\")\n\
679 [node name=\"Box\" type=\"Panel\" parent=\".\"]\n\
680 unique_name_in_owner = true\n\
681 [node name=\"Ok\" type=\"Button\" parent=\"Box\"]\n\
682 [node name=\"Cancel\" type=\"Button\" parent=\"Box\"]\n";
683 change.change_file(FileId(0), scene);
684 change.set_file_path(FileId(0), "res://main.tscn");
685 let gd = "extends Control\nfunc _ready():\n\tvar b := %Box/\n";
686 change.change_file(FileId(1), gd);
687 change.set_file_path(FileId(1), "res://main.gd");
688 host.apply_change(change);
689 let analysis = host.analysis();
690 let offset = u32::try_from(gd.find("%Box/").unwrap() + "%Box/".len()).unwrap();
691 let items = analysis
692 .completions(FilePosition {
693 file: FileId(1),
694 offset,
695 })
696 .unwrap();
697 let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
698 assert!(
699 labels.contains(&"Ok") && labels.contains(&"Cancel"),
700 "{labels:?}"
701 );
702 assert!(
703 !labels.contains(&"func"),
704 "node-path, not keyword completion"
705 );
706 }
707
708 #[test]
709 fn bare_percent_offers_all_unique_nodes() {
710 let mut host = AnalysisHost::new();
712 let mut change = Change::new();
713 let scene = "[gd_scene format=3]\n\
714 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
715 [node name=\"Root\" type=\"Control\"]\n\
716 script = ExtResource(\"1\")\n\
717 [node name=\"Box\" type=\"Panel\" parent=\".\"]\n\
718 unique_name_in_owner = true\n\
719 [node name=\"Hud\" type=\"Control\" parent=\".\"]\n\
720 unique_name_in_owner = true\n";
721 change.change_file(FileId(0), scene);
722 change.set_file_path(FileId(0), "res://main.tscn");
723 let gd = "extends Control\nfunc _ready():\n\tvar b := %\n";
724 change.change_file(FileId(1), gd);
725 change.set_file_path(FileId(1), "res://main.gd");
726 host.apply_change(change);
727 let analysis = host.analysis();
728 let offset = u32::try_from(gd.find("%\n").unwrap() + 1).unwrap();
729 let labels: Vec<_> = analysis
730 .completions(FilePosition {
731 file: FileId(1),
732 offset,
733 })
734 .unwrap()
735 .into_iter()
736 .map(|i| i.label)
737 .collect();
738 assert!(
739 labels.iter().any(|l| l == "Box") && labels.iter().any(|l| l == "Hud"),
740 "{labels:?}"
741 );
742 }
743
744 #[test]
745 fn percent_modulo_is_not_hijacked_as_a_unique_path() {
746 let mut host = AnalysisHost::new();
749 let mut change = Change::new();
750 let scene = "[gd_scene format=3]\n\
751 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
752 [node name=\"Root\" type=\"Control\"]\n\
753 script = ExtResource(\"1\")\n\
754 [node name=\"Box\" type=\"Panel\" parent=\".\"]\n\
755 unique_name_in_owner = true\n";
756 change.change_file(FileId(0), scene);
757 change.set_file_path(FileId(0), "res://main.tscn");
758 let gd = "extends Control\nfunc _ready():\n\tvar count := 10\n\tvar b := count %Box\n";
759 change.change_file(FileId(1), gd);
760 change.set_file_path(FileId(1), "res://main.gd");
761 host.apply_change(change);
762 let analysis = host.analysis();
763 let offset = u32::try_from(gd.find("%Box").unwrap() + "%Box".len()).unwrap();
764 let labels: Vec<_> = analysis
765 .completions(FilePosition {
766 file: FileId(1),
767 offset,
768 })
769 .unwrap()
770 .into_iter()
771 .map(|i| i.label)
772 .collect();
773 assert!(
775 labels.iter().any(|l| l == "func"),
776 "expected by-name completion: {labels:?}"
777 );
778 }
779
780 #[test]
781 fn completion_is_scope_aware_for_locals_and_params() {
782 let mut host = AnalysisHost::new();
787 let mut change = Change::new();
788 let gd = "var member_v := 0\nfunc a(pa):\n\tvar la := 1\n\t\nfunc b(pb):\n\tvar lb := 2\n";
789 change.change_file(FileId(0), gd);
790 change.set_file_path(FileId(0), "res://m.gd");
791 host.apply_change(change);
792 let analysis = host.analysis();
793
794 let upto = "var member_v := 0\nfunc a(pa):\n\tvar la := 1\n\t";
796 let offset = u32::try_from(gd.find(upto).unwrap() + upto.len()).unwrap();
797 let items = analysis
798 .completions(FilePosition {
799 file: FileId(0),
800 offset,
801 })
802 .unwrap();
803 let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
804 assert!(labels.contains(&"pa"), "own param `pa`: {labels:?}");
806 assert!(labels.contains(&"la"), "own local `la`: {labels:?}");
807 assert!(labels.contains(&"member_v"), "class member: {labels:?}");
808 assert!(
809 labels.contains(&"a") && labels.contains(&"b"),
810 "sibling func names: {labels:?}",
811 );
812 assert!(!labels.contains(&"pb"), "leaked b's param: {labels:?}");
814 assert!(!labels.contains(&"lb"), "leaked b's local: {labels:?}");
815 }
816
817 #[test]
818 fn completion_at_class_level_offers_members_not_locals() {
819 let mut host = AnalysisHost::new();
821 let mut change = Change::new();
822 let gd = "var member_v := 0\nfunc a():\n\tvar la := 1\n\nm\n";
823 change.change_file(FileId(0), gd);
824 change.set_file_path(FileId(0), "res://m.gd");
825 host.apply_change(change);
826 let analysis = host.analysis();
827 let offset = u32::try_from(gd.rfind('m').unwrap() + 1).unwrap();
829 let items = analysis
830 .completions(FilePosition {
831 file: FileId(0),
832 offset,
833 })
834 .unwrap();
835 let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
836 assert!(
837 labels.contains(&"member_v") && labels.contains(&"a"),
838 "{labels:?}"
839 );
840 assert!(
841 !labels.contains(&"la"),
842 "a()'s local must not leak to class level: {labels:?}"
843 );
844 }
845
846 #[test]
847 fn completion_offers_params_in_lambda_setter_and_inline_bodies() {
848 let cases = [
852 ("var f := func(px):\n\treturn px\n", "px", "return "),
854 ("var x: int:\n\tset(sv):\n\t\t_x = sv\n", "sv", "_x = "),
855 ("func foo(ia): return ia\n", "ia", "return "),
856 ];
857 for (gd, param, marker) in cases {
858 let mut host = AnalysisHost::new();
859 let mut change = Change::new();
860 change.change_file(FileId(0), gd);
861 change.set_file_path(FileId(0), "res://m.gd");
862 host.apply_change(change);
863 let analysis = host.analysis();
864 let offset = u32::try_from(gd.find(marker).unwrap() + marker.len()).unwrap();
865 let labels: Vec<_> = analysis
866 .completions(FilePosition {
867 file: FileId(0),
868 offset,
869 })
870 .unwrap()
871 .into_iter()
872 .map(|i| i.label)
873 .collect();
874 assert!(
875 labels.iter().any(|l| l == param),
876 "param `{param}` should be offered inside its body for {gd:?}, got {labels:?}",
877 );
878 }
879 }
880
881 #[test]
882 fn goto_definition_on_a_node_path_jumps_into_the_tscn() {
883 let mut host = AnalysisHost::new();
886 let mut change = Change::new();
887 let scene = "[gd_scene format=3]\n\
888 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
889 [node name=\"Root\" type=\"Control\"]\n\
890 script = ExtResource(\"1\")\n\
891 [node name=\"Btn\" type=\"Button\" parent=\".\"]\n";
892 let gd = "extends Control\nfunc _ready():\n\tvar b := $Btn\n";
893 change.change_file(FileId(0), scene);
894 change.set_file_path(FileId(0), "res://main.tscn");
895 change.change_file(FileId(1), gd);
896 change.set_file_path(FileId(1), "res://main.gd");
897 host.apply_change(change);
898 let analysis = host.analysis();
899
900 let offset = u32::try_from(gd.find("$Btn").unwrap() + 1).unwrap(); let targets = analysis
902 .goto_definition(FilePosition {
903 file: FileId(1),
904 offset,
905 })
906 .unwrap();
907 assert_eq!(targets.len(), 1, "{targets:?}");
908 assert_eq!(targets[0].file, FileId(0), "jumps into the .tscn");
909 let focus =
910 &scene[targets[0].focus_range.start as usize..targets[0].focus_range.end as usize];
911 assert!(
912 focus.contains("Btn"),
913 "focus on the node name, got {focus:?}"
914 );
915 }
916
917 #[test]
918 fn find_refs_and_rename_cross_file_through_the_public_api() {
919 let mut host = AnalysisHost::new();
920 let mut change = Change::new();
921 change.change_file(
922 FileId(0),
923 "class_name Widget\nfunc make() -> int:\n\treturn 1\n",
924 );
925 change.set_file_path(FileId(0), "res://widget.gd");
926 change.change_file(
927 FileId(1),
928 "func f():\n\tvar w: Widget\n\tvar x := Widget.new()\n",
929 );
930 change.set_file_path(FileId(1), "res://main.gd");
931 host.apply_change(change);
932 let analysis = host.analysis();
933 let at_decl = FilePosition {
935 file: FileId(0),
936 offset: 11,
937 };
938 let refs = analysis.find_references(at_decl).unwrap();
940 assert_eq!(refs.len(), 3, "{refs:?}");
941 let edit = analysis
943 .rename(at_decl, "Gadget")
944 .unwrap()
945 .expect("rename ok");
946 assert_eq!(edit.edits.len(), 2, "both files edited");
947 }
948
949 #[test]
950 fn removing_a_file_clears_it() {
951 let (mut host, file) = host_with("var x = 1\n");
952 let mut change = Change::new();
953 change.remove_file(file);
954 host.apply_change(change);
955 let analysis = host.analysis();
956 assert!(analysis.document_symbols(file).unwrap().is_empty());
957 }
958}