diagnostics.rs

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