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