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