1pub mod items;
2
3use anyhow::Result;
4use collections::{BTreeMap, HashSet};
5use editor::{
6 diagnostic_block_renderer,
7 display_map::{BlockDisposition, BlockId, BlockProperties, BlockStyle, RenderBlock},
8 highlight_diagnostic_message, Autoscroll, Editor, ExcerptId, ExcerptRange, MultiBuffer,
9 ToOffset,
10};
11use gpui::{
12 actions, elements::*, fonts::TextStyle, impl_internal_actions, serde_json, AnyViewHandle,
13 AppContext, Entity, ModelHandle, MutableAppContext, RenderContext, Task, View, ViewContext,
14 ViewHandle, WeakViewHandle,
15};
16use language::{
17 Anchor, Bias, Buffer, Diagnostic, DiagnosticEntry, DiagnosticSeverity, Selection, SelectionGoal,
18};
19use project::{DiagnosticSummary, Project, ProjectPath};
20use rope::point::Point;
21use serde_json::json;
22use settings::Settings;
23use smallvec::SmallVec;
24use std::{
25 any::{Any, TypeId},
26 cmp::Ordering,
27 ops::Range,
28 path::PathBuf,
29 sync::Arc,
30};
31use util::TryFutureExt;
32use workspace::{ItemHandle as _, ItemNavHistory, Workspace};
33
34actions!(diagnostics, [Deploy]);
35
36impl_internal_actions!(diagnostics, [Jump]);
37
38const CONTEXT_LINE_COUNT: u32 = 1;
39
40pub fn init(cx: &mut MutableAppContext) {
41 cx.add_action(ProjectDiagnosticsEditor::deploy);
42 items::init(cx);
43}
44
45type Event = editor::Event;
46
47struct ProjectDiagnosticsEditor {
48 project: ModelHandle<Project>,
49 workspace: WeakViewHandle<Workspace>,
50 editor: ViewHandle<Editor>,
51 summary: DiagnosticSummary,
52 excerpts: ModelHandle<MultiBuffer>,
53 path_states: Vec<PathState>,
54 paths_to_update: BTreeMap<ProjectPath, usize>,
55}
56
57struct PathState {
58 path: ProjectPath,
59 diagnostic_groups: Vec<DiagnosticGroupState>,
60}
61
62#[derive(Clone, Debug, PartialEq)]
63struct Jump {
64 path: ProjectPath,
65 position: Point,
66 anchor: Anchor,
67}
68
69struct DiagnosticGroupState {
70 primary_diagnostic: DiagnosticEntry<language::Anchor>,
71 primary_excerpt_ix: usize,
72 excerpts: Vec<ExcerptId>,
73 blocks: HashSet<BlockId>,
74 block_count: usize,
75}
76
77impl Entity for ProjectDiagnosticsEditor {
78 type Event = Event;
79}
80
81impl View for ProjectDiagnosticsEditor {
82 fn ui_name() -> &'static str {
83 "ProjectDiagnosticsEditor"
84 }
85
86 fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
87 if self.path_states.is_empty() {
88 let theme = &cx.global::<Settings>().theme.project_diagnostics;
89 Label::new(
90 "No problems in workspace".to_string(),
91 theme.empty_message.clone(),
92 )
93 .aligned()
94 .contained()
95 .with_style(theme.container)
96 .boxed()
97 } else {
98 ChildView::new(&self.editor).boxed()
99 }
100 }
101
102 fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
103 if !self.path_states.is_empty() {
104 cx.focus(&self.editor);
105 }
106 }
107
108 fn debug_json(&self, cx: &AppContext) -> serde_json::Value {
109 let project = self.project.read(cx);
110 json!({
111 "project": json!({
112 "language_servers": project.language_server_statuses().collect::<Vec<_>>(),
113 "summary": project.diagnostic_summary(cx),
114 }),
115 "summary": self.summary,
116 "paths_to_update": self.paths_to_update.iter().map(|(path, server_id)|
117 (path.path.to_string_lossy(), server_id)
118 ).collect::<Vec<_>>(),
119 "paths_states": self.path_states.iter().map(|state|
120 json!({
121 "path": state.path.path.to_string_lossy(),
122 "groups": state.diagnostic_groups.iter().map(|group|
123 json!({
124 "block_count": group.blocks.len(),
125 "excerpt_count": group.excerpts.len(),
126 })
127 ).collect::<Vec<_>>(),
128 })
129 ).collect::<Vec<_>>(),
130 })
131 }
132}
133
134impl ProjectDiagnosticsEditor {
135 fn new(
136 project_handle: ModelHandle<Project>,
137 workspace: WeakViewHandle<Workspace>,
138 cx: &mut ViewContext<Self>,
139 ) -> Self {
140 cx.subscribe(&project_handle, |this, _, event, cx| match event {
141 project::Event::DiskBasedDiagnosticsFinished { language_server_id } => {
142 this.update_excerpts(Some(*language_server_id), cx);
143 this.update_title(cx);
144 }
145 project::Event::DiagnosticsUpdated {
146 language_server_id,
147 path,
148 } => {
149 this.paths_to_update
150 .insert(path.clone(), *language_server_id);
151 }
152 _ => {}
153 })
154 .detach();
155
156 let excerpts = cx.add_model(|cx| MultiBuffer::new(project_handle.read(cx).replica_id()));
157 let editor = cx.add_view(|cx| {
158 let mut editor =
159 Editor::for_multibuffer(excerpts.clone(), Some(project_handle.clone()), cx);
160 editor.set_vertical_scroll_margin(5, cx);
161 editor
162 });
163 cx.subscribe(&editor, |_, _, event, cx| cx.emit(*event))
164 .detach();
165
166 let project = project_handle.read(cx);
167 let paths_to_update = project
168 .diagnostic_summaries(cx)
169 .map(|e| (e.0, e.1.language_server_id))
170 .collect();
171 let summary = project.diagnostic_summary(cx);
172 let mut this = Self {
173 project: project_handle,
174 summary,
175 workspace,
176 excerpts,
177 editor,
178 path_states: Default::default(),
179 paths_to_update,
180 };
181 this.update_excerpts(None, cx);
182 this
183 }
184
185 fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext<Workspace>) {
186 if let Some(existing) = workspace.item_of_type::<ProjectDiagnosticsEditor>(cx) {
187 workspace.activate_item(&existing, cx);
188 } else {
189 let workspace_handle = cx.weak_handle();
190 let diagnostics = cx.add_view(|cx| {
191 ProjectDiagnosticsEditor::new(workspace.project().clone(), workspace_handle, cx)
192 });
193 workspace.add_item(Box::new(diagnostics), cx);
194 }
195 }
196
197 fn update_excerpts(&mut self, language_server_id: Option<usize>, cx: &mut ViewContext<Self>) {
198 let mut paths = Vec::new();
199 self.paths_to_update.retain(|path, server_id| {
200 if language_server_id
201 .map_or(true, |language_server_id| language_server_id == *server_id)
202 {
203 paths.push(path.clone());
204 false
205 } else {
206 true
207 }
208 });
209 let project = self.project.clone();
210 cx.spawn(|this, mut cx| {
211 async move {
212 for path in paths {
213 let buffer = project
214 .update(&mut cx, |project, cx| project.open_buffer(path.clone(), cx))
215 .await?;
216 this.update(&mut cx, |this, cx| this.populate_excerpts(path, buffer, cx))
217 }
218 Result::<_, anyhow::Error>::Ok(())
219 }
220 .log_err()
221 })
222 .detach();
223 }
224
225 fn populate_excerpts(
226 &mut self,
227 path: ProjectPath,
228 buffer: ModelHandle<Buffer>,
229 cx: &mut ViewContext<Self>,
230 ) {
231 let was_empty = self.path_states.is_empty();
232 let snapshot = buffer.read(cx).snapshot();
233 let path_ix = match self.path_states.binary_search_by_key(&&path, |e| &e.path) {
234 Ok(ix) => ix,
235 Err(ix) => {
236 self.path_states.insert(
237 ix,
238 PathState {
239 path: path.clone(),
240 diagnostic_groups: Default::default(),
241 },
242 );
243 ix
244 }
245 };
246
247 let mut prev_excerpt_id = if path_ix > 0 {
248 let prev_path_last_group = &self.path_states[path_ix - 1]
249 .diagnostic_groups
250 .last()
251 .unwrap();
252 prev_path_last_group.excerpts.last().unwrap().clone()
253 } else {
254 ExcerptId::min()
255 };
256
257 let path_state = &mut self.path_states[path_ix];
258 let mut groups_to_add = Vec::new();
259 let mut group_ixs_to_remove = Vec::new();
260 let mut blocks_to_add = Vec::new();
261 let mut blocks_to_remove = HashSet::default();
262 let mut first_excerpt_id = None;
263 let excerpts_snapshot = self.excerpts.update(cx, |excerpts, excerpts_cx| {
264 let mut old_groups = path_state.diagnostic_groups.iter().enumerate().peekable();
265 let mut new_groups = snapshot
266 .diagnostic_groups()
267 .into_iter()
268 .filter(|group| {
269 group.entries[group.primary_ix].diagnostic.severity
270 <= DiagnosticSeverity::WARNING
271 })
272 .peekable();
273 loop {
274 let mut to_insert = None;
275 let mut to_remove = None;
276 let mut to_keep = None;
277 match (old_groups.peek(), new_groups.peek()) {
278 (None, None) => break,
279 (None, Some(_)) => to_insert = new_groups.next(),
280 (Some(_), None) => to_remove = old_groups.next(),
281 (Some((_, old_group)), Some(new_group)) => {
282 let old_primary = &old_group.primary_diagnostic;
283 let new_primary = &new_group.entries[new_group.primary_ix];
284 match compare_diagnostics(old_primary, new_primary, &snapshot) {
285 Ordering::Less => to_remove = old_groups.next(),
286 Ordering::Equal => {
287 to_keep = old_groups.next();
288 new_groups.next();
289 }
290 Ordering::Greater => to_insert = new_groups.next(),
291 }
292 }
293 }
294
295 if let Some(group) = to_insert {
296 let mut group_state = DiagnosticGroupState {
297 primary_diagnostic: group.entries[group.primary_ix].clone(),
298 primary_excerpt_ix: 0,
299 excerpts: Default::default(),
300 blocks: Default::default(),
301 block_count: 0,
302 };
303 let mut pending_range: Option<(Range<Point>, usize)> = None;
304 let mut is_first_excerpt_for_group = true;
305 for (ix, entry) in group.entries.iter().map(Some).chain([None]).enumerate() {
306 let resolved_entry = entry.map(|e| e.resolve::<Point>(&snapshot));
307 if let Some((range, start_ix)) = &mut pending_range {
308 if let Some(entry) = resolved_entry.as_ref() {
309 if entry.range.start.row
310 <= range.end.row + 1 + CONTEXT_LINE_COUNT * 2
311 {
312 range.end = range.end.max(entry.range.end);
313 continue;
314 }
315 }
316
317 let excerpt_start =
318 Point::new(range.start.row.saturating_sub(CONTEXT_LINE_COUNT), 0);
319 let excerpt_end = snapshot.clip_point(
320 Point::new(range.end.row + CONTEXT_LINE_COUNT, u32::MAX),
321 Bias::Left,
322 );
323 let excerpt_id = excerpts
324 .insert_excerpts_after(
325 &prev_excerpt_id,
326 buffer.clone(),
327 [ExcerptRange {
328 context: excerpt_start..excerpt_end,
329 primary: Some(range.clone()),
330 }],
331 excerpts_cx,
332 )
333 .pop()
334 .unwrap();
335
336 prev_excerpt_id = excerpt_id.clone();
337 first_excerpt_id.get_or_insert_with(|| prev_excerpt_id.clone());
338 group_state.excerpts.push(excerpt_id.clone());
339 let header_position = (excerpt_id.clone(), language::Anchor::MIN);
340
341 if is_first_excerpt_for_group {
342 is_first_excerpt_for_group = false;
343 let mut primary =
344 group.entries[group.primary_ix].diagnostic.clone();
345 primary.message =
346 primary.message.split('\n').next().unwrap().to_string();
347 group_state.block_count += 1;
348 blocks_to_add.push(BlockProperties {
349 position: header_position,
350 height: 2,
351 style: BlockStyle::Sticky,
352 render: diagnostic_header_renderer(primary),
353 disposition: BlockDisposition::Above,
354 });
355 }
356
357 for entry in &group.entries[*start_ix..ix] {
358 let mut diagnostic = entry.diagnostic.clone();
359 if diagnostic.is_primary {
360 group_state.primary_excerpt_ix = group_state.excerpts.len() - 1;
361 diagnostic.message =
362 entry.diagnostic.message.split('\n').skip(1).collect();
363 }
364
365 if !diagnostic.message.is_empty() {
366 group_state.block_count += 1;
367 blocks_to_add.push(BlockProperties {
368 position: (excerpt_id.clone(), entry.range.start),
369 height: diagnostic.message.matches('\n').count() as u8 + 1,
370 style: BlockStyle::Fixed,
371 render: diagnostic_block_renderer(diagnostic, true),
372 disposition: BlockDisposition::Below,
373 });
374 }
375 }
376
377 pending_range.take();
378 }
379
380 if let Some(entry) = resolved_entry {
381 pending_range = Some((entry.range.clone(), ix));
382 }
383 }
384
385 groups_to_add.push(group_state);
386 } else if let Some((group_ix, group_state)) = to_remove {
387 excerpts.remove_excerpts(group_state.excerpts.iter(), excerpts_cx);
388 group_ixs_to_remove.push(group_ix);
389 blocks_to_remove.extend(group_state.blocks.iter().copied());
390 } else if let Some((_, group)) = to_keep {
391 prev_excerpt_id = group.excerpts.last().unwrap().clone();
392 first_excerpt_id.get_or_insert_with(|| prev_excerpt_id.clone());
393 }
394 }
395
396 excerpts.snapshot(excerpts_cx)
397 });
398
399 self.editor.update(cx, |editor, cx| {
400 editor.remove_blocks(blocks_to_remove, cx);
401 let block_ids = editor.insert_blocks(
402 blocks_to_add.into_iter().map(|block| {
403 let (excerpt_id, text_anchor) = block.position;
404 BlockProperties {
405 position: excerpts_snapshot.anchor_in_excerpt(excerpt_id, text_anchor),
406 height: block.height,
407 style: block.style,
408 render: block.render,
409 disposition: block.disposition,
410 }
411 }),
412 cx,
413 );
414
415 let mut block_ids = block_ids.into_iter();
416 for group_state in &mut groups_to_add {
417 group_state.blocks = block_ids.by_ref().take(group_state.block_count).collect();
418 }
419 });
420
421 for ix in group_ixs_to_remove.into_iter().rev() {
422 path_state.diagnostic_groups.remove(ix);
423 }
424 path_state.diagnostic_groups.extend(groups_to_add);
425 path_state.diagnostic_groups.sort_unstable_by(|a, b| {
426 let range_a = &a.primary_diagnostic.range;
427 let range_b = &b.primary_diagnostic.range;
428 range_a
429 .start
430 .cmp(&range_b.start, &snapshot)
431 .then_with(|| range_a.end.cmp(&range_b.end, &snapshot))
432 });
433
434 if path_state.diagnostic_groups.is_empty() {
435 self.path_states.remove(path_ix);
436 }
437
438 self.editor.update(cx, |editor, cx| {
439 let groups;
440 let mut selections;
441 let new_excerpt_ids_by_selection_id;
442 if was_empty {
443 groups = self.path_states.first()?.diagnostic_groups.as_slice();
444 new_excerpt_ids_by_selection_id = [(0, ExcerptId::min())].into_iter().collect();
445 selections = vec![Selection {
446 id: 0,
447 start: 0,
448 end: 0,
449 reversed: false,
450 goal: SelectionGoal::None,
451 }];
452 } else {
453 groups = self.path_states.get(path_ix)?.diagnostic_groups.as_slice();
454 new_excerpt_ids_by_selection_id =
455 editor.change_selections(Some(Autoscroll::Fit), cx, |s| s.refresh());
456 selections = editor.selections.all::<usize>(cx);
457 }
458
459 // If any selection has lost its position, move it to start of the next primary diagnostic.
460 for selection in &mut selections {
461 if let Some(new_excerpt_id) = new_excerpt_ids_by_selection_id.get(&selection.id) {
462 let group_ix = match groups.binary_search_by(|probe| {
463 probe.excerpts.last().unwrap().cmp(new_excerpt_id)
464 }) {
465 Ok(ix) | Err(ix) => ix,
466 };
467 if let Some(group) = groups.get(group_ix) {
468 let offset = excerpts_snapshot
469 .anchor_in_excerpt(
470 group.excerpts[group.primary_excerpt_ix].clone(),
471 group.primary_diagnostic.range.start,
472 )
473 .to_offset(&excerpts_snapshot);
474 selection.start = offset;
475 selection.end = offset;
476 }
477 }
478 }
479 editor.change_selections(None, cx, |s| {
480 s.select(selections);
481 });
482 Some(())
483 });
484
485 if self.path_states.is_empty() {
486 if self.editor.is_focused(cx) {
487 cx.focus_self();
488 }
489 } else if cx.handle().is_focused(cx) {
490 cx.focus(&self.editor);
491 }
492 cx.notify();
493 }
494
495 fn update_title(&mut self, cx: &mut ViewContext<Self>) {
496 self.summary = self.project.read(cx).diagnostic_summary(cx);
497 cx.emit(Event::TitleChanged);
498 }
499}
500
501impl workspace::Item for ProjectDiagnosticsEditor {
502 fn tab_content(
503 &self,
504 _detail: Option<usize>,
505 style: &theme::Tab,
506 cx: &AppContext,
507 ) -> ElementBox {
508 render_summary(
509 &self.summary,
510 &style.label.text,
511 &cx.global::<Settings>().theme.project_diagnostics,
512 )
513 }
514
515 fn project_path(&self, _: &AppContext) -> Option<project::ProjectPath> {
516 None
517 }
518
519 fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
520 self.editor.project_entry_ids(cx)
521 }
522
523 fn is_singleton(&self, _: &AppContext) -> bool {
524 false
525 }
526
527 fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
528 self.editor
529 .update(cx, |editor, cx| editor.navigate(data, cx))
530 }
531
532 fn is_dirty(&self, cx: &AppContext) -> bool {
533 self.excerpts.read(cx).is_dirty(cx)
534 }
535
536 fn has_conflict(&self, cx: &AppContext) -> bool {
537 self.excerpts.read(cx).has_conflict(cx)
538 }
539
540 fn can_save(&self, _: &AppContext) -> bool {
541 true
542 }
543
544 fn save(
545 &mut self,
546 project: ModelHandle<Project>,
547 cx: &mut ViewContext<Self>,
548 ) -> Task<Result<()>> {
549 self.editor.save(project, cx)
550 }
551
552 fn reload(
553 &mut self,
554 project: ModelHandle<Project>,
555 cx: &mut ViewContext<Self>,
556 ) -> Task<Result<()>> {
557 self.editor.reload(project, cx)
558 }
559
560 fn save_as(
561 &mut self,
562 _: ModelHandle<Project>,
563 _: PathBuf,
564 _: &mut ViewContext<Self>,
565 ) -> Task<Result<()>> {
566 unreachable!()
567 }
568
569 fn to_item_events(event: &Self::Event) -> Vec<workspace::ItemEvent> {
570 Editor::to_item_events(event)
571 }
572
573 fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext<Self>) {
574 self.editor.update(cx, |editor, _| {
575 editor.set_nav_history(Some(nav_history));
576 });
577 }
578
579 fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self>
580 where
581 Self: Sized,
582 {
583 Some(ProjectDiagnosticsEditor::new(
584 self.project.clone(),
585 self.workspace.clone(),
586 cx,
587 ))
588 }
589
590 fn act_as_type(
591 &self,
592 type_id: TypeId,
593 self_handle: &ViewHandle<Self>,
594 _: &AppContext,
595 ) -> Option<AnyViewHandle> {
596 if type_id == TypeId::of::<Self>() {
597 Some(self_handle.into())
598 } else if type_id == TypeId::of::<Editor>() {
599 Some((&self.editor).into())
600 } else {
601 None
602 }
603 }
604
605 fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
606 self.editor.update(cx, |editor, cx| editor.deactivated(cx));
607 }
608}
609
610fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock {
611 let (message, highlights) = highlight_diagnostic_message(&diagnostic.message);
612 Arc::new(move |cx| {
613 let settings = cx.global::<Settings>();
614 let theme = &settings.theme.editor;
615 let style = theme.diagnostic_header.clone();
616 let font_size = (style.text_scale_factor * settings.buffer_font_size).round();
617 let icon_width = cx.em_width * style.icon_width_factor;
618 let icon = if diagnostic.severity == DiagnosticSeverity::ERROR {
619 Svg::new("icons/circle_x_mark_12.svg")
620 .with_color(theme.error_diagnostic.message.text.color)
621 } else {
622 Svg::new("icons/triangle_exclamation_12.svg")
623 .with_color(theme.warning_diagnostic.message.text.color)
624 };
625
626 Flex::row()
627 .with_child(
628 icon.constrained()
629 .with_width(icon_width)
630 .aligned()
631 .contained()
632 .boxed(),
633 )
634 .with_child(
635 Label::new(
636 message.clone(),
637 style.message.label.clone().with_font_size(font_size),
638 )
639 .with_highlights(highlights.clone())
640 .contained()
641 .with_style(style.message.container)
642 .with_margin_left(cx.gutter_padding)
643 .aligned()
644 .boxed(),
645 )
646 .with_children(diagnostic.code.clone().map(|code| {
647 Label::new(code, style.code.text.clone().with_font_size(font_size))
648 .contained()
649 .with_style(style.code.container)
650 .aligned()
651 .boxed()
652 }))
653 .contained()
654 .with_style(style.container)
655 .with_padding_left(cx.gutter_padding)
656 .with_padding_right(cx.gutter_padding)
657 .expanded()
658 .named("diagnostic header")
659 })
660}
661
662pub(crate) fn render_summary(
663 summary: &DiagnosticSummary,
664 text_style: &TextStyle,
665 theme: &theme::ProjectDiagnostics,
666) -> ElementBox {
667 if summary.error_count == 0 && summary.warning_count == 0 {
668 Label::new("No problems".to_string(), text_style.clone()).boxed()
669 } else {
670 let icon_width = theme.tab_icon_width;
671 let icon_spacing = theme.tab_icon_spacing;
672 let summary_spacing = theme.tab_summary_spacing;
673 Flex::row()
674 .with_children([
675 Svg::new("icons/circle_x_mark_12.svg")
676 .with_color(text_style.color)
677 .constrained()
678 .with_width(icon_width)
679 .aligned()
680 .contained()
681 .with_margin_right(icon_spacing)
682 .named("no-icon"),
683 Label::new(
684 summary.error_count.to_string(),
685 LabelStyle {
686 text: text_style.clone(),
687 highlight_text: None,
688 },
689 )
690 .aligned()
691 .boxed(),
692 Svg::new("icons/triangle_exclamation_12.svg")
693 .with_color(text_style.color)
694 .constrained()
695 .with_width(icon_width)
696 .aligned()
697 .contained()
698 .with_margin_left(summary_spacing)
699 .with_margin_right(icon_spacing)
700 .named("warn-icon"),
701 Label::new(
702 summary.warning_count.to_string(),
703 LabelStyle {
704 text: text_style.clone(),
705 highlight_text: None,
706 },
707 )
708 .aligned()
709 .boxed(),
710 ])
711 .boxed()
712 }
713}
714
715fn compare_diagnostics<L: language::ToOffset, R: language::ToOffset>(
716 lhs: &DiagnosticEntry<L>,
717 rhs: &DiagnosticEntry<R>,
718 snapshot: &language::BufferSnapshot,
719) -> Ordering {
720 lhs.range
721 .start
722 .to_offset(snapshot)
723 .cmp(&rhs.range.start.to_offset(snapshot))
724 .then_with(|| {
725 lhs.range
726 .end
727 .to_offset(snapshot)
728 .cmp(&rhs.range.end.to_offset(snapshot))
729 })
730 .then_with(|| lhs.diagnostic.message.cmp(&rhs.diagnostic.message))
731}
732
733#[cfg(test)]
734mod tests {
735 use super::*;
736 use editor::{
737 display_map::{BlockContext, TransformBlock},
738 DisplayPoint,
739 };
740 use gpui::TestAppContext;
741 use language::{Diagnostic, DiagnosticEntry, DiagnosticSeverity};
742 use rope::point_utf16::PointUtf16;
743 use serde_json::json;
744 use unindent::Unindent as _;
745 use workspace::AppState;
746
747 #[gpui::test]
748 async fn test_diagnostics(cx: &mut TestAppContext) {
749 let app_state = cx.update(AppState::test);
750 app_state
751 .fs
752 .as_fake()
753 .insert_tree(
754 "/test",
755 json!({
756 "consts.rs": "
757 const a: i32 = 'a';
758 const b: i32 = c;
759 "
760 .unindent(),
761
762 "main.rs": "
763 fn main() {
764 let x = vec![];
765 let y = vec![];
766 a(x);
767 b(y);
768 // comment 1
769 // comment 2
770 c(y);
771 d(x);
772 }
773 "
774 .unindent(),
775 }),
776 )
777 .await;
778
779 let project = Project::test(app_state.fs.clone(), ["/test".as_ref()], cx).await;
780 let (_, workspace) =
781 cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx));
782
783 // Create some diagnostics
784 project.update(cx, |project, cx| {
785 project
786 .update_diagnostic_entries(
787 0,
788 PathBuf::from("/test/main.rs"),
789 None,
790 vec![
791 DiagnosticEntry {
792 range: PointUtf16::new(1, 8)..PointUtf16::new(1, 9),
793 diagnostic: Diagnostic {
794 message:
795 "move occurs because `x` has type `Vec<char>`, which does not implement the `Copy` trait"
796 .to_string(),
797 severity: DiagnosticSeverity::INFORMATION,
798 is_primary: false,
799 is_disk_based: true,
800 group_id: 1,
801 ..Default::default()
802 },
803 },
804 DiagnosticEntry {
805 range: PointUtf16::new(2, 8)..PointUtf16::new(2, 9),
806 diagnostic: Diagnostic {
807 message:
808 "move occurs because `y` has type `Vec<char>`, which does not implement the `Copy` trait"
809 .to_string(),
810 severity: DiagnosticSeverity::INFORMATION,
811 is_primary: false,
812 is_disk_based: true,
813 group_id: 0,
814 ..Default::default()
815 },
816 },
817 DiagnosticEntry {
818 range: PointUtf16::new(3, 6)..PointUtf16::new(3, 7),
819 diagnostic: Diagnostic {
820 message: "value moved here".to_string(),
821 severity: DiagnosticSeverity::INFORMATION,
822 is_primary: false,
823 is_disk_based: true,
824 group_id: 1,
825 ..Default::default()
826 },
827 },
828 DiagnosticEntry {
829 range: PointUtf16::new(4, 6)..PointUtf16::new(4, 7),
830 diagnostic: Diagnostic {
831 message: "value moved here".to_string(),
832 severity: DiagnosticSeverity::INFORMATION,
833 is_primary: false,
834 is_disk_based: true,
835 group_id: 0,
836 ..Default::default()
837 },
838 },
839 DiagnosticEntry {
840 range: PointUtf16::new(7, 6)..PointUtf16::new(7, 7),
841 diagnostic: Diagnostic {
842 message: "use of moved value\nvalue used here after move".to_string(),
843 severity: DiagnosticSeverity::ERROR,
844 is_primary: true,
845 is_disk_based: true,
846 group_id: 0,
847 ..Default::default()
848 },
849 },
850 DiagnosticEntry {
851 range: PointUtf16::new(8, 6)..PointUtf16::new(8, 7),
852 diagnostic: Diagnostic {
853 message: "use of moved value\nvalue used here after move".to_string(),
854 severity: DiagnosticSeverity::ERROR,
855 is_primary: true,
856 is_disk_based: true,
857 group_id: 1,
858 ..Default::default()
859 },
860 },
861 ],
862 cx,
863 )
864 .unwrap();
865 });
866
867 // Open the project diagnostics view while there are already diagnostics.
868 let view = cx.add_view(&workspace, |cx| {
869 ProjectDiagnosticsEditor::new(project.clone(), workspace.downgrade(), cx)
870 });
871
872 view.next_notification(cx).await;
873 view.update(cx, |view, cx| {
874 assert_eq!(
875 editor_blocks(&view.editor, cx),
876 [
877 (0, "path header block".into()),
878 (2, "diagnostic header".into()),
879 (15, "collapsed context".into()),
880 (16, "diagnostic header".into()),
881 (25, "collapsed context".into()),
882 ]
883 );
884 assert_eq!(
885 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
886 concat!(
887 //
888 // main.rs
889 //
890 "\n", // filename
891 "\n", // padding
892 // diagnostic group 1
893 "\n", // primary message
894 "\n", // padding
895 " let x = vec![];\n",
896 " let y = vec![];\n",
897 "\n", // supporting diagnostic
898 " a(x);\n",
899 " b(y);\n",
900 "\n", // supporting diagnostic
901 " // comment 1\n",
902 " // comment 2\n",
903 " c(y);\n",
904 "\n", // supporting diagnostic
905 " d(x);\n",
906 "\n", // context ellipsis
907 // diagnostic group 2
908 "\n", // primary message
909 "\n", // padding
910 "fn main() {\n",
911 " let x = vec![];\n",
912 "\n", // supporting diagnostic
913 " let y = vec![];\n",
914 " a(x);\n",
915 "\n", // supporting diagnostic
916 " b(y);\n",
917 "\n", // context ellipsis
918 " c(y);\n",
919 " d(x);\n",
920 "\n", // supporting diagnostic
921 "}"
922 )
923 );
924
925 // Cursor is at the first diagnostic
926 view.editor.update(cx, |editor, cx| {
927 assert_eq!(
928 editor.selections.display_ranges(cx),
929 [DisplayPoint::new(12, 6)..DisplayPoint::new(12, 6)]
930 );
931 });
932 });
933
934 // Diagnostics are added for another earlier path.
935 project.update(cx, |project, cx| {
936 project.disk_based_diagnostics_started(0, cx);
937 project
938 .update_diagnostic_entries(
939 0,
940 PathBuf::from("/test/consts.rs"),
941 None,
942 vec![DiagnosticEntry {
943 range: PointUtf16::new(0, 15)..PointUtf16::new(0, 15),
944 diagnostic: Diagnostic {
945 message: "mismatched types\nexpected `usize`, found `char`".to_string(),
946 severity: DiagnosticSeverity::ERROR,
947 is_primary: true,
948 is_disk_based: true,
949 group_id: 0,
950 ..Default::default()
951 },
952 }],
953 cx,
954 )
955 .unwrap();
956 project.disk_based_diagnostics_finished(0, cx);
957 });
958
959 view.next_notification(cx).await;
960 view.update(cx, |view, cx| {
961 assert_eq!(
962 editor_blocks(&view.editor, cx),
963 [
964 (0, "path header block".into()),
965 (2, "diagnostic header".into()),
966 (7, "path header block".into()),
967 (9, "diagnostic header".into()),
968 (22, "collapsed context".into()),
969 (23, "diagnostic header".into()),
970 (32, "collapsed context".into()),
971 ]
972 );
973 assert_eq!(
974 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
975 concat!(
976 //
977 // consts.rs
978 //
979 "\n", // filename
980 "\n", // padding
981 // diagnostic group 1
982 "\n", // primary message
983 "\n", // padding
984 "const a: i32 = 'a';\n",
985 "\n", // supporting diagnostic
986 "const b: i32 = c;\n",
987 //
988 // main.rs
989 //
990 "\n", // filename
991 "\n", // padding
992 // diagnostic group 1
993 "\n", // primary message
994 "\n", // padding
995 " let x = vec![];\n",
996 " let y = vec![];\n",
997 "\n", // supporting diagnostic
998 " a(x);\n",
999 " b(y);\n",
1000 "\n", // supporting diagnostic
1001 " // comment 1\n",
1002 " // comment 2\n",
1003 " c(y);\n",
1004 "\n", // supporting diagnostic
1005 " d(x);\n",
1006 "\n", // collapsed context
1007 // diagnostic group 2
1008 "\n", // primary message
1009 "\n", // filename
1010 "fn main() {\n",
1011 " let x = vec![];\n",
1012 "\n", // supporting diagnostic
1013 " let y = vec![];\n",
1014 " a(x);\n",
1015 "\n", // supporting diagnostic
1016 " b(y);\n",
1017 "\n", // context ellipsis
1018 " c(y);\n",
1019 " d(x);\n",
1020 "\n", // supporting diagnostic
1021 "}"
1022 )
1023 );
1024
1025 // Cursor keeps its position.
1026 view.editor.update(cx, |editor, cx| {
1027 assert_eq!(
1028 editor.selections.display_ranges(cx),
1029 [DisplayPoint::new(19, 6)..DisplayPoint::new(19, 6)]
1030 );
1031 });
1032 });
1033
1034 // Diagnostics are added to the first path
1035 project.update(cx, |project, cx| {
1036 project.disk_based_diagnostics_started(0, cx);
1037 project
1038 .update_diagnostic_entries(
1039 0,
1040 PathBuf::from("/test/consts.rs"),
1041 None,
1042 vec![
1043 DiagnosticEntry {
1044 range: PointUtf16::new(0, 15)..PointUtf16::new(0, 15),
1045 diagnostic: Diagnostic {
1046 message: "mismatched types\nexpected `usize`, found `char`"
1047 .to_string(),
1048 severity: DiagnosticSeverity::ERROR,
1049 is_primary: true,
1050 is_disk_based: true,
1051 group_id: 0,
1052 ..Default::default()
1053 },
1054 },
1055 DiagnosticEntry {
1056 range: PointUtf16::new(1, 15)..PointUtf16::new(1, 15),
1057 diagnostic: Diagnostic {
1058 message: "unresolved name `c`".to_string(),
1059 severity: DiagnosticSeverity::ERROR,
1060 is_primary: true,
1061 is_disk_based: true,
1062 group_id: 1,
1063 ..Default::default()
1064 },
1065 },
1066 ],
1067 cx,
1068 )
1069 .unwrap();
1070 project.disk_based_diagnostics_finished(0, cx);
1071 });
1072
1073 view.next_notification(cx).await;
1074 view.update(cx, |view, cx| {
1075 assert_eq!(
1076 editor_blocks(&view.editor, cx),
1077 [
1078 (0, "path header block".into()),
1079 (2, "diagnostic header".into()),
1080 (7, "collapsed context".into()),
1081 (8, "diagnostic header".into()),
1082 (13, "path header block".into()),
1083 (15, "diagnostic header".into()),
1084 (28, "collapsed context".into()),
1085 (29, "diagnostic header".into()),
1086 (38, "collapsed context".into()),
1087 ]
1088 );
1089 assert_eq!(
1090 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1091 concat!(
1092 //
1093 // consts.rs
1094 //
1095 "\n", // filename
1096 "\n", // padding
1097 // diagnostic group 1
1098 "\n", // primary message
1099 "\n", // padding
1100 "const a: i32 = 'a';\n",
1101 "\n", // supporting diagnostic
1102 "const b: i32 = c;\n",
1103 "\n", // context ellipsis
1104 // diagnostic group 2
1105 "\n", // primary message
1106 "\n", // padding
1107 "const a: i32 = 'a';\n",
1108 "const b: i32 = c;\n",
1109 "\n", // supporting diagnostic
1110 //
1111 // main.rs
1112 //
1113 "\n", // filename
1114 "\n", // padding
1115 // diagnostic group 1
1116 "\n", // primary message
1117 "\n", // padding
1118 " let x = vec![];\n",
1119 " let y = vec![];\n",
1120 "\n", // supporting diagnostic
1121 " a(x);\n",
1122 " b(y);\n",
1123 "\n", // supporting diagnostic
1124 " // comment 1\n",
1125 " // comment 2\n",
1126 " c(y);\n",
1127 "\n", // supporting diagnostic
1128 " d(x);\n",
1129 "\n", // context ellipsis
1130 // diagnostic group 2
1131 "\n", // primary message
1132 "\n", // filename
1133 "fn main() {\n",
1134 " let x = vec![];\n",
1135 "\n", // supporting diagnostic
1136 " let y = vec![];\n",
1137 " a(x);\n",
1138 "\n", // supporting diagnostic
1139 " b(y);\n",
1140 "\n", // context ellipsis
1141 " c(y);\n",
1142 " d(x);\n",
1143 "\n", // supporting diagnostic
1144 "}"
1145 )
1146 );
1147 });
1148 }
1149
1150 fn editor_blocks(
1151 editor: &ViewHandle<Editor>,
1152 cx: &mut MutableAppContext,
1153 ) -> Vec<(u32, String)> {
1154 let mut presenter = cx.build_presenter(editor.id(), 0., Default::default());
1155 let mut cx = presenter.build_layout_context(Default::default(), false, cx);
1156 cx.render(editor, |editor, cx| {
1157 let snapshot = editor.snapshot(cx);
1158 snapshot
1159 .blocks_in_range(0..snapshot.max_point().row())
1160 .filter_map(|(row, block)| {
1161 let name = match block {
1162 TransformBlock::Custom(block) => block
1163 .render(&mut BlockContext {
1164 cx,
1165 anchor_x: 0.,
1166 scroll_x: 0.,
1167 gutter_padding: 0.,
1168 gutter_width: 0.,
1169 line_height: 0.,
1170 em_width: 0.,
1171 })
1172 .name()?
1173 .to_string(),
1174 TransformBlock::ExcerptHeader {
1175 starts_new_buffer, ..
1176 } => {
1177 if *starts_new_buffer {
1178 "path header block".to_string()
1179 } else {
1180 "collapsed context".to_string()
1181 }
1182 }
1183 };
1184
1185 Some((row, name))
1186 })
1187 .collect()
1188 })
1189 }
1190}