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