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
28mod features;
29mod navigation;
30mod semantic;
31mod semantic_tokens;
32
33fn catch<T>(f: impl FnOnce() -> T) -> Cancellable<T> {
38 salsa::Cancelled::catch(std::panic::AssertUnwindSafe(f)).map_err(|_| gdscript_base::Cancelled)
39}
40
41#[derive(Debug, Clone, Default)]
46pub struct AnalysisHost {
47 db: RootDatabase,
48}
49
50#[derive(Debug, Default)]
52pub struct Change {
53 pub files: Vec<(FileId, Option<Arc<str>>)>,
55 pub paths: Vec<(FileId, String)>,
60 pub project_config: Option<Arc<str>>,
63}
64
65impl Change {
66 #[must_use]
68 pub fn new() -> Self {
69 Self::default()
70 }
71
72 pub fn change_file(&mut self, file: FileId, text: impl Into<Arc<str>>) {
74 self.files.push((file, Some(text.into())));
75 }
76
77 pub fn remove_file(&mut self, file: FileId) {
79 self.files.push((file, None));
80 }
81
82 pub fn set_file_path(&mut self, file: FileId, path: impl Into<String>) {
85 self.paths.push((file, path.into()));
86 }
87
88 pub fn set_project_config(&mut self, text: impl Into<Arc<str>>) {
91 self.project_config = Some(text.into());
92 }
93}
94
95impl AnalysisHost {
96 #[must_use]
98 pub fn new() -> Self {
99 Self::default()
100 }
101
102 pub fn apply_change(&mut self, change: Change) {
106 let mut structure_changed = false;
107 for (id, text) in change.files {
108 if let Some(t) = text {
109 structure_changed |= self.db.file_text(id).is_none();
111 self.db.set_file_text(id, &t, Durability::LOW);
112 } else {
113 structure_changed |= self.db.file_text(id).is_some();
114 self.db.remove_file(id);
115 }
116 }
117 for (id, path) in change.paths {
121 self.db.set_file_path(id, &path);
122 }
123 if let Some(text) = change.project_config {
126 self.db.set_project_config(&text);
127 }
128 if structure_changed {
131 self.db.sync_source_root();
132 }
133 }
134
135 pub fn set_engine_api(&mut self, bytes: &[u8]) -> bool {
143 match gdscript_api::EngineApi::from_bytes(bytes) {
144 Ok(api) => {
145 self.db.set_engine_api(api);
146 true
147 }
148 Err(_) => false,
149 }
150 }
151
152 #[must_use]
154 pub fn analysis(&self) -> Analysis {
155 Analysis {
156 db: self.db.clone(),
157 }
158 }
159}
160
161#[derive(Debug, Clone)]
165pub struct Analysis {
166 db: RootDatabase,
167}
168
169impl Analysis {
170 pub fn syntax_tree(&self, file: FileId) -> Cancellable<Option<String>> {
177 catch(|| {
178 self.db
179 .file_text(file)
180 .map(|ft| gdscript_db::parse(&self.db, ft).debug_tree())
181 })
182 }
183
184 pub fn diagnostics(&self, file: FileId) -> Cancellable<Vec<Diagnostic>> {
189 catch(|| {
190 self.db
191 .file_text(file)
192 .map(|ft| {
193 let mut diags = features::diagnostics(&self.db, ft);
194 diags.extend(semantic::type_diagnostics(&self.db, ft));
195 diags
196 })
197 .unwrap_or_default()
198 })
199 }
200
201 pub fn format(&self, file: FileId) -> Cancellable<Option<String>> {
208 catch(|| {
209 self.db.file_text(file).map(|ft| {
210 gdscript_fmt::format(ft.text(&self.db), &gdscript_fmt::FmtConfig::default())
211 })
212 })
213 }
214
215 pub fn document_symbols(&self, file: FileId) -> Cancellable<Vec<DocumentSymbol>> {
220 catch(|| {
221 self.db
222 .file_text(file)
223 .map(|ft| features::document_symbols(&self.db, ft))
224 .unwrap_or_default()
225 })
226 }
227
228 pub fn semantic_tokens(&self, file: FileId) -> Cancellable<Vec<gdscript_base::SemanticToken>> {
235 catch(|| {
236 self.db
237 .file_text(file)
238 .map(|ft| semantic_tokens::semantic_tokens(&self.db, ft))
239 .unwrap_or_default()
240 })
241 }
242
243 pub fn folding_ranges(&self, file: FileId) -> Cancellable<Vec<FoldRange>> {
248 catch(|| {
249 self.db
250 .file_text(file)
251 .map(|ft| features::folding_ranges(&self.db, ft))
252 .unwrap_or_default()
253 })
254 }
255
256 pub fn completions(&self, pos: FilePosition) -> Cancellable<Vec<CompletionItem>> {
263 catch(|| {
264 self.db
265 .file_text(pos.file)
266 .map(|ft| {
267 semantic::node_path_completions(&self.db, ft, pos.offset)
268 .or_else(|| semantic::member_completions(&self.db, ft, pos.offset))
269 .unwrap_or_else(|| features::completions(&self.db, ft, pos.offset))
270 })
271 .unwrap_or_default()
272 })
273 }
274
275 pub fn hover(&self, pos: FilePosition) -> Cancellable<Option<HoverResult>> {
281 catch(|| {
282 self.db
283 .file_text(pos.file)
284 .and_then(|ft| semantic::hover(&self.db, ft, pos.offset))
285 })
286 }
287
288 pub fn inlay_hints(&self, file: FileId) -> Cancellable<Vec<InlayHint>> {
294 catch(|| {
295 self.db
296 .file_text(file)
297 .map(|ft| semantic::inlay_hints(&self.db, ft))
298 .unwrap_or_default()
299 })
300 }
301
302 pub fn signature_help(&self, pos: FilePosition) -> Cancellable<Option<SignatureHelp>> {
307 catch(|| {
308 self.db
309 .file_text(pos.file)
310 .and_then(|ft| semantic::signature_help(&self.db, ft, pos.offset))
311 })
312 }
313
314 pub fn code_actions(&self, pos: FilePosition) -> Cancellable<Vec<CodeAction>> {
319 catch(|| {
320 self.db
321 .file_text(pos.file)
322 .map(|ft| semantic::code_actions(&self.db, ft, pos.offset))
323 .unwrap_or_default()
324 })
325 }
326
327 pub fn goto_definition(&self, pos: FilePosition) -> Cancellable<Vec<gdscript_base::NavTarget>> {
332 catch(|| navigation::goto_definition(&self.db, pos))
333 }
334
335 pub fn find_references(&self, pos: FilePosition) -> Cancellable<Vec<gdscript_base::Reference>> {
340 catch(|| navigation::find_references(&self.db, pos))
341 }
342
343 pub fn rename(
350 &self,
351 pos: FilePosition,
352 new_name: &str,
353 ) -> Cancellable<Result<gdscript_base::SourceChange, gdscript_base::RenameError>> {
354 catch(|| navigation::rename(&self.db, pos, new_name))
355 }
356
357 pub fn workspace_symbols(&self, query: &str) -> Cancellable<Vec<gdscript_base::NavTarget>> {
362 catch(|| navigation::workspace_symbols(&self.db, query))
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369
370 fn host_with(src: &str) -> (AnalysisHost, FileId) {
371 let mut host = AnalysisHost::new();
372 let file = FileId(0);
373 let mut change = Change::new();
374 change.change_file(file, src);
375 host.apply_change(change);
376 (host, file)
377 }
378
379 #[test]
380 fn snapshot_reads_applied_files() {
381 let (host, file) = host_with("func f():\n\tpass\n");
382 let analysis = host.analysis();
383 let symbols = analysis.document_symbols(file).unwrap();
384 assert_eq!(symbols.len(), 1);
385 assert_eq!(symbols[0].name, "f");
386 }
387
388 #[test]
389 fn preload_resolves_cross_file_through_the_public_api() {
390 let mut host = AnalysisHost::new();
393 let mut change = Change::new();
394 change.change_file(
395 FileId(0),
396 "class_name Markup\nfunc parse() -> int:\n\treturn 1\n",
397 );
398 change.set_file_path(FileId(0), "res://markup.gd");
399 change.change_file(
400 FileId(1),
401 "const M = preload(\"res://markup.gd\")\nfunc go():\n\tvar n := M.new().parse()\n\treturn n\n",
402 );
403 change.set_file_path(FileId(1), "res://main.gd");
404 host.apply_change(change);
405 let analysis = host.analysis();
406
407 assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
409 let hints = analysis.inlay_hints(FileId(1)).unwrap();
412 assert!(
413 hints.iter().any(|h| h.label.contains("int")),
414 "expected an `: int` inlay on the preload-resolved binding, got {hints:?}",
415 );
416 }
417
418 #[test]
419 fn autoload_resolves_cross_file_through_the_public_api() {
420 let mut host = AnalysisHost::new();
423 let mut change = Change::new();
424 change.change_file(FileId(0), "func volume() -> int:\n\treturn 50\n");
425 change.set_file_path(FileId(0), "res://audio.gd");
426 change.change_file(
427 FileId(1),
428 "func go():\n\tvar v := Audio.volume()\n\treturn v\n",
429 );
430 change.set_file_path(FileId(1), "res://main.gd");
431 change.set_project_config("[autoload]\nAudio=\"*res://audio.gd\"\n");
432 host.apply_change(change);
433 let analysis = host.analysis();
434
435 assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
436 let hints = analysis.inlay_hints(FileId(1)).unwrap();
438 assert!(
439 hints.iter().any(|h| h.label.contains("int")),
440 "expected an `: int` inlay on the autoload-resolved binding, got {hints:?}",
441 );
442 }
443
444 #[test]
445 fn scene_node_path_typing_through_the_public_api() {
446 let mut host = AnalysisHost::new();
449 let mut change = Change::new();
450 change.change_file(
451 FileId(0),
452 "[gd_scene format=3]\n\
453 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
454 [node name=\"Root\" type=\"Control\"]\n\
455 script = ExtResource(\"1\")\n\
456 [node name=\"Btn\" type=\"Button\" parent=\".\"]\n",
457 );
458 change.set_file_path(FileId(0), "res://main.tscn");
459 change.change_file(
460 FileId(1),
461 "extends Control\nfunc _ready():\n\tvar b := $Btn\n\tb.show()\n",
462 );
463 change.set_file_path(FileId(1), "res://main.gd");
464 host.apply_change(change);
465 let analysis = host.analysis();
466
467 assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
468 let hints = analysis.inlay_hints(FileId(1)).unwrap();
469 assert!(
470 hints.iter().any(|h| h.label.contains("Button")),
471 "expected a `: Button` inlay on `var b := $Btn`, got {hints:?}",
472 );
473 }
474
475 #[test]
476 fn node_path_completion_offers_scene_children() {
477 let mut host = AnalysisHost::new();
479 let mut change = Change::new();
480 change.change_file(
481 FileId(0),
482 "[gd_scene format=3]\n\
483 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
484 [node name=\"Root\" type=\"Control\"]\n\
485 script = ExtResource(\"1\")\n\
486 [node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
487 [node name=\"Ok\" type=\"Button\" parent=\"Panel\"]\n\
488 [node name=\"Cancel\" type=\"Button\" parent=\"Panel\"]\n",
489 );
490 change.set_file_path(FileId(0), "res://main.tscn");
491 let gd = "extends Control\nfunc _ready():\n\tvar b := $Panel/\n";
492 change.change_file(FileId(1), gd);
493 change.set_file_path(FileId(1), "res://main.gd");
494 host.apply_change(change);
495 let analysis = host.analysis();
496
497 let offset = u32::try_from(gd.find("$Panel/").unwrap() + "$Panel/".len()).unwrap();
498 let items = analysis
499 .completions(FilePosition {
500 file: FileId(1),
501 offset,
502 })
503 .unwrap();
504 let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
505 assert!(
506 labels.contains(&"Ok") && labels.contains(&"Cancel"),
507 "{labels:?}"
508 );
509 assert!(
511 items
512 .iter()
513 .find(|i| i.label == "Ok")
514 .is_some_and(|i| i.detail.as_deref() == Some("Button")),
515 "{items:?}",
516 );
517 assert!(
518 !labels.contains(&"func"),
519 "should be node-path, not keyword, completion"
520 );
521 }
522
523 #[test]
524 fn node_path_completion_does_not_hijack_inside_a_string_literal() {
525 let mut host = AnalysisHost::new();
528 let mut change = Change::new();
529 change.change_file(
530 FileId(0),
531 "[gd_scene format=3]\n\
532 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
533 [node name=\"Root\" type=\"Control\"]\n\
534 script = ExtResource(\"1\")\n\
535 [node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
536 [node name=\"Ok\" type=\"Button\" parent=\"Panel\"]\n",
537 );
538 change.set_file_path(FileId(0), "res://main.tscn");
539 let gd = "extends Control\nfunc _ready():\n\tvar s := \"$Panel/\"\n";
540 change.change_file(FileId(1), gd);
541 change.set_file_path(FileId(1), "res://main.gd");
542 host.apply_change(change);
543 let analysis = host.analysis();
544
545 let offset = u32::try_from(gd.find("$Panel/").unwrap() + "$Panel/".len()).unwrap();
547 let items = analysis
548 .completions(FilePosition {
549 file: FileId(1),
550 offset,
551 })
552 .unwrap();
553 assert!(
554 !items.iter().any(|i| i.label == "Ok"),
555 "node names must not leak into a string literal: {items:?}",
556 );
557 }
558
559 #[test]
560 fn unique_node_path_completion_offers_children() {
561 let mut host = AnalysisHost::new();
563 let mut change = Change::new();
564 let scene = "[gd_scene format=3]\n\
565 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
566 [node name=\"Root\" type=\"Control\"]\n\
567 script = ExtResource(\"1\")\n\
568 [node name=\"Box\" type=\"Panel\" parent=\".\"]\n\
569 unique_name_in_owner = true\n\
570 [node name=\"Ok\" type=\"Button\" parent=\"Box\"]\n\
571 [node name=\"Cancel\" type=\"Button\" parent=\"Box\"]\n";
572 change.change_file(FileId(0), scene);
573 change.set_file_path(FileId(0), "res://main.tscn");
574 let gd = "extends Control\nfunc _ready():\n\tvar b := %Box/\n";
575 change.change_file(FileId(1), gd);
576 change.set_file_path(FileId(1), "res://main.gd");
577 host.apply_change(change);
578 let analysis = host.analysis();
579 let offset = u32::try_from(gd.find("%Box/").unwrap() + "%Box/".len()).unwrap();
580 let items = analysis
581 .completions(FilePosition {
582 file: FileId(1),
583 offset,
584 })
585 .unwrap();
586 let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
587 assert!(
588 labels.contains(&"Ok") && labels.contains(&"Cancel"),
589 "{labels:?}"
590 );
591 assert!(
592 !labels.contains(&"func"),
593 "node-path, not keyword completion"
594 );
595 }
596
597 #[test]
598 fn bare_percent_offers_all_unique_nodes() {
599 let mut host = AnalysisHost::new();
601 let mut change = Change::new();
602 let scene = "[gd_scene format=3]\n\
603 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
604 [node name=\"Root\" type=\"Control\"]\n\
605 script = ExtResource(\"1\")\n\
606 [node name=\"Box\" type=\"Panel\" parent=\".\"]\n\
607 unique_name_in_owner = true\n\
608 [node name=\"Hud\" type=\"Control\" parent=\".\"]\n\
609 unique_name_in_owner = true\n";
610 change.change_file(FileId(0), scene);
611 change.set_file_path(FileId(0), "res://main.tscn");
612 let gd = "extends Control\nfunc _ready():\n\tvar b := %\n";
613 change.change_file(FileId(1), gd);
614 change.set_file_path(FileId(1), "res://main.gd");
615 host.apply_change(change);
616 let analysis = host.analysis();
617 let offset = u32::try_from(gd.find("%\n").unwrap() + 1).unwrap();
618 let labels: Vec<_> = analysis
619 .completions(FilePosition {
620 file: FileId(1),
621 offset,
622 })
623 .unwrap()
624 .into_iter()
625 .map(|i| i.label)
626 .collect();
627 assert!(
628 labels.iter().any(|l| l == "Box") && labels.iter().any(|l| l == "Hud"),
629 "{labels:?}"
630 );
631 }
632
633 #[test]
634 fn percent_modulo_is_not_hijacked_as_a_unique_path() {
635 let mut host = AnalysisHost::new();
638 let mut change = Change::new();
639 let scene = "[gd_scene format=3]\n\
640 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
641 [node name=\"Root\" type=\"Control\"]\n\
642 script = ExtResource(\"1\")\n\
643 [node name=\"Box\" type=\"Panel\" parent=\".\"]\n\
644 unique_name_in_owner = true\n";
645 change.change_file(FileId(0), scene);
646 change.set_file_path(FileId(0), "res://main.tscn");
647 let gd = "extends Control\nfunc _ready():\n\tvar count := 10\n\tvar b := count %Box\n";
648 change.change_file(FileId(1), gd);
649 change.set_file_path(FileId(1), "res://main.gd");
650 host.apply_change(change);
651 let analysis = host.analysis();
652 let offset = u32::try_from(gd.find("%Box").unwrap() + "%Box".len()).unwrap();
653 let labels: Vec<_> = analysis
654 .completions(FilePosition {
655 file: FileId(1),
656 offset,
657 })
658 .unwrap()
659 .into_iter()
660 .map(|i| i.label)
661 .collect();
662 assert!(
664 labels.iter().any(|l| l == "func"),
665 "expected by-name completion: {labels:?}"
666 );
667 }
668
669 #[test]
670 fn completion_is_scope_aware_for_locals_and_params() {
671 let mut host = AnalysisHost::new();
676 let mut change = Change::new();
677 let gd = "var member_v := 0\nfunc a(pa):\n\tvar la := 1\n\t\nfunc b(pb):\n\tvar lb := 2\n";
678 change.change_file(FileId(0), gd);
679 change.set_file_path(FileId(0), "res://m.gd");
680 host.apply_change(change);
681 let analysis = host.analysis();
682
683 let upto = "var member_v := 0\nfunc a(pa):\n\tvar la := 1\n\t";
685 let offset = u32::try_from(gd.find(upto).unwrap() + upto.len()).unwrap();
686 let items = analysis
687 .completions(FilePosition {
688 file: FileId(0),
689 offset,
690 })
691 .unwrap();
692 let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
693 assert!(labels.contains(&"pa"), "own param `pa`: {labels:?}");
695 assert!(labels.contains(&"la"), "own local `la`: {labels:?}");
696 assert!(labels.contains(&"member_v"), "class member: {labels:?}");
697 assert!(
698 labels.contains(&"a") && labels.contains(&"b"),
699 "sibling func names: {labels:?}",
700 );
701 assert!(!labels.contains(&"pb"), "leaked b's param: {labels:?}");
703 assert!(!labels.contains(&"lb"), "leaked b's local: {labels:?}");
704 }
705
706 #[test]
707 fn completion_at_class_level_offers_members_not_locals() {
708 let mut host = AnalysisHost::new();
710 let mut change = Change::new();
711 let gd = "var member_v := 0\nfunc a():\n\tvar la := 1\n\nm\n";
712 change.change_file(FileId(0), gd);
713 change.set_file_path(FileId(0), "res://m.gd");
714 host.apply_change(change);
715 let analysis = host.analysis();
716 let offset = u32::try_from(gd.rfind('m').unwrap() + 1).unwrap();
718 let items = analysis
719 .completions(FilePosition {
720 file: FileId(0),
721 offset,
722 })
723 .unwrap();
724 let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
725 assert!(
726 labels.contains(&"member_v") && labels.contains(&"a"),
727 "{labels:?}"
728 );
729 assert!(
730 !labels.contains(&"la"),
731 "a()'s local must not leak to class level: {labels:?}"
732 );
733 }
734
735 #[test]
736 fn completion_offers_params_in_lambda_setter_and_inline_bodies() {
737 let cases = [
741 ("var f := func(px):\n\treturn px\n", "px", "return "),
743 ("var x: int:\n\tset(sv):\n\t\t_x = sv\n", "sv", "_x = "),
744 ("func foo(ia): return ia\n", "ia", "return "),
745 ];
746 for (gd, param, marker) in cases {
747 let mut host = AnalysisHost::new();
748 let mut change = Change::new();
749 change.change_file(FileId(0), gd);
750 change.set_file_path(FileId(0), "res://m.gd");
751 host.apply_change(change);
752 let analysis = host.analysis();
753 let offset = u32::try_from(gd.find(marker).unwrap() + marker.len()).unwrap();
754 let labels: Vec<_> = analysis
755 .completions(FilePosition {
756 file: FileId(0),
757 offset,
758 })
759 .unwrap()
760 .into_iter()
761 .map(|i| i.label)
762 .collect();
763 assert!(
764 labels.iter().any(|l| l == param),
765 "param `{param}` should be offered inside its body for {gd:?}, got {labels:?}",
766 );
767 }
768 }
769
770 #[test]
771 fn goto_definition_on_a_node_path_jumps_into_the_tscn() {
772 let mut host = AnalysisHost::new();
775 let mut change = Change::new();
776 let scene = "[gd_scene format=3]\n\
777 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
778 [node name=\"Root\" type=\"Control\"]\n\
779 script = ExtResource(\"1\")\n\
780 [node name=\"Btn\" type=\"Button\" parent=\".\"]\n";
781 let gd = "extends Control\nfunc _ready():\n\tvar b := $Btn\n";
782 change.change_file(FileId(0), scene);
783 change.set_file_path(FileId(0), "res://main.tscn");
784 change.change_file(FileId(1), gd);
785 change.set_file_path(FileId(1), "res://main.gd");
786 host.apply_change(change);
787 let analysis = host.analysis();
788
789 let offset = u32::try_from(gd.find("$Btn").unwrap() + 1).unwrap(); let targets = analysis
791 .goto_definition(FilePosition {
792 file: FileId(1),
793 offset,
794 })
795 .unwrap();
796 assert_eq!(targets.len(), 1, "{targets:?}");
797 assert_eq!(targets[0].file, FileId(0), "jumps into the .tscn");
798 let focus =
799 &scene[targets[0].focus_range.start as usize..targets[0].focus_range.end as usize];
800 assert!(
801 focus.contains("Btn"),
802 "focus on the node name, got {focus:?}"
803 );
804 }
805
806 #[test]
807 fn find_refs_and_rename_cross_file_through_the_public_api() {
808 let mut host = AnalysisHost::new();
809 let mut change = Change::new();
810 change.change_file(
811 FileId(0),
812 "class_name Widget\nfunc make() -> int:\n\treturn 1\n",
813 );
814 change.set_file_path(FileId(0), "res://widget.gd");
815 change.change_file(
816 FileId(1),
817 "func f():\n\tvar w: Widget\n\tvar x := Widget.new()\n",
818 );
819 change.set_file_path(FileId(1), "res://main.gd");
820 host.apply_change(change);
821 let analysis = host.analysis();
822 let at_decl = FilePosition {
824 file: FileId(0),
825 offset: 11,
826 };
827 let refs = analysis.find_references(at_decl).unwrap();
829 assert_eq!(refs.len(), 3, "{refs:?}");
830 let edit = analysis
832 .rename(at_decl, "Gadget")
833 .unwrap()
834 .expect("rename ok");
835 assert_eq!(edit.edits.len(), 2, "both files edited");
836 }
837
838 #[test]
839 fn removing_a_file_clears_it() {
840 let (mut host, file) = host_with("var x = 1\n");
841 let mut change = Change::new();
842 change.remove_file(file);
843 host.apply_change(change);
844 let analysis = host.analysis();
845 assert!(analysis.document_symbols(file).unwrap().is_empty());
846 }
847}