diagnostics.rs

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