diagnostics.rs

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