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 pub workspace_complete: Option<bool>,
75}
76
77impl Change {
78 #[must_use]
80 pub fn new() -> Self {
81 Self::default()
82 }
83
84 pub fn change_file(&mut self, file: FileId, text: impl Into<Arc<str>>) {
86 self.files.push((file, Some(text.into())));
87 }
88
89 pub fn remove_file(&mut self, file: FileId) {
91 self.files.push((file, None));
92 }
93
94 pub fn set_file_path(&mut self, file: FileId, path: impl Into<String>) {
97 self.paths.push((file, path.into()));
98 }
99
100 pub fn set_project_config(&mut self, text: impl Into<Arc<str>>) {
103 self.project_config = Some(text.into());
104 }
105
106 pub fn set_workspace_complete(&mut self, complete: bool) {
110 self.workspace_complete = Some(complete);
111 }
112}
113
114impl AnalysisHost {
115 #[must_use]
117 pub fn new() -> Self {
118 Self::default()
119 }
120
121 pub fn apply_change(&mut self, change: Change) {
125 let mut structure_changed = false;
126 for (id, text) in change.files {
127 if let Some(t) = text {
128 structure_changed |= self.db.file_text(id).is_none();
130 self.db.set_file_text(id, &t, Durability::LOW);
131 } else {
132 structure_changed |= self.db.file_text(id).is_some();
133 self.db.remove_file(id);
134 }
135 }
136 for (id, path) in change.paths {
140 self.db.set_file_path(id, &path);
141 }
142 if let Some(text) = change.project_config {
145 self.db.set_project_config(&text);
146 }
147 if structure_changed {
150 self.db.sync_source_root();
151 }
152 if let Some(complete) = change.workspace_complete {
155 self.db.set_workspace_complete(complete);
156 }
157 }
158
159 pub fn set_engine_api(&mut self, bytes: &[u8]) -> bool {
167 match gdscript_api::EngineApi::from_bytes(bytes) {
168 Ok(api) => {
169 self.db.set_engine_api(api);
170 true
171 }
172 Err(_) => false,
173 }
174 }
175
176 pub fn set_warning_override(&mut self, ov: gdscript_db::WarningOverride) {
180 self.db.set_warning_override(ov);
181 }
182
183 #[must_use]
185 pub fn analysis(&self) -> Analysis {
186 Analysis {
187 db: self.db.clone(),
188 }
189 }
190}
191
192#[derive(Debug, Clone)]
196pub struct Analysis {
197 db: RootDatabase,
198}
199
200impl Analysis {
201 pub fn syntax_tree(&self, file: FileId) -> Cancellable<Option<String>> {
208 catch(|| {
209 self.db
210 .file_text(file)
211 .map(|ft| gdscript_db::parse(&self.db, ft).debug_tree())
212 })
213 }
214
215 pub fn diagnostics(&self, file: FileId) -> Cancellable<Vec<Diagnostic>> {
220 catch(|| {
221 self.db
222 .file_text(file)
223 .map(|ft| {
224 let mut diags = features::diagnostics(&self.db, ft);
225 diags.extend(semantic::type_diagnostics(&self.db, ft));
226 diags
227 })
228 .unwrap_or_default()
229 })
230 }
231
232 pub fn format(&self, file: FileId) -> Cancellable<Option<String>> {
239 catch(|| {
240 self.db.file_text(file).map(|ft| {
241 gdscript_fmt::format(ft.text(&self.db), &gdscript_fmt::FmtConfig::default())
242 })
243 })
244 }
245
246 pub fn format_range(
253 &self,
254 file: FileId,
255 start: u32,
256 end: u32,
257 ) -> Cancellable<Option<(u32, u32, String)>> {
258 catch(|| {
259 self.db.file_text(file).and_then(|ft| {
260 let sel = (start as usize)..(end as usize);
261 gdscript_fmt::format_range(
262 ft.text(&self.db),
263 &gdscript_fmt::FmtConfig::default(),
264 sel,
265 )
266 .map(|e| {
267 (
268 u32::try_from(e.range.start).unwrap_or(u32::MAX),
269 u32::try_from(e.range.end).unwrap_or(u32::MAX),
270 e.new_text,
271 )
272 })
273 })
274 })
275 }
276
277 pub fn document_symbols(&self, file: FileId) -> Cancellable<Vec<DocumentSymbol>> {
282 catch(|| {
283 self.db
284 .file_text(file)
285 .map(|ft| features::document_symbols(&self.db, ft))
286 .unwrap_or_default()
287 })
288 }
289
290 pub fn semantic_tokens(&self, file: FileId) -> Cancellable<Vec<gdscript_base::SemanticToken>> {
297 catch(|| {
298 self.db
299 .file_text(file)
300 .map(|ft| semantic_tokens::semantic_tokens(&self.db, ft))
301 .unwrap_or_default()
302 })
303 }
304
305 pub fn folding_ranges(&self, file: FileId) -> Cancellable<Vec<FoldRange>> {
310 catch(|| {
311 self.db
312 .file_text(file)
313 .map(|ft| features::folding_ranges(&self.db, ft))
314 .unwrap_or_default()
315 })
316 }
317
318 pub fn completions(&self, pos: FilePosition) -> Cancellable<Vec<CompletionItem>> {
325 catch(|| {
326 self.db
327 .file_text(pos.file)
328 .map(|ft| {
329 semantic::node_path_completions(&self.db, ft, pos.offset)
330 .or_else(|| semantic::member_completions(&self.db, ft, pos.offset))
331 .unwrap_or_else(|| features::completions(&self.db, ft, pos.offset))
332 })
333 .unwrap_or_default()
334 })
335 }
336
337 pub fn hover(&self, pos: FilePosition) -> Cancellable<Option<HoverResult>> {
343 catch(|| {
344 self.db
345 .file_text(pos.file)
346 .and_then(|ft| semantic::hover(&self.db, ft, pos.offset))
347 })
348 }
349
350 pub fn inlay_hints(&self, file: FileId) -> Cancellable<Vec<InlayHint>> {
356 catch(|| {
357 self.db
358 .file_text(file)
359 .map(|ft| semantic::inlay_hints(&self.db, ft))
360 .unwrap_or_default()
361 })
362 }
363
364 pub fn signature_help(&self, pos: FilePosition) -> Cancellable<Option<SignatureHelp>> {
369 catch(|| {
370 self.db
371 .file_text(pos.file)
372 .and_then(|ft| semantic::signature_help(&self.db, ft, pos.offset))
373 })
374 }
375
376 pub fn code_actions(&self, pos: FilePosition) -> Cancellable<Vec<CodeAction>> {
381 catch(|| {
382 self.db
383 .file_text(pos.file)
384 .map(|ft| semantic::code_actions(&self.db, ft, pos.offset))
385 .unwrap_or_default()
386 })
387 }
388
389 pub fn goto_definition(&self, pos: FilePosition) -> Cancellable<Vec<gdscript_base::NavTarget>> {
394 catch(|| navigation::goto_definition(&self.db, pos))
395 }
396
397 pub fn find_references(&self, pos: FilePosition) -> Cancellable<Vec<gdscript_base::Reference>> {
402 catch(|| navigation::find_references(&self.db, pos))
403 }
404
405 pub fn rename(
412 &self,
413 pos: FilePosition,
414 new_name: &str,
415 ) -> Cancellable<Result<gdscript_base::SourceChange, gdscript_base::RenameError>> {
416 catch(|| navigation::rename(&self.db, pos, new_name))
417 }
418
419 pub fn workspace_symbols(&self, query: &str) -> Cancellable<Vec<gdscript_base::NavTarget>> {
424 catch(|| navigation::workspace_symbols(&self.db, query))
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431
432 fn host_with(src: &str) -> (AnalysisHost, FileId) {
433 let mut host = AnalysisHost::new();
434 let file = FileId(0);
435 let mut change = Change::new();
436 change.change_file(file, src);
437 host.apply_change(change);
438 (host, file)
439 }
440
441 #[test]
442 fn snapshot_reads_applied_files() {
443 let (host, file) = host_with("func f():\n\tpass\n");
444 let analysis = host.analysis();
445 let symbols = analysis.document_symbols(file).unwrap();
446 assert_eq!(symbols.len(), 1);
447 assert_eq!(symbols[0].name, "f");
448 }
449
450 #[test]
451 fn preload_resolves_cross_file_through_the_public_api() {
452 let mut host = AnalysisHost::new();
455 let mut change = Change::new();
456 change.change_file(
457 FileId(0),
458 "class_name Markup\nfunc parse() -> int:\n\treturn 1\n",
459 );
460 change.set_file_path(FileId(0), "res://markup.gd");
461 change.change_file(
462 FileId(1),
463 "const M = preload(\"res://markup.gd\")\nfunc go():\n\tvar n := M.new().parse()\n\treturn n\n",
464 );
465 change.set_file_path(FileId(1), "res://main.gd");
466 host.apply_change(change);
467 let analysis = host.analysis();
468
469 assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
471 let hints = analysis.inlay_hints(FileId(1)).unwrap();
474 assert!(
475 hints.iter().any(|h| h.label.contains("int")),
476 "expected an `: int` inlay on the preload-resolved binding, got {hints:?}",
477 );
478 }
479
480 #[test]
481 fn autoload_resolves_cross_file_through_the_public_api() {
482 let mut host = AnalysisHost::new();
485 let mut change = Change::new();
486 change.change_file(FileId(0), "func volume() -> int:\n\treturn 50\n");
487 change.set_file_path(FileId(0), "res://audio.gd");
488 change.change_file(
489 FileId(1),
490 "func go():\n\tvar v := Audio.volume()\n\treturn v\n",
491 );
492 change.set_file_path(FileId(1), "res://main.gd");
493 change.set_project_config("[autoload]\nAudio=\"*res://audio.gd\"\n");
494 host.apply_change(change);
495 let analysis = host.analysis();
496
497 assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
498 let hints = analysis.inlay_hints(FileId(1)).unwrap();
500 assert!(
501 hints.iter().any(|h| h.label.contains("int")),
502 "expected an `: int` inlay on the autoload-resolved binding, got {hints:?}",
503 );
504 }
505
506 #[test]
507 fn multi_scene_node_path_unions_to_the_common_base() {
508 let mut host = AnalysisHost::new();
511 let mut change = Change::new();
512 change.change_file(
513 FileId(0),
514 "[gd_scene format=3]\n\
515 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
516 [node name=\"Root\" type=\"Control\"]\n\
517 script = ExtResource(\"1\")\n\
518 [node name=\"Btn\" type=\"HBoxContainer\" parent=\".\"]\n",
519 );
520 change.set_file_path(FileId(0), "res://a.tscn");
521 change.change_file(
522 FileId(2),
523 "[gd_scene format=3]\n\
524 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
525 [node name=\"Root\" type=\"Control\"]\n\
526 script = ExtResource(\"1\")\n\
527 [node name=\"Btn\" type=\"VBoxContainer\" parent=\".\"]\n",
528 );
529 change.set_file_path(FileId(2), "res://b.tscn");
530 change.change_file(
531 FileId(1),
532 "extends Control\nfunc _ready():\n\tvar b := $Btn\n\tb.queue_free()\n",
533 );
534 change.set_file_path(FileId(1), "res://main.gd");
535 host.apply_change(change);
536 let analysis = host.analysis();
537
538 let hints = analysis.inlay_hints(FileId(1)).unwrap();
539 assert!(
540 hints.iter().any(|h| h.label.contains("BoxContainer")),
541 "expected the common base `: BoxContainer` of HBox/VBoxContainer, got {hints:?}",
542 );
543 }
544
545 #[test]
546 fn non_singleton_autoload_resolves_via_root_path() {
547 let mut host = AnalysisHost::new();
550 let mut change = Change::new();
551 change.change_file(FileId(0), "func volume() -> int:\n\treturn 50\n");
552 change.set_file_path(FileId(0), "res://audio.gd");
553 change.change_file(
554 FileId(1),
555 "func go():\n\tvar v := get_node(\"/root/Audio\").volume()\n\treturn v\n",
556 );
557 change.set_file_path(FileId(1), "res://main.gd");
558 change.set_project_config("[autoload]\nAudio=\"res://audio.gd\"\n");
560 host.apply_change(change);
561 let analysis = host.analysis();
562
563 assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
564 let hints = analysis.inlay_hints(FileId(1)).unwrap();
565 assert!(
566 hints.iter().any(|h| h.label.contains("int")),
567 "expected an `: int` inlay on the /root/-autoload-resolved binding, got {hints:?}",
568 );
569 }
570
571 #[test]
572 fn scene_node_path_typing_through_the_public_api() {
573 let mut host = AnalysisHost::new();
576 let mut change = Change::new();
577 change.change_file(
578 FileId(0),
579 "[gd_scene format=3]\n\
580 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
581 [node name=\"Root\" type=\"Control\"]\n\
582 script = ExtResource(\"1\")\n\
583 [node name=\"Btn\" type=\"Button\" parent=\".\"]\n",
584 );
585 change.set_file_path(FileId(0), "res://main.tscn");
586 change.change_file(
587 FileId(1),
588 "extends Control\nfunc _ready():\n\tvar b := $Btn\n\tb.show()\n",
589 );
590 change.set_file_path(FileId(1), "res://main.gd");
591 host.apply_change(change);
592 let analysis = host.analysis();
593
594 assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
595 let hints = analysis.inlay_hints(FileId(1)).unwrap();
596 assert!(
597 hints.iter().any(|h| h.label.contains("Button")),
598 "expected a `: Button` inlay on `var b := $Btn`, got {hints:?}",
599 );
600 }
601
602 #[test]
603 fn node_path_completion_offers_scene_children() {
604 let mut host = AnalysisHost::new();
606 let mut change = Change::new();
607 change.change_file(
608 FileId(0),
609 "[gd_scene format=3]\n\
610 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
611 [node name=\"Root\" type=\"Control\"]\n\
612 script = ExtResource(\"1\")\n\
613 [node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
614 [node name=\"Ok\" type=\"Button\" parent=\"Panel\"]\n\
615 [node name=\"Cancel\" type=\"Button\" parent=\"Panel\"]\n",
616 );
617 change.set_file_path(FileId(0), "res://main.tscn");
618 let gd = "extends Control\nfunc _ready():\n\tvar b := $Panel/\n";
619 change.change_file(FileId(1), gd);
620 change.set_file_path(FileId(1), "res://main.gd");
621 host.apply_change(change);
622 let analysis = host.analysis();
623
624 let offset = u32::try_from(gd.find("$Panel/").unwrap() + "$Panel/".len()).unwrap();
625 let items = analysis
626 .completions(FilePosition {
627 file: FileId(1),
628 offset,
629 })
630 .unwrap();
631 let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
632 assert!(
633 labels.contains(&"Ok") && labels.contains(&"Cancel"),
634 "{labels:?}"
635 );
636 assert!(
638 items
639 .iter()
640 .find(|i| i.label == "Ok")
641 .is_some_and(|i| i.detail.as_deref() == Some("Button")),
642 "{items:?}",
643 );
644 assert!(
645 !labels.contains(&"func"),
646 "should be node-path, not keyword, completion"
647 );
648 }
649
650 #[test]
651 fn node_path_completion_does_not_hijack_inside_a_string_literal() {
652 let mut host = AnalysisHost::new();
655 let mut change = Change::new();
656 change.change_file(
657 FileId(0),
658 "[gd_scene format=3]\n\
659 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
660 [node name=\"Root\" type=\"Control\"]\n\
661 script = ExtResource(\"1\")\n\
662 [node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
663 [node name=\"Ok\" type=\"Button\" parent=\"Panel\"]\n",
664 );
665 change.set_file_path(FileId(0), "res://main.tscn");
666 let gd = "extends Control\nfunc _ready():\n\tvar s := \"$Panel/\"\n";
667 change.change_file(FileId(1), gd);
668 change.set_file_path(FileId(1), "res://main.gd");
669 host.apply_change(change);
670 let analysis = host.analysis();
671
672 let offset = u32::try_from(gd.find("$Panel/").unwrap() + "$Panel/".len()).unwrap();
674 let items = analysis
675 .completions(FilePosition {
676 file: FileId(1),
677 offset,
678 })
679 .unwrap();
680 assert!(
681 !items.iter().any(|i| i.label == "Ok"),
682 "node names must not leak into a string literal: {items:?}",
683 );
684 }
685
686 #[test]
687 fn unique_node_path_completion_offers_children() {
688 let mut host = AnalysisHost::new();
690 let mut change = Change::new();
691 let scene = "[gd_scene format=3]\n\
692 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
693 [node name=\"Root\" type=\"Control\"]\n\
694 script = ExtResource(\"1\")\n\
695 [node name=\"Box\" type=\"Panel\" parent=\".\"]\n\
696 unique_name_in_owner = true\n\
697 [node name=\"Ok\" type=\"Button\" parent=\"Box\"]\n\
698 [node name=\"Cancel\" type=\"Button\" parent=\"Box\"]\n";
699 change.change_file(FileId(0), scene);
700 change.set_file_path(FileId(0), "res://main.tscn");
701 let gd = "extends Control\nfunc _ready():\n\tvar b := %Box/\n";
702 change.change_file(FileId(1), gd);
703 change.set_file_path(FileId(1), "res://main.gd");
704 host.apply_change(change);
705 let analysis = host.analysis();
706 let offset = u32::try_from(gd.find("%Box/").unwrap() + "%Box/".len()).unwrap();
707 let items = analysis
708 .completions(FilePosition {
709 file: FileId(1),
710 offset,
711 })
712 .unwrap();
713 let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
714 assert!(
715 labels.contains(&"Ok") && labels.contains(&"Cancel"),
716 "{labels:?}"
717 );
718 assert!(
719 !labels.contains(&"func"),
720 "node-path, not keyword completion"
721 );
722 }
723
724 #[test]
725 fn bare_percent_offers_all_unique_nodes() {
726 let mut host = AnalysisHost::new();
728 let mut change = Change::new();
729 let scene = "[gd_scene format=3]\n\
730 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
731 [node name=\"Root\" type=\"Control\"]\n\
732 script = ExtResource(\"1\")\n\
733 [node name=\"Box\" type=\"Panel\" parent=\".\"]\n\
734 unique_name_in_owner = true\n\
735 [node name=\"Hud\" type=\"Control\" parent=\".\"]\n\
736 unique_name_in_owner = true\n";
737 change.change_file(FileId(0), scene);
738 change.set_file_path(FileId(0), "res://main.tscn");
739 let gd = "extends Control\nfunc _ready():\n\tvar b := %\n";
740 change.change_file(FileId(1), gd);
741 change.set_file_path(FileId(1), "res://main.gd");
742 host.apply_change(change);
743 let analysis = host.analysis();
744 let offset = u32::try_from(gd.find("%\n").unwrap() + 1).unwrap();
745 let labels: Vec<_> = analysis
746 .completions(FilePosition {
747 file: FileId(1),
748 offset,
749 })
750 .unwrap()
751 .into_iter()
752 .map(|i| i.label)
753 .collect();
754 assert!(
755 labels.iter().any(|l| l == "Box") && labels.iter().any(|l| l == "Hud"),
756 "{labels:?}"
757 );
758 }
759
760 #[test]
761 fn percent_modulo_is_not_hijacked_as_a_unique_path() {
762 let mut host = AnalysisHost::new();
765 let mut change = Change::new();
766 let scene = "[gd_scene format=3]\n\
767 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
768 [node name=\"Root\" type=\"Control\"]\n\
769 script = ExtResource(\"1\")\n\
770 [node name=\"Box\" type=\"Panel\" parent=\".\"]\n\
771 unique_name_in_owner = true\n";
772 change.change_file(FileId(0), scene);
773 change.set_file_path(FileId(0), "res://main.tscn");
774 let gd = "extends Control\nfunc _ready():\n\tvar count := 10\n\tvar b := count %Box\n";
775 change.change_file(FileId(1), gd);
776 change.set_file_path(FileId(1), "res://main.gd");
777 host.apply_change(change);
778 let analysis = host.analysis();
779 let offset = u32::try_from(gd.find("%Box").unwrap() + "%Box".len()).unwrap();
780 let labels: Vec<_> = analysis
781 .completions(FilePosition {
782 file: FileId(1),
783 offset,
784 })
785 .unwrap()
786 .into_iter()
787 .map(|i| i.label)
788 .collect();
789 assert!(
791 labels.iter().any(|l| l == "func"),
792 "expected by-name completion: {labels:?}"
793 );
794 }
795
796 #[test]
797 fn completion_is_scope_aware_for_locals_and_params() {
798 let mut host = AnalysisHost::new();
803 let mut change = Change::new();
804 let gd = "var member_v := 0\nfunc a(pa):\n\tvar la := 1\n\t\nfunc b(pb):\n\tvar lb := 2\n";
805 change.change_file(FileId(0), gd);
806 change.set_file_path(FileId(0), "res://m.gd");
807 host.apply_change(change);
808 let analysis = host.analysis();
809
810 let upto = "var member_v := 0\nfunc a(pa):\n\tvar la := 1\n\t";
812 let offset = u32::try_from(gd.find(upto).unwrap() + upto.len()).unwrap();
813 let items = analysis
814 .completions(FilePosition {
815 file: FileId(0),
816 offset,
817 })
818 .unwrap();
819 let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
820 assert!(labels.contains(&"pa"), "own param `pa`: {labels:?}");
822 assert!(labels.contains(&"la"), "own local `la`: {labels:?}");
823 assert!(labels.contains(&"member_v"), "class member: {labels:?}");
824 assert!(
825 labels.contains(&"a") && labels.contains(&"b"),
826 "sibling func names: {labels:?}",
827 );
828 assert!(!labels.contains(&"pb"), "leaked b's param: {labels:?}");
830 assert!(!labels.contains(&"lb"), "leaked b's local: {labels:?}");
831 }
832
833 #[test]
834 fn completion_at_class_level_offers_members_not_locals() {
835 let mut host = AnalysisHost::new();
837 let mut change = Change::new();
838 let gd = "var member_v := 0\nfunc a():\n\tvar la := 1\n\nm\n";
839 change.change_file(FileId(0), gd);
840 change.set_file_path(FileId(0), "res://m.gd");
841 host.apply_change(change);
842 let analysis = host.analysis();
843 let offset = u32::try_from(gd.rfind('m').unwrap() + 1).unwrap();
845 let items = analysis
846 .completions(FilePosition {
847 file: FileId(0),
848 offset,
849 })
850 .unwrap();
851 let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
852 assert!(
853 labels.contains(&"member_v") && labels.contains(&"a"),
854 "{labels:?}"
855 );
856 assert!(
857 !labels.contains(&"la"),
858 "a()'s local must not leak to class level: {labels:?}"
859 );
860 }
861
862 #[test]
863 fn completion_offers_params_in_lambda_setter_and_inline_bodies() {
864 let cases = [
868 ("var f := func(px):\n\treturn px\n", "px", "return "),
870 ("var x: int:\n\tset(sv):\n\t\t_x = sv\n", "sv", "_x = "),
871 ("func foo(ia): return ia\n", "ia", "return "),
872 ];
873 for (gd, param, marker) in cases {
874 let mut host = AnalysisHost::new();
875 let mut change = Change::new();
876 change.change_file(FileId(0), gd);
877 change.set_file_path(FileId(0), "res://m.gd");
878 host.apply_change(change);
879 let analysis = host.analysis();
880 let offset = u32::try_from(gd.find(marker).unwrap() + marker.len()).unwrap();
881 let labels: Vec<_> = analysis
882 .completions(FilePosition {
883 file: FileId(0),
884 offset,
885 })
886 .unwrap()
887 .into_iter()
888 .map(|i| i.label)
889 .collect();
890 assert!(
891 labels.iter().any(|l| l == param),
892 "param `{param}` should be offered inside its body for {gd:?}, got {labels:?}",
893 );
894 }
895 }
896
897 #[test]
898 fn goto_definition_on_a_node_path_jumps_into_the_tscn() {
899 let mut host = AnalysisHost::new();
902 let mut change = Change::new();
903 let scene = "[gd_scene format=3]\n\
904 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
905 [node name=\"Root\" type=\"Control\"]\n\
906 script = ExtResource(\"1\")\n\
907 [node name=\"Btn\" type=\"Button\" parent=\".\"]\n";
908 let gd = "extends Control\nfunc _ready():\n\tvar b := $Btn\n";
909 change.change_file(FileId(0), scene);
910 change.set_file_path(FileId(0), "res://main.tscn");
911 change.change_file(FileId(1), gd);
912 change.set_file_path(FileId(1), "res://main.gd");
913 host.apply_change(change);
914 let analysis = host.analysis();
915
916 let offset = u32::try_from(gd.find("$Btn").unwrap() + 1).unwrap(); let targets = analysis
918 .goto_definition(FilePosition {
919 file: FileId(1),
920 offset,
921 })
922 .unwrap();
923 assert_eq!(targets.len(), 1, "{targets:?}");
924 assert_eq!(targets[0].file, FileId(0), "jumps into the .tscn");
925 let focus =
926 &scene[targets[0].focus_range.start as usize..targets[0].focus_range.end as usize];
927 assert!(
928 focus.contains("Btn"),
929 "focus on the node name, got {focus:?}"
930 );
931 }
932
933 #[test]
934 fn find_refs_and_rename_cross_file_through_the_public_api() {
935 let mut host = AnalysisHost::new();
936 let mut change = Change::new();
937 change.change_file(
938 FileId(0),
939 "class_name Widget\nfunc make() -> int:\n\treturn 1\n",
940 );
941 change.set_file_path(FileId(0), "res://widget.gd");
942 change.change_file(
943 FileId(1),
944 "func f():\n\tvar w: Widget\n\tvar x := Widget.new()\n",
945 );
946 change.set_file_path(FileId(1), "res://main.gd");
947 host.apply_change(change);
948 let analysis = host.analysis();
949 let at_decl = FilePosition {
951 file: FileId(0),
952 offset: 11,
953 };
954 let refs = analysis.find_references(at_decl).unwrap();
956 assert_eq!(refs.len(), 3, "{refs:?}");
957 let edit = analysis
959 .rename(at_decl, "Gadget")
960 .unwrap()
961 .expect("rename ok");
962 assert_eq!(edit.edits.len(), 2, "both files edited");
963 }
964
965 #[test]
966 fn removing_a_file_clears_it() {
967 let (mut host, file) = host_with("var x = 1\n");
968 let mut change = Change::new();
969 change.remove_file(file);
970 host.apply_change(change);
971 let analysis = host.analysis();
972 assert!(analysis.document_symbols(file).unwrap().is_empty());
973 }
974}