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 0,
809 project.clone(),
810 |_, _| unimplemented!(),
811 cx,
812 )
813 });
814
815 // Create some diagnostics
816 project.update(cx, |project, cx| {
817 project
818 .update_diagnostic_entries(
819 0,
820 PathBuf::from("/test/main.rs"),
821 None,
822 vec![
823 DiagnosticEntry {
824 range: Unclipped(PointUtf16::new(1, 8))..Unclipped(PointUtf16::new(1, 9)),
825 diagnostic: Diagnostic {
826 message:
827 "move occurs because `x` has type `Vec<char>`, which does not implement the `Copy` trait"
828 .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: Unclipped(PointUtf16::new(2, 8))..Unclipped(PointUtf16::new(2, 9)),
838 diagnostic: Diagnostic {
839 message:
840 "move occurs because `y` has type `Vec<char>`, which does not implement the `Copy` trait"
841 .to_string(),
842 severity: DiagnosticSeverity::INFORMATION,
843 is_primary: false,
844 is_disk_based: true,
845 group_id: 0,
846 ..Default::default()
847 },
848 },
849 DiagnosticEntry {
850 range: Unclipped(PointUtf16::new(3, 6))..Unclipped(PointUtf16::new(3, 7)),
851 diagnostic: Diagnostic {
852 message: "value moved here".to_string(),
853 severity: DiagnosticSeverity::INFORMATION,
854 is_primary: false,
855 is_disk_based: true,
856 group_id: 1,
857 ..Default::default()
858 },
859 },
860 DiagnosticEntry {
861 range: Unclipped(PointUtf16::new(4, 6))..Unclipped(PointUtf16::new(4, 7)),
862 diagnostic: Diagnostic {
863 message: "value moved here".to_string(),
864 severity: DiagnosticSeverity::INFORMATION,
865 is_primary: false,
866 is_disk_based: true,
867 group_id: 0,
868 ..Default::default()
869 },
870 },
871 DiagnosticEntry {
872 range: Unclipped(PointUtf16::new(7, 6))..Unclipped(PointUtf16::new(7, 7)),
873 diagnostic: Diagnostic {
874 message: "use of moved value\nvalue used here after move".to_string(),
875 severity: DiagnosticSeverity::ERROR,
876 is_primary: true,
877 is_disk_based: true,
878 group_id: 0,
879 ..Default::default()
880 },
881 },
882 DiagnosticEntry {
883 range: Unclipped(PointUtf16::new(8, 6))..Unclipped(PointUtf16::new(8, 7)),
884 diagnostic: Diagnostic {
885 message: "use of moved value\nvalue used here after move".to_string(),
886 severity: DiagnosticSeverity::ERROR,
887 is_primary: true,
888 is_disk_based: true,
889 group_id: 1,
890 ..Default::default()
891 },
892 },
893 ],
894 cx,
895 )
896 .unwrap();
897 });
898
899 // Open the project diagnostics view while there are already diagnostics.
900 let view = cx.add_view(&workspace, |cx| {
901 ProjectDiagnosticsEditor::new(project.clone(), workspace.downgrade(), cx)
902 });
903
904 view.next_notification(cx).await;
905 view.update(cx, |view, cx| {
906 assert_eq!(
907 editor_blocks(&view.editor, cx),
908 [
909 (0, "path header block".into()),
910 (2, "diagnostic header".into()),
911 (15, "collapsed context".into()),
912 (16, "diagnostic header".into()),
913 (25, "collapsed context".into()),
914 ]
915 );
916 assert_eq!(
917 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
918 concat!(
919 //
920 // main.rs
921 //
922 "\n", // filename
923 "\n", // padding
924 // diagnostic group 1
925 "\n", // primary message
926 "\n", // padding
927 " let x = vec![];\n",
928 " let y = vec![];\n",
929 "\n", // supporting diagnostic
930 " a(x);\n",
931 " b(y);\n",
932 "\n", // supporting diagnostic
933 " // comment 1\n",
934 " // comment 2\n",
935 " c(y);\n",
936 "\n", // supporting diagnostic
937 " d(x);\n",
938 "\n", // context ellipsis
939 // diagnostic group 2
940 "\n", // primary message
941 "\n", // padding
942 "fn main() {\n",
943 " let x = vec![];\n",
944 "\n", // supporting diagnostic
945 " let y = vec![];\n",
946 " a(x);\n",
947 "\n", // supporting diagnostic
948 " b(y);\n",
949 "\n", // context ellipsis
950 " c(y);\n",
951 " d(x);\n",
952 "\n", // supporting diagnostic
953 "}"
954 )
955 );
956
957 // Cursor is at the first diagnostic
958 view.editor.update(cx, |editor, cx| {
959 assert_eq!(
960 editor.selections.display_ranges(cx),
961 [DisplayPoint::new(12, 6)..DisplayPoint::new(12, 6)]
962 );
963 });
964 });
965
966 // Diagnostics are added for another earlier path.
967 project.update(cx, |project, cx| {
968 project.disk_based_diagnostics_started(0, cx);
969 project
970 .update_diagnostic_entries(
971 0,
972 PathBuf::from("/test/consts.rs"),
973 None,
974 vec![DiagnosticEntry {
975 range: Unclipped(PointUtf16::new(0, 15))..Unclipped(PointUtf16::new(0, 15)),
976 diagnostic: Diagnostic {
977 message: "mismatched types\nexpected `usize`, found `char`".to_string(),
978 severity: DiagnosticSeverity::ERROR,
979 is_primary: true,
980 is_disk_based: true,
981 group_id: 0,
982 ..Default::default()
983 },
984 }],
985 cx,
986 )
987 .unwrap();
988 project.disk_based_diagnostics_finished(0, cx);
989 });
990
991 view.next_notification(cx).await;
992 view.update(cx, |view, cx| {
993 assert_eq!(
994 editor_blocks(&view.editor, cx),
995 [
996 (0, "path header block".into()),
997 (2, "diagnostic header".into()),
998 (7, "path header block".into()),
999 (9, "diagnostic header".into()),
1000 (22, "collapsed context".into()),
1001 (23, "diagnostic header".into()),
1002 (32, "collapsed context".into()),
1003 ]
1004 );
1005 assert_eq!(
1006 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1007 concat!(
1008 //
1009 // consts.rs
1010 //
1011 "\n", // filename
1012 "\n", // padding
1013 // diagnostic group 1
1014 "\n", // primary message
1015 "\n", // padding
1016 "const a: i32 = 'a';\n",
1017 "\n", // supporting diagnostic
1018 "const b: i32 = c;\n",
1019 //
1020 // main.rs
1021 //
1022 "\n", // filename
1023 "\n", // padding
1024 // diagnostic group 1
1025 "\n", // primary message
1026 "\n", // padding
1027 " let x = vec![];\n",
1028 " let y = vec![];\n",
1029 "\n", // supporting diagnostic
1030 " a(x);\n",
1031 " b(y);\n",
1032 "\n", // supporting diagnostic
1033 " // comment 1\n",
1034 " // comment 2\n",
1035 " c(y);\n",
1036 "\n", // supporting diagnostic
1037 " d(x);\n",
1038 "\n", // collapsed context
1039 // diagnostic group 2
1040 "\n", // primary message
1041 "\n", // filename
1042 "fn main() {\n",
1043 " let x = vec![];\n",
1044 "\n", // supporting diagnostic
1045 " let y = vec![];\n",
1046 " a(x);\n",
1047 "\n", // supporting diagnostic
1048 " b(y);\n",
1049 "\n", // context ellipsis
1050 " c(y);\n",
1051 " d(x);\n",
1052 "\n", // supporting diagnostic
1053 "}"
1054 )
1055 );
1056
1057 // Cursor keeps its position.
1058 view.editor.update(cx, |editor, cx| {
1059 assert_eq!(
1060 editor.selections.display_ranges(cx),
1061 [DisplayPoint::new(19, 6)..DisplayPoint::new(19, 6)]
1062 );
1063 });
1064 });
1065
1066 // Diagnostics are added to the first path
1067 project.update(cx, |project, cx| {
1068 project.disk_based_diagnostics_started(0, cx);
1069 project
1070 .update_diagnostic_entries(
1071 0,
1072 PathBuf::from("/test/consts.rs"),
1073 None,
1074 vec![
1075 DiagnosticEntry {
1076 range: Unclipped(PointUtf16::new(0, 15))
1077 ..Unclipped(PointUtf16::new(0, 15)),
1078 diagnostic: Diagnostic {
1079 message: "mismatched types\nexpected `usize`, found `char`"
1080 .to_string(),
1081 severity: DiagnosticSeverity::ERROR,
1082 is_primary: true,
1083 is_disk_based: true,
1084 group_id: 0,
1085 ..Default::default()
1086 },
1087 },
1088 DiagnosticEntry {
1089 range: Unclipped(PointUtf16::new(1, 15))
1090 ..Unclipped(PointUtf16::new(1, 15)),
1091 diagnostic: Diagnostic {
1092 message: "unresolved name `c`".to_string(),
1093 severity: DiagnosticSeverity::ERROR,
1094 is_primary: true,
1095 is_disk_based: true,
1096 group_id: 1,
1097 ..Default::default()
1098 },
1099 },
1100 ],
1101 cx,
1102 )
1103 .unwrap();
1104 project.disk_based_diagnostics_finished(0, cx);
1105 });
1106
1107 view.next_notification(cx).await;
1108 view.update(cx, |view, cx| {
1109 assert_eq!(
1110 editor_blocks(&view.editor, cx),
1111 [
1112 (0, "path header block".into()),
1113 (2, "diagnostic header".into()),
1114 (7, "collapsed context".into()),
1115 (8, "diagnostic header".into()),
1116 (13, "path header block".into()),
1117 (15, "diagnostic header".into()),
1118 (28, "collapsed context".into()),
1119 (29, "diagnostic header".into()),
1120 (38, "collapsed context".into()),
1121 ]
1122 );
1123 assert_eq!(
1124 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1125 concat!(
1126 //
1127 // consts.rs
1128 //
1129 "\n", // filename
1130 "\n", // padding
1131 // diagnostic group 1
1132 "\n", // primary message
1133 "\n", // padding
1134 "const a: i32 = 'a';\n",
1135 "\n", // supporting diagnostic
1136 "const b: i32 = c;\n",
1137 "\n", // context ellipsis
1138 // diagnostic group 2
1139 "\n", // primary message
1140 "\n", // padding
1141 "const a: i32 = 'a';\n",
1142 "const b: i32 = c;\n",
1143 "\n", // supporting diagnostic
1144 //
1145 // main.rs
1146 //
1147 "\n", // filename
1148 "\n", // padding
1149 // diagnostic group 1
1150 "\n", // primary message
1151 "\n", // padding
1152 " let x = vec![];\n",
1153 " let y = vec![];\n",
1154 "\n", // supporting diagnostic
1155 " a(x);\n",
1156 " b(y);\n",
1157 "\n", // supporting diagnostic
1158 " // comment 1\n",
1159 " // comment 2\n",
1160 " c(y);\n",
1161 "\n", // supporting diagnostic
1162 " d(x);\n",
1163 "\n", // context ellipsis
1164 // diagnostic group 2
1165 "\n", // primary message
1166 "\n", // filename
1167 "fn main() {\n",
1168 " let x = vec![];\n",
1169 "\n", // supporting diagnostic
1170 " let y = vec![];\n",
1171 " a(x);\n",
1172 "\n", // supporting diagnostic
1173 " b(y);\n",
1174 "\n", // context ellipsis
1175 " c(y);\n",
1176 " d(x);\n",
1177 "\n", // supporting diagnostic
1178 "}"
1179 )
1180 );
1181 });
1182 }
1183
1184 fn editor_blocks(
1185 editor: &ViewHandle<Editor>,
1186 cx: &mut MutableAppContext,
1187 ) -> Vec<(u32, String)> {
1188 let mut presenter = cx.build_presenter(editor.id(), 0., Default::default());
1189 let mut cx = presenter.build_layout_context(Default::default(), false, cx);
1190 cx.render(editor, |editor, cx| {
1191 let snapshot = editor.snapshot(cx);
1192 snapshot
1193 .blocks_in_range(0..snapshot.max_point().row())
1194 .filter_map(|(row, block)| {
1195 let name = match block {
1196 TransformBlock::Custom(block) => block
1197 .render(&mut BlockContext {
1198 cx,
1199 anchor_x: 0.,
1200 scroll_x: 0.,
1201 gutter_padding: 0.,
1202 gutter_width: 0.,
1203 line_height: 0.,
1204 em_width: 0.,
1205 })
1206 .name()?
1207 .to_string(),
1208 TransformBlock::ExcerptHeader {
1209 starts_new_buffer, ..
1210 } => {
1211 if *starts_new_buffer {
1212 "path header block".to_string()
1213 } else {
1214 "collapsed context".to_string()
1215 }
1216 }
1217 };
1218
1219 Some((row, name))
1220 })
1221 .collect()
1222 })
1223 }
1224}