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