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