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>) -> AnyElement<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 .into_any()
100 } else {
101 ChildView::new(&self.editor, cx).into_any()
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 ) -> AnyElement<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 )
699 .with_child(
700 Label::new(
701 message.clone(),
702 style.message.label.clone().with_font_size(font_size),
703 )
704 .with_highlights(highlights.clone())
705 .contained()
706 .with_style(style.message.container)
707 .with_margin_left(cx.gutter_padding)
708 .aligned(),
709 )
710 .with_children(diagnostic.code.clone().map(|code| {
711 Label::new(code, style.code.text.clone().with_font_size(font_size))
712 .contained()
713 .with_style(style.code.container)
714 .aligned()
715 }))
716 .contained()
717 .with_style(style.container)
718 .with_padding_left(cx.gutter_padding)
719 .with_padding_right(cx.gutter_padding)
720 .expanded()
721 .into_any_named("diagnostic header")
722 })
723}
724
725pub(crate) fn render_summary<T: View>(
726 summary: &DiagnosticSummary,
727 text_style: &TextStyle,
728 theme: &theme::ProjectDiagnostics,
729) -> AnyElement<T> {
730 if summary.error_count == 0 && summary.warning_count == 0 {
731 Label::new("No problems", text_style.clone()).into_any()
732 } else {
733 let icon_width = theme.tab_icon_width;
734 let icon_spacing = theme.tab_icon_spacing;
735 let summary_spacing = theme.tab_summary_spacing;
736 Flex::row()
737 .with_child(
738 Svg::new("icons/circle_x_mark_12.svg")
739 .with_color(text_style.color)
740 .constrained()
741 .with_width(icon_width)
742 .aligned()
743 .contained()
744 .with_margin_right(icon_spacing),
745 )
746 .with_child(
747 Label::new(
748 summary.error_count.to_string(),
749 LabelStyle {
750 text: text_style.clone(),
751 highlight_text: None,
752 },
753 )
754 .aligned(),
755 )
756 .with_child(
757 Svg::new("icons/triangle_exclamation_12.svg")
758 .with_color(text_style.color)
759 .constrained()
760 .with_width(icon_width)
761 .aligned()
762 .contained()
763 .with_margin_left(summary_spacing)
764 .with_margin_right(icon_spacing),
765 )
766 .with_child(
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 )
776 .into_any()
777 }
778}
779
780fn compare_diagnostics<L: language::ToOffset, R: language::ToOffset>(
781 lhs: &DiagnosticEntry<L>,
782 rhs: &DiagnosticEntry<R>,
783 snapshot: &language::BufferSnapshot,
784) -> Ordering {
785 lhs.range
786 .start
787 .to_offset(snapshot)
788 .cmp(&rhs.range.start.to_offset(snapshot))
789 .then_with(|| {
790 lhs.range
791 .end
792 .to_offset(snapshot)
793 .cmp(&rhs.range.end.to_offset(snapshot))
794 })
795 .then_with(|| lhs.diagnostic.message.cmp(&rhs.diagnostic.message))
796}
797
798#[cfg(test)]
799mod tests {
800 use super::*;
801 use editor::{
802 display_map::{BlockContext, TransformBlock},
803 DisplayPoint,
804 };
805 use gpui::{TestAppContext, WindowContext};
806 use language::{Diagnostic, DiagnosticEntry, DiagnosticSeverity, PointUtf16, Unclipped};
807 use project::FakeFs;
808 use serde_json::json;
809 use unindent::Unindent as _;
810
811 #[gpui::test]
812 async fn test_diagnostics(cx: &mut TestAppContext) {
813 Settings::test_async(cx);
814 let fs = FakeFs::new(cx.background());
815 fs.insert_tree(
816 "/test",
817 json!({
818 "consts.rs": "
819 const a: i32 = 'a';
820 const b: i32 = c;
821 "
822 .unindent(),
823
824 "main.rs": "
825 fn main() {
826 let x = vec![];
827 let y = vec![];
828 a(x);
829 b(y);
830 // comment 1
831 // comment 2
832 c(y);
833 d(x);
834 }
835 "
836 .unindent(),
837 }),
838 )
839 .await;
840
841 let language_server_id = LanguageServerId(0);
842 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
843 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
844
845 // Create some diagnostics
846 project.update(cx, |project, cx| {
847 project
848 .update_diagnostic_entries(
849 language_server_id,
850 PathBuf::from("/test/main.rs"),
851 None,
852 vec![
853 DiagnosticEntry {
854 range: Unclipped(PointUtf16::new(1, 8))..Unclipped(PointUtf16::new(1, 9)),
855 diagnostic: Diagnostic {
856 message:
857 "move occurs because `x` has type `Vec<char>`, which does not implement the `Copy` trait"
858 .to_string(),
859 severity: DiagnosticSeverity::INFORMATION,
860 is_primary: false,
861 is_disk_based: true,
862 group_id: 1,
863 ..Default::default()
864 },
865 },
866 DiagnosticEntry {
867 range: Unclipped(PointUtf16::new(2, 8))..Unclipped(PointUtf16::new(2, 9)),
868 diagnostic: Diagnostic {
869 message:
870 "move occurs because `y` has type `Vec<char>`, which does not implement the `Copy` trait"
871 .to_string(),
872 severity: DiagnosticSeverity::INFORMATION,
873 is_primary: false,
874 is_disk_based: true,
875 group_id: 0,
876 ..Default::default()
877 },
878 },
879 DiagnosticEntry {
880 range: Unclipped(PointUtf16::new(3, 6))..Unclipped(PointUtf16::new(3, 7)),
881 diagnostic: Diagnostic {
882 message: "value moved here".to_string(),
883 severity: DiagnosticSeverity::INFORMATION,
884 is_primary: false,
885 is_disk_based: true,
886 group_id: 1,
887 ..Default::default()
888 },
889 },
890 DiagnosticEntry {
891 range: Unclipped(PointUtf16::new(4, 6))..Unclipped(PointUtf16::new(4, 7)),
892 diagnostic: Diagnostic {
893 message: "value moved here".to_string(),
894 severity: DiagnosticSeverity::INFORMATION,
895 is_primary: false,
896 is_disk_based: true,
897 group_id: 0,
898 ..Default::default()
899 },
900 },
901 DiagnosticEntry {
902 range: Unclipped(PointUtf16::new(7, 6))..Unclipped(PointUtf16::new(7, 7)),
903 diagnostic: Diagnostic {
904 message: "use of moved value\nvalue used here after move".to_string(),
905 severity: DiagnosticSeverity::ERROR,
906 is_primary: true,
907 is_disk_based: true,
908 group_id: 0,
909 ..Default::default()
910 },
911 },
912 DiagnosticEntry {
913 range: Unclipped(PointUtf16::new(8, 6))..Unclipped(PointUtf16::new(8, 7)),
914 diagnostic: Diagnostic {
915 message: "use of moved value\nvalue used here after move".to_string(),
916 severity: DiagnosticSeverity::ERROR,
917 is_primary: true,
918 is_disk_based: true,
919 group_id: 1,
920 ..Default::default()
921 },
922 },
923 ],
924 cx,
925 )
926 .unwrap();
927 });
928
929 // Open the project diagnostics view while there are already diagnostics.
930 let view = cx.add_view(&workspace, |cx| {
931 ProjectDiagnosticsEditor::new(project.clone(), workspace.downgrade(), cx)
932 });
933
934 view.next_notification(cx).await;
935 view.update(cx, |view, cx| {
936 assert_eq!(
937 editor_blocks(&view.editor, cx),
938 [
939 (0, "path header block".into()),
940 (2, "diagnostic header".into()),
941 (15, "collapsed context".into()),
942 (16, "diagnostic header".into()),
943 (25, "collapsed context".into()),
944 ]
945 );
946 assert_eq!(
947 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
948 concat!(
949 //
950 // main.rs
951 //
952 "\n", // filename
953 "\n", // padding
954 // diagnostic group 1
955 "\n", // primary message
956 "\n", // padding
957 " let x = vec![];\n",
958 " let y = vec![];\n",
959 "\n", // supporting diagnostic
960 " a(x);\n",
961 " b(y);\n",
962 "\n", // supporting diagnostic
963 " // comment 1\n",
964 " // comment 2\n",
965 " c(y);\n",
966 "\n", // supporting diagnostic
967 " d(x);\n",
968 "\n", // context ellipsis
969 // diagnostic group 2
970 "\n", // primary message
971 "\n", // padding
972 "fn main() {\n",
973 " let x = vec![];\n",
974 "\n", // supporting diagnostic
975 " let y = vec![];\n",
976 " a(x);\n",
977 "\n", // supporting diagnostic
978 " b(y);\n",
979 "\n", // context ellipsis
980 " c(y);\n",
981 " d(x);\n",
982 "\n", // supporting diagnostic
983 "}"
984 )
985 );
986
987 // Cursor is at the first diagnostic
988 view.editor.update(cx, |editor, cx| {
989 assert_eq!(
990 editor.selections.display_ranges(cx),
991 [DisplayPoint::new(12, 6)..DisplayPoint::new(12, 6)]
992 );
993 });
994 });
995
996 // Diagnostics are added for another earlier path.
997 project.update(cx, |project, cx| {
998 project.disk_based_diagnostics_started(language_server_id, cx);
999 project
1000 .update_diagnostic_entries(
1001 language_server_id,
1002 PathBuf::from("/test/consts.rs"),
1003 None,
1004 vec![DiagnosticEntry {
1005 range: Unclipped(PointUtf16::new(0, 15))..Unclipped(PointUtf16::new(0, 15)),
1006 diagnostic: Diagnostic {
1007 message: "mismatched types\nexpected `usize`, found `char`".to_string(),
1008 severity: DiagnosticSeverity::ERROR,
1009 is_primary: true,
1010 is_disk_based: true,
1011 group_id: 0,
1012 ..Default::default()
1013 },
1014 }],
1015 cx,
1016 )
1017 .unwrap();
1018 project.disk_based_diagnostics_finished(language_server_id, cx);
1019 });
1020
1021 view.next_notification(cx).await;
1022 view.update(cx, |view, cx| {
1023 assert_eq!(
1024 editor_blocks(&view.editor, cx),
1025 [
1026 (0, "path header block".into()),
1027 (2, "diagnostic header".into()),
1028 (7, "path header block".into()),
1029 (9, "diagnostic header".into()),
1030 (22, "collapsed context".into()),
1031 (23, "diagnostic header".into()),
1032 (32, "collapsed context".into()),
1033 ]
1034 );
1035 assert_eq!(
1036 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1037 concat!(
1038 //
1039 // consts.rs
1040 //
1041 "\n", // filename
1042 "\n", // padding
1043 // diagnostic group 1
1044 "\n", // primary message
1045 "\n", // padding
1046 "const a: i32 = 'a';\n",
1047 "\n", // supporting diagnostic
1048 "const b: i32 = c;\n",
1049 //
1050 // main.rs
1051 //
1052 "\n", // filename
1053 "\n", // padding
1054 // diagnostic group 1
1055 "\n", // primary message
1056 "\n", // padding
1057 " let x = vec![];\n",
1058 " let y = vec![];\n",
1059 "\n", // supporting diagnostic
1060 " a(x);\n",
1061 " b(y);\n",
1062 "\n", // supporting diagnostic
1063 " // comment 1\n",
1064 " // comment 2\n",
1065 " c(y);\n",
1066 "\n", // supporting diagnostic
1067 " d(x);\n",
1068 "\n", // collapsed context
1069 // diagnostic group 2
1070 "\n", // primary message
1071 "\n", // filename
1072 "fn main() {\n",
1073 " let x = vec![];\n",
1074 "\n", // supporting diagnostic
1075 " let y = vec![];\n",
1076 " a(x);\n",
1077 "\n", // supporting diagnostic
1078 " b(y);\n",
1079 "\n", // context ellipsis
1080 " c(y);\n",
1081 " d(x);\n",
1082 "\n", // supporting diagnostic
1083 "}"
1084 )
1085 );
1086
1087 // Cursor keeps its position.
1088 view.editor.update(cx, |editor, cx| {
1089 assert_eq!(
1090 editor.selections.display_ranges(cx),
1091 [DisplayPoint::new(19, 6)..DisplayPoint::new(19, 6)]
1092 );
1093 });
1094 });
1095
1096 // Diagnostics are added to the first path
1097 project.update(cx, |project, cx| {
1098 project.disk_based_diagnostics_started(language_server_id, cx);
1099 project
1100 .update_diagnostic_entries(
1101 language_server_id,
1102 PathBuf::from("/test/consts.rs"),
1103 None,
1104 vec![
1105 DiagnosticEntry {
1106 range: Unclipped(PointUtf16::new(0, 15))
1107 ..Unclipped(PointUtf16::new(0, 15)),
1108 diagnostic: Diagnostic {
1109 message: "mismatched types\nexpected `usize`, found `char`"
1110 .to_string(),
1111 severity: DiagnosticSeverity::ERROR,
1112 is_primary: true,
1113 is_disk_based: true,
1114 group_id: 0,
1115 ..Default::default()
1116 },
1117 },
1118 DiagnosticEntry {
1119 range: Unclipped(PointUtf16::new(1, 15))
1120 ..Unclipped(PointUtf16::new(1, 15)),
1121 diagnostic: Diagnostic {
1122 message: "unresolved name `c`".to_string(),
1123 severity: DiagnosticSeverity::ERROR,
1124 is_primary: true,
1125 is_disk_based: true,
1126 group_id: 1,
1127 ..Default::default()
1128 },
1129 },
1130 ],
1131 cx,
1132 )
1133 .unwrap();
1134 project.disk_based_diagnostics_finished(language_server_id, cx);
1135 });
1136
1137 view.next_notification(cx).await;
1138 view.update(cx, |view, cx| {
1139 assert_eq!(
1140 editor_blocks(&view.editor, cx),
1141 [
1142 (0, "path header block".into()),
1143 (2, "diagnostic header".into()),
1144 (7, "collapsed context".into()),
1145 (8, "diagnostic header".into()),
1146 (13, "path header block".into()),
1147 (15, "diagnostic header".into()),
1148 (28, "collapsed context".into()),
1149 (29, "diagnostic header".into()),
1150 (38, "collapsed context".into()),
1151 ]
1152 );
1153 assert_eq!(
1154 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1155 concat!(
1156 //
1157 // consts.rs
1158 //
1159 "\n", // filename
1160 "\n", // padding
1161 // diagnostic group 1
1162 "\n", // primary message
1163 "\n", // padding
1164 "const a: i32 = 'a';\n",
1165 "\n", // supporting diagnostic
1166 "const b: i32 = c;\n",
1167 "\n", // context ellipsis
1168 // diagnostic group 2
1169 "\n", // primary message
1170 "\n", // padding
1171 "const a: i32 = 'a';\n",
1172 "const b: i32 = c;\n",
1173 "\n", // supporting diagnostic
1174 //
1175 // main.rs
1176 //
1177 "\n", // filename
1178 "\n", // padding
1179 // diagnostic group 1
1180 "\n", // primary message
1181 "\n", // padding
1182 " let x = vec![];\n",
1183 " let y = vec![];\n",
1184 "\n", // supporting diagnostic
1185 " a(x);\n",
1186 " b(y);\n",
1187 "\n", // supporting diagnostic
1188 " // comment 1\n",
1189 " // comment 2\n",
1190 " c(y);\n",
1191 "\n", // supporting diagnostic
1192 " d(x);\n",
1193 "\n", // context ellipsis
1194 // diagnostic group 2
1195 "\n", // primary message
1196 "\n", // filename
1197 "fn main() {\n",
1198 " let x = vec![];\n",
1199 "\n", // supporting diagnostic
1200 " let y = vec![];\n",
1201 " a(x);\n",
1202 "\n", // supporting diagnostic
1203 " b(y);\n",
1204 "\n", // context ellipsis
1205 " c(y);\n",
1206 " d(x);\n",
1207 "\n", // supporting diagnostic
1208 "}"
1209 )
1210 );
1211 });
1212 }
1213
1214 #[gpui::test]
1215 async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
1216 Settings::test_async(cx);
1217 let fs = FakeFs::new(cx.background());
1218 fs.insert_tree(
1219 "/test",
1220 json!({
1221 "main.js": "
1222 a();
1223 b();
1224 c();
1225 d();
1226 e();
1227 ".unindent()
1228 }),
1229 )
1230 .await;
1231
1232 let server_id_1 = LanguageServerId(100);
1233 let server_id_2 = LanguageServerId(101);
1234 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1235 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1236
1237 let view = cx.add_view(&workspace, |cx| {
1238 ProjectDiagnosticsEditor::new(project.clone(), workspace.downgrade(), cx)
1239 });
1240
1241 // Two language servers start updating diagnostics
1242 project.update(cx, |project, cx| {
1243 project.disk_based_diagnostics_started(server_id_1, cx);
1244 project.disk_based_diagnostics_started(server_id_2, cx);
1245 project
1246 .update_diagnostic_entries(
1247 server_id_1,
1248 PathBuf::from("/test/main.js"),
1249 None,
1250 vec![DiagnosticEntry {
1251 range: Unclipped(PointUtf16::new(0, 0))..Unclipped(PointUtf16::new(0, 1)),
1252 diagnostic: Diagnostic {
1253 message: "error 1".to_string(),
1254 severity: DiagnosticSeverity::WARNING,
1255 is_primary: true,
1256 is_disk_based: true,
1257 group_id: 1,
1258 ..Default::default()
1259 },
1260 }],
1261 cx,
1262 )
1263 .unwrap();
1264 project
1265 .update_diagnostic_entries(
1266 server_id_2,
1267 PathBuf::from("/test/main.js"),
1268 None,
1269 vec![DiagnosticEntry {
1270 range: Unclipped(PointUtf16::new(1, 0))..Unclipped(PointUtf16::new(1, 1)),
1271 diagnostic: Diagnostic {
1272 message: "warning 1".to_string(),
1273 severity: DiagnosticSeverity::ERROR,
1274 is_primary: true,
1275 is_disk_based: true,
1276 group_id: 2,
1277 ..Default::default()
1278 },
1279 }],
1280 cx,
1281 )
1282 .unwrap();
1283 });
1284
1285 // The first language server finishes
1286 project.update(cx, |project, cx| {
1287 project.disk_based_diagnostics_finished(server_id_1, cx);
1288 });
1289
1290 // Only the first language server's diagnostics are shown.
1291 cx.foreground().run_until_parked();
1292 view.update(cx, |view, cx| {
1293 assert_eq!(
1294 editor_blocks(&view.editor, cx),
1295 [
1296 (0, "path header block".into()),
1297 (2, "diagnostic header".into()),
1298 ]
1299 );
1300 assert_eq!(
1301 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1302 concat!(
1303 "\n", // filename
1304 "\n", // padding
1305 // diagnostic group 1
1306 "\n", // primary message
1307 "\n", // padding
1308 "a();\n", //
1309 "b();",
1310 )
1311 );
1312 });
1313
1314 // The second language server finishes
1315 project.update(cx, |project, cx| {
1316 project.disk_based_diagnostics_finished(server_id_2, cx);
1317 });
1318
1319 // Both language server's diagnostics are shown.
1320 cx.foreground().run_until_parked();
1321 view.update(cx, |view, cx| {
1322 assert_eq!(
1323 editor_blocks(&view.editor, cx),
1324 [
1325 (0, "path header block".into()),
1326 (2, "diagnostic header".into()),
1327 (6, "collapsed context".into()),
1328 (7, "diagnostic header".into()),
1329 ]
1330 );
1331 assert_eq!(
1332 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1333 concat!(
1334 "\n", // filename
1335 "\n", // padding
1336 // diagnostic group 1
1337 "\n", // primary message
1338 "\n", // padding
1339 "a();\n", // location
1340 "b();\n", //
1341 "\n", // collapsed context
1342 // diagnostic group 2
1343 "\n", // primary message
1344 "\n", // padding
1345 "a();\n", // context
1346 "b();\n", //
1347 "c();", // context
1348 )
1349 );
1350 });
1351
1352 // Both language servers start updating diagnostics, and the first server finishes.
1353 project.update(cx, |project, cx| {
1354 project.disk_based_diagnostics_started(server_id_1, cx);
1355 project.disk_based_diagnostics_started(server_id_2, cx);
1356 project
1357 .update_diagnostic_entries(
1358 server_id_1,
1359 PathBuf::from("/test/main.js"),
1360 None,
1361 vec![DiagnosticEntry {
1362 range: Unclipped(PointUtf16::new(2, 0))..Unclipped(PointUtf16::new(2, 1)),
1363 diagnostic: Diagnostic {
1364 message: "warning 2".to_string(),
1365 severity: DiagnosticSeverity::WARNING,
1366 is_primary: true,
1367 is_disk_based: true,
1368 group_id: 1,
1369 ..Default::default()
1370 },
1371 }],
1372 cx,
1373 )
1374 .unwrap();
1375 project
1376 .update_diagnostic_entries(
1377 server_id_2,
1378 PathBuf::from("/test/main.rs"),
1379 None,
1380 vec![],
1381 cx,
1382 )
1383 .unwrap();
1384 project.disk_based_diagnostics_finished(server_id_1, cx);
1385 });
1386
1387 // Only the first language server's diagnostics are updated.
1388 cx.foreground().run_until_parked();
1389 view.update(cx, |view, cx| {
1390 assert_eq!(
1391 editor_blocks(&view.editor, cx),
1392 [
1393 (0, "path header block".into()),
1394 (2, "diagnostic header".into()),
1395 (7, "collapsed context".into()),
1396 (8, "diagnostic header".into()),
1397 ]
1398 );
1399 assert_eq!(
1400 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1401 concat!(
1402 "\n", // filename
1403 "\n", // padding
1404 // diagnostic group 1
1405 "\n", // primary message
1406 "\n", // padding
1407 "a();\n", // location
1408 "b();\n", //
1409 "c();\n", // context
1410 "\n", // collapsed context
1411 // diagnostic group 2
1412 "\n", // primary message
1413 "\n", // padding
1414 "b();\n", // context
1415 "c();\n", //
1416 "d();", // context
1417 )
1418 );
1419 });
1420
1421 // The second language server finishes.
1422 project.update(cx, |project, cx| {
1423 project
1424 .update_diagnostic_entries(
1425 server_id_2,
1426 PathBuf::from("/test/main.js"),
1427 None,
1428 vec![DiagnosticEntry {
1429 range: Unclipped(PointUtf16::new(3, 0))..Unclipped(PointUtf16::new(3, 1)),
1430 diagnostic: Diagnostic {
1431 message: "warning 2".to_string(),
1432 severity: DiagnosticSeverity::WARNING,
1433 is_primary: true,
1434 is_disk_based: true,
1435 group_id: 1,
1436 ..Default::default()
1437 },
1438 }],
1439 cx,
1440 )
1441 .unwrap();
1442 project.disk_based_diagnostics_finished(server_id_2, cx);
1443 });
1444
1445 // Both language servers' diagnostics are updated.
1446 cx.foreground().run_until_parked();
1447 view.update(cx, |view, cx| {
1448 assert_eq!(
1449 editor_blocks(&view.editor, cx),
1450 [
1451 (0, "path header block".into()),
1452 (2, "diagnostic header".into()),
1453 (7, "collapsed context".into()),
1454 (8, "diagnostic header".into()),
1455 ]
1456 );
1457 assert_eq!(
1458 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1459 concat!(
1460 "\n", // filename
1461 "\n", // padding
1462 // diagnostic group 1
1463 "\n", // primary message
1464 "\n", // padding
1465 "b();\n", // location
1466 "c();\n", //
1467 "d();\n", // context
1468 "\n", // collapsed context
1469 // diagnostic group 2
1470 "\n", // primary message
1471 "\n", // padding
1472 "c();\n", // context
1473 "d();\n", //
1474 "e();", // context
1475 )
1476 );
1477 });
1478 }
1479
1480 fn editor_blocks(editor: &ViewHandle<Editor>, cx: &mut WindowContext) -> Vec<(u32, String)> {
1481 editor.update(cx, |editor, cx| {
1482 let snapshot = editor.snapshot(cx);
1483 snapshot
1484 .blocks_in_range(0..snapshot.max_point().row())
1485 .filter_map(|(row, block)| {
1486 let name = match block {
1487 TransformBlock::Custom(block) => block
1488 .render(&mut BlockContext {
1489 view_context: cx,
1490 anchor_x: 0.,
1491 scroll_x: 0.,
1492 gutter_padding: 0.,
1493 gutter_width: 0.,
1494 line_height: 0.,
1495 em_width: 0.,
1496 })
1497 .name()?
1498 .to_string(),
1499 TransformBlock::ExcerptHeader {
1500 starts_new_buffer, ..
1501 } => {
1502 if *starts_new_buffer {
1503 "path header block".to_string()
1504 } else {
1505 "collapsed context".to_string()
1506 }
1507 }
1508 };
1509
1510 Some((row, name))
1511 })
1512 .collect()
1513 })
1514 }
1515}