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