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, 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 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.clone()),
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.clone(),
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 {
490 if cx.handle().is_focused(cx) {
491 cx.focus(&self.editor);
492 }
493 }
494 cx.notify();
495 }
496
497 fn update_title(&mut self, cx: &mut ViewContext<Self>) {
498 self.summary = self.project.read(cx).diagnostic_summary(cx);
499 cx.emit(Event::TitleChanged);
500 }
501}
502
503impl workspace::Item for ProjectDiagnosticsEditor {
504 fn tab_content(&self, style: &theme::Tab, cx: &AppContext) -> ElementBox {
505 render_summary(
506 &self.summary,
507 &style.label.text,
508 &cx.global::<Settings>().theme.project_diagnostics,
509 )
510 }
511
512 fn project_path(&self, _: &AppContext) -> Option<project::ProjectPath> {
513 None
514 }
515
516 fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
517 self.editor.project_entry_ids(cx)
518 }
519
520 fn is_singleton(&self, _: &AppContext) -> bool {
521 false
522 }
523
524 fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
525 self.editor
526 .update(cx, |editor, cx| editor.navigate(data, cx))
527 }
528
529 fn is_dirty(&self, cx: &AppContext) -> bool {
530 self.excerpts.read(cx).is_dirty(cx)
531 }
532
533 fn has_conflict(&self, cx: &AppContext) -> bool {
534 self.excerpts.read(cx).has_conflict(cx)
535 }
536
537 fn can_save(&self, _: &AppContext) -> bool {
538 true
539 }
540
541 fn save(
542 &mut self,
543 project: ModelHandle<Project>,
544 cx: &mut ViewContext<Self>,
545 ) -> Task<Result<()>> {
546 self.editor.save(project, cx)
547 }
548
549 fn reload(
550 &mut self,
551 project: ModelHandle<Project>,
552 cx: &mut ViewContext<Self>,
553 ) -> Task<Result<()>> {
554 self.editor.reload(project, cx)
555 }
556
557 fn save_as(
558 &mut self,
559 _: ModelHandle<Project>,
560 _: PathBuf,
561 _: &mut ViewContext<Self>,
562 ) -> Task<Result<()>> {
563 unreachable!()
564 }
565
566 fn should_activate_item_on_event(event: &Self::Event) -> bool {
567 Editor::should_activate_item_on_event(event)
568 }
569
570 fn should_update_tab_on_event(event: &Event) -> bool {
571 matches!(event, Event::Saved | Event::Dirtied | Event::TitleChanged)
572 }
573
574 fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext<Self>) {
575 self.editor.update(cx, |editor, _| {
576 editor.set_nav_history(Some(nav_history));
577 });
578 }
579
580 fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self>
581 where
582 Self: Sized,
583 {
584 Some(ProjectDiagnosticsEditor::new(
585 self.project.clone(),
586 self.workspace.clone(),
587 cx,
588 ))
589 }
590
591 fn act_as_type(
592 &self,
593 type_id: TypeId,
594 self_handle: &ViewHandle<Self>,
595 _: &AppContext,
596 ) -> Option<AnyViewHandle> {
597 if type_id == TypeId::of::<Self>() {
598 Some(self_handle.into())
599 } else if type_id == TypeId::of::<Editor>() {
600 Some((&self.editor).into())
601 } else {
602 None
603 }
604 }
605
606 fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
607 self.editor.update(cx, |editor, cx| editor.deactivated(cx));
608 }
609}
610
611fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock {
612 let (message, highlights) = highlight_diagnostic_message(&diagnostic.message);
613 Arc::new(move |cx| {
614 let settings = cx.global::<Settings>();
615 let theme = &settings.theme.editor;
616 let style = theme.diagnostic_header.clone();
617 let font_size = (style.text_scale_factor * settings.buffer_font_size).round();
618 let icon_width = cx.em_width * style.icon_width_factor;
619 let icon = if diagnostic.severity == DiagnosticSeverity::ERROR {
620 Svg::new("icons/diagnostic-error-10.svg")
621 .with_color(theme.error_diagnostic.message.text.color)
622 } else {
623 Svg::new("icons/diagnostic-warning-10.svg")
624 .with_color(theme.warning_diagnostic.message.text.color)
625 };
626
627 Flex::row()
628 .with_child(
629 icon.constrained()
630 .with_width(icon_width)
631 .aligned()
632 .contained()
633 .boxed(),
634 )
635 .with_child(
636 Label::new(
637 message.clone(),
638 style.message.label.clone().with_font_size(font_size),
639 )
640 .with_highlights(highlights.clone())
641 .contained()
642 .with_style(style.message.container)
643 .with_margin_left(cx.gutter_padding)
644 .aligned()
645 .boxed(),
646 )
647 .with_children(diagnostic.code.clone().map(|code| {
648 Label::new(code, style.code.text.clone().with_font_size(font_size))
649 .contained()
650 .with_style(style.code.container)
651 .aligned()
652 .boxed()
653 }))
654 .contained()
655 .with_style(style.container)
656 .with_padding_left(cx.gutter_padding)
657 .with_padding_right(cx.gutter_padding)
658 .expanded()
659 .named("diagnostic header")
660 })
661}
662
663pub(crate) fn render_summary(
664 summary: &DiagnosticSummary,
665 text_style: &TextStyle,
666 theme: &theme::ProjectDiagnostics,
667) -> ElementBox {
668 if summary.error_count == 0 && summary.warning_count == 0 {
669 Label::new("No problems".to_string(), text_style.clone()).boxed()
670 } else {
671 let icon_width = theme.tab_icon_width;
672 let icon_spacing = theme.tab_icon_spacing;
673 let summary_spacing = theme.tab_summary_spacing;
674 Flex::row()
675 .with_children([
676 Svg::new("icons/diagnostic-summary-error.svg")
677 .with_color(text_style.color)
678 .constrained()
679 .with_width(icon_width)
680 .aligned()
681 .contained()
682 .with_margin_right(icon_spacing)
683 .named("no-icon"),
684 Label::new(
685 summary.error_count.to_string(),
686 LabelStyle {
687 text: text_style.clone(),
688 highlight_text: None,
689 },
690 )
691 .aligned()
692 .boxed(),
693 Svg::new("icons/diagnostic-summary-warning.svg")
694 .with_color(text_style.color)
695 .constrained()
696 .with_width(icon_width)
697 .aligned()
698 .contained()
699 .with_margin_left(summary_spacing)
700 .with_margin_right(icon_spacing)
701 .named("warn-icon"),
702 Label::new(
703 summary.warning_count.to_string(),
704 LabelStyle {
705 text: text_style.clone(),
706 highlight_text: None,
707 },
708 )
709 .aligned()
710 .boxed(),
711 ])
712 .boxed()
713 }
714}
715
716fn compare_diagnostics<L: language::ToOffset, R: language::ToOffset>(
717 lhs: &DiagnosticEntry<L>,
718 rhs: &DiagnosticEntry<R>,
719 snapshot: &language::BufferSnapshot,
720) -> Ordering {
721 lhs.range
722 .start
723 .to_offset(&snapshot)
724 .cmp(&rhs.range.start.to_offset(snapshot))
725 .then_with(|| {
726 lhs.range
727 .end
728 .to_offset(&snapshot)
729 .cmp(&rhs.range.end.to_offset(snapshot))
730 })
731 .then_with(|| lhs.diagnostic.message.cmp(&rhs.diagnostic.message))
732}
733
734#[cfg(test)]
735mod tests {
736 use super::*;
737 use editor::{
738 display_map::{BlockContext, TransformBlock},
739 DisplayPoint,
740 };
741 use gpui::TestAppContext;
742 use language::{Diagnostic, DiagnosticEntry, DiagnosticSeverity, 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 = cx.add_view(0, |cx| Workspace::new(project.clone(), cx));
781
782 // Create some diagnostics
783 project.update(cx, |project, cx| {
784 project
785 .update_diagnostic_entries(
786 0,
787 PathBuf::from("/test/main.rs"),
788 None,
789 vec![
790 DiagnosticEntry {
791 range: PointUtf16::new(1, 8)..PointUtf16::new(1, 9),
792 diagnostic: Diagnostic {
793 message:
794 "move occurs because `x` has type `Vec<char>`, which does not implement the `Copy` trait"
795 .to_string(),
796 severity: DiagnosticSeverity::INFORMATION,
797 is_primary: false,
798 is_disk_based: true,
799 group_id: 1,
800 ..Default::default()
801 },
802 },
803 DiagnosticEntry {
804 range: PointUtf16::new(2, 8)..PointUtf16::new(2, 9),
805 diagnostic: Diagnostic {
806 message:
807 "move occurs because `y` has type `Vec<char>`, which does not implement the `Copy` trait"
808 .to_string(),
809 severity: DiagnosticSeverity::INFORMATION,
810 is_primary: false,
811 is_disk_based: true,
812 group_id: 0,
813 ..Default::default()
814 },
815 },
816 DiagnosticEntry {
817 range: PointUtf16::new(3, 6)..PointUtf16::new(3, 7),
818 diagnostic: Diagnostic {
819 message: "value moved here".to_string(),
820 severity: DiagnosticSeverity::INFORMATION,
821 is_primary: false,
822 is_disk_based: true,
823 group_id: 1,
824 ..Default::default()
825 },
826 },
827 DiagnosticEntry {
828 range: PointUtf16::new(4, 6)..PointUtf16::new(4, 7),
829 diagnostic: Diagnostic {
830 message: "value moved here".to_string(),
831 severity: DiagnosticSeverity::INFORMATION,
832 is_primary: false,
833 is_disk_based: true,
834 group_id: 0,
835 ..Default::default()
836 },
837 },
838 DiagnosticEntry {
839 range: PointUtf16::new(7, 6)..PointUtf16::new(7, 7),
840 diagnostic: Diagnostic {
841 message: "use of moved value\nvalue used here after move".to_string(),
842 severity: DiagnosticSeverity::ERROR,
843 is_primary: true,
844 is_disk_based: true,
845 group_id: 0,
846 ..Default::default()
847 },
848 },
849 DiagnosticEntry {
850 range: PointUtf16::new(8, 6)..PointUtf16::new(8, 7),
851 diagnostic: Diagnostic {
852 message: "use of moved value\nvalue used here after move".to_string(),
853 severity: DiagnosticSeverity::ERROR,
854 is_primary: true,
855 is_disk_based: true,
856 group_id: 1,
857 ..Default::default()
858 },
859 },
860 ],
861 cx,
862 )
863 .unwrap();
864 });
865
866 // Open the project diagnostics view while there are already diagnostics.
867 let view = cx.add_view(0, |cx| {
868 ProjectDiagnosticsEditor::new(project.clone(), workspace.downgrade(), cx)
869 });
870
871 view.next_notification(&cx).await;
872 view.update(cx, |view, cx| {
873 assert_eq!(
874 editor_blocks(&view.editor, cx),
875 [
876 (0, "path header block".into()),
877 (2, "diagnostic header".into()),
878 (15, "collapsed context".into()),
879 (16, "diagnostic header".into()),
880 (25, "collapsed context".into()),
881 ]
882 );
883 assert_eq!(
884 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
885 concat!(
886 //
887 // main.rs
888 //
889 "\n", // filename
890 "\n", // padding
891 // diagnostic group 1
892 "\n", // primary message
893 "\n", // padding
894 " let x = vec![];\n",
895 " let y = vec![];\n",
896 "\n", // supporting diagnostic
897 " a(x);\n",
898 " b(y);\n",
899 "\n", // supporting diagnostic
900 " // comment 1\n",
901 " // comment 2\n",
902 " c(y);\n",
903 "\n", // supporting diagnostic
904 " d(x);\n",
905 "\n", // context ellipsis
906 // diagnostic group 2
907 "\n", // primary message
908 "\n", // padding
909 "fn main() {\n",
910 " let x = vec![];\n",
911 "\n", // supporting diagnostic
912 " let y = vec![];\n",
913 " a(x);\n",
914 "\n", // supporting diagnostic
915 " b(y);\n",
916 "\n", // context ellipsis
917 " c(y);\n",
918 " d(x);\n",
919 "\n", // supporting diagnostic
920 "}"
921 )
922 );
923
924 // Cursor is at the first diagnostic
925 view.editor.update(cx, |editor, cx| {
926 assert_eq!(
927 editor.selections.display_ranges(cx),
928 [DisplayPoint::new(12, 6)..DisplayPoint::new(12, 6)]
929 );
930 });
931 });
932
933 // Diagnostics are added for another earlier path.
934 project.update(cx, |project, cx| {
935 project.disk_based_diagnostics_started(0, cx);
936 project
937 .update_diagnostic_entries(
938 0,
939 PathBuf::from("/test/consts.rs"),
940 None,
941 vec![DiagnosticEntry {
942 range: PointUtf16::new(0, 15)..PointUtf16::new(0, 15),
943 diagnostic: Diagnostic {
944 message: "mismatched types\nexpected `usize`, found `char`".to_string(),
945 severity: DiagnosticSeverity::ERROR,
946 is_primary: true,
947 is_disk_based: true,
948 group_id: 0,
949 ..Default::default()
950 },
951 }],
952 cx,
953 )
954 .unwrap();
955 project.disk_based_diagnostics_finished(0, cx);
956 });
957
958 view.next_notification(&cx).await;
959 view.update(cx, |view, cx| {
960 assert_eq!(
961 editor_blocks(&view.editor, cx),
962 [
963 (0, "path header block".into()),
964 (2, "diagnostic header".into()),
965 (7, "path header block".into()),
966 (9, "diagnostic header".into()),
967 (22, "collapsed context".into()),
968 (23, "diagnostic header".into()),
969 (32, "collapsed context".into()),
970 ]
971 );
972 assert_eq!(
973 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
974 concat!(
975 //
976 // consts.rs
977 //
978 "\n", // filename
979 "\n", // padding
980 // diagnostic group 1
981 "\n", // primary message
982 "\n", // padding
983 "const a: i32 = 'a';\n",
984 "\n", // supporting diagnostic
985 "const b: i32 = c;\n",
986 //
987 // main.rs
988 //
989 "\n", // filename
990 "\n", // padding
991 // diagnostic group 1
992 "\n", // primary message
993 "\n", // padding
994 " let x = vec![];\n",
995 " let y = vec![];\n",
996 "\n", // supporting diagnostic
997 " a(x);\n",
998 " b(y);\n",
999 "\n", // supporting diagnostic
1000 " // comment 1\n",
1001 " // comment 2\n",
1002 " c(y);\n",
1003 "\n", // supporting diagnostic
1004 " d(x);\n",
1005 "\n", // collapsed context
1006 // diagnostic group 2
1007 "\n", // primary message
1008 "\n", // filename
1009 "fn main() {\n",
1010 " let x = vec![];\n",
1011 "\n", // supporting diagnostic
1012 " let y = vec![];\n",
1013 " a(x);\n",
1014 "\n", // supporting diagnostic
1015 " b(y);\n",
1016 "\n", // context ellipsis
1017 " c(y);\n",
1018 " d(x);\n",
1019 "\n", // supporting diagnostic
1020 "}"
1021 )
1022 );
1023
1024 // Cursor keeps its position.
1025 view.editor.update(cx, |editor, cx| {
1026 assert_eq!(
1027 editor.selections.display_ranges(cx),
1028 [DisplayPoint::new(19, 6)..DisplayPoint::new(19, 6)]
1029 );
1030 });
1031 });
1032
1033 // Diagnostics are added to the first path
1034 project.update(cx, |project, cx| {
1035 project.disk_based_diagnostics_started(0, cx);
1036 project
1037 .update_diagnostic_entries(
1038 0,
1039 PathBuf::from("/test/consts.rs"),
1040 None,
1041 vec![
1042 DiagnosticEntry {
1043 range: PointUtf16::new(0, 15)..PointUtf16::new(0, 15),
1044 diagnostic: Diagnostic {
1045 message: "mismatched types\nexpected `usize`, found `char`"
1046 .to_string(),
1047 severity: DiagnosticSeverity::ERROR,
1048 is_primary: true,
1049 is_disk_based: true,
1050 group_id: 0,
1051 ..Default::default()
1052 },
1053 },
1054 DiagnosticEntry {
1055 range: PointUtf16::new(1, 15)..PointUtf16::new(1, 15),
1056 diagnostic: Diagnostic {
1057 message: "unresolved name `c`".to_string(),
1058 severity: DiagnosticSeverity::ERROR,
1059 is_primary: true,
1060 is_disk_based: true,
1061 group_id: 1,
1062 ..Default::default()
1063 },
1064 },
1065 ],
1066 cx,
1067 )
1068 .unwrap();
1069 project.disk_based_diagnostics_finished(0, cx);
1070 });
1071
1072 view.next_notification(&cx).await;
1073 view.update(cx, |view, cx| {
1074 assert_eq!(
1075 editor_blocks(&view.editor, cx),
1076 [
1077 (0, "path header block".into()),
1078 (2, "diagnostic header".into()),
1079 (7, "collapsed context".into()),
1080 (8, "diagnostic header".into()),
1081 (13, "path header block".into()),
1082 (15, "diagnostic header".into()),
1083 (28, "collapsed context".into()),
1084 (29, "diagnostic header".into()),
1085 (38, "collapsed context".into()),
1086 ]
1087 );
1088 assert_eq!(
1089 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1090 concat!(
1091 //
1092 // consts.rs
1093 //
1094 "\n", // filename
1095 "\n", // padding
1096 // diagnostic group 1
1097 "\n", // primary message
1098 "\n", // padding
1099 "const a: i32 = 'a';\n",
1100 "\n", // supporting diagnostic
1101 "const b: i32 = c;\n",
1102 "\n", // context ellipsis
1103 // diagnostic group 2
1104 "\n", // primary message
1105 "\n", // padding
1106 "const a: i32 = 'a';\n",
1107 "const b: i32 = c;\n",
1108 "\n", // supporting diagnostic
1109 //
1110 // main.rs
1111 //
1112 "\n", // filename
1113 "\n", // padding
1114 // diagnostic group 1
1115 "\n", // primary message
1116 "\n", // padding
1117 " let x = vec![];\n",
1118 " let y = vec![];\n",
1119 "\n", // supporting diagnostic
1120 " a(x);\n",
1121 " b(y);\n",
1122 "\n", // supporting diagnostic
1123 " // comment 1\n",
1124 " // comment 2\n",
1125 " c(y);\n",
1126 "\n", // supporting diagnostic
1127 " d(x);\n",
1128 "\n", // context ellipsis
1129 // diagnostic group 2
1130 "\n", // primary message
1131 "\n", // filename
1132 "fn main() {\n",
1133 " let x = vec![];\n",
1134 "\n", // supporting diagnostic
1135 " let y = vec![];\n",
1136 " a(x);\n",
1137 "\n", // supporting diagnostic
1138 " b(y);\n",
1139 "\n", // context ellipsis
1140 " c(y);\n",
1141 " d(x);\n",
1142 "\n", // supporting diagnostic
1143 "}"
1144 )
1145 );
1146 });
1147 }
1148
1149 fn editor_blocks(
1150 editor: &ViewHandle<Editor>,
1151 cx: &mut MutableAppContext,
1152 ) -> Vec<(u32, String)> {
1153 let mut presenter = cx.build_presenter(editor.id(), 0.);
1154 let mut cx = presenter.build_layout_context(Default::default(), false, cx);
1155 cx.render(editor, |editor, cx| {
1156 let snapshot = editor.snapshot(cx);
1157 snapshot
1158 .blocks_in_range(0..snapshot.max_point().row())
1159 .filter_map(|(row, block)| {
1160 let name = match block {
1161 TransformBlock::Custom(block) => block
1162 .render(&mut BlockContext {
1163 cx,
1164 anchor_x: 0.,
1165 scroll_x: 0.,
1166 gutter_padding: 0.,
1167 gutter_width: 0.,
1168 line_height: 0.,
1169 em_width: 0.,
1170 })
1171 .name()?
1172 .to_string(),
1173 TransformBlock::ExcerptHeader {
1174 starts_new_buffer, ..
1175 } => {
1176 if *starts_new_buffer {
1177 "path header block".to_string()
1178 } else {
1179 "collapsed context".to_string()
1180 }
1181 }
1182 };
1183
1184 Some((row, name))
1185 })
1186 .collect()
1187 })
1188 }
1189}