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