1pub mod items;
2
3use anyhow::Result;
4use collections::{BTreeSet, HashSet};
5use editor::{
6 diagnostic_block_renderer,
7 display_map::{BlockDisposition, BlockId, BlockProperties, BlockStyle, RenderBlock},
8 highlight_diagnostic_message,
9 scroll::autoscroll::Autoscroll,
10 Editor, ExcerptId, ExcerptRange, MultiBuffer, ToOffset,
11};
12use gpui::{
13 actions, elements::*, fonts::TextStyle, impl_internal_actions, serde_json, AnyViewHandle,
14 AppContext, Entity, ModelHandle, Task, View, ViewContext, ViewHandle, WeakViewHandle,
15};
16use language::{
17 Anchor, Bias, Buffer, Diagnostic, DiagnosticEntry, DiagnosticSeverity, Point, Selection,
18 SelectionGoal,
19};
20use lsp::LanguageServerId;
21use project::{DiagnosticSummary, Project, ProjectPath};
22use serde_json::json;
23use settings::Settings;
24use smallvec::SmallVec;
25use std::{
26 any::{Any, TypeId},
27 borrow::Cow,
28 cmp::Ordering,
29 ops::Range,
30 path::PathBuf,
31 sync::Arc,
32};
33use util::TryFutureExt;
34use workspace::{
35 item::{BreadcrumbText, Item, ItemEvent, ItemHandle},
36 ItemNavHistory, Pane, ToolbarItemLocation, Workspace,
37};
38
39actions!(diagnostics, [Deploy]);
40
41impl_internal_actions!(diagnostics, [Jump]);
42
43const CONTEXT_LINE_COUNT: u32 = 1;
44
45pub fn init(cx: &mut AppContext) {
46 cx.add_action(ProjectDiagnosticsEditor::deploy);
47 items::init(cx);
48}
49
50type Event = editor::Event;
51
52struct ProjectDiagnosticsEditor {
53 project: ModelHandle<Project>,
54 workspace: WeakViewHandle<Workspace>,
55 editor: ViewHandle<Editor>,
56 summary: DiagnosticSummary,
57 excerpts: ModelHandle<MultiBuffer>,
58 path_states: Vec<PathState>,
59 paths_to_update: BTreeSet<(ProjectPath, LanguageServerId)>,
60}
61
62struct PathState {
63 path: ProjectPath,
64 diagnostic_groups: Vec<DiagnosticGroupState>,
65}
66
67#[derive(Clone, Debug, PartialEq)]
68struct Jump {
69 path: ProjectPath,
70 position: Point,
71 anchor: Anchor,
72}
73
74struct DiagnosticGroupState {
75 language_server_id: LanguageServerId,
76 primary_diagnostic: DiagnosticEntry<language::Anchor>,
77 primary_excerpt_ix: usize,
78 excerpts: Vec<ExcerptId>,
79 blocks: HashSet<BlockId>,
80 block_count: usize,
81}
82
83impl Entity for ProjectDiagnosticsEditor {
84 type Event = Event;
85}
86
87impl View for ProjectDiagnosticsEditor {
88 fn ui_name() -> &'static str {
89 "ProjectDiagnosticsEditor"
90 }
91
92 fn render(&mut self, cx: &mut ViewContext<Self>) -> Element<Self> {
93 if self.path_states.is_empty() {
94 let theme = &cx.global::<Settings>().theme.project_diagnostics;
95 Label::new("No problems in workspace", theme.empty_message.clone())
96 .aligned()
97 .contained()
98 .with_style(theme.container)
99 .boxed()
100 } else {
101 ChildView::new(&self.editor, cx).boxed()
102 }
103 }
104
105 fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
106 if cx.is_self_focused() && !self.path_states.is_empty() {
107 cx.focus(&self.editor);
108 }
109 }
110
111 fn debug_json(&self, cx: &AppContext) -> serde_json::Value {
112 let project = self.project.read(cx);
113 json!({
114 "project": json!({
115 "language_servers": project.language_server_statuses().collect::<Vec<_>>(),
116 "summary": project.diagnostic_summary(cx),
117 }),
118 "summary": self.summary,
119 "paths_to_update": self.paths_to_update.iter().map(|(path, server_id)|
120 (path.path.to_string_lossy(), server_id.0)
121 ).collect::<Vec<_>>(),
122 "paths_states": self.path_states.iter().map(|state|
123 json!({
124 "path": state.path.path.to_string_lossy(),
125 "groups": state.diagnostic_groups.iter().map(|group|
126 json!({
127 "block_count": group.blocks.len(),
128 "excerpt_count": group.excerpts.len(),
129 })
130 ).collect::<Vec<_>>(),
131 })
132 ).collect::<Vec<_>>(),
133 })
134 }
135}
136
137impl ProjectDiagnosticsEditor {
138 fn new(
139 project_handle: ModelHandle<Project>,
140 workspace: WeakViewHandle<Workspace>,
141 cx: &mut ViewContext<Self>,
142 ) -> Self {
143 cx.subscribe(&project_handle, |this, _, event, cx| match event {
144 project::Event::DiskBasedDiagnosticsFinished { language_server_id } => {
145 this.update_excerpts(Some(*language_server_id), cx);
146 this.update_title(cx);
147 }
148 project::Event::DiagnosticsUpdated {
149 language_server_id,
150 path,
151 } => {
152 this.paths_to_update
153 .insert((path.clone(), *language_server_id));
154 }
155 _ => {}
156 })
157 .detach();
158
159 let excerpts = cx.add_model(|cx| MultiBuffer::new(project_handle.read(cx).replica_id()));
160 let editor = cx.add_view(|cx| {
161 let mut editor =
162 Editor::for_multibuffer(excerpts.clone(), Some(project_handle.clone()), cx);
163 editor.set_vertical_scroll_margin(5, cx);
164 editor
165 });
166 cx.subscribe(&editor, |_, _, event, cx| cx.emit(event.clone()))
167 .detach();
168
169 let project = project_handle.read(cx);
170 let paths_to_update = project
171 .diagnostic_summaries(cx)
172 .map(|(path, server_id, _)| (path, server_id))
173 .collect();
174 let summary = project.diagnostic_summary(cx);
175 let mut this = Self {
176 project: project_handle,
177 summary,
178 workspace,
179 excerpts,
180 editor,
181 path_states: Default::default(),
182 paths_to_update,
183 };
184 this.update_excerpts(None, cx);
185 this
186 }
187
188 fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext<Workspace>) {
189 if let Some(existing) = workspace.item_of_type::<ProjectDiagnosticsEditor>(cx) {
190 workspace.activate_item(&existing, cx);
191 } else {
192 let workspace_handle = cx.weak_handle();
193 let diagnostics = cx.add_view(|cx| {
194 ProjectDiagnosticsEditor::new(workspace.project().clone(), workspace_handle, cx)
195 });
196 workspace.add_item(Box::new(diagnostics), cx);
197 }
198 }
199
200 fn update_excerpts(
201 &mut self,
202 language_server_id: Option<LanguageServerId>,
203 cx: &mut ViewContext<Self>,
204 ) {
205 let mut paths = Vec::new();
206 self.paths_to_update.retain(|(path, server_id)| {
207 if language_server_id
208 .map_or(true, |language_server_id| language_server_id == *server_id)
209 {
210 paths.push(path.clone());
211 false
212 } else {
213 true
214 }
215 });
216 let project = self.project.clone();
217 cx.spawn(|this, mut cx| {
218 async move {
219 for path in paths {
220 let buffer = project
221 .update(&mut cx, |project, cx| project.open_buffer(path.clone(), cx))
222 .await?;
223 this.update(&mut cx, |this, cx| {
224 this.populate_excerpts(path, language_server_id, buffer, cx)
225 })?;
226 }
227 Result::<_, anyhow::Error>::Ok(())
228 }
229 .log_err()
230 })
231 .detach();
232 }
233
234 fn populate_excerpts(
235 &mut self,
236 path: ProjectPath,
237 language_server_id: Option<LanguageServerId>,
238 buffer: ModelHandle<Buffer>,
239 cx: &mut ViewContext<Self>,
240 ) {
241 let was_empty = self.path_states.is_empty();
242 let snapshot = buffer.read(cx).snapshot();
243 let path_ix = match self.path_states.binary_search_by_key(&&path, |e| &e.path) {
244 Ok(ix) => ix,
245 Err(ix) => {
246 self.path_states.insert(
247 ix,
248 PathState {
249 path: path.clone(),
250 diagnostic_groups: Default::default(),
251 },
252 );
253 ix
254 }
255 };
256
257 let mut prev_excerpt_id = if path_ix > 0 {
258 let prev_path_last_group = &self.path_states[path_ix - 1]
259 .diagnostic_groups
260 .last()
261 .unwrap();
262 prev_path_last_group.excerpts.last().unwrap().clone()
263 } else {
264 ExcerptId::min()
265 };
266
267 let path_state = &mut self.path_states[path_ix];
268 let mut groups_to_add = Vec::new();
269 let mut group_ixs_to_remove = Vec::new();
270 let mut blocks_to_add = Vec::new();
271 let mut blocks_to_remove = HashSet::default();
272 let mut first_excerpt_id = None;
273 let excerpts_snapshot = self.excerpts.update(cx, |excerpts, excerpts_cx| {
274 let mut old_groups = path_state.diagnostic_groups.iter().enumerate().peekable();
275 let mut new_groups = snapshot
276 .diagnostic_groups(language_server_id)
277 .into_iter()
278 .filter(|(_, group)| {
279 group.entries[group.primary_ix].diagnostic.severity
280 <= DiagnosticSeverity::WARNING
281 })
282 .peekable();
283 loop {
284 let mut to_insert = None;
285 let mut to_remove = None;
286 let mut to_keep = None;
287 match (old_groups.peek(), new_groups.peek()) {
288 (None, None) => break,
289 (None, Some(_)) => to_insert = new_groups.next(),
290 (Some((_, old_group)), None) => {
291 if language_server_id.map_or(true, |id| id == old_group.language_server_id)
292 {
293 to_remove = old_groups.next();
294 } else {
295 to_keep = old_groups.next();
296 }
297 }
298 (Some((_, old_group)), Some((_, new_group))) => {
299 let old_primary = &old_group.primary_diagnostic;
300 let new_primary = &new_group.entries[new_group.primary_ix];
301 match compare_diagnostics(old_primary, new_primary, &snapshot) {
302 Ordering::Less => {
303 if language_server_id
304 .map_or(true, |id| id == old_group.language_server_id)
305 {
306 to_remove = old_groups.next();
307 } else {
308 to_keep = old_groups.next();
309 }
310 }
311 Ordering::Equal => {
312 to_keep = old_groups.next();
313 new_groups.next();
314 }
315 Ordering::Greater => to_insert = new_groups.next(),
316 }
317 }
318 }
319
320 if let Some((language_server_id, group)) = to_insert {
321 let mut group_state = DiagnosticGroupState {
322 language_server_id,
323 primary_diagnostic: group.entries[group.primary_ix].clone(),
324 primary_excerpt_ix: 0,
325 excerpts: Default::default(),
326 blocks: Default::default(),
327 block_count: 0,
328 };
329 let mut pending_range: Option<(Range<Point>, usize)> = None;
330 let mut is_first_excerpt_for_group = true;
331 for (ix, entry) in group.entries.iter().map(Some).chain([None]).enumerate() {
332 let resolved_entry = entry.map(|e| e.resolve::<Point>(&snapshot));
333 if let Some((range, start_ix)) = &mut pending_range {
334 if let Some(entry) = resolved_entry.as_ref() {
335 if entry.range.start.row
336 <= range.end.row + 1 + CONTEXT_LINE_COUNT * 2
337 {
338 range.end = range.end.max(entry.range.end);
339 continue;
340 }
341 }
342
343 let excerpt_start =
344 Point::new(range.start.row.saturating_sub(CONTEXT_LINE_COUNT), 0);
345 let excerpt_end = snapshot.clip_point(
346 Point::new(range.end.row + CONTEXT_LINE_COUNT, u32::MAX),
347 Bias::Left,
348 );
349 let excerpt_id = excerpts
350 .insert_excerpts_after(
351 prev_excerpt_id,
352 buffer.clone(),
353 [ExcerptRange {
354 context: excerpt_start..excerpt_end,
355 primary: Some(range.clone()),
356 }],
357 excerpts_cx,
358 )
359 .pop()
360 .unwrap();
361
362 prev_excerpt_id = excerpt_id.clone();
363 first_excerpt_id.get_or_insert_with(|| prev_excerpt_id.clone());
364 group_state.excerpts.push(excerpt_id.clone());
365 let header_position = (excerpt_id.clone(), language::Anchor::MIN);
366
367 if is_first_excerpt_for_group {
368 is_first_excerpt_for_group = false;
369 let mut primary =
370 group.entries[group.primary_ix].diagnostic.clone();
371 primary.message =
372 primary.message.split('\n').next().unwrap().to_string();
373 group_state.block_count += 1;
374 blocks_to_add.push(BlockProperties {
375 position: header_position,
376 height: 2,
377 style: BlockStyle::Sticky,
378 render: diagnostic_header_renderer(primary),
379 disposition: BlockDisposition::Above,
380 });
381 }
382
383 for entry in &group.entries[*start_ix..ix] {
384 let mut diagnostic = entry.diagnostic.clone();
385 if diagnostic.is_primary {
386 group_state.primary_excerpt_ix = group_state.excerpts.len() - 1;
387 diagnostic.message =
388 entry.diagnostic.message.split('\n').skip(1).collect();
389 }
390
391 if !diagnostic.message.is_empty() {
392 group_state.block_count += 1;
393 blocks_to_add.push(BlockProperties {
394 position: (excerpt_id.clone(), entry.range.start),
395 height: diagnostic.message.matches('\n').count() as u8 + 1,
396 style: BlockStyle::Fixed,
397 render: diagnostic_block_renderer(diagnostic, true),
398 disposition: BlockDisposition::Below,
399 });
400 }
401 }
402
403 pending_range.take();
404 }
405
406 if let Some(entry) = resolved_entry {
407 pending_range = Some((entry.range.clone(), ix));
408 }
409 }
410
411 groups_to_add.push(group_state);
412 } else if let Some((group_ix, group_state)) = to_remove {
413 excerpts.remove_excerpts(group_state.excerpts.iter().copied(), excerpts_cx);
414 group_ixs_to_remove.push(group_ix);
415 blocks_to_remove.extend(group_state.blocks.iter().copied());
416 } else if let Some((_, group)) = to_keep {
417 prev_excerpt_id = group.excerpts.last().unwrap().clone();
418 first_excerpt_id.get_or_insert_with(|| prev_excerpt_id.clone());
419 }
420 }
421
422 excerpts.snapshot(excerpts_cx)
423 });
424
425 self.editor.update(cx, |editor, cx| {
426 editor.remove_blocks(blocks_to_remove, cx);
427 let block_ids = editor.insert_blocks(
428 blocks_to_add.into_iter().map(|block| {
429 let (excerpt_id, text_anchor) = block.position;
430 BlockProperties {
431 position: excerpts_snapshot.anchor_in_excerpt(excerpt_id, text_anchor),
432 height: block.height,
433 style: block.style,
434 render: block.render,
435 disposition: block.disposition,
436 }
437 }),
438 cx,
439 );
440
441 let mut block_ids = block_ids.into_iter();
442 for group_state in &mut groups_to_add {
443 group_state.blocks = block_ids.by_ref().take(group_state.block_count).collect();
444 }
445 });
446
447 for ix in group_ixs_to_remove.into_iter().rev() {
448 path_state.diagnostic_groups.remove(ix);
449 }
450 path_state.diagnostic_groups.extend(groups_to_add);
451 path_state.diagnostic_groups.sort_unstable_by(|a, b| {
452 let range_a = &a.primary_diagnostic.range;
453 let range_b = &b.primary_diagnostic.range;
454 range_a
455 .start
456 .cmp(&range_b.start, &snapshot)
457 .then_with(|| range_a.end.cmp(&range_b.end, &snapshot))
458 });
459
460 if path_state.diagnostic_groups.is_empty() {
461 self.path_states.remove(path_ix);
462 }
463
464 self.editor.update(cx, |editor, cx| {
465 let groups;
466 let mut selections;
467 let new_excerpt_ids_by_selection_id;
468 if was_empty {
469 groups = self.path_states.first()?.diagnostic_groups.as_slice();
470 new_excerpt_ids_by_selection_id = [(0, ExcerptId::min())].into_iter().collect();
471 selections = vec![Selection {
472 id: 0,
473 start: 0,
474 end: 0,
475 reversed: false,
476 goal: SelectionGoal::None,
477 }];
478 } else {
479 groups = self.path_states.get(path_ix)?.diagnostic_groups.as_slice();
480 new_excerpt_ids_by_selection_id =
481 editor.change_selections(Some(Autoscroll::fit()), cx, |s| s.refresh());
482 selections = editor.selections.all::<usize>(cx);
483 }
484
485 // If any selection has lost its position, move it to start of the next primary diagnostic.
486 let snapshot = editor.snapshot(cx);
487 for selection in &mut selections {
488 if let Some(new_excerpt_id) = new_excerpt_ids_by_selection_id.get(&selection.id) {
489 let group_ix = match groups.binary_search_by(|probe| {
490 probe
491 .excerpts
492 .last()
493 .unwrap()
494 .cmp(new_excerpt_id, &snapshot.buffer_snapshot)
495 }) {
496 Ok(ix) | Err(ix) => ix,
497 };
498 if let Some(group) = groups.get(group_ix) {
499 let offset = excerpts_snapshot
500 .anchor_in_excerpt(
501 group.excerpts[group.primary_excerpt_ix].clone(),
502 group.primary_diagnostic.range.start,
503 )
504 .to_offset(&excerpts_snapshot);
505 selection.start = offset;
506 selection.end = offset;
507 }
508 }
509 }
510 editor.change_selections(None, cx, |s| {
511 s.select(selections);
512 });
513 Some(())
514 });
515
516 if self.path_states.is_empty() {
517 if self.editor.is_focused(cx) {
518 cx.focus_self();
519 }
520 } else if cx.handle().is_focused(cx) {
521 cx.focus(&self.editor);
522 }
523 cx.notify();
524 }
525
526 fn update_title(&mut self, cx: &mut ViewContext<Self>) {
527 self.summary = self.project.read(cx).diagnostic_summary(cx);
528 cx.emit(Event::TitleChanged);
529 }
530}
531
532impl Item for ProjectDiagnosticsEditor {
533 fn tab_content<T: View>(
534 &self,
535 _detail: Option<usize>,
536 style: &theme::Tab,
537 cx: &AppContext,
538 ) -> Element<T> {
539 render_summary(
540 &self.summary,
541 &style.label.text,
542 &cx.global::<Settings>().theme.project_diagnostics,
543 )
544 }
545
546 fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
547 self.editor.for_each_project_item(cx, f)
548 }
549
550 fn is_singleton(&self, _: &AppContext) -> bool {
551 false
552 }
553
554 fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
555 self.editor
556 .update(cx, |editor, cx| editor.navigate(data, cx))
557 }
558
559 fn tab_tooltip_text(&self, _: &AppContext) -> Option<Cow<str>> {
560 Some("Project Diagnostics".into())
561 }
562
563 fn is_dirty(&self, cx: &AppContext) -> bool {
564 self.excerpts.read(cx).is_dirty(cx)
565 }
566
567 fn has_conflict(&self, cx: &AppContext) -> bool {
568 self.excerpts.read(cx).has_conflict(cx)
569 }
570
571 fn can_save(&self, _: &AppContext) -> bool {
572 true
573 }
574
575 fn save(
576 &mut self,
577 project: ModelHandle<Project>,
578 cx: &mut ViewContext<Self>,
579 ) -> Task<Result<()>> {
580 self.editor.save(project, cx)
581 }
582
583 fn reload(
584 &mut self,
585 project: ModelHandle<Project>,
586 cx: &mut ViewContext<Self>,
587 ) -> Task<Result<()>> {
588 self.editor.reload(project, cx)
589 }
590
591 fn save_as(
592 &mut self,
593 _: ModelHandle<Project>,
594 _: PathBuf,
595 _: &mut ViewContext<Self>,
596 ) -> Task<Result<()>> {
597 unreachable!()
598 }
599
600 fn git_diff_recalc(
601 &mut self,
602 project: ModelHandle<Project>,
603 cx: &mut ViewContext<Self>,
604 ) -> Task<Result<()>> {
605 self.editor
606 .update(cx, |editor, cx| editor.git_diff_recalc(project, cx))
607 }
608
609 fn to_item_events(event: &Self::Event) -> SmallVec<[ItemEvent; 2]> {
610 Editor::to_item_events(event)
611 }
612
613 fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext<Self>) {
614 self.editor.update(cx, |editor, _| {
615 editor.set_nav_history(Some(nav_history));
616 });
617 }
618
619 fn clone_on_split(
620 &self,
621 _workspace_id: workspace::WorkspaceId,
622 cx: &mut ViewContext<Self>,
623 ) -> Option<Self>
624 where
625 Self: Sized,
626 {
627 Some(ProjectDiagnosticsEditor::new(
628 self.project.clone(),
629 self.workspace.clone(),
630 cx,
631 ))
632 }
633
634 fn act_as_type<'a>(
635 &'a self,
636 type_id: TypeId,
637 self_handle: &'a ViewHandle<Self>,
638 _: &'a AppContext,
639 ) -> Option<&AnyViewHandle> {
640 if type_id == TypeId::of::<Self>() {
641 Some(self_handle)
642 } else if type_id == TypeId::of::<Editor>() {
643 Some(&self.editor)
644 } else {
645 None
646 }
647 }
648
649 fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
650 self.editor.update(cx, |editor, cx| editor.deactivated(cx));
651 }
652
653 fn serialized_item_kind() -> Option<&'static str> {
654 Some("diagnostics")
655 }
656
657 fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>> {
658 self.editor.breadcrumbs(theme, cx)
659 }
660
661 fn breadcrumb_location(&self) -> ToolbarItemLocation {
662 ToolbarItemLocation::PrimaryLeft { flex: None }
663 }
664
665 fn deserialize(
666 project: ModelHandle<Project>,
667 workspace: WeakViewHandle<Workspace>,
668 _workspace_id: workspace::WorkspaceId,
669 _item_id: workspace::ItemId,
670 cx: &mut ViewContext<Pane>,
671 ) -> Task<Result<ViewHandle<Self>>> {
672 Task::ready(Ok(cx.add_view(|cx| Self::new(project, workspace, cx))))
673 }
674}
675
676fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock {
677 let (message, highlights) = highlight_diagnostic_message(&diagnostic.message);
678 Arc::new(move |cx| {
679 let settings = cx.global::<Settings>();
680 let theme = &settings.theme.editor;
681 let style = theme.diagnostic_header.clone();
682 let font_size = (style.text_scale_factor * settings.buffer_font_size).round();
683 let icon_width = cx.em_width * style.icon_width_factor;
684 let icon = if diagnostic.severity == DiagnosticSeverity::ERROR {
685 Svg::new("icons/circle_x_mark_12.svg")
686 .with_color(theme.error_diagnostic.message.text.color)
687 } else {
688 Svg::new("icons/triangle_exclamation_12.svg")
689 .with_color(theme.warning_diagnostic.message.text.color)
690 };
691
692 Flex::row()
693 .with_child(
694 icon.constrained()
695 .with_width(icon_width)
696 .aligned()
697 .contained()
698 .boxed(),
699 )
700 .with_child(
701 Label::new(
702 message.clone(),
703 style.message.label.clone().with_font_size(font_size),
704 )
705 .with_highlights(highlights.clone())
706 .contained()
707 .with_style(style.message.container)
708 .with_margin_left(cx.gutter_padding)
709 .aligned()
710 .boxed(),
711 )
712 .with_children(diagnostic.code.clone().map(|code| {
713 Label::new(code, style.code.text.clone().with_font_size(font_size))
714 .contained()
715 .with_style(style.code.container)
716 .aligned()
717 .boxed()
718 }))
719 .contained()
720 .with_style(style.container)
721 .with_padding_left(cx.gutter_padding)
722 .with_padding_right(cx.gutter_padding)
723 .expanded()
724 .named("diagnostic header")
725 })
726}
727
728pub(crate) fn render_summary<T: View>(
729 summary: &DiagnosticSummary,
730 text_style: &TextStyle,
731 theme: &theme::ProjectDiagnostics,
732) -> Element<T> {
733 if summary.error_count == 0 && summary.warning_count == 0 {
734 Label::new("No problems", text_style.clone()).boxed()
735 } else {
736 let icon_width = theme.tab_icon_width;
737 let icon_spacing = theme.tab_icon_spacing;
738 let summary_spacing = theme.tab_summary_spacing;
739 Flex::row()
740 .with_children([
741 Svg::new("icons/circle_x_mark_12.svg")
742 .with_color(text_style.color)
743 .constrained()
744 .with_width(icon_width)
745 .aligned()
746 .contained()
747 .with_margin_right(icon_spacing)
748 .named("no-icon"),
749 Label::new(
750 summary.error_count.to_string(),
751 LabelStyle {
752 text: text_style.clone(),
753 highlight_text: None,
754 },
755 )
756 .aligned()
757 .boxed(),
758 Svg::new("icons/triangle_exclamation_12.svg")
759 .with_color(text_style.color)
760 .constrained()
761 .with_width(icon_width)
762 .aligned()
763 .contained()
764 .with_margin_left(summary_spacing)
765 .with_margin_right(icon_spacing)
766 .named("warn-icon"),
767 Label::new(
768 summary.warning_count.to_string(),
769 LabelStyle {
770 text: text_style.clone(),
771 highlight_text: None,
772 },
773 )
774 .aligned()
775 .boxed(),
776 ])
777 .boxed()
778 }
779}
780
781fn compare_diagnostics<L: language::ToOffset, R: language::ToOffset>(
782 lhs: &DiagnosticEntry<L>,
783 rhs: &DiagnosticEntry<R>,
784 snapshot: &language::BufferSnapshot,
785) -> Ordering {
786 lhs.range
787 .start
788 .to_offset(snapshot)
789 .cmp(&rhs.range.start.to_offset(snapshot))
790 .then_with(|| {
791 lhs.range
792 .end
793 .to_offset(snapshot)
794 .cmp(&rhs.range.end.to_offset(snapshot))
795 })
796 .then_with(|| lhs.diagnostic.message.cmp(&rhs.diagnostic.message))
797}
798
799#[cfg(test)]
800mod tests {
801 use super::*;
802 use editor::{
803 display_map::{BlockContext, TransformBlock},
804 DisplayPoint,
805 };
806 use gpui::{TestAppContext, WindowContext};
807 use language::{Diagnostic, DiagnosticEntry, DiagnosticSeverity, PointUtf16, Unclipped};
808 use project::FakeFs;
809 use serde_json::json;
810 use unindent::Unindent as _;
811
812 #[gpui::test]
813 async fn test_diagnostics(cx: &mut TestAppContext) {
814 Settings::test_async(cx);
815 let fs = FakeFs::new(cx.background());
816 fs.insert_tree(
817 "/test",
818 json!({
819 "consts.rs": "
820 const a: i32 = 'a';
821 const b: i32 = c;
822 "
823 .unindent(),
824
825 "main.rs": "
826 fn main() {
827 let x = vec![];
828 let y = vec![];
829 a(x);
830 b(y);
831 // comment 1
832 // comment 2
833 c(y);
834 d(x);
835 }
836 "
837 .unindent(),
838 }),
839 )
840 .await;
841
842 let language_server_id = LanguageServerId(0);
843 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
844 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
845
846 // Create some diagnostics
847 project.update(cx, |project, cx| {
848 project
849 .update_diagnostic_entries(
850 language_server_id,
851 PathBuf::from("/test/main.rs"),
852 None,
853 vec![
854 DiagnosticEntry {
855 range: Unclipped(PointUtf16::new(1, 8))..Unclipped(PointUtf16::new(1, 9)),
856 diagnostic: Diagnostic {
857 message:
858 "move occurs because `x` has type `Vec<char>`, which does not implement the `Copy` trait"
859 .to_string(),
860 severity: DiagnosticSeverity::INFORMATION,
861 is_primary: false,
862 is_disk_based: true,
863 group_id: 1,
864 ..Default::default()
865 },
866 },
867 DiagnosticEntry {
868 range: Unclipped(PointUtf16::new(2, 8))..Unclipped(PointUtf16::new(2, 9)),
869 diagnostic: Diagnostic {
870 message:
871 "move occurs because `y` has type `Vec<char>`, which does not implement the `Copy` trait"
872 .to_string(),
873 severity: DiagnosticSeverity::INFORMATION,
874 is_primary: false,
875 is_disk_based: true,
876 group_id: 0,
877 ..Default::default()
878 },
879 },
880 DiagnosticEntry {
881 range: Unclipped(PointUtf16::new(3, 6))..Unclipped(PointUtf16::new(3, 7)),
882 diagnostic: Diagnostic {
883 message: "value moved here".to_string(),
884 severity: DiagnosticSeverity::INFORMATION,
885 is_primary: false,
886 is_disk_based: true,
887 group_id: 1,
888 ..Default::default()
889 },
890 },
891 DiagnosticEntry {
892 range: Unclipped(PointUtf16::new(4, 6))..Unclipped(PointUtf16::new(4, 7)),
893 diagnostic: Diagnostic {
894 message: "value moved here".to_string(),
895 severity: DiagnosticSeverity::INFORMATION,
896 is_primary: false,
897 is_disk_based: true,
898 group_id: 0,
899 ..Default::default()
900 },
901 },
902 DiagnosticEntry {
903 range: Unclipped(PointUtf16::new(7, 6))..Unclipped(PointUtf16::new(7, 7)),
904 diagnostic: Diagnostic {
905 message: "use of moved value\nvalue used here after move".to_string(),
906 severity: DiagnosticSeverity::ERROR,
907 is_primary: true,
908 is_disk_based: true,
909 group_id: 0,
910 ..Default::default()
911 },
912 },
913 DiagnosticEntry {
914 range: Unclipped(PointUtf16::new(8, 6))..Unclipped(PointUtf16::new(8, 7)),
915 diagnostic: Diagnostic {
916 message: "use of moved value\nvalue used here after move".to_string(),
917 severity: DiagnosticSeverity::ERROR,
918 is_primary: true,
919 is_disk_based: true,
920 group_id: 1,
921 ..Default::default()
922 },
923 },
924 ],
925 cx,
926 )
927 .unwrap();
928 });
929
930 // Open the project diagnostics view while there are already diagnostics.
931 let view = cx.add_view(&workspace, |cx| {
932 ProjectDiagnosticsEditor::new(project.clone(), workspace.downgrade(), cx)
933 });
934
935 view.next_notification(cx).await;
936 view.update(cx, |view, cx| {
937 assert_eq!(
938 editor_blocks(&view.editor, cx),
939 [
940 (0, "path header block".into()),
941 (2, "diagnostic header".into()),
942 (15, "collapsed context".into()),
943 (16, "diagnostic header".into()),
944 (25, "collapsed context".into()),
945 ]
946 );
947 assert_eq!(
948 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
949 concat!(
950 //
951 // main.rs
952 //
953 "\n", // filename
954 "\n", // padding
955 // diagnostic group 1
956 "\n", // primary message
957 "\n", // padding
958 " let x = vec![];\n",
959 " let y = vec![];\n",
960 "\n", // supporting diagnostic
961 " a(x);\n",
962 " b(y);\n",
963 "\n", // supporting diagnostic
964 " // comment 1\n",
965 " // comment 2\n",
966 " c(y);\n",
967 "\n", // supporting diagnostic
968 " d(x);\n",
969 "\n", // context ellipsis
970 // diagnostic group 2
971 "\n", // primary message
972 "\n", // padding
973 "fn main() {\n",
974 " let x = vec![];\n",
975 "\n", // supporting diagnostic
976 " let y = vec![];\n",
977 " a(x);\n",
978 "\n", // supporting diagnostic
979 " b(y);\n",
980 "\n", // context ellipsis
981 " c(y);\n",
982 " d(x);\n",
983 "\n", // supporting diagnostic
984 "}"
985 )
986 );
987
988 // Cursor is at the first diagnostic
989 view.editor.update(cx, |editor, cx| {
990 assert_eq!(
991 editor.selections.display_ranges(cx),
992 [DisplayPoint::new(12, 6)..DisplayPoint::new(12, 6)]
993 );
994 });
995 });
996
997 // Diagnostics are added for another earlier path.
998 project.update(cx, |project, cx| {
999 project.disk_based_diagnostics_started(language_server_id, cx);
1000 project
1001 .update_diagnostic_entries(
1002 language_server_id,
1003 PathBuf::from("/test/consts.rs"),
1004 None,
1005 vec![DiagnosticEntry {
1006 range: Unclipped(PointUtf16::new(0, 15))..Unclipped(PointUtf16::new(0, 15)),
1007 diagnostic: Diagnostic {
1008 message: "mismatched types\nexpected `usize`, found `char`".to_string(),
1009 severity: DiagnosticSeverity::ERROR,
1010 is_primary: true,
1011 is_disk_based: true,
1012 group_id: 0,
1013 ..Default::default()
1014 },
1015 }],
1016 cx,
1017 )
1018 .unwrap();
1019 project.disk_based_diagnostics_finished(language_server_id, cx);
1020 });
1021
1022 view.next_notification(cx).await;
1023 view.update(cx, |view, cx| {
1024 assert_eq!(
1025 editor_blocks(&view.editor, cx),
1026 [
1027 (0, "path header block".into()),
1028 (2, "diagnostic header".into()),
1029 (7, "path header block".into()),
1030 (9, "diagnostic header".into()),
1031 (22, "collapsed context".into()),
1032 (23, "diagnostic header".into()),
1033 (32, "collapsed context".into()),
1034 ]
1035 );
1036 assert_eq!(
1037 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1038 concat!(
1039 //
1040 // consts.rs
1041 //
1042 "\n", // filename
1043 "\n", // padding
1044 // diagnostic group 1
1045 "\n", // primary message
1046 "\n", // padding
1047 "const a: i32 = 'a';\n",
1048 "\n", // supporting diagnostic
1049 "const b: i32 = c;\n",
1050 //
1051 // main.rs
1052 //
1053 "\n", // filename
1054 "\n", // padding
1055 // diagnostic group 1
1056 "\n", // primary message
1057 "\n", // padding
1058 " let x = vec![];\n",
1059 " let y = vec![];\n",
1060 "\n", // supporting diagnostic
1061 " a(x);\n",
1062 " b(y);\n",
1063 "\n", // supporting diagnostic
1064 " // comment 1\n",
1065 " // comment 2\n",
1066 " c(y);\n",
1067 "\n", // supporting diagnostic
1068 " d(x);\n",
1069 "\n", // collapsed context
1070 // diagnostic group 2
1071 "\n", // primary message
1072 "\n", // filename
1073 "fn main() {\n",
1074 " let x = vec![];\n",
1075 "\n", // supporting diagnostic
1076 " let y = vec![];\n",
1077 " a(x);\n",
1078 "\n", // supporting diagnostic
1079 " b(y);\n",
1080 "\n", // context ellipsis
1081 " c(y);\n",
1082 " d(x);\n",
1083 "\n", // supporting diagnostic
1084 "}"
1085 )
1086 );
1087
1088 // Cursor keeps its position.
1089 view.editor.update(cx, |editor, cx| {
1090 assert_eq!(
1091 editor.selections.display_ranges(cx),
1092 [DisplayPoint::new(19, 6)..DisplayPoint::new(19, 6)]
1093 );
1094 });
1095 });
1096
1097 // Diagnostics are added to the first path
1098 project.update(cx, |project, cx| {
1099 project.disk_based_diagnostics_started(language_server_id, cx);
1100 project
1101 .update_diagnostic_entries(
1102 language_server_id,
1103 PathBuf::from("/test/consts.rs"),
1104 None,
1105 vec![
1106 DiagnosticEntry {
1107 range: Unclipped(PointUtf16::new(0, 15))
1108 ..Unclipped(PointUtf16::new(0, 15)),
1109 diagnostic: Diagnostic {
1110 message: "mismatched types\nexpected `usize`, found `char`"
1111 .to_string(),
1112 severity: DiagnosticSeverity::ERROR,
1113 is_primary: true,
1114 is_disk_based: true,
1115 group_id: 0,
1116 ..Default::default()
1117 },
1118 },
1119 DiagnosticEntry {
1120 range: Unclipped(PointUtf16::new(1, 15))
1121 ..Unclipped(PointUtf16::new(1, 15)),
1122 diagnostic: Diagnostic {
1123 message: "unresolved name `c`".to_string(),
1124 severity: DiagnosticSeverity::ERROR,
1125 is_primary: true,
1126 is_disk_based: true,
1127 group_id: 1,
1128 ..Default::default()
1129 },
1130 },
1131 ],
1132 cx,
1133 )
1134 .unwrap();
1135 project.disk_based_diagnostics_finished(language_server_id, cx);
1136 });
1137
1138 view.next_notification(cx).await;
1139 view.update(cx, |view, cx| {
1140 assert_eq!(
1141 editor_blocks(&view.editor, cx),
1142 [
1143 (0, "path header block".into()),
1144 (2, "diagnostic header".into()),
1145 (7, "collapsed context".into()),
1146 (8, "diagnostic header".into()),
1147 (13, "path header block".into()),
1148 (15, "diagnostic header".into()),
1149 (28, "collapsed context".into()),
1150 (29, "diagnostic header".into()),
1151 (38, "collapsed context".into()),
1152 ]
1153 );
1154 assert_eq!(
1155 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1156 concat!(
1157 //
1158 // consts.rs
1159 //
1160 "\n", // filename
1161 "\n", // padding
1162 // diagnostic group 1
1163 "\n", // primary message
1164 "\n", // padding
1165 "const a: i32 = 'a';\n",
1166 "\n", // supporting diagnostic
1167 "const b: i32 = c;\n",
1168 "\n", // context ellipsis
1169 // diagnostic group 2
1170 "\n", // primary message
1171 "\n", // padding
1172 "const a: i32 = 'a';\n",
1173 "const b: i32 = c;\n",
1174 "\n", // supporting diagnostic
1175 //
1176 // main.rs
1177 //
1178 "\n", // filename
1179 "\n", // padding
1180 // diagnostic group 1
1181 "\n", // primary message
1182 "\n", // padding
1183 " let x = vec![];\n",
1184 " let y = vec![];\n",
1185 "\n", // supporting diagnostic
1186 " a(x);\n",
1187 " b(y);\n",
1188 "\n", // supporting diagnostic
1189 " // comment 1\n",
1190 " // comment 2\n",
1191 " c(y);\n",
1192 "\n", // supporting diagnostic
1193 " d(x);\n",
1194 "\n", // context ellipsis
1195 // diagnostic group 2
1196 "\n", // primary message
1197 "\n", // filename
1198 "fn main() {\n",
1199 " let x = vec![];\n",
1200 "\n", // supporting diagnostic
1201 " let y = vec![];\n",
1202 " a(x);\n",
1203 "\n", // supporting diagnostic
1204 " b(y);\n",
1205 "\n", // context ellipsis
1206 " c(y);\n",
1207 " d(x);\n",
1208 "\n", // supporting diagnostic
1209 "}"
1210 )
1211 );
1212 });
1213 }
1214
1215 #[gpui::test]
1216 async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
1217 Settings::test_async(cx);
1218 let fs = FakeFs::new(cx.background());
1219 fs.insert_tree(
1220 "/test",
1221 json!({
1222 "main.js": "
1223 a();
1224 b();
1225 c();
1226 d();
1227 e();
1228 ".unindent()
1229 }),
1230 )
1231 .await;
1232
1233 let server_id_1 = LanguageServerId(100);
1234 let server_id_2 = LanguageServerId(101);
1235 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1236 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1237
1238 let view = cx.add_view(&workspace, |cx| {
1239 ProjectDiagnosticsEditor::new(project.clone(), workspace.downgrade(), cx)
1240 });
1241
1242 // Two language servers start updating diagnostics
1243 project.update(cx, |project, cx| {
1244 project.disk_based_diagnostics_started(server_id_1, cx);
1245 project.disk_based_diagnostics_started(server_id_2, cx);
1246 project
1247 .update_diagnostic_entries(
1248 server_id_1,
1249 PathBuf::from("/test/main.js"),
1250 None,
1251 vec![DiagnosticEntry {
1252 range: Unclipped(PointUtf16::new(0, 0))..Unclipped(PointUtf16::new(0, 1)),
1253 diagnostic: Diagnostic {
1254 message: "error 1".to_string(),
1255 severity: DiagnosticSeverity::WARNING,
1256 is_primary: true,
1257 is_disk_based: true,
1258 group_id: 1,
1259 ..Default::default()
1260 },
1261 }],
1262 cx,
1263 )
1264 .unwrap();
1265 project
1266 .update_diagnostic_entries(
1267 server_id_2,
1268 PathBuf::from("/test/main.js"),
1269 None,
1270 vec![DiagnosticEntry {
1271 range: Unclipped(PointUtf16::new(1, 0))..Unclipped(PointUtf16::new(1, 1)),
1272 diagnostic: Diagnostic {
1273 message: "warning 1".to_string(),
1274 severity: DiagnosticSeverity::ERROR,
1275 is_primary: true,
1276 is_disk_based: true,
1277 group_id: 2,
1278 ..Default::default()
1279 },
1280 }],
1281 cx,
1282 )
1283 .unwrap();
1284 });
1285
1286 // The first language server finishes
1287 project.update(cx, |project, cx| {
1288 project.disk_based_diagnostics_finished(server_id_1, cx);
1289 });
1290
1291 // Only the first language server's diagnostics are shown.
1292 cx.foreground().run_until_parked();
1293 view.update(cx, |view, cx| {
1294 assert_eq!(
1295 editor_blocks(&view.editor, cx),
1296 [
1297 (0, "path header block".into()),
1298 (2, "diagnostic header".into()),
1299 ]
1300 );
1301 assert_eq!(
1302 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1303 concat!(
1304 "\n", // filename
1305 "\n", // padding
1306 // diagnostic group 1
1307 "\n", // primary message
1308 "\n", // padding
1309 "a();\n", //
1310 "b();",
1311 )
1312 );
1313 });
1314
1315 // The second language server finishes
1316 project.update(cx, |project, cx| {
1317 project.disk_based_diagnostics_finished(server_id_2, cx);
1318 });
1319
1320 // Both language server's diagnostics are shown.
1321 cx.foreground().run_until_parked();
1322 view.update(cx, |view, cx| {
1323 assert_eq!(
1324 editor_blocks(&view.editor, cx),
1325 [
1326 (0, "path header block".into()),
1327 (2, "diagnostic header".into()),
1328 (6, "collapsed context".into()),
1329 (7, "diagnostic header".into()),
1330 ]
1331 );
1332 assert_eq!(
1333 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1334 concat!(
1335 "\n", // filename
1336 "\n", // padding
1337 // diagnostic group 1
1338 "\n", // primary message
1339 "\n", // padding
1340 "a();\n", // location
1341 "b();\n", //
1342 "\n", // collapsed context
1343 // diagnostic group 2
1344 "\n", // primary message
1345 "\n", // padding
1346 "a();\n", // context
1347 "b();\n", //
1348 "c();", // context
1349 )
1350 );
1351 });
1352
1353 // Both language servers start updating diagnostics, and the first server finishes.
1354 project.update(cx, |project, cx| {
1355 project.disk_based_diagnostics_started(server_id_1, cx);
1356 project.disk_based_diagnostics_started(server_id_2, cx);
1357 project
1358 .update_diagnostic_entries(
1359 server_id_1,
1360 PathBuf::from("/test/main.js"),
1361 None,
1362 vec![DiagnosticEntry {
1363 range: Unclipped(PointUtf16::new(2, 0))..Unclipped(PointUtf16::new(2, 1)),
1364 diagnostic: Diagnostic {
1365 message: "warning 2".to_string(),
1366 severity: DiagnosticSeverity::WARNING,
1367 is_primary: true,
1368 is_disk_based: true,
1369 group_id: 1,
1370 ..Default::default()
1371 },
1372 }],
1373 cx,
1374 )
1375 .unwrap();
1376 project
1377 .update_diagnostic_entries(
1378 server_id_2,
1379 PathBuf::from("/test/main.rs"),
1380 None,
1381 vec![],
1382 cx,
1383 )
1384 .unwrap();
1385 project.disk_based_diagnostics_finished(server_id_1, cx);
1386 });
1387
1388 // Only the first language server's diagnostics are updated.
1389 cx.foreground().run_until_parked();
1390 view.update(cx, |view, cx| {
1391 assert_eq!(
1392 editor_blocks(&view.editor, cx),
1393 [
1394 (0, "path header block".into()),
1395 (2, "diagnostic header".into()),
1396 (7, "collapsed context".into()),
1397 (8, "diagnostic header".into()),
1398 ]
1399 );
1400 assert_eq!(
1401 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1402 concat!(
1403 "\n", // filename
1404 "\n", // padding
1405 // diagnostic group 1
1406 "\n", // primary message
1407 "\n", // padding
1408 "a();\n", // location
1409 "b();\n", //
1410 "c();\n", // context
1411 "\n", // collapsed context
1412 // diagnostic group 2
1413 "\n", // primary message
1414 "\n", // padding
1415 "b();\n", // context
1416 "c();\n", //
1417 "d();", // context
1418 )
1419 );
1420 });
1421
1422 // The second language server finishes.
1423 project.update(cx, |project, cx| {
1424 project
1425 .update_diagnostic_entries(
1426 server_id_2,
1427 PathBuf::from("/test/main.js"),
1428 None,
1429 vec![DiagnosticEntry {
1430 range: Unclipped(PointUtf16::new(3, 0))..Unclipped(PointUtf16::new(3, 1)),
1431 diagnostic: Diagnostic {
1432 message: "warning 2".to_string(),
1433 severity: DiagnosticSeverity::WARNING,
1434 is_primary: true,
1435 is_disk_based: true,
1436 group_id: 1,
1437 ..Default::default()
1438 },
1439 }],
1440 cx,
1441 )
1442 .unwrap();
1443 project.disk_based_diagnostics_finished(server_id_2, cx);
1444 });
1445
1446 // Both language servers' diagnostics are updated.
1447 cx.foreground().run_until_parked();
1448 view.update(cx, |view, cx| {
1449 assert_eq!(
1450 editor_blocks(&view.editor, cx),
1451 [
1452 (0, "path header block".into()),
1453 (2, "diagnostic header".into()),
1454 (7, "collapsed context".into()),
1455 (8, "diagnostic header".into()),
1456 ]
1457 );
1458 assert_eq!(
1459 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1460 concat!(
1461 "\n", // filename
1462 "\n", // padding
1463 // diagnostic group 1
1464 "\n", // primary message
1465 "\n", // padding
1466 "b();\n", // location
1467 "c();\n", //
1468 "d();\n", // context
1469 "\n", // collapsed context
1470 // diagnostic group 2
1471 "\n", // primary message
1472 "\n", // padding
1473 "c();\n", // context
1474 "d();\n", //
1475 "e();", // context
1476 )
1477 );
1478 });
1479 }
1480
1481 fn editor_blocks(editor: &ViewHandle<Editor>, cx: &mut WindowContext) -> Vec<(u32, String)> {
1482 editor.update(cx, |editor, cx| {
1483 let snapshot = editor.snapshot(cx);
1484 snapshot
1485 .blocks_in_range(0..snapshot.max_point().row())
1486 .filter_map(|(row, block)| {
1487 let name = match block {
1488 TransformBlock::Custom(block) => block
1489 .render(&mut BlockContext {
1490 view_context: cx,
1491 anchor_x: 0.,
1492 scroll_x: 0.,
1493 gutter_padding: 0.,
1494 gutter_width: 0.,
1495 line_height: 0.,
1496 em_width: 0.,
1497 })
1498 .name()?
1499 .to_string(),
1500 TransformBlock::ExcerptHeader {
1501 starts_new_buffer, ..
1502 } => {
1503 if *starts_new_buffer {
1504 "path header block".to_string()
1505 } else {
1506 "collapsed context".to_string()
1507 }
1508 }
1509 };
1510
1511 Some((row, name))
1512 })
1513 .collect()
1514 })
1515 }
1516}