diagnostic_renderer.rs

  1use std::{ops::Range, sync::Arc};
  2
  3use editor::{
  4    Anchor, Editor, EditorSnapshot, ToOffset,
  5    display_map::{BlockContext, BlockPlacement, BlockProperties, BlockStyle},
  6    hover_markdown_style,
  7    scroll::Autoscroll,
  8};
  9use gpui::{AppContext, Entity, Focusable, WeakEntity};
 10use language::{BufferId, DiagnosticEntry};
 11use lsp::DiagnosticSeverity;
 12use markdown::{Markdown, MarkdownElement};
 13use settings::Settings;
 14use text::{AnchorRangeExt, Point};
 15use theme::ThemeSettings;
 16use ui::{
 17    ActiveTheme, AnyElement, App, Context, IntoElement, ParentElement, SharedString, Styled,
 18    Window, div,
 19};
 20use util::maybe;
 21
 22use crate::ProjectDiagnosticsEditor;
 23
 24pub struct DiagnosticRenderer;
 25
 26impl DiagnosticRenderer {
 27    pub fn diagnostic_blocks_for_group(
 28        diagnostic_group: Vec<DiagnosticEntry<Point>>,
 29        buffer_id: BufferId,
 30        diagnostics_editor: Option<WeakEntity<ProjectDiagnosticsEditor>>,
 31        merge_same_row: bool,
 32        cx: &mut App,
 33    ) -> Vec<DiagnosticBlock> {
 34        let Some(primary_ix) = diagnostic_group
 35            .iter()
 36            .position(|d| d.diagnostic.is_primary)
 37        else {
 38            return Vec::new();
 39        };
 40        let primary = diagnostic_group[primary_ix].clone();
 41        let mut same_row = Vec::new();
 42        let mut close = Vec::new();
 43        let mut distant = Vec::new();
 44        let group_id = primary.diagnostic.group_id;
 45        for (ix, entry) in diagnostic_group.into_iter().enumerate() {
 46            if entry.diagnostic.is_primary {
 47                continue;
 48            }
 49            if entry.range.start.row == primary.range.start.row && merge_same_row {
 50                same_row.push(entry)
 51            } else if entry.range.start.row.abs_diff(primary.range.start.row) < 5 {
 52                close.push(entry)
 53            } else {
 54                distant.push((ix, entry))
 55            }
 56        }
 57
 58        let mut markdown = String::new();
 59        let diagnostic = &primary.diagnostic;
 60        markdown.push_str(&Markdown::escape(&diagnostic.message));
 61        for entry in same_row {
 62            markdown.push_str("\n- hint: ");
 63            markdown.push_str(&Markdown::escape(&entry.diagnostic.message))
 64        }
 65        if diagnostic.source.is_some() || diagnostic.code.is_some() {
 66            markdown.push_str(" (");
 67        }
 68        if let Some(source) = diagnostic.source.as_ref() {
 69            markdown.push_str(&Markdown::escape(&source));
 70        }
 71        if diagnostic.source.is_some() && diagnostic.code.is_some() {
 72            markdown.push(' ');
 73        }
 74        if let Some(code) = diagnostic.code.as_ref() {
 75            if let Some(description) = diagnostic.code_description.as_ref() {
 76                markdown.push('[');
 77                markdown.push_str(&Markdown::escape(&code.to_string()));
 78                markdown.push_str("](");
 79                markdown.push_str(&Markdown::escape(description.as_ref()));
 80                markdown.push(')');
 81            } else {
 82                markdown.push_str(&Markdown::escape(&code.to_string()));
 83            }
 84        }
 85        if diagnostic.source.is_some() || diagnostic.code.is_some() {
 86            markdown.push(')');
 87        }
 88
 89        for (ix, entry) in &distant {
 90            markdown.push_str("\n- hint: [");
 91            markdown.push_str(&Markdown::escape(&entry.diagnostic.message));
 92            markdown.push_str(&format!(
 93                "](file://#diagnostic-{buffer_id}-{group_id}-{ix})\n",
 94            ))
 95        }
 96
 97        let mut results = vec![DiagnosticBlock {
 98            initial_range: primary.range,
 99            severity: primary.diagnostic.severity,
100            diagnostics_editor: diagnostics_editor.clone(),
101            markdown: cx.new(|cx| Markdown::new(markdown.into(), None, None, cx)),
102        }];
103
104        for entry in close {
105            let markdown = if let Some(source) = entry.diagnostic.source.as_ref() {
106                format!("{}: {}", source, entry.diagnostic.message)
107            } else {
108                entry.diagnostic.message
109            };
110            let markdown = Markdown::escape(&markdown).to_string();
111
112            results.push(DiagnosticBlock {
113                initial_range: entry.range,
114                severity: entry.diagnostic.severity,
115                diagnostics_editor: diagnostics_editor.clone(),
116                markdown: cx.new(|cx| Markdown::new(markdown.into(), None, None, cx)),
117            });
118        }
119
120        for (_, entry) in distant {
121            let markdown = if let Some(source) = entry.diagnostic.source.as_ref() {
122                format!("{}: {}", source, entry.diagnostic.message)
123            } else {
124                entry.diagnostic.message
125            };
126            let mut markdown = Markdown::escape(&markdown).to_string();
127            markdown.push_str(&format!(
128                " ([back](file://#diagnostic-{buffer_id}-{group_id}-{primary_ix}))"
129            ));
130
131            results.push(DiagnosticBlock {
132                initial_range: entry.range,
133                severity: entry.diagnostic.severity,
134                diagnostics_editor: diagnostics_editor.clone(),
135                markdown: cx.new(|cx| Markdown::new(markdown.into(), None, None, cx)),
136            });
137        }
138
139        results
140    }
141}
142
143impl editor::DiagnosticRenderer for DiagnosticRenderer {
144    fn render_group(
145        &self,
146        diagnostic_group: Vec<DiagnosticEntry<Point>>,
147        buffer_id: BufferId,
148        snapshot: EditorSnapshot,
149        editor: WeakEntity<Editor>,
150        cx: &mut App,
151    ) -> Vec<BlockProperties<Anchor>> {
152        let blocks = Self::diagnostic_blocks_for_group(diagnostic_group, buffer_id, None, true, cx);
153        blocks
154            .into_iter()
155            .map(|block| {
156                let editor = editor.clone();
157                BlockProperties {
158                    placement: BlockPlacement::Near(
159                        snapshot
160                            .buffer_snapshot
161                            .anchor_after(block.initial_range.start),
162                    ),
163                    height: Some(1),
164                    style: BlockStyle::Flex,
165                    render: Arc::new(move |bcx| block.render_block(editor.clone(), bcx)),
166                    priority: 1,
167                }
168            })
169            .collect()
170    }
171
172    fn render_hover(
173        &self,
174        diagnostic_group: Vec<DiagnosticEntry<Point>>,
175        range: Range<Point>,
176        buffer_id: BufferId,
177        cx: &mut App,
178    ) -> Option<Entity<Markdown>> {
179        let blocks =
180            Self::diagnostic_blocks_for_group(diagnostic_group, buffer_id, None, false, cx);
181        blocks.into_iter().find_map(|block| {
182            if block.initial_range == range {
183                Some(block.markdown)
184            } else {
185                None
186            }
187        })
188    }
189
190    fn open_link(
191        &self,
192        editor: &mut Editor,
193        link: SharedString,
194        window: &mut Window,
195        cx: &mut Context<Editor>,
196    ) {
197        DiagnosticBlock::open_link(editor, &None, link, window, cx);
198    }
199}
200
201#[derive(Clone)]
202pub(crate) struct DiagnosticBlock {
203    pub(crate) initial_range: Range<Point>,
204    pub(crate) severity: DiagnosticSeverity,
205    pub(crate) markdown: Entity<Markdown>,
206    pub(crate) diagnostics_editor: Option<WeakEntity<ProjectDiagnosticsEditor>>,
207}
208
209impl DiagnosticBlock {
210    pub fn render_block(&self, editor: WeakEntity<Editor>, bcx: &BlockContext) -> AnyElement {
211        let cx = &bcx.app;
212        let status_colors = bcx.app.theme().status();
213
214        let max_width = bcx.em_width * 100.;
215
216        let (background_color, border_color) = match self.severity {
217            DiagnosticSeverity::ERROR => (status_colors.error_background, status_colors.error),
218            DiagnosticSeverity::WARNING => {
219                (status_colors.warning_background, status_colors.warning)
220            }
221            DiagnosticSeverity::INFORMATION => (status_colors.info_background, status_colors.info),
222            DiagnosticSeverity::HINT => (status_colors.hint_background, status_colors.info),
223            _ => (status_colors.ignored_background, status_colors.ignored),
224        };
225        let settings = ThemeSettings::get_global(cx);
226        let editor_line_height = (settings.line_height() * settings.buffer_font_size(cx)).round();
227        let line_height = editor_line_height;
228        let diagnostics_editor = self.diagnostics_editor.clone();
229
230        div()
231            .border_l_2()
232            .px_2()
233            .line_height(line_height)
234            .bg(background_color)
235            .border_color(border_color)
236            .max_w(max_width)
237            .child(
238                MarkdownElement::new(self.markdown.clone(), hover_markdown_style(bcx.window, cx))
239                    .on_url_click({
240                        move |link, window, cx| {
241                            editor
242                                .update(cx, |editor, cx| {
243                                    Self::open_link(editor, &diagnostics_editor, link, window, cx)
244                                })
245                                .ok();
246                        }
247                    }),
248            )
249            .into_any_element()
250    }
251
252    pub fn open_link(
253        editor: &mut Editor,
254        diagnostics_editor: &Option<WeakEntity<ProjectDiagnosticsEditor>>,
255        link: SharedString,
256        window: &mut Window,
257        cx: &mut Context<Editor>,
258    ) {
259        let Some(diagnostic_link) = link.strip_prefix("file://#diagnostic-") else {
260            editor::hover_popover::open_markdown_url(link, window, cx);
261            return;
262        };
263        let Some((buffer_id, group_id, ix)) = maybe!({
264            let mut parts = diagnostic_link.split('-');
265            let buffer_id: u64 = parts.next()?.parse().ok()?;
266            let group_id: usize = parts.next()?.parse().ok()?;
267            let ix: usize = parts.next()?.parse().ok()?;
268            Some((BufferId::new(buffer_id).ok()?, group_id, ix))
269        }) else {
270            return;
271        };
272
273        if let Some(diagnostics_editor) = diagnostics_editor {
274            if let Some(diagnostic) = diagnostics_editor
275                .update(cx, |diagnostics, _| {
276                    diagnostics
277                        .diagnostics
278                        .get(&buffer_id)
279                        .cloned()
280                        .unwrap_or_default()
281                        .into_iter()
282                        .filter(|d| d.diagnostic.group_id == group_id)
283                        .nth(ix)
284                })
285                .ok()
286                .flatten()
287            {
288                let multibuffer = editor.buffer().read(cx);
289                let Some(snapshot) = multibuffer
290                    .buffer(buffer_id)
291                    .map(|entity| entity.read(cx).snapshot())
292                else {
293                    return;
294                };
295
296                for (excerpt_id, range) in multibuffer.excerpts_for_buffer(buffer_id, cx) {
297                    if range.context.overlaps(&diagnostic.range, &snapshot) {
298                        Self::jump_to(
299                            editor,
300                            Anchor::range_in_buffer(excerpt_id, buffer_id, diagnostic.range),
301                            window,
302                            cx,
303                        );
304                        return;
305                    }
306                }
307            }
308        } else {
309            if let Some(diagnostic) = editor
310                .snapshot(window, cx)
311                .buffer_snapshot
312                .diagnostic_group(buffer_id, group_id)
313                .nth(ix)
314            {
315                Self::jump_to(editor, diagnostic.range, window, cx)
316            }
317        };
318    }
319
320    fn jump_to<T: ToOffset>(
321        editor: &mut Editor,
322        range: Range<T>,
323        window: &mut Window,
324        cx: &mut Context<Editor>,
325    ) {
326        let snapshot = &editor.buffer().read(cx).snapshot(cx);
327        let range = range.start.to_offset(&snapshot)..range.end.to_offset(&snapshot);
328
329        editor.unfold_ranges(&[range.start..range.end], true, false, cx);
330        editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
331            s.select_ranges([range.start..range.start]);
332        });
333        window.focus(&editor.focus_handle(cx));
334    }
335}