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