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 document_symbols(&self, file: FileId) -> Cancellable<Vec<DocumentSymbol>> {
206 catch(|| {
207 self.db
208 .file_text(file)
209 .map(|ft| features::document_symbols(&self.db, ft))
210 .unwrap_or_default()
211 })
212 }
213
214 pub fn semantic_tokens(&self, file: FileId) -> Cancellable<Vec<gdscript_base::SemanticToken>> {
221 catch(|| {
222 self.db
223 .file_text(file)
224 .map(|ft| semantic_tokens::semantic_tokens(&self.db, ft))
225 .unwrap_or_default()
226 })
227 }
228
229 pub fn folding_ranges(&self, file: FileId) -> Cancellable<Vec<FoldRange>> {
234 catch(|| {
235 self.db
236 .file_text(file)
237 .map(|ft| features::folding_ranges(&self.db, ft))
238 .unwrap_or_default()
239 })
240 }
241
242 pub fn completions(&self, pos: FilePosition) -> Cancellable<Vec<CompletionItem>> {
249 catch(|| {
250 self.db
251 .file_text(pos.file)
252 .map(|ft| {
253 semantic::node_path_completions(&self.db, ft, pos.offset)
254 .or_else(|| semantic::member_completions(&self.db, ft, pos.offset))
255 .unwrap_or_else(|| features::completions(&self.db, ft, pos.offset))
256 })
257 .unwrap_or_default()
258 })
259 }
260
261 pub fn hover(&self, pos: FilePosition) -> Cancellable<Option<HoverResult>> {
267 catch(|| {
268 self.db
269 .file_text(pos.file)
270 .and_then(|ft| semantic::hover(&self.db, ft, pos.offset))
271 })
272 }
273
274 pub fn inlay_hints(&self, file: FileId) -> Cancellable<Vec<InlayHint>> {
280 catch(|| {
281 self.db
282 .file_text(file)
283 .map(|ft| semantic::inlay_hints(&self.db, ft))
284 .unwrap_or_default()
285 })
286 }
287
288 pub fn signature_help(&self, pos: FilePosition) -> Cancellable<Option<SignatureHelp>> {
293 catch(|| {
294 self.db
295 .file_text(pos.file)
296 .and_then(|ft| semantic::signature_help(&self.db, ft, pos.offset))
297 })
298 }
299
300 pub fn code_actions(&self, pos: FilePosition) -> Cancellable<Vec<CodeAction>> {
305 catch(|| {
306 self.db
307 .file_text(pos.file)
308 .map(|ft| semantic::code_actions(&self.db, ft, pos.offset))
309 .unwrap_or_default()
310 })
311 }
312
313 pub fn goto_definition(&self, pos: FilePosition) -> Cancellable<Vec<gdscript_base::NavTarget>> {
318 catch(|| navigation::goto_definition(&self.db, pos))
319 }
320
321 pub fn find_references(&self, pos: FilePosition) -> Cancellable<Vec<gdscript_base::Reference>> {
326 catch(|| navigation::find_references(&self.db, pos))
327 }
328
329 pub fn rename(
336 &self,
337 pos: FilePosition,
338 new_name: &str,
339 ) -> Cancellable<Result<gdscript_base::SourceChange, gdscript_base::RenameError>> {
340 catch(|| navigation::rename(&self.db, pos, new_name))
341 }
342
343 pub fn workspace_symbols(&self, query: &str) -> Cancellable<Vec<gdscript_base::NavTarget>> {
348 catch(|| navigation::workspace_symbols(&self.db, query))
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 fn host_with(src: &str) -> (AnalysisHost, FileId) {
357 let mut host = AnalysisHost::new();
358 let file = FileId(0);
359 let mut change = Change::new();
360 change.change_file(file, src);
361 host.apply_change(change);
362 (host, file)
363 }
364
365 #[test]
366 fn snapshot_reads_applied_files() {
367 let (host, file) = host_with("func f():\n\tpass\n");
368 let analysis = host.analysis();
369 let symbols = analysis.document_symbols(file).unwrap();
370 assert_eq!(symbols.len(), 1);
371 assert_eq!(symbols[0].name, "f");
372 }
373
374 #[test]
375 fn preload_resolves_cross_file_through_the_public_api() {
376 let mut host = AnalysisHost::new();
379 let mut change = Change::new();
380 change.change_file(
381 FileId(0),
382 "class_name Markup\nfunc parse() -> int:\n\treturn 1\n",
383 );
384 change.set_file_path(FileId(0), "res://markup.gd");
385 change.change_file(
386 FileId(1),
387 "const M = preload(\"res://markup.gd\")\nfunc go():\n\tvar n := M.new().parse()\n",
388 );
389 change.set_file_path(FileId(1), "res://main.gd");
390 host.apply_change(change);
391 let analysis = host.analysis();
392
393 assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
395 let hints = analysis.inlay_hints(FileId(1)).unwrap();
398 assert!(
399 hints.iter().any(|h| h.label.contains("int")),
400 "expected an `: int` inlay on the preload-resolved binding, got {hints:?}",
401 );
402 }
403
404 #[test]
405 fn autoload_resolves_cross_file_through_the_public_api() {
406 let mut host = AnalysisHost::new();
409 let mut change = Change::new();
410 change.change_file(FileId(0), "func volume() -> int:\n\treturn 50\n");
411 change.set_file_path(FileId(0), "res://audio.gd");
412 change.change_file(FileId(1), "func go():\n\tvar v := Audio.volume()\n");
413 change.set_file_path(FileId(1), "res://main.gd");
414 change.set_project_config("[autoload]\nAudio=\"*res://audio.gd\"\n");
415 host.apply_change(change);
416 let analysis = host.analysis();
417
418 assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
419 let hints = analysis.inlay_hints(FileId(1)).unwrap();
421 assert!(
422 hints.iter().any(|h| h.label.contains("int")),
423 "expected an `: int` inlay on the autoload-resolved binding, got {hints:?}",
424 );
425 }
426
427 #[test]
428 fn scene_node_path_typing_through_the_public_api() {
429 let mut host = AnalysisHost::new();
432 let mut change = Change::new();
433 change.change_file(
434 FileId(0),
435 "[gd_scene format=3]\n\
436 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
437 [node name=\"Root\" type=\"Control\"]\n\
438 script = ExtResource(\"1\")\n\
439 [node name=\"Btn\" type=\"Button\" parent=\".\"]\n",
440 );
441 change.set_file_path(FileId(0), "res://main.tscn");
442 change.change_file(
443 FileId(1),
444 "extends Control\nfunc _ready():\n\tvar b := $Btn\n",
445 );
446 change.set_file_path(FileId(1), "res://main.gd");
447 host.apply_change(change);
448 let analysis = host.analysis();
449
450 assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
451 let hints = analysis.inlay_hints(FileId(1)).unwrap();
452 assert!(
453 hints.iter().any(|h| h.label.contains("Button")),
454 "expected a `: Button` inlay on `var b := $Btn`, got {hints:?}",
455 );
456 }
457
458 #[test]
459 fn node_path_completion_offers_scene_children() {
460 let mut host = AnalysisHost::new();
462 let mut change = Change::new();
463 change.change_file(
464 FileId(0),
465 "[gd_scene format=3]\n\
466 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
467 [node name=\"Root\" type=\"Control\"]\n\
468 script = ExtResource(\"1\")\n\
469 [node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
470 [node name=\"Ok\" type=\"Button\" parent=\"Panel\"]\n\
471 [node name=\"Cancel\" type=\"Button\" parent=\"Panel\"]\n",
472 );
473 change.set_file_path(FileId(0), "res://main.tscn");
474 let gd = "extends Control\nfunc _ready():\n\tvar b := $Panel/\n";
475 change.change_file(FileId(1), gd);
476 change.set_file_path(FileId(1), "res://main.gd");
477 host.apply_change(change);
478 let analysis = host.analysis();
479
480 let offset = u32::try_from(gd.find("$Panel/").unwrap() + "$Panel/".len()).unwrap();
481 let items = analysis
482 .completions(FilePosition {
483 file: FileId(1),
484 offset,
485 })
486 .unwrap();
487 let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
488 assert!(
489 labels.contains(&"Ok") && labels.contains(&"Cancel"),
490 "{labels:?}"
491 );
492 assert!(
494 items
495 .iter()
496 .find(|i| i.label == "Ok")
497 .is_some_and(|i| i.detail.as_deref() == Some("Button")),
498 "{items:?}",
499 );
500 assert!(
501 !labels.contains(&"func"),
502 "should be node-path, not keyword, completion"
503 );
504 }
505
506 #[test]
507 fn node_path_completion_does_not_hijack_inside_a_string_literal() {
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=\"Panel\" type=\"Panel\" parent=\".\"]\n\
519 [node name=\"Ok\" type=\"Button\" parent=\"Panel\"]\n",
520 );
521 change.set_file_path(FileId(0), "res://main.tscn");
522 let gd = "extends Control\nfunc _ready():\n\tvar s := \"$Panel/\"\n";
523 change.change_file(FileId(1), gd);
524 change.set_file_path(FileId(1), "res://main.gd");
525 host.apply_change(change);
526 let analysis = host.analysis();
527
528 let offset = u32::try_from(gd.find("$Panel/").unwrap() + "$Panel/".len()).unwrap();
530 let items = analysis
531 .completions(FilePosition {
532 file: FileId(1),
533 offset,
534 })
535 .unwrap();
536 assert!(
537 !items.iter().any(|i| i.label == "Ok"),
538 "node names must not leak into a string literal: {items:?}",
539 );
540 }
541
542 #[test]
543 fn unique_node_path_completion_offers_children() {
544 let mut host = AnalysisHost::new();
546 let mut change = Change::new();
547 let scene = "[gd_scene format=3]\n\
548 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
549 [node name=\"Root\" type=\"Control\"]\n\
550 script = ExtResource(\"1\")\n\
551 [node name=\"Box\" type=\"Panel\" parent=\".\"]\n\
552 unique_name_in_owner = true\n\
553 [node name=\"Ok\" type=\"Button\" parent=\"Box\"]\n\
554 [node name=\"Cancel\" type=\"Button\" parent=\"Box\"]\n";
555 change.change_file(FileId(0), scene);
556 change.set_file_path(FileId(0), "res://main.tscn");
557 let gd = "extends Control\nfunc _ready():\n\tvar b := %Box/\n";
558 change.change_file(FileId(1), gd);
559 change.set_file_path(FileId(1), "res://main.gd");
560 host.apply_change(change);
561 let analysis = host.analysis();
562 let offset = u32::try_from(gd.find("%Box/").unwrap() + "%Box/".len()).unwrap();
563 let items = analysis
564 .completions(FilePosition {
565 file: FileId(1),
566 offset,
567 })
568 .unwrap();
569 let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
570 assert!(
571 labels.contains(&"Ok") && labels.contains(&"Cancel"),
572 "{labels:?}"
573 );
574 assert!(
575 !labels.contains(&"func"),
576 "node-path, not keyword completion"
577 );
578 }
579
580 #[test]
581 fn bare_percent_offers_all_unique_nodes() {
582 let mut host = AnalysisHost::new();
584 let mut change = Change::new();
585 let scene = "[gd_scene format=3]\n\
586 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
587 [node name=\"Root\" type=\"Control\"]\n\
588 script = ExtResource(\"1\")\n\
589 [node name=\"Box\" type=\"Panel\" parent=\".\"]\n\
590 unique_name_in_owner = true\n\
591 [node name=\"Hud\" type=\"Control\" parent=\".\"]\n\
592 unique_name_in_owner = true\n";
593 change.change_file(FileId(0), scene);
594 change.set_file_path(FileId(0), "res://main.tscn");
595 let gd = "extends Control\nfunc _ready():\n\tvar b := %\n";
596 change.change_file(FileId(1), gd);
597 change.set_file_path(FileId(1), "res://main.gd");
598 host.apply_change(change);
599 let analysis = host.analysis();
600 let offset = u32::try_from(gd.find("%\n").unwrap() + 1).unwrap();
601 let labels: Vec<_> = analysis
602 .completions(FilePosition {
603 file: FileId(1),
604 offset,
605 })
606 .unwrap()
607 .into_iter()
608 .map(|i| i.label)
609 .collect();
610 assert!(
611 labels.iter().any(|l| l == "Box") && labels.iter().any(|l| l == "Hud"),
612 "{labels:?}"
613 );
614 }
615
616 #[test]
617 fn percent_modulo_is_not_hijacked_as_a_unique_path() {
618 let mut host = AnalysisHost::new();
621 let mut change = Change::new();
622 let scene = "[gd_scene format=3]\n\
623 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
624 [node name=\"Root\" type=\"Control\"]\n\
625 script = ExtResource(\"1\")\n\
626 [node name=\"Box\" type=\"Panel\" parent=\".\"]\n\
627 unique_name_in_owner = true\n";
628 change.change_file(FileId(0), scene);
629 change.set_file_path(FileId(0), "res://main.tscn");
630 let gd = "extends Control\nfunc _ready():\n\tvar count := 10\n\tvar b := count %Box\n";
631 change.change_file(FileId(1), gd);
632 change.set_file_path(FileId(1), "res://main.gd");
633 host.apply_change(change);
634 let analysis = host.analysis();
635 let offset = u32::try_from(gd.find("%Box").unwrap() + "%Box".len()).unwrap();
636 let labels: Vec<_> = analysis
637 .completions(FilePosition {
638 file: FileId(1),
639 offset,
640 })
641 .unwrap()
642 .into_iter()
643 .map(|i| i.label)
644 .collect();
645 assert!(
647 labels.iter().any(|l| l == "func"),
648 "expected by-name completion: {labels:?}"
649 );
650 }
651
652 #[test]
653 fn completion_is_scope_aware_for_locals_and_params() {
654 let mut host = AnalysisHost::new();
659 let mut change = Change::new();
660 let gd = "var member_v := 0\nfunc a(pa):\n\tvar la := 1\n\t\nfunc b(pb):\n\tvar lb := 2\n";
661 change.change_file(FileId(0), gd);
662 change.set_file_path(FileId(0), "res://m.gd");
663 host.apply_change(change);
664 let analysis = host.analysis();
665
666 let upto = "var member_v := 0\nfunc a(pa):\n\tvar la := 1\n\t";
668 let offset = u32::try_from(gd.find(upto).unwrap() + upto.len()).unwrap();
669 let items = analysis
670 .completions(FilePosition {
671 file: FileId(0),
672 offset,
673 })
674 .unwrap();
675 let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
676 assert!(labels.contains(&"pa"), "own param `pa`: {labels:?}");
678 assert!(labels.contains(&"la"), "own local `la`: {labels:?}");
679 assert!(labels.contains(&"member_v"), "class member: {labels:?}");
680 assert!(
681 labels.contains(&"a") && labels.contains(&"b"),
682 "sibling func names: {labels:?}",
683 );
684 assert!(!labels.contains(&"pb"), "leaked b's param: {labels:?}");
686 assert!(!labels.contains(&"lb"), "leaked b's local: {labels:?}");
687 }
688
689 #[test]
690 fn completion_at_class_level_offers_members_not_locals() {
691 let mut host = AnalysisHost::new();
693 let mut change = Change::new();
694 let gd = "var member_v := 0\nfunc a():\n\tvar la := 1\n\nm\n";
695 change.change_file(FileId(0), gd);
696 change.set_file_path(FileId(0), "res://m.gd");
697 host.apply_change(change);
698 let analysis = host.analysis();
699 let offset = u32::try_from(gd.rfind('m').unwrap() + 1).unwrap();
701 let items = analysis
702 .completions(FilePosition {
703 file: FileId(0),
704 offset,
705 })
706 .unwrap();
707 let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
708 assert!(
709 labels.contains(&"member_v") && labels.contains(&"a"),
710 "{labels:?}"
711 );
712 assert!(
713 !labels.contains(&"la"),
714 "a()'s local must not leak to class level: {labels:?}"
715 );
716 }
717
718 #[test]
719 fn completion_offers_params_in_lambda_setter_and_inline_bodies() {
720 let cases = [
724 ("var f := func(px):\n\treturn px\n", "px", "return "),
726 ("var x: int:\n\tset(sv):\n\t\t_x = sv\n", "sv", "_x = "),
727 ("func foo(ia): return ia\n", "ia", "return "),
728 ];
729 for (gd, param, marker) in cases {
730 let mut host = AnalysisHost::new();
731 let mut change = Change::new();
732 change.change_file(FileId(0), gd);
733 change.set_file_path(FileId(0), "res://m.gd");
734 host.apply_change(change);
735 let analysis = host.analysis();
736 let offset = u32::try_from(gd.find(marker).unwrap() + marker.len()).unwrap();
737 let labels: Vec<_> = analysis
738 .completions(FilePosition {
739 file: FileId(0),
740 offset,
741 })
742 .unwrap()
743 .into_iter()
744 .map(|i| i.label)
745 .collect();
746 assert!(
747 labels.iter().any(|l| l == param),
748 "param `{param}` should be offered inside its body for {gd:?}, got {labels:?}",
749 );
750 }
751 }
752
753 #[test]
754 fn goto_definition_on_a_node_path_jumps_into_the_tscn() {
755 let mut host = AnalysisHost::new();
758 let mut change = Change::new();
759 let scene = "[gd_scene format=3]\n\
760 [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
761 [node name=\"Root\" type=\"Control\"]\n\
762 script = ExtResource(\"1\")\n\
763 [node name=\"Btn\" type=\"Button\" parent=\".\"]\n";
764 let gd = "extends Control\nfunc _ready():\n\tvar b := $Btn\n";
765 change.change_file(FileId(0), scene);
766 change.set_file_path(FileId(0), "res://main.tscn");
767 change.change_file(FileId(1), gd);
768 change.set_file_path(FileId(1), "res://main.gd");
769 host.apply_change(change);
770 let analysis = host.analysis();
771
772 let offset = u32::try_from(gd.find("$Btn").unwrap() + 1).unwrap(); let targets = analysis
774 .goto_definition(FilePosition {
775 file: FileId(1),
776 offset,
777 })
778 .unwrap();
779 assert_eq!(targets.len(), 1, "{targets:?}");
780 assert_eq!(targets[0].file, FileId(0), "jumps into the .tscn");
781 let focus =
782 &scene[targets[0].focus_range.start as usize..targets[0].focus_range.end as usize];
783 assert!(
784 focus.contains("Btn"),
785 "focus on the node name, got {focus:?}"
786 );
787 }
788
789 #[test]
790 fn find_refs_and_rename_cross_file_through_the_public_api() {
791 let mut host = AnalysisHost::new();
792 let mut change = Change::new();
793 change.change_file(
794 FileId(0),
795 "class_name Widget\nfunc make() -> int:\n\treturn 1\n",
796 );
797 change.set_file_path(FileId(0), "res://widget.gd");
798 change.change_file(
799 FileId(1),
800 "func f():\n\tvar w: Widget\n\tvar x := Widget.new()\n",
801 );
802 change.set_file_path(FileId(1), "res://main.gd");
803 host.apply_change(change);
804 let analysis = host.analysis();
805 let at_decl = FilePosition {
807 file: FileId(0),
808 offset: 11,
809 };
810 let refs = analysis.find_references(at_decl).unwrap();
812 assert_eq!(refs.len(), 3, "{refs:?}");
813 let edit = analysis
815 .rename(at_decl, "Gadget")
816 .unwrap()
817 .expect("rename ok");
818 assert_eq!(edit.edits.len(), 2, "both files edited");
819 }
820
821 #[test]
822 fn removing_a_file_clears_it() {
823 let (mut host, file) = host_with("var x = 1\n");
824 let mut change = Change::new();
825 change.remove_file(file);
826 host.apply_change(change);
827 let analysis = host.analysis();
828 assert!(analysis.document_symbols(file).unwrap().is_empty());
829 }
830}