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