1#![cfg_attr(docsrs, feature(doc_cfg))]
18
19use std::sync::Arc;
20
21use gdscript_base::{
22 Cancellable, CodeAction, CompletionItem, Diagnostic, DocumentSymbol, FileId, FilePosition,
23 FoldRange, HoverResult, InlayHint, SignatureHelp,
24};
25use gdscript_db::{Db, RootDatabase};
26use salsa::Durability;
27
28pub use gdscript_db::WarningOverride;
31
32mod features;
33mod navigation;
34mod semantic;
35mod semantic_tokens;
36
37fn catch<T>(f: impl FnOnce() -> T) -> Cancellable<T> {
42 salsa::Cancelled::catch(std::panic::AssertUnwindSafe(f)).map_err(|_| gdscript_base::Cancelled)
43}
44
45#[derive(Debug, Clone, Default)]
50pub struct AnalysisHost {
51 db: RootDatabase,
52}
53
54#[derive(Debug, Default)]
56pub struct Change {
57 pub files: Vec<(FileId, Option<Arc<str>>)>,
59 pub paths: Vec<(FileId, String)>,
64 pub project_config: Option<Arc<str>>,
67}
68
69impl Change {
70 #[must_use]
72 pub fn new() -> Self {
73 Self::default()
74 }
75
76 pub fn change_file(&mut self, file: FileId, text: impl Into<Arc<str>>) {
78 self.files.push((file, Some(text.into())));
79 }
80
81 pub fn remove_file(&mut self, file: FileId) {
83 self.files.push((file, None));
84 }
85
86 pub fn set_file_path(&mut self, file: FileId, path: impl Into<String>) {
89 self.paths.push((file, path.into()));
90 }
91
92 pub fn set_project_config(&mut self, text: impl Into<Arc<str>>) {
95 self.project_config = Some(text.into());
96 }
97}
98
99impl AnalysisHost {
100 #[must_use]
102 pub fn new() -> Self {
103 Self::default()
104 }
105
106 pub fn apply_change(&mut self, change: Change) {
110 let mut structure_changed = false;
111 for (id, text) in change.files {
112 if let Some(t) = text {
113 structure_changed |= self.db.file_text(id).is_none();
115 self.db.set_file_text(id, &t, Durability::LOW);
116 } else {
117 structure_changed |= self.db.file_text(id).is_some();
118 self.db.remove_file(id);
119 }
120 }
121 for (id, path) in change.paths {
125 self.db.set_file_path(id, &path);
126 }
127 if let Some(text) = change.project_config {
130 self.db.set_project_config(&text);
131 }
132 if structure_changed {
135 self.db.sync_source_root();
136 }
137 }
138
139 pub fn set_engine_api(&mut self, bytes: &[u8]) -> bool {
147 match gdscript_api::EngineApi::from_bytes(bytes) {
148 Ok(api) => {
149 self.db.set_engine_api(api);
150 true
151 }
152 Err(_) => false,
153 }
154 }
155
156 pub fn set_warning_override(&mut self, ov: gdscript_db::WarningOverride) {
160 self.db.set_warning_override(ov);
161 }
162
163 #[must_use]
165 pub fn analysis(&self) -> Analysis {
166 Analysis {
167 db: self.db.clone(),
168 }
169 }
170}
171
172#[derive(Debug, Clone)]
176pub struct Analysis {
177 db: RootDatabase,
178}
179
180impl Analysis {
181 pub fn syntax_tree(&self, file: FileId) -> Cancellable<Option<String>> {
188 catch(|| {
189 self.db
190 .file_text(file)
191 .map(|ft| gdscript_db::parse(&self.db, ft).debug_tree())
192 })
193 }
194
195 pub fn diagnostics(&self, file: FileId) -> Cancellable<Vec<Diagnostic>> {
200 catch(|| {
201 self.db
202 .file_text(file)
203 .map(|ft| {
204 let mut diags = features::diagnostics(&self.db, ft);
205 diags.extend(semantic::type_diagnostics(&self.db, ft));
206 diags
207 })
208 .unwrap_or_default()
209 })
210 }
211
212 pub fn format(&self, file: FileId) -> Cancellable<Option<String>> {
219 catch(|| {
220 self.db.file_text(file).map(|ft| {
221 gdscript_fmt::format(ft.text(&self.db), &gdscript_fmt::FmtConfig::default())
222 })
223 })
224 }
225
226 pub fn document_symbols(&self, file: FileId) -> Cancellable<Vec<DocumentSymbol>> {
231 catch(|| {
232 self.db
233 .file_text(file)
234 .map(|ft| features::document_symbols(&self.db, ft))
235 .unwrap_or_default()
236 })
237 }
238
239 pub fn semantic_tokens(&self, file: FileId) -> Cancellable<Vec<gdscript_base::SemanticToken>> {
246 catch(|| {
247 self.db
248 .file_text(file)
249 .map(|ft| semantic_tokens::semantic_tokens(&self.db, ft))
250 .unwrap_or_default()
251 })
252 }
253
254 pub fn folding_ranges(&self, file: FileId) -> Cancellable<Vec<FoldRange>> {
259 catch(|| {
260 self.db
261 .file_text(file)
262 .map(|ft| features::folding_ranges(&self.db, ft))
263 .unwrap_or_default()
264 })
265 }
266
267 pub fn completions(&self, pos: FilePosition) -> Cancellable<Vec<CompletionItem>> {
274 catch(|| {
275 self.db
276 .file_text(pos.file)
277 .map(|ft| {
278 semantic::node_path_completions(&self.db, ft, pos.offset)
279 .or_else(|| semantic::member_completions(&self.db, ft, pos.offset))
280 .unwrap_or_else(|| features::completions(&self.db, ft, pos.offset))
281 })
282 .unwrap_or_default()
283 })
284 }
285
286 pub fn hover(&self, pos: FilePosition) -> Cancellable<Option<HoverResult>> {
292 catch(|| {
293 self.db
294 .file_text(pos.file)
295 .and_then(|ft| semantic::hover(&self.db, ft, pos.offset))
296 })
297 }
298
299 pub fn inlay_hints(&self, file: FileId) -> Cancellable<Vec<InlayHint>> {
305 catch(|| {
306 self.db
307 .file_text(file)
308 .map(|ft| semantic::inlay_hints(&self.db, ft))
309 .unwrap_or_default()
310 })
311 }
312
313 pub fn signature_help(&self, pos: FilePosition) -> Cancellable<Option<SignatureHelp>> {
318 catch(|| {
319 self.db
320 .file_text(pos.file)
321 .and_then(|ft| semantic::signature_help(&self.db, ft, pos.offset))
322 })
323 }
324
325 pub fn code_actions(&self, pos: FilePosition) -> Cancellable<Vec<CodeAction>> {
330 catch(|| {
331 self.db
332 .file_text(pos.file)
333 .map(|ft| semantic::code_actions(&self.db, ft, pos.offset))
334 .unwrap_or_default()
335 })
336 }
337
338 pub fn goto_definition(&self, pos: FilePosition) -> Cancellable<Vec<gdscript_base::NavTarget>> {
343 catch(|| navigation::goto_definition(&self.db, pos))
344 }
345
346 pub fn find_references(&self, pos: FilePosition) -> Cancellable<Vec<gdscript_base::Reference>> {
351 catch(|| navigation::find_references(&self.db, pos))
352 }
353
354 pub fn rename(
361 &self,
362 pos: FilePosition,
363 new_name: &str,
364 ) -> Cancellable<Result<gdscript_base::SourceChange, gdscript_base::RenameError>> {
365 catch(|| navigation::rename(&self.db, pos, new_name))
366 }
367
368 pub fn workspace_symbols(&self, query: &str) -> Cancellable<Vec<gdscript_base::NavTarget>> {
373 catch(|| navigation::workspace_symbols(&self.db, query))
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 fn host_with(src: &str) -> (AnalysisHost, FileId) {
382 let mut host = AnalysisHost::new();
383 let file = FileId(0);
384 let mut change = Change::new();
385 change.change_file(file, src);
386 host.apply_change(change);
387 (host, file)
388 }
389
390 #[test]
391 fn snapshot_reads_applied_files() {
392 let (host, file) = host_with("func f():\n\tpass\n");
393 let analysis = host.analysis();
394 let symbols = analysis.document_symbols(file).unwrap();
395 assert_eq!(symbols.len(), 1);
396 assert_eq!(symbols[0].name, "f");
397 }
398
399 #[test]
400 fn preload_resolves_cross_file_through_the_public_api() {
401 let mut host = AnalysisHost::new();
404 let mut change = Change::new();
405 change.change_file(
406 FileId(0),
407 "class_name Markup\nfunc parse() -> int:\n\treturn 1\n",
408 );
409 change.set_file_path(FileId(0), "res://markup.gd");
410 change.change_file(
411 FileId(1),
412 "const M = preload(\"res://markup.gd\")\nfunc go():\n\tvar n := M.new().parse()\n\treturn n\n",
413 );
414 change.set_file_path(FileId(1), "res://main.gd");
415 host.apply_change(change);
416 let analysis = host.analysis();
417
418 assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
420 let hints = analysis.inlay_hints(FileId(1)).unwrap();
423 assert!(
424 hints.iter().any(|h| h.label.contains("int")),
425 "expected an `: int` inlay on the preload-resolved binding, got {hints:?}",
426 );
427 }
428
429 #[test]
430 fn autoload_resolves_cross_file_through_the_public_api() {
431 let mut host = AnalysisHost::new();
434 let mut change = Change::new();
435 change.change_file(FileId(0), "func volume() -> int:\n\treturn 50\n");
436 change.set_file_path(FileId(0), "res://audio.gd");
437 change.change_file(
438 FileId(1),
439 "func go():\n\tvar v := Audio.volume()\n\treturn v\n",
440 );
441 change.set_file_path(FileId(1), "res://main.gd");
442 change.set_project_config("[autoload]\nAudio=\"*res://audio.gd\"\n");
443 host.apply_change(change);
444 let analysis = host.analysis();
445
446 assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
447 let hints = analysis.inlay_hints(FileId(1)).unwrap();
449 assert!(
450 hints.iter().any(|h| h.label.contains("int")),
451 "expected an `: int` inlay on the autoload-resolved binding, got {hints:?}",
452 );
453 }
454
455 #[test]
456 fn scene_node_path_typing_through_the_public_api() {
457 let mut host = AnalysisHost::new();
460 let mut change = Change::new();
461 change.change_file(
462 FileId(0),
463 "[gd_scene format=3]\n\
464 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
465 [node name=\"Root\" type=\"Control\"]\n\
466 script = ExtResource(\"1\")\n\
467 [node name=\"Btn\" type=\"Button\" parent=\".\"]\n",
468 );
469 change.set_file_path(FileId(0), "res://main.tscn");
470 change.change_file(
471 FileId(1),
472 "extends Control\nfunc _ready():\n\tvar b := $Btn\n\tb.show()\n",
473 );
474 change.set_file_path(FileId(1), "res://main.gd");
475 host.apply_change(change);
476 let analysis = host.analysis();
477
478 assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
479 let hints = analysis.inlay_hints(FileId(1)).unwrap();
480 assert!(
481 hints.iter().any(|h| h.label.contains("Button")),
482 "expected a `: Button` inlay on `var b := $Btn`, got {hints:?}",
483 );
484 }
485
486 #[test]
487 fn node_path_completion_offers_scene_children() {
488 let mut host = AnalysisHost::new();
490 let mut change = Change::new();
491 change.change_file(
492 FileId(0),
493 "[gd_scene format=3]\n\
494 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
495 [node name=\"Root\" type=\"Control\"]\n\
496 script = ExtResource(\"1\")\n\
497 [node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
498 [node name=\"Ok\" type=\"Button\" parent=\"Panel\"]\n\
499 [node name=\"Cancel\" type=\"Button\" parent=\"Panel\"]\n",
500 );
501 change.set_file_path(FileId(0), "res://main.tscn");
502 let gd = "extends Control\nfunc _ready():\n\tvar b := $Panel/\n";
503 change.change_file(FileId(1), gd);
504 change.set_file_path(FileId(1), "res://main.gd");
505 host.apply_change(change);
506 let analysis = host.analysis();
507
508 let offset = u32::try_from(gd.find("$Panel/").unwrap() + "$Panel/".len()).unwrap();
509 let items = analysis
510 .completions(FilePosition {
511 file: FileId(1),
512 offset,
513 })
514 .unwrap();
515 let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
516 assert!(
517 labels.contains(&"Ok") && labels.contains(&"Cancel"),
518 "{labels:?}"
519 );
520 assert!(
522 items
523 .iter()
524 .find(|i| i.label == "Ok")
525 .is_some_and(|i| i.detail.as_deref() == Some("Button")),
526 "{items:?}",
527 );
528 assert!(
529 !labels.contains(&"func"),
530 "should be node-path, not keyword, completion"
531 );
532 }
533
534 #[test]
535 fn node_path_completion_does_not_hijack_inside_a_string_literal() {
536 let mut host = AnalysisHost::new();
539 let mut change = Change::new();
540 change.change_file(
541 FileId(0),
542 "[gd_scene format=3]\n\
543 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
544 [node name=\"Root\" type=\"Control\"]\n\
545 script = ExtResource(\"1\")\n\
546 [node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
547 [node name=\"Ok\" type=\"Button\" parent=\"Panel\"]\n",
548 );
549 change.set_file_path(FileId(0), "res://main.tscn");
550 let gd = "extends Control\nfunc _ready():\n\tvar s := \"$Panel/\"\n";
551 change.change_file(FileId(1), gd);
552 change.set_file_path(FileId(1), "res://main.gd");
553 host.apply_change(change);
554 let analysis = host.analysis();
555
556 let offset = u32::try_from(gd.find("$Panel/").unwrap() + "$Panel/".len()).unwrap();
558 let items = analysis
559 .completions(FilePosition {
560 file: FileId(1),
561 offset,
562 })
563 .unwrap();
564 assert!(
565 !items.iter().any(|i| i.label == "Ok"),
566 "node names must not leak into a string literal: {items:?}",
567 );
568 }
569
570 #[test]
571 fn unique_node_path_completion_offers_children() {
572 let mut host = AnalysisHost::new();
574 let mut change = Change::new();
575 let scene = "[gd_scene format=3]\n\
576 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
577 [node name=\"Root\" type=\"Control\"]\n\
578 script = ExtResource(\"1\")\n\
579 [node name=\"Box\" type=\"Panel\" parent=\".\"]\n\
580 unique_name_in_owner = true\n\
581 [node name=\"Ok\" type=\"Button\" parent=\"Box\"]\n\
582 [node name=\"Cancel\" type=\"Button\" parent=\"Box\"]\n";
583 change.change_file(FileId(0), scene);
584 change.set_file_path(FileId(0), "res://main.tscn");
585 let gd = "extends Control\nfunc _ready():\n\tvar b := %Box/\n";
586 change.change_file(FileId(1), gd);
587 change.set_file_path(FileId(1), "res://main.gd");
588 host.apply_change(change);
589 let analysis = host.analysis();
590 let offset = u32::try_from(gd.find("%Box/").unwrap() + "%Box/".len()).unwrap();
591 let items = analysis
592 .completions(FilePosition {
593 file: FileId(1),
594 offset,
595 })
596 .unwrap();
597 let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
598 assert!(
599 labels.contains(&"Ok") && labels.contains(&"Cancel"),
600 "{labels:?}"
601 );
602 assert!(
603 !labels.contains(&"func"),
604 "node-path, not keyword completion"
605 );
606 }
607
608 #[test]
609 fn bare_percent_offers_all_unique_nodes() {
610 let mut host = AnalysisHost::new();
612 let mut change = Change::new();
613 let scene = "[gd_scene format=3]\n\
614 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
615 [node name=\"Root\" type=\"Control\"]\n\
616 script = ExtResource(\"1\")\n\
617 [node name=\"Box\" type=\"Panel\" parent=\".\"]\n\
618 unique_name_in_owner = true\n\
619 [node name=\"Hud\" type=\"Control\" parent=\".\"]\n\
620 unique_name_in_owner = true\n";
621 change.change_file(FileId(0), scene);
622 change.set_file_path(FileId(0), "res://main.tscn");
623 let gd = "extends Control\nfunc _ready():\n\tvar b := %\n";
624 change.change_file(FileId(1), gd);
625 change.set_file_path(FileId(1), "res://main.gd");
626 host.apply_change(change);
627 let analysis = host.analysis();
628 let offset = u32::try_from(gd.find("%\n").unwrap() + 1).unwrap();
629 let labels: Vec<_> = analysis
630 .completions(FilePosition {
631 file: FileId(1),
632 offset,
633 })
634 .unwrap()
635 .into_iter()
636 .map(|i| i.label)
637 .collect();
638 assert!(
639 labels.iter().any(|l| l == "Box") && labels.iter().any(|l| l == "Hud"),
640 "{labels:?}"
641 );
642 }
643
644 #[test]
645 fn percent_modulo_is_not_hijacked_as_a_unique_path() {
646 let mut host = AnalysisHost::new();
649 let mut change = Change::new();
650 let scene = "[gd_scene format=3]\n\
651 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
652 [node name=\"Root\" type=\"Control\"]\n\
653 script = ExtResource(\"1\")\n\
654 [node name=\"Box\" type=\"Panel\" parent=\".\"]\n\
655 unique_name_in_owner = true\n";
656 change.change_file(FileId(0), scene);
657 change.set_file_path(FileId(0), "res://main.tscn");
658 let gd = "extends Control\nfunc _ready():\n\tvar count := 10\n\tvar b := count %Box\n";
659 change.change_file(FileId(1), gd);
660 change.set_file_path(FileId(1), "res://main.gd");
661 host.apply_change(change);
662 let analysis = host.analysis();
663 let offset = u32::try_from(gd.find("%Box").unwrap() + "%Box".len()).unwrap();
664 let labels: Vec<_> = analysis
665 .completions(FilePosition {
666 file: FileId(1),
667 offset,
668 })
669 .unwrap()
670 .into_iter()
671 .map(|i| i.label)
672 .collect();
673 assert!(
675 labels.iter().any(|l| l == "func"),
676 "expected by-name completion: {labels:?}"
677 );
678 }
679
680 #[test]
681 fn completion_is_scope_aware_for_locals_and_params() {
682 let mut host = AnalysisHost::new();
687 let mut change = Change::new();
688 let gd = "var member_v := 0\nfunc a(pa):\n\tvar la := 1\n\t\nfunc b(pb):\n\tvar lb := 2\n";
689 change.change_file(FileId(0), gd);
690 change.set_file_path(FileId(0), "res://m.gd");
691 host.apply_change(change);
692 let analysis = host.analysis();
693
694 let upto = "var member_v := 0\nfunc a(pa):\n\tvar la := 1\n\t";
696 let offset = u32::try_from(gd.find(upto).unwrap() + upto.len()).unwrap();
697 let items = analysis
698 .completions(FilePosition {
699 file: FileId(0),
700 offset,
701 })
702 .unwrap();
703 let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
704 assert!(labels.contains(&"pa"), "own param `pa`: {labels:?}");
706 assert!(labels.contains(&"la"), "own local `la`: {labels:?}");
707 assert!(labels.contains(&"member_v"), "class member: {labels:?}");
708 assert!(
709 labels.contains(&"a") && labels.contains(&"b"),
710 "sibling func names: {labels:?}",
711 );
712 assert!(!labels.contains(&"pb"), "leaked b's param: {labels:?}");
714 assert!(!labels.contains(&"lb"), "leaked b's local: {labels:?}");
715 }
716
717 #[test]
718 fn completion_at_class_level_offers_members_not_locals() {
719 let mut host = AnalysisHost::new();
721 let mut change = Change::new();
722 let gd = "var member_v := 0\nfunc a():\n\tvar la := 1\n\nm\n";
723 change.change_file(FileId(0), gd);
724 change.set_file_path(FileId(0), "res://m.gd");
725 host.apply_change(change);
726 let analysis = host.analysis();
727 let offset = u32::try_from(gd.rfind('m').unwrap() + 1).unwrap();
729 let items = analysis
730 .completions(FilePosition {
731 file: FileId(0),
732 offset,
733 })
734 .unwrap();
735 let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
736 assert!(
737 labels.contains(&"member_v") && labels.contains(&"a"),
738 "{labels:?}"
739 );
740 assert!(
741 !labels.contains(&"la"),
742 "a()'s local must not leak to class level: {labels:?}"
743 );
744 }
745
746 #[test]
747 fn completion_offers_params_in_lambda_setter_and_inline_bodies() {
748 let cases = [
752 ("var f := func(px):\n\treturn px\n", "px", "return "),
754 ("var x: int:\n\tset(sv):\n\t\t_x = sv\n", "sv", "_x = "),
755 ("func foo(ia): return ia\n", "ia", "return "),
756 ];
757 for (gd, param, marker) in cases {
758 let mut host = AnalysisHost::new();
759 let mut change = Change::new();
760 change.change_file(FileId(0), gd);
761 change.set_file_path(FileId(0), "res://m.gd");
762 host.apply_change(change);
763 let analysis = host.analysis();
764 let offset = u32::try_from(gd.find(marker).unwrap() + marker.len()).unwrap();
765 let labels: Vec<_> = analysis
766 .completions(FilePosition {
767 file: FileId(0),
768 offset,
769 })
770 .unwrap()
771 .into_iter()
772 .map(|i| i.label)
773 .collect();
774 assert!(
775 labels.iter().any(|l| l == param),
776 "param `{param}` should be offered inside its body for {gd:?}, got {labels:?}",
777 );
778 }
779 }
780
781 #[test]
782 fn goto_definition_on_a_node_path_jumps_into_the_tscn() {
783 let mut host = AnalysisHost::new();
786 let mut change = Change::new();
787 let scene = "[gd_scene format=3]\n\
788 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
789 [node name=\"Root\" type=\"Control\"]\n\
790 script = ExtResource(\"1\")\n\
791 [node name=\"Btn\" type=\"Button\" parent=\".\"]\n";
792 let gd = "extends Control\nfunc _ready():\n\tvar b := $Btn\n";
793 change.change_file(FileId(0), scene);
794 change.set_file_path(FileId(0), "res://main.tscn");
795 change.change_file(FileId(1), gd);
796 change.set_file_path(FileId(1), "res://main.gd");
797 host.apply_change(change);
798 let analysis = host.analysis();
799
800 let offset = u32::try_from(gd.find("$Btn").unwrap() + 1).unwrap(); let targets = analysis
802 .goto_definition(FilePosition {
803 file: FileId(1),
804 offset,
805 })
806 .unwrap();
807 assert_eq!(targets.len(), 1, "{targets:?}");
808 assert_eq!(targets[0].file, FileId(0), "jumps into the .tscn");
809 let focus =
810 &scene[targets[0].focus_range.start as usize..targets[0].focus_range.end as usize];
811 assert!(
812 focus.contains("Btn"),
813 "focus on the node name, got {focus:?}"
814 );
815 }
816
817 #[test]
818 fn find_refs_and_rename_cross_file_through_the_public_api() {
819 let mut host = AnalysisHost::new();
820 let mut change = Change::new();
821 change.change_file(
822 FileId(0),
823 "class_name Widget\nfunc make() -> int:\n\treturn 1\n",
824 );
825 change.set_file_path(FileId(0), "res://widget.gd");
826 change.change_file(
827 FileId(1),
828 "func f():\n\tvar w: Widget\n\tvar x := Widget.new()\n",
829 );
830 change.set_file_path(FileId(1), "res://main.gd");
831 host.apply_change(change);
832 let analysis = host.analysis();
833 let at_decl = FilePosition {
835 file: FileId(0),
836 offset: 11,
837 };
838 let refs = analysis.find_references(at_decl).unwrap();
840 assert_eq!(refs.len(), 3, "{refs:?}");
841 let edit = analysis
843 .rename(at_decl, "Gadget")
844 .unwrap()
845 .expect("rename ok");
846 assert_eq!(edit.edits.len(), 2, "both files edited");
847 }
848
849 #[test]
850 fn removing_a_file_clears_it() {
851 let (mut host, file) = host_with("var x = 1\n");
852 let mut change = Change::new();
853 change.remove_file(file);
854 host.apply_change(change);
855 let analysis = host.analysis();
856 assert!(analysis.document_symbols(file).unwrap().is_empty());
857 }
858}