1pub mod items;
2
3use anyhow::Result;
4use collections::{BTreeMap, HashSet};
5use editor::{
6 diagnostic_block_renderer,
7 display_map::{BlockDisposition, BlockId, BlockProperties, 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, Point, Selection,
18 SelectionGoal,
19};
20use project::{DiagnosticSummary, Project, ProjectPath};
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(&mut self, 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 render: diagnostic_header_renderer(primary),
352 disposition: BlockDisposition::Above,
353 });
354 }
355
356 for entry in &group.entries[*start_ix..ix] {
357 let mut diagnostic = entry.diagnostic.clone();
358 if diagnostic.is_primary {
359 group_state.primary_excerpt_ix = group_state.excerpts.len() - 1;
360 diagnostic.message =
361 entry.diagnostic.message.split('\n').skip(1).collect();
362 }
363
364 if !diagnostic.message.is_empty() {
365 group_state.block_count += 1;
366 blocks_to_add.push(BlockProperties {
367 position: (excerpt_id.clone(), entry.range.start.clone()),
368 height: diagnostic.message.matches('\n').count() as u8 + 1,
369 render: diagnostic_block_renderer(diagnostic, true),
370 disposition: BlockDisposition::Below,
371 });
372 }
373 }
374
375 pending_range.take();
376 }
377
378 if let Some(entry) = resolved_entry {
379 pending_range = Some((entry.range.clone(), ix));
380 }
381 }
382
383 groups_to_add.push(group_state);
384 } else if let Some((group_ix, group_state)) = to_remove {
385 excerpts.remove_excerpts(group_state.excerpts.iter(), excerpts_cx);
386 group_ixs_to_remove.push(group_ix);
387 blocks_to_remove.extend(group_state.blocks.iter().copied());
388 } else if let Some((_, group)) = to_keep {
389 prev_excerpt_id = group.excerpts.last().unwrap().clone();
390 first_excerpt_id.get_or_insert_with(|| prev_excerpt_id.clone());
391 }
392 }
393
394 excerpts.snapshot(excerpts_cx)
395 });
396
397 self.editor.update(cx, |editor, cx| {
398 editor.remove_blocks(blocks_to_remove, cx);
399 let block_ids = editor.insert_blocks(
400 blocks_to_add.into_iter().map(|block| {
401 let (excerpt_id, text_anchor) = block.position;
402 BlockProperties {
403 position: excerpts_snapshot.anchor_in_excerpt(excerpt_id, text_anchor),
404 height: block.height,
405 render: block.render,
406 disposition: block.disposition,
407 }
408 }),
409 cx,
410 );
411
412 let mut block_ids = block_ids.into_iter();
413 for group_state in &mut groups_to_add {
414 group_state.blocks = block_ids.by_ref().take(group_state.block_count).collect();
415 }
416 });
417
418 for ix in group_ixs_to_remove.into_iter().rev() {
419 path_state.diagnostic_groups.remove(ix);
420 }
421 path_state.diagnostic_groups.extend(groups_to_add);
422 path_state.diagnostic_groups.sort_unstable_by(|a, b| {
423 let range_a = &a.primary_diagnostic.range;
424 let range_b = &b.primary_diagnostic.range;
425 range_a
426 .start
427 .cmp(&range_b.start, &snapshot)
428 .then_with(|| range_a.end.cmp(&range_b.end, &snapshot))
429 });
430
431 if path_state.diagnostic_groups.is_empty() {
432 self.path_states.remove(path_ix);
433 }
434
435 self.editor.update(cx, |editor, cx| {
436 let groups;
437 let mut selections;
438 let new_excerpt_ids_by_selection_id;
439 if was_empty {
440 groups = self.path_states.first()?.diagnostic_groups.as_slice();
441 new_excerpt_ids_by_selection_id = [(0, ExcerptId::min())].into_iter().collect();
442 selections = vec![Selection {
443 id: 0,
444 start: 0,
445 end: 0,
446 reversed: false,
447 goal: SelectionGoal::None,
448 }];
449 } else {
450 groups = self.path_states.get(path_ix)?.diagnostic_groups.as_slice();
451 new_excerpt_ids_by_selection_id =
452 editor.change_selections(Some(Autoscroll::Fit), cx, |s| s.refresh());
453 selections = editor.selections.all::<usize>(cx);
454 }
455
456 // If any selection has lost its position, move it to start of the next primary diagnostic.
457 for selection in &mut selections {
458 if let Some(new_excerpt_id) = new_excerpt_ids_by_selection_id.get(&selection.id) {
459 let group_ix = match groups.binary_search_by(|probe| {
460 probe.excerpts.last().unwrap().cmp(&new_excerpt_id)
461 }) {
462 Ok(ix) | Err(ix) => ix,
463 };
464 if let Some(group) = groups.get(group_ix) {
465 let offset = excerpts_snapshot
466 .anchor_in_excerpt(
467 group.excerpts[group.primary_excerpt_ix].clone(),
468 group.primary_diagnostic.range.start.clone(),
469 )
470 .to_offset(&excerpts_snapshot);
471 selection.start = offset;
472 selection.end = offset;
473 }
474 }
475 }
476 editor.change_selections(None, cx, |s| {
477 s.select(selections);
478 });
479 Some(())
480 });
481
482 if self.path_states.is_empty() {
483 if self.editor.is_focused(cx) {
484 cx.focus_self();
485 }
486 } else {
487 if cx.handle().is_focused(cx) {
488 cx.focus(&self.editor);
489 }
490 }
491 cx.notify();
492 }
493
494 fn update_title(&mut self, cx: &mut ViewContext<Self>) {
495 self.summary = self.project.read(cx).diagnostic_summary(cx);
496 cx.emit(Event::TitleChanged);
497 }
498}
499
500impl workspace::Item for ProjectDiagnosticsEditor {
501 fn tab_content(&self, style: &theme::Tab, cx: &AppContext) -> ElementBox {
502 render_summary(
503 &self.summary,
504 &style.label.text,
505 &cx.global::<Settings>().theme.project_diagnostics,
506 )
507 }
508
509 fn project_path(&self, _: &AppContext) -> Option<project::ProjectPath> {
510 None
511 }
512
513 fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
514 self.editor.project_entry_ids(cx)
515 }
516
517 fn is_singleton(&self, _: &AppContext) -> bool {
518 false
519 }
520
521 fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
522 self.editor
523 .update(cx, |editor, cx| editor.navigate(data, cx))
524 }
525
526 fn is_dirty(&self, cx: &AppContext) -> bool {
527 self.excerpts.read(cx).is_dirty(cx)
528 }
529
530 fn has_conflict(&self, cx: &AppContext) -> bool {
531 self.excerpts.read(cx).has_conflict(cx)
532 }
533
534 fn can_save(&self, _: &AppContext) -> bool {
535 true
536 }
537
538 fn save(
539 &mut self,
540 project: ModelHandle<Project>,
541 cx: &mut ViewContext<Self>,
542 ) -> Task<Result<()>> {
543 self.editor.save(project, cx)
544 }
545
546 fn reload(
547 &mut self,
548 project: ModelHandle<Project>,
549 cx: &mut ViewContext<Self>,
550 ) -> Task<Result<()>> {
551 self.editor.reload(project, cx)
552 }
553
554 fn save_as(
555 &mut self,
556 _: ModelHandle<Project>,
557 _: PathBuf,
558 _: &mut ViewContext<Self>,
559 ) -> Task<Result<()>> {
560 unreachable!()
561 }
562
563 fn should_activate_item_on_event(event: &Self::Event) -> bool {
564 Editor::should_activate_item_on_event(event)
565 }
566
567 fn should_update_tab_on_event(event: &Event) -> bool {
568 matches!(event, Event::Saved | Event::Dirtied | Event::TitleChanged)
569 }
570
571 fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext<Self>) {
572 self.editor.update(cx, |editor, _| {
573 editor.set_nav_history(Some(nav_history));
574 });
575 }
576
577 fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self>
578 where
579 Self: Sized,
580 {
581 Some(ProjectDiagnosticsEditor::new(
582 self.project.clone(),
583 self.workspace.clone(),
584 cx,
585 ))
586 }
587
588 fn act_as_type(
589 &self,
590 type_id: TypeId,
591 self_handle: &ViewHandle<Self>,
592 _: &AppContext,
593 ) -> Option<AnyViewHandle> {
594 if type_id == TypeId::of::<Self>() {
595 Some(self_handle.into())
596 } else if type_id == TypeId::of::<Editor>() {
597 Some((&self.editor).into())
598 } else {
599 None
600 }
601 }
602
603 fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
604 self.editor.update(cx, |editor, cx| editor.deactivated(cx));
605 }
606}
607
608fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock {
609 let (message, highlights) = highlight_diagnostic_message(&diagnostic.message);
610 Arc::new(move |cx| {
611 let settings = cx.global::<Settings>();
612 let theme = &settings.theme.editor;
613 let style = theme.diagnostic_header.clone();
614 let font_size = (style.text_scale_factor * settings.buffer_font_size).round();
615 let icon_width = cx.em_width * style.icon_width_factor;
616 let icon = if diagnostic.severity == DiagnosticSeverity::ERROR {
617 Svg::new("icons/diagnostic-error-10.svg")
618 .with_color(theme.error_diagnostic.message.text.color)
619 } else {
620 Svg::new("icons/diagnostic-warning-10.svg")
621 .with_color(theme.warning_diagnostic.message.text.color)
622 };
623
624 let x_padding = cx.gutter_padding + cx.scroll_x * cx.em_width;
625 Flex::row()
626 .with_child(
627 icon.constrained()
628 .with_width(icon_width)
629 .aligned()
630 .contained()
631 .boxed(),
632 )
633 .with_child(
634 Label::new(
635 message.clone(),
636 style.message.label.clone().with_font_size(font_size),
637 )
638 .with_highlights(highlights.clone())
639 .contained()
640 .with_style(style.message.container)
641 .with_margin_left(cx.gutter_padding)
642 .aligned()
643 .boxed(),
644 )
645 .with_children(diagnostic.code.clone().map(|code| {
646 Label::new(code, style.code.text.clone().with_font_size(font_size))
647 .contained()
648 .with_style(style.code.container)
649 .aligned()
650 .boxed()
651 }))
652 .contained()
653 .with_style(style.container)
654 .with_padding_left(x_padding)
655 .with_padding_right(x_padding)
656 .expanded()
657 .named("diagnostic header")
658 })
659}
660
661pub(crate) fn render_summary(
662 summary: &DiagnosticSummary,
663 text_style: &TextStyle,
664 theme: &theme::ProjectDiagnostics,
665) -> ElementBox {
666 if summary.error_count == 0 && summary.warning_count == 0 {
667 Label::new("No problems".to_string(), text_style.clone()).boxed()
668 } else {
669 let icon_width = theme.tab_icon_width;
670 let icon_spacing = theme.tab_icon_spacing;
671 let summary_spacing = theme.tab_summary_spacing;
672 Flex::row()
673 .with_children([
674 Svg::new("icons/diagnostic-summary-error.svg")
675 .with_color(text_style.color)
676 .constrained()
677 .with_width(icon_width)
678 .aligned()
679 .contained()
680 .with_margin_right(icon_spacing)
681 .named("no-icon"),
682 Label::new(
683 summary.error_count.to_string(),
684 LabelStyle {
685 text: text_style.clone(),
686 highlight_text: None,
687 },
688 )
689 .aligned()
690 .boxed(),
691 Svg::new("icons/diagnostic-summary-warning.svg")
692 .with_color(text_style.color)
693 .constrained()
694 .with_width(icon_width)
695 .aligned()
696 .contained()
697 .with_margin_left(summary_spacing)
698 .with_margin_right(icon_spacing)
699 .named("warn-icon"),
700 Label::new(
701 summary.warning_count.to_string(),
702 LabelStyle {
703 text: text_style.clone(),
704 highlight_text: None,
705 },
706 )
707 .aligned()
708 .boxed(),
709 ])
710 .boxed()
711 }
712}
713
714fn compare_diagnostics<L: language::ToOffset, R: language::ToOffset>(
715 lhs: &DiagnosticEntry<L>,
716 rhs: &DiagnosticEntry<R>,
717 snapshot: &language::BufferSnapshot,
718) -> Ordering {
719 lhs.range
720 .start
721 .to_offset(&snapshot)
722 .cmp(&rhs.range.start.to_offset(snapshot))
723 .then_with(|| {
724 lhs.range
725 .end
726 .to_offset(&snapshot)
727 .cmp(&rhs.range.end.to_offset(snapshot))
728 })
729 .then_with(|| lhs.diagnostic.message.cmp(&rhs.diagnostic.message))
730}
731
732#[cfg(test)]
733mod tests {
734 use super::*;
735 use editor::{
736 display_map::{BlockContext, TransformBlock},
737 DisplayPoint,
738 };
739 use gpui::TestAppContext;
740 use language::{Diagnostic, DiagnosticEntry, DiagnosticSeverity, PointUtf16};
741 use serde_json::json;
742 use unindent::Unindent as _;
743 use workspace::AppState;
744
745 #[gpui::test]
746 async fn test_diagnostics(cx: &mut TestAppContext) {
747 let app_state = cx.update(AppState::test);
748 app_state
749 .fs
750 .as_fake()
751 .insert_tree(
752 "/test",
753 json!({
754 "consts.rs": "
755 const a: i32 = 'a';
756 const b: i32 = c;
757 "
758 .unindent(),
759
760 "main.rs": "
761 fn main() {
762 let x = vec![];
763 let y = vec![];
764 a(x);
765 b(y);
766 // comment 1
767 // comment 2
768 c(y);
769 d(x);
770 }
771 "
772 .unindent(),
773 }),
774 )
775 .await;
776
777 let project = Project::test(app_state.fs.clone(), ["/test".as_ref()], cx).await;
778 let workspace = cx.add_view(0, |cx| Workspace::new(project.clone(), cx));
779
780 // Create some diagnostics
781 project.update(cx, |project, cx| {
782 project
783 .update_diagnostic_entries(
784 0,
785 PathBuf::from("/test/main.rs"),
786 None,
787 vec![
788 DiagnosticEntry {
789 range: PointUtf16::new(1, 8)..PointUtf16::new(1, 9),
790 diagnostic: Diagnostic {
791 message:
792 "move occurs because `x` has type `Vec<char>`, which does not implement the `Copy` trait"
793 .to_string(),
794 severity: DiagnosticSeverity::INFORMATION,
795 is_primary: false,
796 is_disk_based: true,
797 group_id: 1,
798 ..Default::default()
799 },
800 },
801 DiagnosticEntry {
802 range: PointUtf16::new(2, 8)..PointUtf16::new(2, 9),
803 diagnostic: Diagnostic {
804 message:
805 "move occurs because `y` has type `Vec<char>`, which does not implement the `Copy` trait"
806 .to_string(),
807 severity: DiagnosticSeverity::INFORMATION,
808 is_primary: false,
809 is_disk_based: true,
810 group_id: 0,
811 ..Default::default()
812 },
813 },
814 DiagnosticEntry {
815 range: PointUtf16::new(3, 6)..PointUtf16::new(3, 7),
816 diagnostic: Diagnostic {
817 message: "value moved here".to_string(),
818 severity: DiagnosticSeverity::INFORMATION,
819 is_primary: false,
820 is_disk_based: true,
821 group_id: 1,
822 ..Default::default()
823 },
824 },
825 DiagnosticEntry {
826 range: PointUtf16::new(4, 6)..PointUtf16::new(4, 7),
827 diagnostic: Diagnostic {
828 message: "value moved here".to_string(),
829 severity: DiagnosticSeverity::INFORMATION,
830 is_primary: false,
831 is_disk_based: true,
832 group_id: 0,
833 ..Default::default()
834 },
835 },
836 DiagnosticEntry {
837 range: PointUtf16::new(7, 6)..PointUtf16::new(7, 7),
838 diagnostic: Diagnostic {
839 message: "use of moved value\nvalue used here after move".to_string(),
840 severity: DiagnosticSeverity::ERROR,
841 is_primary: true,
842 is_disk_based: true,
843 group_id: 0,
844 ..Default::default()
845 },
846 },
847 DiagnosticEntry {
848 range: PointUtf16::new(8, 6)..PointUtf16::new(8, 7),
849 diagnostic: Diagnostic {
850 message: "use of moved value\nvalue used here after move".to_string(),
851 severity: DiagnosticSeverity::ERROR,
852 is_primary: true,
853 is_disk_based: true,
854 group_id: 1,
855 ..Default::default()
856 },
857 },
858 ],
859 cx,
860 )
861 .unwrap();
862 });
863
864 // Open the project diagnostics view while there are already diagnostics.
865 let view = cx.add_view(0, |cx| {
866 ProjectDiagnosticsEditor::new(project.clone(), workspace.downgrade(), cx)
867 });
868
869 view.next_notification(&cx).await;
870 view.update(cx, |view, cx| {
871 assert_eq!(
872 editor_blocks(&view.editor, cx),
873 [
874 (0, "path header block".into()),
875 (2, "diagnostic header".into()),
876 (15, "collapsed context".into()),
877 (16, "diagnostic header".into()),
878 (25, "collapsed context".into()),
879 ]
880 );
881 assert_eq!(
882 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
883 concat!(
884 //
885 // main.rs
886 //
887 "\n", // filename
888 "\n", // padding
889 // diagnostic group 1
890 "\n", // primary message
891 "\n", // padding
892 " let x = vec![];\n",
893 " let y = vec![];\n",
894 "\n", // supporting diagnostic
895 " a(x);\n",
896 " b(y);\n",
897 "\n", // supporting diagnostic
898 " // comment 1\n",
899 " // comment 2\n",
900 " c(y);\n",
901 "\n", // supporting diagnostic
902 " d(x);\n",
903 "\n", // context ellipsis
904 // diagnostic group 2
905 "\n", // primary message
906 "\n", // padding
907 "fn main() {\n",
908 " let x = vec![];\n",
909 "\n", // supporting diagnostic
910 " let y = vec![];\n",
911 " a(x);\n",
912 "\n", // supporting diagnostic
913 " b(y);\n",
914 "\n", // context ellipsis
915 " c(y);\n",
916 " d(x);\n",
917 "\n", // supporting diagnostic
918 "}"
919 )
920 );
921
922 // Cursor is at the first diagnostic
923 view.editor.update(cx, |editor, cx| {
924 assert_eq!(
925 editor.selections.display_ranges(cx),
926 [DisplayPoint::new(12, 6)..DisplayPoint::new(12, 6)]
927 );
928 });
929 });
930
931 // Diagnostics are added for another earlier path.
932 project.update(cx, |project, cx| {
933 project.disk_based_diagnostics_started(0, cx);
934 project
935 .update_diagnostic_entries(
936 0,
937 PathBuf::from("/test/consts.rs"),
938 None,
939 vec![DiagnosticEntry {
940 range: PointUtf16::new(0, 15)..PointUtf16::new(0, 15),
941 diagnostic: Diagnostic {
942 message: "mismatched types\nexpected `usize`, found `char`".to_string(),
943 severity: DiagnosticSeverity::ERROR,
944 is_primary: true,
945 is_disk_based: true,
946 group_id: 0,
947 ..Default::default()
948 },
949 }],
950 cx,
951 )
952 .unwrap();
953 project.disk_based_diagnostics_finished(0, cx);
954 });
955
956 view.next_notification(&cx).await;
957 view.update(cx, |view, cx| {
958 assert_eq!(
959 editor_blocks(&view.editor, cx),
960 [
961 (0, "path header block".into()),
962 (2, "diagnostic header".into()),
963 (7, "path header block".into()),
964 (9, "diagnostic header".into()),
965 (22, "collapsed context".into()),
966 (23, "diagnostic header".into()),
967 (32, "collapsed context".into()),
968 ]
969 );
970 assert_eq!(
971 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
972 concat!(
973 //
974 // consts.rs
975 //
976 "\n", // filename
977 "\n", // padding
978 // diagnostic group 1
979 "\n", // primary message
980 "\n", // padding
981 "const a: i32 = 'a';\n",
982 "\n", // supporting diagnostic
983 "const b: i32 = c;\n",
984 //
985 // main.rs
986 //
987 "\n", // filename
988 "\n", // padding
989 // diagnostic group 1
990 "\n", // primary message
991 "\n", // padding
992 " let x = vec![];\n",
993 " let y = vec![];\n",
994 "\n", // supporting diagnostic
995 " a(x);\n",
996 " b(y);\n",
997 "\n", // supporting diagnostic
998 " // comment 1\n",
999 " // comment 2\n",
1000 " c(y);\n",
1001 "\n", // supporting diagnostic
1002 " d(x);\n",
1003 "\n", // collapsed context
1004 // diagnostic group 2
1005 "\n", // primary message
1006 "\n", // filename
1007 "fn main() {\n",
1008 " let x = vec![];\n",
1009 "\n", // supporting diagnostic
1010 " let y = vec![];\n",
1011 " a(x);\n",
1012 "\n", // supporting diagnostic
1013 " b(y);\n",
1014 "\n", // context ellipsis
1015 " c(y);\n",
1016 " d(x);\n",
1017 "\n", // supporting diagnostic
1018 "}"
1019 )
1020 );
1021
1022 // Cursor keeps its position.
1023 view.editor.update(cx, |editor, cx| {
1024 assert_eq!(
1025 editor.selections.display_ranges(cx),
1026 [DisplayPoint::new(19, 6)..DisplayPoint::new(19, 6)]
1027 );
1028 });
1029 });
1030
1031 // Diagnostics are added to the first path
1032 project.update(cx, |project, cx| {
1033 project.disk_based_diagnostics_started(0, cx);
1034 project
1035 .update_diagnostic_entries(
1036 0,
1037 PathBuf::from("/test/consts.rs"),
1038 None,
1039 vec![
1040 DiagnosticEntry {
1041 range: PointUtf16::new(0, 15)..PointUtf16::new(0, 15),
1042 diagnostic: Diagnostic {
1043 message: "mismatched types\nexpected `usize`, found `char`"
1044 .to_string(),
1045 severity: DiagnosticSeverity::ERROR,
1046 is_primary: true,
1047 is_disk_based: true,
1048 group_id: 0,
1049 ..Default::default()
1050 },
1051 },
1052 DiagnosticEntry {
1053 range: PointUtf16::new(1, 15)..PointUtf16::new(1, 15),
1054 diagnostic: Diagnostic {
1055 message: "unresolved name `c`".to_string(),
1056 severity: DiagnosticSeverity::ERROR,
1057 is_primary: true,
1058 is_disk_based: true,
1059 group_id: 1,
1060 ..Default::default()
1061 },
1062 },
1063 ],
1064 cx,
1065 )
1066 .unwrap();
1067 project.disk_based_diagnostics_finished(0, cx);
1068 });
1069
1070 view.next_notification(&cx).await;
1071 view.update(cx, |view, cx| {
1072 assert_eq!(
1073 editor_blocks(&view.editor, cx),
1074 [
1075 (0, "path header block".into()),
1076 (2, "diagnostic header".into()),
1077 (7, "collapsed context".into()),
1078 (8, "diagnostic header".into()),
1079 (13, "path header block".into()),
1080 (15, "diagnostic header".into()),
1081 (28, "collapsed context".into()),
1082 (29, "diagnostic header".into()),
1083 (38, "collapsed context".into()),
1084 ]
1085 );
1086 assert_eq!(
1087 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1088 concat!(
1089 //
1090 // consts.rs
1091 //
1092 "\n", // filename
1093 "\n", // padding
1094 // diagnostic group 1
1095 "\n", // primary message
1096 "\n", // padding
1097 "const a: i32 = 'a';\n",
1098 "\n", // supporting diagnostic
1099 "const b: i32 = c;\n",
1100 "\n", // context ellipsis
1101 // diagnostic group 2
1102 "\n", // primary message
1103 "\n", // padding
1104 "const a: i32 = 'a';\n",
1105 "const b: i32 = c;\n",
1106 "\n", // supporting diagnostic
1107 //
1108 // main.rs
1109 //
1110 "\n", // filename
1111 "\n", // padding
1112 // diagnostic group 1
1113 "\n", // primary message
1114 "\n", // padding
1115 " let x = vec![];\n",
1116 " let y = vec![];\n",
1117 "\n", // supporting diagnostic
1118 " a(x);\n",
1119 " b(y);\n",
1120 "\n", // supporting diagnostic
1121 " // comment 1\n",
1122 " // comment 2\n",
1123 " c(y);\n",
1124 "\n", // supporting diagnostic
1125 " d(x);\n",
1126 "\n", // context ellipsis
1127 // diagnostic group 2
1128 "\n", // primary message
1129 "\n", // filename
1130 "fn main() {\n",
1131 " let x = vec![];\n",
1132 "\n", // supporting diagnostic
1133 " let y = vec![];\n",
1134 " a(x);\n",
1135 "\n", // supporting diagnostic
1136 " b(y);\n",
1137 "\n", // context ellipsis
1138 " c(y);\n",
1139 " d(x);\n",
1140 "\n", // supporting diagnostic
1141 "}"
1142 )
1143 );
1144 });
1145 }
1146
1147 fn editor_blocks(
1148 editor: &ViewHandle<Editor>,
1149 cx: &mut MutableAppContext,
1150 ) -> Vec<(u32, String)> {
1151 let mut presenter = cx.build_presenter(editor.id(), 0.);
1152 let mut cx = presenter.build_layout_context(Default::default(), false, cx);
1153 cx.render(editor, |editor, cx| {
1154 let snapshot = editor.snapshot(cx);
1155 snapshot
1156 .blocks_in_range(0..snapshot.max_point().row())
1157 .filter_map(|(row, block)| {
1158 let name = match block {
1159 TransformBlock::Custom(block) => block
1160 .render(&mut BlockContext {
1161 cx,
1162 anchor_x: 0.,
1163 scroll_x: 0.,
1164 gutter_padding: 0.,
1165 gutter_width: 0.,
1166 line_height: 0.,
1167 em_width: 0.,
1168 })
1169 .name()?
1170 .to_string(),
1171 TransformBlock::ExcerptHeader {
1172 starts_new_buffer, ..
1173 } => {
1174 if *starts_new_buffer {
1175 "path header block".to_string()
1176 } else {
1177 "collapsed context".to_string()
1178 }
1179 }
1180 };
1181
1182 Some((row, name))
1183 })
1184 .collect()
1185 })
1186 }
1187}