diagnostics.rs

  1pub mod items;
  2
  3use anyhow::Result;
  4use collections::{HashMap, HashSet};
  5use editor::{
  6    context_header_renderer, diagnostic_block_renderer, diagnostic_header_renderer,
  7    display_map::{BlockDisposition, BlockId, BlockProperties},
  8    BuildSettings, Editor, ExcerptId, ExcerptProperties, MultiBuffer,
  9};
 10use gpui::{
 11    action, elements::*, keymap::Binding, AppContext, Entity, ModelHandle, MutableAppContext,
 12    RenderContext, Task, View, ViewContext, ViewHandle,
 13};
 14use language::{Bias, Buffer, Diagnostic, DiagnosticEntry, Point, Selection, SelectionGoal};
 15use postage::watch;
 16use project::{Project, ProjectPath, WorktreeId};
 17use std::{cmp::Ordering, ops::Range, path::Path, sync::Arc};
 18use util::TryFutureExt;
 19use workspace::Workspace;
 20
 21action!(Deploy);
 22
 23const CONTEXT_LINE_COUNT: u32 = 1;
 24
 25pub fn init(cx: &mut MutableAppContext) {
 26    cx.add_bindings([Binding::new("alt-shift-D", Deploy, None)]);
 27    cx.add_action(ProjectDiagnosticsEditor::deploy);
 28}
 29
 30type Event = editor::Event;
 31
 32struct ProjectDiagnostics {
 33    project: ModelHandle<Project>,
 34}
 35
 36struct ProjectDiagnosticsEditor {
 37    project: ModelHandle<Project>,
 38    editor: ViewHandle<Editor>,
 39    excerpts: ModelHandle<MultiBuffer>,
 40    path_states: Vec<(Arc<Path>, Vec<DiagnosticGroupState>)>,
 41    paths_to_update: HashMap<WorktreeId, HashSet<ProjectPath>>,
 42    build_settings: BuildSettings,
 43    settings: watch::Receiver<workspace::Settings>,
 44}
 45
 46struct DiagnosticGroupState {
 47    primary_diagnostic: DiagnosticEntry<language::Anchor>,
 48    excerpts: Vec<ExcerptId>,
 49    blocks: HashMap<BlockId, DiagnosticBlock>,
 50    block_count: usize,
 51}
 52
 53enum DiagnosticBlock {
 54    Header(Diagnostic),
 55    Inline(Diagnostic),
 56    Context,
 57}
 58
 59impl ProjectDiagnostics {
 60    fn new(project: ModelHandle<Project>) -> Self {
 61        Self { project }
 62    }
 63}
 64
 65impl Entity for ProjectDiagnostics {
 66    type Event = ();
 67}
 68
 69impl Entity for ProjectDiagnosticsEditor {
 70    type Event = Event;
 71}
 72
 73impl View for ProjectDiagnosticsEditor {
 74    fn ui_name() -> &'static str {
 75        "ProjectDiagnosticsEditor"
 76    }
 77
 78    fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
 79        if self.path_states.is_empty() {
 80            let theme = &self.settings.borrow().theme.project_diagnostics;
 81            Label::new(
 82                "No problems detected in the project".to_string(),
 83                theme.empty_message.clone(),
 84            )
 85            .aligned()
 86            .contained()
 87            .with_style(theme.container)
 88            .boxed()
 89        } else {
 90            ChildView::new(self.editor.id()).boxed()
 91        }
 92    }
 93
 94    fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
 95        if !self.path_states.is_empty() {
 96            cx.focus(&self.editor);
 97        }
 98    }
 99}
100
101impl ProjectDiagnosticsEditor {
102    fn new(
103        project: ModelHandle<Project>,
104        settings: watch::Receiver<workspace::Settings>,
105        cx: &mut ViewContext<Self>,
106    ) -> Self {
107        cx.subscribe(&project, |this, _, event, cx| match event {
108            project::Event::DiskBasedDiagnosticsUpdated { worktree_id } => {
109                if let Some(paths) = this.paths_to_update.remove(&worktree_id) {
110                    this.update_excerpts(paths, cx);
111                }
112            }
113            project::Event::DiagnosticsUpdated(path) => {
114                this.paths_to_update
115                    .entry(path.worktree_id)
116                    .or_default()
117                    .insert(path.clone());
118            }
119            _ => {}
120        })
121        .detach();
122
123        let excerpts = cx.add_model(|cx| MultiBuffer::new(project.read(cx).replica_id()));
124        let build_settings = editor::settings_builder(excerpts.downgrade(), settings.clone());
125        let editor =
126            cx.add_view(|cx| Editor::for_buffer(excerpts.clone(), build_settings.clone(), cx));
127        cx.subscribe(&editor, |_, _, event, cx| cx.emit(*event))
128            .detach();
129
130        let paths_to_update = project
131            .read(cx)
132            .diagnostic_summaries(cx)
133            .map(|e| e.0)
134            .collect();
135        let this = Self {
136            project,
137            excerpts,
138            editor,
139            build_settings,
140            settings,
141            path_states: Default::default(),
142            paths_to_update: Default::default(),
143        };
144        this.update_excerpts(paths_to_update, cx);
145        this
146    }
147
148    #[cfg(test)]
149    fn text(&self, cx: &AppContext) -> String {
150        self.editor.read(cx).text(cx)
151    }
152
153    fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext<Workspace>) {
154        let diagnostics = cx.add_model(|_| ProjectDiagnostics::new(workspace.project().clone()));
155        workspace.add_item(diagnostics, cx);
156    }
157
158    fn update_excerpts(&self, paths: HashSet<ProjectPath>, cx: &mut ViewContext<Self>) {
159        let project = self.project.clone();
160        cx.spawn(|this, mut cx| {
161            async move {
162                for path in paths {
163                    let buffer = project
164                        .update(&mut cx, |project, cx| project.open_buffer(path, cx))
165                        .await?;
166                    this.update(&mut cx, |view, cx| view.populate_excerpts(buffer, cx))
167                }
168                Result::<_, anyhow::Error>::Ok(())
169            }
170            .log_err()
171        })
172        .detach();
173    }
174
175    fn populate_excerpts(&mut self, buffer: ModelHandle<Buffer>, cx: &mut ViewContext<Self>) {
176        let snapshot;
177        let path;
178        {
179            let buffer = buffer.read(cx);
180            snapshot = buffer.snapshot();
181            if let Some(file) = buffer.file() {
182                path = file.path().clone();
183            } else {
184                return;
185            }
186        }
187
188        let was_empty = self.path_states.is_empty();
189        let path_ix = match self
190            .path_states
191            .binary_search_by_key(&path.as_ref(), |e| e.0.as_ref())
192        {
193            Ok(ix) => ix,
194            Err(ix) => {
195                self.path_states
196                    .insert(ix, (path.clone(), Default::default()));
197                ix
198            }
199        };
200
201        let mut prev_excerpt_id = if path_ix > 0 {
202            let prev_path_last_group = &self.path_states[path_ix - 1].1.last().unwrap();
203            prev_path_last_group.excerpts.last().unwrap().clone()
204        } else {
205            ExcerptId::min()
206        };
207
208        let groups = &mut self.path_states[path_ix].1;
209        let mut groups_to_add = Vec::new();
210        let mut group_ixs_to_remove = Vec::new();
211        let mut blocks_to_add = Vec::new();
212        let mut blocks_to_remove = HashSet::default();
213        let mut diagnostic_blocks = Vec::new();
214        let excerpts_snapshot = self.excerpts.update(cx, |excerpts, excerpts_cx| {
215            let mut old_groups = groups.iter().enumerate().peekable();
216            let mut new_groups = snapshot
217                .diagnostic_groups()
218                .into_iter()
219                .filter(|group| group.entries[group.primary_ix].diagnostic.is_disk_based)
220                .peekable();
221
222            loop {
223                let mut to_insert = None;
224                let mut to_invalidate = None;
225                let mut to_keep = None;
226                match (old_groups.peek(), new_groups.peek()) {
227                    (None, None) => break,
228                    (None, Some(_)) => to_insert = new_groups.next(),
229                    (Some(_), None) => to_invalidate = old_groups.next(),
230                    (Some((_, old_group)), Some(new_group)) => {
231                        let old_primary = &old_group.primary_diagnostic;
232                        let new_primary = &new_group.entries[new_group.primary_ix];
233                        match compare_diagnostics(old_primary, new_primary, &snapshot) {
234                            Ordering::Less => to_invalidate = old_groups.next(),
235                            Ordering::Equal => {
236                                to_keep = old_groups.next();
237                                new_groups.next();
238                            }
239                            Ordering::Greater => to_insert = new_groups.next(),
240                        }
241                    }
242                }
243
244                if let Some(group) = to_insert {
245                    let mut group_state = DiagnosticGroupState {
246                        primary_diagnostic: group.entries[group.primary_ix].clone(),
247                        excerpts: Default::default(),
248                        blocks: Default::default(),
249                        block_count: 0,
250                    };
251                    let mut pending_range: Option<(Range<Point>, usize)> = None;
252                    let mut is_first_excerpt_for_group = true;
253                    for (ix, entry) in group.entries.iter().map(Some).chain([None]).enumerate() {
254                        let resolved_entry = entry.map(|e| e.resolve::<Point>(&snapshot));
255                        if let Some((range, start_ix)) = &mut pending_range {
256                            if let Some(entry) = resolved_entry.as_ref() {
257                                if entry.range.start.row
258                                    <= range.end.row + 1 + CONTEXT_LINE_COUNT * 2
259                                {
260                                    range.end = range.end.max(entry.range.end);
261                                    continue;
262                                }
263                            }
264
265                            let excerpt_start =
266                                Point::new(range.start.row.saturating_sub(CONTEXT_LINE_COUNT), 0);
267                            let excerpt_end = snapshot.clip_point(
268                                Point::new(range.end.row + CONTEXT_LINE_COUNT, u32::MAX),
269                                Bias::Left,
270                            );
271                            let excerpt_id = excerpts.insert_excerpt_after(
272                                &prev_excerpt_id,
273                                ExcerptProperties {
274                                    buffer: &buffer,
275                                    range: excerpt_start..excerpt_end,
276                                },
277                                excerpts_cx,
278                            );
279
280                            prev_excerpt_id = excerpt_id.clone();
281                            group_state.excerpts.push(excerpt_id.clone());
282                            let header_position = (excerpt_id.clone(), language::Anchor::min());
283
284                            if is_first_excerpt_for_group {
285                                is_first_excerpt_for_group = false;
286                                let primary = &group.entries[group.primary_ix].diagnostic;
287                                let mut header = primary.clone();
288                                header.message =
289                                    primary.message.split('\n').next().unwrap().to_string();
290                                group_state.block_count += 1;
291                                diagnostic_blocks.push(DiagnosticBlock::Header(header.clone()));
292                                blocks_to_add.push(BlockProperties {
293                                    position: header_position,
294                                    height: 3,
295                                    render: diagnostic_header_renderer(
296                                        buffer.clone(),
297                                        header,
298                                        true,
299                                        self.build_settings.clone(),
300                                    ),
301                                    disposition: BlockDisposition::Above,
302                                });
303                            } else {
304                                group_state.block_count += 1;
305                                diagnostic_blocks.push(DiagnosticBlock::Context);
306                                blocks_to_add.push(BlockProperties {
307                                    position: header_position,
308                                    height: 1,
309                                    render: context_header_renderer(self.build_settings.clone()),
310                                    disposition: BlockDisposition::Above,
311                                });
312                            }
313
314                            for entry in &group.entries[*start_ix..ix] {
315                                let mut diagnostic = entry.diagnostic.clone();
316                                if diagnostic.is_primary {
317                                    diagnostic.message =
318                                        entry.diagnostic.message.split('\n').skip(1).collect();
319                                }
320
321                                if !diagnostic.message.is_empty() {
322                                    group_state.block_count += 1;
323                                    diagnostic_blocks
324                                        .push(DiagnosticBlock::Inline(diagnostic.clone()));
325                                    blocks_to_add.push(BlockProperties {
326                                        position: (excerpt_id.clone(), entry.range.start.clone()),
327                                        height: diagnostic.message.matches('\n').count() as u8 + 1,
328                                        render: diagnostic_block_renderer(
329                                            diagnostic,
330                                            true,
331                                            self.build_settings.clone(),
332                                        ),
333                                        disposition: BlockDisposition::Below,
334                                    });
335                                }
336                            }
337
338                            pending_range.take();
339                        }
340
341                        if let Some(entry) = resolved_entry {
342                            pending_range = Some((entry.range.clone(), ix));
343                        }
344                    }
345
346                    groups_to_add.push(group_state);
347                } else if let Some((group_ix, group_state)) = to_invalidate {
348                    excerpts.remove_excerpts(group_state.excerpts.iter(), excerpts_cx);
349                    group_ixs_to_remove.push(group_ix);
350                    blocks_to_remove.extend(group_state.blocks.keys().copied());
351                } else if let Some((_, group)) = to_keep {
352                    prev_excerpt_id = group.excerpts.last().unwrap().clone();
353                }
354            }
355
356            excerpts.snapshot(excerpts_cx)
357        });
358
359        self.editor.update(cx, |editor, cx| {
360            editor.remove_blocks(blocks_to_remove, cx);
361            let mut block_ids = editor
362                .insert_blocks(
363                    blocks_to_add.into_iter().map(|block| {
364                        let (excerpt_id, text_anchor) = block.position;
365                        BlockProperties {
366                            position: excerpts_snapshot.anchor_in_excerpt(excerpt_id, text_anchor),
367                            height: block.height,
368                            render: block.render,
369                            disposition: block.disposition,
370                        }
371                    }),
372                    cx,
373                )
374                .into_iter()
375                .zip(diagnostic_blocks);
376
377            for group_state in &mut groups_to_add {
378                group_state.blocks = block_ids.by_ref().take(group_state.block_count).collect();
379            }
380
381            if was_empty {
382                editor.update_selections(
383                    vec![Selection {
384                        id: 0,
385                        start: 0,
386                        end: 0,
387                        reversed: false,
388                        goal: SelectionGoal::None,
389                    }],
390                    None,
391                    cx,
392                );
393            } else {
394                editor.refresh_selections(cx);
395            }
396        });
397
398        for ix in group_ixs_to_remove.into_iter().rev() {
399            groups.remove(ix);
400        }
401        groups.extend(groups_to_add);
402        groups.sort_unstable_by(|a, b| {
403            let range_a = &a.primary_diagnostic.range;
404            let range_b = &b.primary_diagnostic.range;
405            range_a
406                .start
407                .cmp(&range_b.start, &snapshot)
408                .unwrap()
409                .then_with(|| range_a.end.cmp(&range_b.end, &snapshot).unwrap())
410        });
411
412        if groups.is_empty() {
413            self.path_states.remove(path_ix);
414        }
415
416        if self.path_states.is_empty() {
417            if self.editor.is_focused(cx) {
418                cx.focus_self();
419            }
420        } else {
421            if cx.handle().is_focused(cx) {
422                cx.focus(&self.editor);
423            }
424        }
425        cx.notify();
426    }
427}
428
429impl workspace::Item for ProjectDiagnostics {
430    type View = ProjectDiagnosticsEditor;
431
432    fn build_view(
433        handle: ModelHandle<Self>,
434        settings: watch::Receiver<workspace::Settings>,
435        cx: &mut ViewContext<Self::View>,
436    ) -> Self::View {
437        let project = handle.read(cx).project.clone();
438        ProjectDiagnosticsEditor::new(project, settings, cx)
439    }
440
441    fn project_path(&self) -> Option<project::ProjectPath> {
442        None
443    }
444}
445
446impl workspace::ItemView for ProjectDiagnosticsEditor {
447    fn title(&self, _: &AppContext) -> String {
448        "Project Diagnostics".to_string()
449    }
450
451    fn project_path(&self, _: &AppContext) -> Option<project::ProjectPath> {
452        None
453    }
454
455    fn save(&mut self, cx: &mut ViewContext<Self>) -> Result<Task<Result<()>>> {
456        self.excerpts.update(cx, |excerpts, cx| excerpts.save(cx))
457    }
458
459    fn save_as(
460        &mut self,
461        _: ModelHandle<project::Worktree>,
462        _: &std::path::Path,
463        _: &mut ViewContext<Self>,
464    ) -> Task<Result<()>> {
465        unreachable!()
466    }
467
468    fn is_dirty(&self, cx: &AppContext) -> bool {
469        self.excerpts.read(cx).read(cx).is_dirty()
470    }
471
472    fn has_conflict(&self, cx: &AppContext) -> bool {
473        self.excerpts.read(cx).read(cx).has_conflict()
474    }
475
476    fn should_update_tab_on_event(event: &Event) -> bool {
477        matches!(
478            event,
479            Event::Saved | Event::Dirtied | Event::FileHandleChanged
480        )
481    }
482
483    fn can_save(&self, _: &AppContext) -> bool {
484        true
485    }
486
487    fn can_save_as(&self, _: &AppContext) -> bool {
488        false
489    }
490}
491
492fn compare_diagnostics<L: language::ToOffset, R: language::ToOffset>(
493    lhs: &DiagnosticEntry<L>,
494    rhs: &DiagnosticEntry<R>,
495    snapshot: &language::BufferSnapshot,
496) -> Ordering {
497    lhs.range
498        .start
499        .to_offset(&snapshot)
500        .cmp(&rhs.range.start.to_offset(snapshot))
501        .then_with(|| {
502            lhs.range
503                .end
504                .to_offset(&snapshot)
505                .cmp(&rhs.range.end.to_offset(snapshot))
506        })
507        .then_with(|| lhs.diagnostic.message.cmp(&rhs.diagnostic.message))
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use client::{http::ServerResponse, test::FakeHttpClient, Client, UserStore};
514    use gpui::TestAppContext;
515    use language::{Diagnostic, DiagnosticEntry, DiagnosticSeverity, LanguageRegistry, PointUtf16};
516    use project::{worktree, FakeFs};
517    use serde_json::json;
518    use std::sync::Arc;
519    use unindent::Unindent as _;
520    use workspace::WorkspaceParams;
521
522    #[gpui::test]
523    async fn test_diagnostics(mut cx: TestAppContext) {
524        let settings = cx.update(WorkspaceParams::test).settings;
525        let http_client = FakeHttpClient::new(|_| async move { Ok(ServerResponse::new(404)) });
526        let client = Client::new(http_client.clone());
527        let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
528        let fs = Arc::new(FakeFs::new());
529
530        let project = cx.update(|cx| {
531            Project::local(
532                client.clone(),
533                user_store,
534                Arc::new(LanguageRegistry::new()),
535                fs.clone(),
536                cx,
537            )
538        });
539
540        fs.insert_tree(
541            "/test",
542            json!({
543                "a.rs": "
544                    const a: i32 = 'a';
545                ".unindent(),
546
547                "main.rs": "
548                    fn main() {
549                        let x = vec![];
550                        let y = vec![];
551                        a(x);
552                        b(y);
553                        // comment 1
554                        // comment 2
555                        c(y);
556                        d(x);
557                    }
558                "
559                .unindent(),
560            }),
561        )
562        .await;
563
564        let worktree = project
565            .update(&mut cx, |project, cx| {
566                project.add_local_worktree("/test", cx)
567            })
568            .await
569            .unwrap();
570
571        worktree.update(&mut cx, |worktree, cx| {
572            worktree
573                .update_diagnostic_entries(
574                    Arc::from("/test/main.rs".as_ref()),
575                    None,
576                    vec![
577                        DiagnosticEntry {
578                            range: PointUtf16::new(1, 8)..PointUtf16::new(1, 9),
579                            diagnostic: Diagnostic {
580                                message:
581                                    "move occurs because `x` has type `Vec<char>`, which does not implement the `Copy` trait"
582                                        .to_string(),
583                                severity: DiagnosticSeverity::INFORMATION,
584                                is_primary: false,
585                                is_disk_based: true,
586                                group_id: 1,
587                                ..Default::default()
588                            },
589                        },
590                        DiagnosticEntry {
591                            range: PointUtf16::new(2, 8)..PointUtf16::new(2, 9),
592                            diagnostic: Diagnostic {
593                                message:
594                                    "move occurs because `y` has type `Vec<char>`, which does not implement the `Copy` trait"
595                                        .to_string(),
596                                severity: DiagnosticSeverity::INFORMATION,
597                                is_primary: false,
598                                is_disk_based: true,
599                                group_id: 0,
600                                ..Default::default()
601                            },
602                        },
603                        DiagnosticEntry {
604                            range: PointUtf16::new(3, 6)..PointUtf16::new(3, 7),
605                            diagnostic: Diagnostic {
606                                message: "value moved here".to_string(),
607                                severity: DiagnosticSeverity::INFORMATION,
608                                is_primary: false,
609                                is_disk_based: true,
610                                group_id: 1,
611                                ..Default::default()
612                            },
613                        },
614                        DiagnosticEntry {
615                            range: PointUtf16::new(4, 6)..PointUtf16::new(4, 7),
616                            diagnostic: Diagnostic {
617                                message: "value moved here".to_string(),
618                                severity: DiagnosticSeverity::INFORMATION,
619                                is_primary: false,
620                                is_disk_based: true,
621                                group_id: 0,
622                                ..Default::default()
623                            },
624                        },
625                        DiagnosticEntry {
626                            range: PointUtf16::new(7, 6)..PointUtf16::new(7, 7),
627                            diagnostic: Diagnostic {
628                                message: "use of moved value\nvalue used here after move".to_string(),
629                                severity: DiagnosticSeverity::ERROR,
630                                is_primary: true,
631                                is_disk_based: true,
632                                group_id: 0,
633                                ..Default::default()
634                            },
635                        },
636                        DiagnosticEntry {
637                            range: PointUtf16::new(8, 6)..PointUtf16::new(8, 7),
638                            diagnostic: Diagnostic {
639                                message: "use of moved value\nvalue used here after move".to_string(),
640                                severity: DiagnosticSeverity::ERROR,
641                                is_primary: true,
642                                is_disk_based: true,
643                                group_id: 1,
644                                ..Default::default()
645                            },
646                        },
647                    ],
648                    cx,
649                )
650                .unwrap();
651        });
652
653        let view = cx.add_view(Default::default(), |cx| {
654            ProjectDiagnosticsEditor::new(project.clone(), settings, cx)
655        });
656
657        view.condition(&mut cx, |view, cx| view.text(cx).contains("fn main()"))
658            .await;
659
660        view.update(&mut cx, |view, cx| {
661            let editor = view.editor.update(cx, |editor, cx| editor.snapshot(cx));
662
663            assert_eq!(
664                editor.text(),
665                concat!(
666                    //
667                    // main.rs, diagnostic group 1
668                    //
669                    "\n", // primary message
670                    "\n", // filename
671                    "    let x = vec![];\n",
672                    "    let y = vec![];\n",
673                    "\n", // supporting diagnostic
674                    "    a(x);\n",
675                    "    b(y);\n",
676                    "\n", // supporting diagnostic
677                    "    // comment 1\n",
678                    "    // comment 2\n",
679                    "    c(y);\n",
680                    "\n", // supporting diagnostic
681                    "    d(x);\n",
682                    //
683                    // main.rs, diagnostic group 2
684                    //
685                    "\n", // primary message
686                    "\n", // filename
687                    "fn main() {\n",
688                    "    let x = vec![];\n",
689                    "\n", // supporting diagnostic
690                    "    let y = vec![];\n",
691                    "    a(x);\n",
692                    "\n", // supporting diagnostic
693                    "    b(y);\n",
694                    "\n", // context ellipsis
695                    "    c(y);\n",
696                    "    d(x);\n",
697                    "\n", // supporting diagnostic
698                    "}"
699                )
700            );
701
702            view.editor.update(cx, |editor, cx| {
703                assert_eq!(editor.selected_ranges::<usize>(cx), [0..0]);
704            });
705        });
706
707        worktree.update(&mut cx, |worktree, cx| {
708            worktree
709                .update_diagnostic_entries(
710                    Arc::from("/test/a.rs".as_ref()),
711                    None,
712                    vec![DiagnosticEntry {
713                        range: PointUtf16::new(0, 15)..PointUtf16::new(0, 15),
714                        diagnostic: Diagnostic {
715                            message: "mismatched types\nexpected `usize`, found `char`".to_string(),
716                            severity: DiagnosticSeverity::ERROR,
717                            is_primary: true,
718                            is_disk_based: true,
719                            group_id: 0,
720                            ..Default::default()
721                        },
722                    }],
723                    cx,
724                )
725                .unwrap();
726            cx.emit(worktree::Event::DiskBasedDiagnosticsUpdated);
727        });
728
729        view.condition(&mut cx, |view, cx| view.text(cx).contains("const a"))
730            .await;
731
732        view.update(&mut cx, |view, cx| {
733            let editor = view.editor.update(cx, |editor, cx| editor.snapshot(cx));
734
735            assert_eq!(
736                editor.text(),
737                concat!(
738                    //
739                    // a.rs
740                    //
741                    "\n", // primary message
742                    "\n", // filename
743                    "const a: i32 = 'a';\n",
744                    "\n", // supporting diagnostic
745                    "\n", // context line
746                    //
747                    // main.rs, diagnostic group 1
748                    //
749                    "\n", // primary message
750                    "\n", // filename
751                    "    let x = vec![];\n",
752                    "    let y = vec![];\n",
753                    "\n", // supporting diagnostic
754                    "    a(x);\n",
755                    "    b(y);\n",
756                    "\n", // supporting diagnostic
757                    "    // comment 1\n",
758                    "    // comment 2\n",
759                    "    c(y);\n",
760                    "\n", // supporting diagnostic
761                    "    d(x);\n",
762                    //
763                    // main.rs, diagnostic group 2
764                    //
765                    "\n", // primary message
766                    "\n", // filename
767                    "fn main() {\n",
768                    "    let x = vec![];\n",
769                    "\n", // supporting diagnostic
770                    "    let y = vec![];\n",
771                    "    a(x);\n",
772                    "\n", // supporting diagnostic
773                    "    b(y);\n",
774                    "\n", // context ellipsis
775                    "    c(y);\n",
776                    "    d(x);\n",
777                    "\n", // supporting diagnostic
778                    "}"
779                )
780            );
781        });
782    }
783}