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