Show diagnostic codes (#29296)

Conrad Irwin , Neo Nie , and Zed AI created

Closes #28135
Closes #4388
Closes #28136

Release Notes:

- diagnostics: Show the diagnostic code if available

---------

Co-authored-by: Neo Nie <nihgwu@live.com>
Co-authored-by: Zed AI <ai+claude-3.7@zed.dev>

Change summary

crates/diagnostics/src/diagnostic_renderer.rs | 214 ++++++++++-------
crates/diagnostics/src/diagnostics.rs         |   1 
crates/diagnostics/src/diagnostics_tests.rs   | 242 +++++++++++++++++++
crates/editor/src/editor.rs                   |  40 ++-
crates/editor/src/editor_tests.rs             |  40 ---
crates/editor/src/hover_links.rs              |   2 
crates/editor/src/hover_popover.rs            | 252 +++++++--------------
crates/language/src/buffer.rs                 |   2 
crates/language/src/proto.rs                  |   8 
crates/markdown/src/markdown.rs               |   9 
crates/multi_buffer/src/multi_buffer.rs       |  23 +
crates/project/src/lsp_store.rs               |   8 
crates/proto/proto/buffer.proto               |   1 
13 files changed, 516 insertions(+), 326 deletions(-)

Detailed changes

crates/diagnostics/src/diagnostic_renderer.rs 🔗

@@ -28,6 +28,7 @@ impl DiagnosticRenderer {
         diagnostic_group: Vec<DiagnosticEntry<Point>>,
         buffer_id: BufferId,
         diagnostics_editor: Option<WeakEntity<ProjectDiagnosticsEditor>>,
+        merge_same_row: bool,
         cx: &mut App,
     ) -> Vec<DiagnosticBlock> {
         let Some(primary_ix) = diagnostic_group
@@ -45,7 +46,7 @@ impl DiagnosticRenderer {
             if entry.diagnostic.is_primary {
                 continue;
             }
-            if entry.range.start.row == primary.range.start.row {
+            if entry.range.start.row == primary.range.start.row && merge_same_row {
                 same_row.push(entry)
             } else if entry.range.start.row.abs_diff(primary.range.start.row) < 5 {
                 close.push(entry)
@@ -54,28 +55,48 @@ impl DiagnosticRenderer {
             }
         }
 
-        let mut markdown =
-            Markdown::escape(&if let Some(source) = primary.diagnostic.source.as_ref() {
-                format!("{}: {}", source, primary.diagnostic.message)
-            } else {
-                primary.diagnostic.message
-            })
-            .to_string();
+        let mut markdown = String::new();
+        let diagnostic = &primary.diagnostic;
+        markdown.push_str(&Markdown::escape(&diagnostic.message));
         for entry in same_row {
             markdown.push_str("\n- hint: ");
             markdown.push_str(&Markdown::escape(&entry.diagnostic.message))
         }
+        if diagnostic.source.is_some() || diagnostic.code.is_some() {
+            markdown.push_str(" (");
+        }
+        if let Some(source) = diagnostic.source.as_ref() {
+            markdown.push_str(&Markdown::escape(&source));
+        }
+        if diagnostic.source.is_some() && diagnostic.code.is_some() {
+            markdown.push(' ');
+        }
+        if let Some(code) = diagnostic.code.as_ref() {
+            if let Some(description) = diagnostic.code_description.as_ref() {
+                markdown.push('[');
+                markdown.push_str(&Markdown::escape(&code.to_string()));
+                markdown.push_str("](");
+                markdown.push_str(&Markdown::escape(description.as_ref()));
+                markdown.push(')');
+            } else {
+                markdown.push_str(&Markdown::escape(&code.to_string()));
+            }
+        }
+        if diagnostic.source.is_some() || diagnostic.code.is_some() {
+            markdown.push(')');
+        }
 
         for (ix, entry) in &distant {
             markdown.push_str("\n- hint: [");
             markdown.push_str(&Markdown::escape(&entry.diagnostic.message));
-            markdown.push_str(&format!("](file://#diagnostic-{group_id}-{ix})\n",))
+            markdown.push_str(&format!(
+                "](file://#diagnostic-{buffer_id}-{group_id}-{ix})\n",
+            ))
         }
 
         let mut results = vec![DiagnosticBlock {
             initial_range: primary.range,
             severity: primary.diagnostic.severity,
-            buffer_id,
             diagnostics_editor: diagnostics_editor.clone(),
             markdown: cx.new(|cx| Markdown::new(markdown.into(), None, None, cx)),
         }];
@@ -91,7 +112,6 @@ impl DiagnosticRenderer {
             results.push(DiagnosticBlock {
                 initial_range: entry.range,
                 severity: entry.diagnostic.severity,
-                buffer_id,
                 diagnostics_editor: diagnostics_editor.clone(),
                 markdown: cx.new(|cx| Markdown::new(markdown.into(), None, None, cx)),
             });
@@ -105,15 +125,12 @@ impl DiagnosticRenderer {
             };
             let mut markdown = Markdown::escape(&markdown).to_string();
             markdown.push_str(&format!(
-                " ([back](file://#diagnostic-{group_id}-{primary_ix}))"
+                " ([back](file://#diagnostic-{buffer_id}-{group_id}-{primary_ix}))"
             ));
-            // problem: group-id changes...
-            //  - only an issue in diagnostics because caching
 
             results.push(DiagnosticBlock {
                 initial_range: entry.range,
                 severity: entry.diagnostic.severity,
-                buffer_id,
                 diagnostics_editor: diagnostics_editor.clone(),
                 markdown: cx.new(|cx| Markdown::new(markdown.into(), None, None, cx)),
             });
@@ -132,7 +149,7 @@ impl editor::DiagnosticRenderer for DiagnosticRenderer {
         editor: WeakEntity<Editor>,
         cx: &mut App,
     ) -> Vec<BlockProperties<Anchor>> {
-        let blocks = Self::diagnostic_blocks_for_group(diagnostic_group, buffer_id, None, cx);
+        let blocks = Self::diagnostic_blocks_for_group(diagnostic_group, buffer_id, None, true, cx);
         blocks
             .into_iter()
             .map(|block| {
@@ -151,13 +168,40 @@ impl editor::DiagnosticRenderer for DiagnosticRenderer {
             })
             .collect()
     }
+
+    fn render_hover(
+        &self,
+        diagnostic_group: Vec<DiagnosticEntry<Point>>,
+        range: Range<Point>,
+        buffer_id: BufferId,
+        cx: &mut App,
+    ) -> Option<Entity<Markdown>> {
+        let blocks =
+            Self::diagnostic_blocks_for_group(diagnostic_group, buffer_id, None, false, cx);
+        blocks.into_iter().find_map(|block| {
+            if block.initial_range == range {
+                Some(block.markdown)
+            } else {
+                None
+            }
+        })
+    }
+
+    fn open_link(
+        &self,
+        editor: &mut Editor,
+        link: SharedString,
+        window: &mut Window,
+        cx: &mut Context<Editor>,
+    ) {
+        DiagnosticBlock::open_link(editor, &None, link, window, cx);
+    }
 }
 
 #[derive(Clone)]
 pub(crate) struct DiagnosticBlock {
     pub(crate) initial_range: Range<Point>,
     pub(crate) severity: DiagnosticSeverity,
-    pub(crate) buffer_id: BufferId,
     pub(crate) markdown: Entity<Markdown>,
     pub(crate) diagnostics_editor: Option<WeakEntity<ProjectDiagnosticsEditor>>,
 }
@@ -181,7 +225,6 @@ impl DiagnosticBlock {
         let settings = ThemeSettings::get_global(cx);
         let editor_line_height = (settings.line_height() * settings.buffer_font_size(cx)).round();
         let line_height = editor_line_height;
-        let buffer_id = self.buffer_id;
         let diagnostics_editor = self.diagnostics_editor.clone();
 
         div()
@@ -195,14 +238,11 @@ impl DiagnosticBlock {
                 MarkdownElement::new(self.markdown.clone(), hover_markdown_style(bcx.window, cx))
                     .on_url_click({
                         move |link, window, cx| {
-                            Self::open_link(
-                                editor.clone(),
-                                &diagnostics_editor,
-                                link,
-                                window,
-                                buffer_id,
-                                cx,
-                            )
+                            editor
+                                .update(cx, |editor, cx| {
+                                    Self::open_link(editor, &diagnostics_editor, link, window, cx)
+                                })
+                                .ok();
                         }
                     }),
             )
@@ -210,79 +250,71 @@ impl DiagnosticBlock {
     }
 
     pub fn open_link(
-        editor: WeakEntity<Editor>,
+        editor: &mut Editor,
         diagnostics_editor: &Option<WeakEntity<ProjectDiagnosticsEditor>>,
         link: SharedString,
         window: &mut Window,
-        buffer_id: BufferId,
-        cx: &mut App,
+        cx: &mut Context<Editor>,
     ) {
-        editor
-            .update(cx, |editor, cx| {
-                let Some(diagnostic_link) = link.strip_prefix("file://#diagnostic-") else {
-                    editor::hover_popover::open_markdown_url(link, window, cx);
-                    return;
-                };
-                let Some((group_id, ix)) = maybe!({
-                    let (group_id, ix) = diagnostic_link.split_once('-')?;
-                    let group_id: usize = group_id.parse().ok()?;
-                    let ix: usize = ix.parse().ok()?;
-                    Some((group_id, ix))
-                }) else {
+        let Some(diagnostic_link) = link.strip_prefix("file://#diagnostic-") else {
+            editor::hover_popover::open_markdown_url(link, window, cx);
+            return;
+        };
+        let Some((buffer_id, group_id, ix)) = maybe!({
+            let mut parts = diagnostic_link.split('-');
+            let buffer_id: u64 = parts.next()?.parse().ok()?;
+            let group_id: usize = parts.next()?.parse().ok()?;
+            let ix: usize = parts.next()?.parse().ok()?;
+            Some((BufferId::new(buffer_id).ok()?, group_id, ix))
+        }) else {
+            return;
+        };
+
+        if let Some(diagnostics_editor) = diagnostics_editor {
+            if let Some(diagnostic) = diagnostics_editor
+                .update(cx, |diagnostics, _| {
+                    diagnostics
+                        .diagnostics
+                        .get(&buffer_id)
+                        .cloned()
+                        .unwrap_or_default()
+                        .into_iter()
+                        .filter(|d| d.diagnostic.group_id == group_id)
+                        .nth(ix)
+                })
+                .ok()
+                .flatten()
+            {
+                let multibuffer = editor.buffer().read(cx);
+                let Some(snapshot) = multibuffer
+                    .buffer(buffer_id)
+                    .map(|entity| entity.read(cx).snapshot())
+                else {
                     return;
                 };
 
-                if let Some(diagnostics_editor) = diagnostics_editor {
-                    if let Some(diagnostic) = diagnostics_editor
-                        .update(cx, |diagnostics, _| {
-                            diagnostics
-                                .diagnostics
-                                .get(&buffer_id)
-                                .cloned()
-                                .unwrap_or_default()
-                                .into_iter()
-                                .filter(|d| d.diagnostic.group_id == group_id)
-                                .nth(ix)
-                        })
-                        .ok()
-                        .flatten()
-                    {
-                        let multibuffer = editor.buffer().read(cx);
-                        let Some(snapshot) = multibuffer
-                            .buffer(buffer_id)
-                            .map(|entity| entity.read(cx).snapshot())
-                        else {
-                            return;
-                        };
-
-                        for (excerpt_id, range) in multibuffer.excerpts_for_buffer(buffer_id, cx) {
-                            if range.context.overlaps(&diagnostic.range, &snapshot) {
-                                Self::jump_to(
-                                    editor,
-                                    Anchor::range_in_buffer(
-                                        excerpt_id,
-                                        buffer_id,
-                                        diagnostic.range,
-                                    ),
-                                    window,
-                                    cx,
-                                );
-                                return;
-                            }
-                        }
+                for (excerpt_id, range) in multibuffer.excerpts_for_buffer(buffer_id, cx) {
+                    if range.context.overlaps(&diagnostic.range, &snapshot) {
+                        Self::jump_to(
+                            editor,
+                            Anchor::range_in_buffer(excerpt_id, buffer_id, diagnostic.range),
+                            window,
+                            cx,
+                        );
+                        return;
                     }
-                } else {
-                    if let Some(diagnostic) = editor
-                        .snapshot(window, cx)
-                        .buffer_snapshot
-                        .diagnostic_group(buffer_id, group_id)
-                        .nth(ix)
-                    {
-                        Self::jump_to(editor, diagnostic.range, window, cx)
-                    }
-                };
-            })
-            .ok();
+                }
+            }
+        } else {
+            if let Some(diagnostic) = editor
+                .snapshot(window, cx)
+                .buffer_snapshot
+                .diagnostic_group(buffer_id, group_id)
+                .nth(ix)
+            {
+                Self::jump_to(editor, diagnostic.range, window, cx)
+            }
+        };
     }
 
     fn jump_to<T: ToOffset>(

crates/diagnostics/src/diagnostics_tests.rs 🔗

@@ -1,10 +1,13 @@
 use super::*;
 use collections::{HashMap, HashSet};
 use editor::{
-    DisplayPoint, InlayId,
-    actions::{GoToDiagnostic, GoToPreviousDiagnostic, MoveToBeginning},
+    DisplayPoint, EditorSettings, InlayId,
+    actions::{GoToDiagnostic, GoToPreviousDiagnostic, Hover, MoveToBeginning},
     display_map::{DisplayRow, Inlay},
-    test::{editor_content_with_blocks, editor_test_context::EditorTestContext},
+    test::{
+        editor_content_with_blocks, editor_lsp_test_context::EditorLspTestContext,
+        editor_test_context::EditorTestContext,
+    },
 };
 use gpui::{TestAppContext, VisualTestContext};
 use indoc::indoc;
@@ -134,11 +137,13 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
                  // comment 1
                  // comment 2
                  c(y);
-             § use of moved value value used here after move
+             § use of moved value
+             § value used here after move
              § hint: move occurs because `y` has type `Vec<char>`, which does not
              § implement the `Copy` trait
                  d(x);
-             § use of moved value value used here after move
+             § use of moved value
+             § value used here after move
              § hint: move occurs because `x` has type `Vec<char>`, which does not
              § implement the `Copy` trait
              § hint: value moved here
@@ -168,7 +173,7 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
                             lsp::Position::new(0, 15),
                         ),
                         severity: Some(lsp::DiagnosticSeverity::ERROR),
-                        message: "mismatched types\nexpected `usize`, found `char`".to_string(),
+                        message: "mismatched types expected `usize`, found `char`".to_string(),
                         ..Default::default()
                     }],
                     version: None,
@@ -206,11 +211,13 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
                  // comment 1
                  // comment 2
                  c(y);
-             § use of moved value value used here after move
+             § use of moved value
+             § value used here after move
              § hint: move occurs because `y` has type `Vec<char>`, which does not
              § implement the `Copy` trait
                  d(x);
-             § use of moved value value used here after move
+             § use of moved value
+             § value used here after move
              § hint: move occurs because `x` has type `Vec<char>`, which does not
              § implement the `Copy` trait
              § hint: value moved here
@@ -241,7 +248,7 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
                                 lsp::Position::new(0, 15),
                             ),
                             severity: Some(lsp::DiagnosticSeverity::ERROR),
-                            message: "mismatched types\nexpected `usize`, found `char`".to_string(),
+                            message: "mismatched types expected `usize`, found `char`".to_string(),
                             ..Default::default()
                         },
                         lsp::Diagnostic {
@@ -289,11 +296,13 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
                  // comment 1
                  // comment 2
                  c(y);
-             § use of moved value value used here after move
+             § use of moved value
+             § value used here after move
              § hint: move occurs because `y` has type `Vec<char>`, which does not
              § implement the `Copy` trait
                  d(x);
-             § use of moved value value used here after move
+             § use of moved value
+             § value used here after move
              § hint: move occurs because `x` has type `Vec<char>`, which does not
              § implement the `Copy` trait
              § hint: value moved here
@@ -1192,8 +1201,219 @@ async fn cycle_through_same_place_diagnostics(cx: &mut TestAppContext) {
     "});
 }
 
+#[gpui::test]
+async fn test_diagnostics_with_links(cx: &mut TestAppContext) {
+    init_test(cx);
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    cx.set_state(indoc! {"
+        fn func(abˇc def: i32) -> u32 {
+        }
+    "});
+    let lsp_store =
+        cx.update_editor(|editor, _, cx| editor.project.as_ref().unwrap().read(cx).lsp_store());
+
+    cx.update(|_, cx| {
+        lsp_store.update(cx, |lsp_store, cx| {
+            lsp_store.update_diagnostics(
+                LanguageServerId(0),
+                lsp::PublishDiagnosticsParams {
+                    uri: lsp::Url::from_file_path(path!("/root/file")).unwrap(),
+                    version: None,
+                    diagnostics: vec![lsp::Diagnostic {
+                        range: lsp::Range::new(lsp::Position::new(0, 8), lsp::Position::new(0, 12)),
+                        severity: Some(lsp::DiagnosticSeverity::ERROR),
+                        message: "we've had problems with <https://link.one>, and <https://link.two> is broken".to_string(),
+                        ..Default::default()
+                    }],
+                },
+                &[],
+                cx,
+            )
+        })
+    }).unwrap();
+    cx.run_until_parked();
+    cx.update_editor(|editor, window, cx| {
+        editor::hover_popover::hover(editor, &Default::default(), window, cx)
+    });
+    cx.run_until_parked();
+    cx.update_editor(|editor, _, _| assert!(editor.hover_state.diagnostic_popover.is_some()))
+}
+
+#[gpui::test]
+async fn test_hover_diagnostic_and_info_popovers(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let mut cx = EditorLspTestContext::new_rust(
+        lsp::ServerCapabilities {
+            hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
+            ..Default::default()
+        },
+        cx,
+    )
+    .await;
+
+    // Hover with just diagnostic, pops DiagnosticPopover immediately and then
+    // info popover once request completes
+    cx.set_state(indoc! {"
+        fn teˇst() { println!(); }
+    "});
+    // Send diagnostic to client
+    let range = cx.lsp_range(indoc! {"
+        fn «test»() { println!(); }
+    "});
+    let lsp_store =
+        cx.update_editor(|editor, _, cx| editor.project.as_ref().unwrap().read(cx).lsp_store());
+    cx.update(|_, cx| {
+        lsp_store.update(cx, |lsp_store, cx| {
+            lsp_store.update_diagnostics(
+                LanguageServerId(0),
+                lsp::PublishDiagnosticsParams {
+                    uri: lsp::Url::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+                    version: None,
+                    diagnostics: vec![lsp::Diagnostic {
+                        range,
+                        severity: Some(lsp::DiagnosticSeverity::ERROR),
+                        message: "A test diagnostic message.".to_string(),
+                        ..Default::default()
+                    }],
+                },
+                &[],
+                cx,
+            )
+        })
+    })
+    .unwrap();
+    cx.run_until_parked();
+
+    // Hover pops diagnostic immediately
+    cx.update_editor(|editor, window, cx| editor::hover_popover::hover(editor, &Hover, window, cx));
+    cx.background_executor.run_until_parked();
+
+    cx.editor(|Editor { hover_state, .. }, _, _| {
+        assert!(hover_state.diagnostic_popover.is_some());
+        assert!(hover_state.info_popovers.is_empty());
+    });
+
+    // Info Popover shows after request responded to
+    let range = cx.lsp_range(indoc! {"
+            fn «test»() { println!(); }
+        "});
+    cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
+        Ok(Some(lsp::Hover {
+            contents: lsp::HoverContents::Markup(lsp::MarkupContent {
+                kind: lsp::MarkupKind::Markdown,
+                value: "some new docs".to_string(),
+            }),
+            range: Some(range),
+        }))
+    });
+    let delay = cx.update(|_, cx| EditorSettings::get_global(cx).hover_popover_delay + 1);
+    cx.background_executor
+        .advance_clock(Duration::from_millis(delay));
+
+    cx.background_executor.run_until_parked();
+    cx.editor(|Editor { hover_state, .. }, _, _| {
+        hover_state.diagnostic_popover.is_some() && hover_state.info_task.is_some()
+    });
+}
+#[gpui::test]
+async fn test_diagnostics_with_code(cx: &mut TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/root"),
+        json!({
+            "main.js": "
+                function test() {
+                    const x = 10;
+                    const y = 20;
+                    return 1;
+                }
+                test();
+            "
+            .unindent(),
+        }),
+    )
+    .await;
+
+    let language_server_id = LanguageServerId(0);
+    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+    let lsp_store = project.read_with(cx, |project, _| project.lsp_store());
+    let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+    let cx = &mut VisualTestContext::from_window(*window, cx);
+    let workspace = window.root(cx).unwrap();
+    let uri = lsp::Url::from_file_path(path!("/root/main.js")).unwrap();
+
+    // Create diagnostics with code fields
+    lsp_store.update(cx, |lsp_store, cx| {
+        lsp_store
+            .update_diagnostics(
+                language_server_id,
+                lsp::PublishDiagnosticsParams {
+                    uri: uri.clone(),
+                    diagnostics: vec![
+                        lsp::Diagnostic {
+                            range: lsp::Range::new(
+                                lsp::Position::new(1, 4),
+                                lsp::Position::new(1, 14),
+                            ),
+                            severity: Some(lsp::DiagnosticSeverity::WARNING),
+                            code: Some(lsp::NumberOrString::String("no-unused-vars".to_string())),
+                            source: Some("eslint".to_string()),
+                            message: "'x' is assigned a value but never used".to_string(),
+                            ..Default::default()
+                        },
+                        lsp::Diagnostic {
+                            range: lsp::Range::new(
+                                lsp::Position::new(2, 4),
+                                lsp::Position::new(2, 14),
+                            ),
+                            severity: Some(lsp::DiagnosticSeverity::WARNING),
+                            code: Some(lsp::NumberOrString::String("no-unused-vars".to_string())),
+                            source: Some("eslint".to_string()),
+                            message: "'y' is assigned a value but never used".to_string(),
+                            ..Default::default()
+                        },
+                    ],
+                    version: None,
+                },
+                &[],
+                cx,
+            )
+            .unwrap();
+    });
+
+    // Open the project diagnostics view
+    let diagnostics = window.build_entity(cx, |window, cx| {
+        ProjectDiagnosticsEditor::new(true, project.clone(), workspace.downgrade(), window, cx)
+    });
+    let editor = diagnostics.update(cx, |diagnostics, _| diagnostics.editor.clone());
+
+    diagnostics
+        .next_notification(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10), cx)
+        .await;
+
+    // Verify that the diagnostic codes are displayed correctly
+    pretty_assertions::assert_eq!(
+        editor_content_with_blocks(&editor, cx),
+        indoc::indoc! {
+            "§ main.js
+             § -----
+             function test() {
+                 const x = 10; § 'x' is assigned a value but never used (eslint no-unused-vars)
+                 const y = 20; § 'y' is assigned a value but never used (eslint no-unused-vars)
+                 return 1;
+             }"
+        }
+    );
+}
+
 fn init_test(cx: &mut TestAppContext) {
     cx.update(|cx| {
+        env_logger::try_init().ok();
         let settings = SettingsStore::test(cx);
         cx.set_global(settings);
         theme::init(theme::LoadThemes::JustBase, cx);

crates/editor/src/editor.rs 🔗

@@ -394,10 +394,32 @@ pub trait DiagnosticRenderer {
         editor: WeakEntity<Editor>,
         cx: &mut App,
     ) -> Vec<BlockProperties<Anchor>>;
+
+    fn render_hover(
+        &self,
+        diagnostic_group: Vec<DiagnosticEntry<Point>>,
+        range: Range<Point>,
+        buffer_id: BufferId,
+        cx: &mut App,
+    ) -> Option<Entity<markdown::Markdown>>;
+
+    fn open_link(
+        &self,
+        editor: &mut Editor,
+        link: SharedString,
+        window: &mut Window,
+        cx: &mut Context<Editor>,
+    );
 }
 
 pub(crate) struct GlobalDiagnosticRenderer(pub Arc<dyn DiagnosticRenderer>);
 
+impl GlobalDiagnosticRenderer {
+    fn global(cx: &App) -> Option<Arc<dyn DiagnosticRenderer>> {
+        cx.try_global::<Self>().map(|g| g.0.clone())
+    }
+}
+
 impl gpui::Global for GlobalDiagnosticRenderer {}
 pub fn set_diagnostic_renderer(renderer: impl DiagnosticRenderer + 'static, cx: &mut App) {
     cx.set_global(GlobalDiagnosticRenderer(Arc::new(renderer)));
@@ -867,7 +889,7 @@ pub struct Editor {
     read_only: bool,
     leader_peer_id: Option<PeerId>,
     remote_id: Option<ViewId>,
-    hover_state: HoverState,
+    pub hover_state: HoverState,
     pending_mouse_down: Option<Rc<RefCell<Option<MouseDownEvent>>>>,
     gutter_hovered: bool,
     hovered_link_state: Option<HoveredLinkState>,
@@ -14788,25 +14810,17 @@ impl Editor {
         }
         self.dismiss_diagnostics(cx);
         let snapshot = self.snapshot(window, cx);
-        let Some(diagnostic_renderer) = cx
-            .try_global::<GlobalDiagnosticRenderer>()
-            .map(|g| g.0.clone())
-        else {
+        let buffer = self.buffer.read(cx).snapshot(cx);
+        let Some(renderer) = GlobalDiagnosticRenderer::global(cx) else {
             return;
         };
-        let buffer = self.buffer.read(cx).snapshot(cx);
 
         let diagnostic_group = buffer
             .diagnostic_group(buffer_id, diagnostic.diagnostic.group_id)
             .collect::<Vec<_>>();
 
-        let blocks = diagnostic_renderer.render_group(
-            diagnostic_group,
-            buffer_id,
-            snapshot,
-            cx.weak_entity(),
-            cx,
-        );
+        let blocks =
+            renderer.render_group(diagnostic_group, buffer_id, snapshot, cx.weak_entity(), cx);
 
         let blocks = self.display_map.update(cx, |display_map, cx| {
             display_map.insert_blocks(blocks, cx).into_iter().collect()

crates/editor/src/editor_tests.rs 🔗

@@ -12855,46 +12855,6 @@ async fn go_to_prev_overlapping_diagnostic(executor: BackgroundExecutor, cx: &mu
     "});
 }
 
-#[gpui::test]
-async fn test_diagnostics_with_links(cx: &mut TestAppContext) {
-    init_test(cx, |_| {});
-
-    let mut cx = EditorTestContext::new(cx).await;
-
-    cx.set_state(indoc! {"
-        fn func(abˇc def: i32) -> u32 {
-        }
-    "});
-    let lsp_store =
-        cx.update_editor(|editor, _, cx| editor.project.as_ref().unwrap().read(cx).lsp_store());
-
-    cx.update(|_, cx| {
-        lsp_store.update(cx, |lsp_store, cx| {
-            lsp_store.update_diagnostics(
-                LanguageServerId(0),
-                lsp::PublishDiagnosticsParams {
-                    uri: lsp::Url::from_file_path(path!("/root/file")).unwrap(),
-                    version: None,
-                    diagnostics: vec![lsp::Diagnostic {
-                        range: lsp::Range::new(lsp::Position::new(0, 8), lsp::Position::new(0, 12)),
-                        severity: Some(lsp::DiagnosticSeverity::ERROR),
-                        message: "we've had problems with <https://link.one>, and <https://link.two> is broken".to_string(),
-                        ..Default::default()
-                    }],
-                },
-                &[],
-                cx,
-            )
-        })
-    }).unwrap();
-    cx.run_until_parked();
-    cx.update_editor(|editor, window, cx| {
-        hover_popover::hover(editor, &Default::default(), window, cx)
-    });
-    cx.run_until_parked();
-    cx.update_editor(|editor, _, _| assert!(editor.hover_state.diagnostic_popover.is_some()))
-}
-
 #[gpui::test]
 async fn test_go_to_hunk(executor: BackgroundExecutor, cx: &mut TestAppContext) {
     init_test(cx, |_| {});

crates/editor/src/hover_links.rs 🔗

@@ -71,7 +71,7 @@ pub enum HoverLink {
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub(crate) struct InlayHighlight {
+pub struct InlayHighlight {
     pub inlay: InlayId,
     pub inlay_position: Anchor,
     pub range: Range<usize>,

crates/editor/src/hover_popover.rs 🔗

@@ -1,6 +1,6 @@
 use crate::{
     ActiveDiagnostic, Anchor, AnchorRangeExt, DisplayPoint, DisplayRow, Editor, EditorSettings,
-    EditorSnapshot, Hover,
+    EditorSnapshot, GlobalDiagnosticRenderer, Hover,
     display_map::{InlayOffset, ToDisplayPoint, invisibles::is_invisible},
     hover_links::{InlayHighlight, RangeInEditor},
     scroll::{Autoscroll, ScrollAmount},
@@ -15,7 +15,7 @@ use itertools::Itertools;
 use language::{DiagnosticEntry, Language, LanguageRegistry};
 use lsp::DiagnosticSeverity;
 use markdown::{Markdown, MarkdownElement, MarkdownStyle};
-use multi_buffer::{MultiOrSingleBufferOffsetRange, ToOffset};
+use multi_buffer::{MultiOrSingleBufferOffsetRange, ToOffset, ToPoint};
 use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart};
 use settings::Settings;
 use std::{borrow::Cow, cell::RefCell};
@@ -81,7 +81,7 @@ pub fn show_keyboard_hover(
         .as_ref()
         .and_then(|d| {
             if *d.keyboard_grace.borrow() {
-                d.anchor
+                Some(d.anchor)
             } else {
                 None
             }
@@ -283,6 +283,7 @@ fn show_hover(
         None
     };
 
+    let renderer = GlobalDiagnosticRenderer::global(cx);
     let task = cx.spawn_in(window, async move |this, cx| {
         async move {
             // If we need to delay, delay a set amount initially before making the lsp request
@@ -313,28 +314,35 @@ fn show_hover(
             } else {
                 snapshot
                     .buffer_snapshot
-                    .diagnostics_in_range::<usize>(offset..offset)
-                    .filter(|diagnostic| Some(diagnostic.diagnostic.group_id) != active_group_id)
+                    .diagnostics_with_buffer_ids_in_range::<usize>(offset..offset)
+                    .filter(|(_, diagnostic)| {
+                        Some(diagnostic.diagnostic.group_id) != active_group_id
+                    })
                     // Find the entry with the most specific range
-                    .min_by_key(|entry| entry.range.len())
+                    .min_by_key(|(_, entry)| entry.range.len())
             };
 
-            let diagnostic_popover = if let Some(local_diagnostic) = local_diagnostic {
-                let text = match local_diagnostic.diagnostic.source {
-                    Some(ref source) => {
-                        format!("{source}: {}", local_diagnostic.diagnostic.message)
-                    }
-                    None => local_diagnostic.diagnostic.message.clone(),
-                };
-                let local_diagnostic = DiagnosticEntry {
-                    diagnostic: local_diagnostic.diagnostic,
-                    range: snapshot
-                        .buffer_snapshot
-                        .anchor_before(local_diagnostic.range.start)
-                        ..snapshot
-                            .buffer_snapshot
-                            .anchor_after(local_diagnostic.range.end),
-                };
+            let diagnostic_popover = if let Some((buffer_id, local_diagnostic)) = local_diagnostic {
+                let group = snapshot
+                    .buffer_snapshot
+                    .diagnostic_group(buffer_id, local_diagnostic.diagnostic.group_id)
+                    .collect::<Vec<_>>();
+                let point_range = local_diagnostic
+                    .range
+                    .start
+                    .to_point(&snapshot.buffer_snapshot)
+                    ..local_diagnostic
+                        .range
+                        .end
+                        .to_point(&snapshot.buffer_snapshot);
+                let markdown = cx.update(|_, cx| {
+                    renderer
+                        .as_ref()
+                        .and_then(|renderer| {
+                            renderer.render_hover(group, point_range, buffer_id, cx)
+                        })
+                        .ok_or_else(|| anyhow::anyhow!("no rendered diagnostic"))
+                })??;
 
                 let (background_color, border_color) = cx.update(|_, cx| {
                     let status_colors = cx.theme().status();
@@ -359,28 +367,26 @@ fn show_hover(
                     }
                 })?;
 
-                let parsed_content = cx
-                    .new(|cx| Markdown::new_text(SharedString::new(text), cx))
-                    .ok();
+                let subscription =
+                    this.update(cx, |_, cx| cx.observe(&markdown, |_, _, cx| cx.notify()))?;
 
-                let subscription = this
-                    .update(cx, |_, cx| {
-                        if let Some(parsed_content) = &parsed_content {
-                            Some(cx.observe(parsed_content, |_, _, cx| cx.notify()))
-                        } else {
-                            None
-                        }
-                    })
-                    .ok()
-                    .flatten();
+                let local_diagnostic = DiagnosticEntry {
+                    diagnostic: local_diagnostic.diagnostic,
+                    range: snapshot
+                        .buffer_snapshot
+                        .anchor_before(local_diagnostic.range.start)
+                        ..snapshot
+                            .buffer_snapshot
+                            .anchor_after(local_diagnostic.range.end),
+                };
 
                 Some(DiagnosticPopover {
                     local_diagnostic,
-                    parsed_content,
+                    markdown,
                     border_color,
                     background_color,
                     keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
-                    anchor: Some(anchor),
+                    anchor,
                     _subscription: subscription,
                 })
             } else {
@@ -719,7 +725,7 @@ pub fn open_markdown_url(link: SharedString, window: &mut Window, cx: &mut App)
 
 #[derive(Default)]
 pub struct HoverState {
-    pub(crate) info_popovers: Vec<InfoPopover>,
+    pub info_popovers: Vec<InfoPopover>,
     pub diagnostic_popover: Option<DiagnosticPopover>,
     pub triggered_from: Option<Anchor>,
     pub info_task: Option<Task<Option<()>>>,
@@ -789,23 +795,25 @@ impl HoverState {
             }
         }
         if let Some(diagnostic_popover) = &self.diagnostic_popover {
-            if let Some(markdown_view) = &diagnostic_popover.parsed_content {
-                if markdown_view.focus_handle(cx).is_focused(window) {
-                    hover_popover_is_focused = true;
-                }
+            if diagnostic_popover
+                .markdown
+                .focus_handle(cx)
+                .is_focused(window)
+            {
+                hover_popover_is_focused = true;
             }
         }
         hover_popover_is_focused
     }
 }
 
-pub(crate) struct InfoPopover {
-    pub(crate) symbol_range: RangeInEditor,
-    pub(crate) parsed_content: Option<Entity<Markdown>>,
-    pub(crate) scroll_handle: ScrollHandle,
-    pub(crate) scrollbar_state: ScrollbarState,
-    pub(crate) keyboard_grace: Rc<RefCell<bool>>,
-    pub(crate) anchor: Option<Anchor>,
+pub struct InfoPopover {
+    pub symbol_range: RangeInEditor,
+    pub parsed_content: Option<Entity<Markdown>>,
+    pub scroll_handle: ScrollHandle,
+    pub scrollbar_state: ScrollbarState,
+    pub keyboard_grace: Rc<RefCell<bool>>,
+    pub anchor: Option<Anchor>,
     _subscription: Option<Subscription>,
 }
 
@@ -897,12 +905,12 @@ impl InfoPopover {
 
 pub struct DiagnosticPopover {
     pub(crate) local_diagnostic: DiagnosticEntry<Anchor>,
-    parsed_content: Option<Entity<Markdown>>,
+    markdown: Entity<Markdown>,
     border_color: Hsla,
     background_color: Hsla,
     pub keyboard_grace: Rc<RefCell<bool>>,
-    pub anchor: Option<Anchor>,
-    _subscription: Option<Subscription>,
+    pub anchor: Anchor,
+    _subscription: Subscription,
 }
 
 impl DiagnosticPopover {
@@ -913,6 +921,7 @@ impl DiagnosticPopover {
         cx: &mut Context<Editor>,
     ) -> AnyElement {
         let keyboard_grace = Rc::clone(&self.keyboard_grace);
+        let this = cx.entity().downgrade();
         div()
             .id("diagnostic")
             .block()
@@ -935,51 +944,29 @@ impl DiagnosticPopover {
                 *keyboard_grace = false;
                 cx.stop_propagation();
             })
-            .when_some(self.parsed_content.clone(), |this, markdown| {
-                this.child(
-                    div()
-                        .py_1()
-                        .px_2()
-                        .child(
-                            MarkdownElement::new(markdown, {
-                                let settings = ThemeSettings::get_global(cx);
-                                let mut base_text_style = window.text_style();
-                                base_text_style.refine(&TextStyleRefinement {
-                                    font_family: Some(settings.ui_font.family.clone()),
-                                    font_fallbacks: settings.ui_font.fallbacks.clone(),
-                                    font_size: Some(settings.ui_font_size(cx).into()),
-                                    color: Some(cx.theme().colors().editor_foreground),
-                                    background_color: Some(gpui::transparent_black()),
-                                    ..Default::default()
-                                });
-                                MarkdownStyle {
-                                    base_text_style,
-                                    selection_background_color: {
-                                        cx.theme().players().local().selection
-                                    },
-                                    link: TextStyleRefinement {
-                                        underline: Some(gpui::UnderlineStyle {
-                                            thickness: px(1.),
-                                            color: Some(cx.theme().colors().editor_foreground),
-                                            wavy: false,
-                                        }),
-                                        ..Default::default()
-                                    },
-                                    ..Default::default()
-                                }
-                            })
-                            .code_block_renderer(markdown::CodeBlockRenderer::Default {
-                                copy_button: false,
-                                border: false,
-                            })
-                            .on_url_click(open_markdown_url),
+            .child(
+                div()
+                    .py_1()
+                    .px_2()
+                    .child(
+                        MarkdownElement::new(
+                            self.markdown.clone(),
+                            hover_markdown_style(window, cx),
                         )
-                        .bg(self.background_color)
-                        .border_1()
-                        .border_color(self.border_color)
-                        .rounded_lg(),
-                )
-            })
+                        .on_url_click(move |link, window, cx| {
+                            if let Some(renderer) = GlobalDiagnosticRenderer::global(cx) {
+                                this.update(cx, |this, cx| {
+                                    renderer.as_ref().open_link(this, link, window, cx);
+                                })
+                                .ok();
+                            }
+                        }),
+                    )
+                    .bg(self.background_color)
+                    .border_1()
+                    .border_color(self.border_color)
+                    .rounded_lg(),
+            )
             .into_any_element()
     }
 }
@@ -998,8 +985,7 @@ mod tests {
     use collections::BTreeSet;
     use gpui::App;
     use indoc::indoc;
-    use language::{Diagnostic, DiagnosticSet, language_settings::InlayHintSettings};
-    use lsp::LanguageServerId;
+    use language::language_settings::InlayHintSettings;
     use markdown::parser::MarkdownEvent;
     use smol::stream::StreamExt;
     use std::sync::atomic;
@@ -1484,76 +1470,6 @@ mod tests {
         });
     }
 
-    #[gpui::test]
-    async fn test_hover_diagnostic_and_info_popovers(cx: &mut gpui::TestAppContext) {
-        init_test(cx, |_| {});
-
-        let mut cx = EditorLspTestContext::new_rust(
-            lsp::ServerCapabilities {
-                hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
-                ..Default::default()
-            },
-            cx,
-        )
-        .await;
-
-        // Hover with just diagnostic, pops DiagnosticPopover immediately and then
-        // info popover once request completes
-        cx.set_state(indoc! {"
-            fn teˇst() { println!(); }
-        "});
-
-        // Send diagnostic to client
-        let range = cx.text_anchor_range(indoc! {"
-            fn «test»() { println!(); }
-        "});
-        cx.update_buffer(|buffer, cx| {
-            let snapshot = buffer.text_snapshot();
-            let set = DiagnosticSet::from_sorted_entries(
-                vec![DiagnosticEntry {
-                    range,
-                    diagnostic: Diagnostic {
-                        message: "A test diagnostic message.".to_string(),
-                        ..Default::default()
-                    },
-                }],
-                &snapshot,
-            );
-            buffer.update_diagnostics(LanguageServerId(0), set, cx);
-        });
-
-        // Hover pops diagnostic immediately
-        cx.update_editor(|editor, window, cx| hover(editor, &Hover, window, cx));
-        cx.background_executor.run_until_parked();
-
-        cx.editor(|Editor { hover_state, .. }, _, _| {
-            assert!(
-                hover_state.diagnostic_popover.is_some() && hover_state.info_popovers.is_empty()
-            )
-        });
-
-        // Info Popover shows after request responded to
-        let range = cx.lsp_range(indoc! {"
-            fn «test»() { println!(); }
-        "});
-        cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
-            Ok(Some(lsp::Hover {
-                contents: lsp::HoverContents::Markup(lsp::MarkupContent {
-                    kind: lsp::MarkupKind::Markdown,
-                    value: "some new docs".to_string(),
-                }),
-                range: Some(range),
-            }))
-        });
-        cx.background_executor
-            .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
-
-        cx.background_executor.run_until_parked();
-        cx.editor(|Editor { hover_state, .. }, _, _| {
-            hover_state.diagnostic_popover.is_some() && hover_state.info_task.is_some()
-        });
-    }
-
     #[gpui::test]
     // https://github.com/zed-industries/zed/issues/15498
     async fn test_info_hover_with_hrs(cx: &mut gpui::TestAppContext) {

crates/language/src/buffer.rs 🔗

@@ -208,6 +208,7 @@ pub struct Diagnostic {
     pub source: Option<String>,
     /// A machine-readable code that identifies this diagnostic.
     pub code: Option<NumberOrString>,
+    pub code_description: Option<lsp::Url>,
     /// Whether this diagnostic is a hint, warning, or error.
     pub severity: DiagnosticSeverity,
     /// The human-readable message associated with this diagnostic.
@@ -4612,6 +4613,7 @@ impl Default for Diagnostic {
         Self {
             source: Default::default(),
             code: None,
+            code_description: None,
             severity: DiagnosticSeverity::ERROR,
             message: Default::default(),
             group_id: 0,

crates/language/src/proto.rs 🔗

@@ -213,6 +213,11 @@ pub fn serialize_diagnostics<'a>(
             group_id: entry.diagnostic.group_id as u64,
             is_primary: entry.diagnostic.is_primary,
             code: entry.diagnostic.code.as_ref().map(|s| s.to_string()),
+            code_description: entry
+                .diagnostic
+                .code_description
+                .as_ref()
+                .map(|s| s.to_string()),
             is_disk_based: entry.diagnostic.is_disk_based,
             is_unnecessary: entry.diagnostic.is_unnecessary,
             data: entry.diagnostic.data.as_ref().map(|data| data.to_string()),
@@ -419,6 +424,9 @@ pub fn deserialize_diagnostics(
                     message: diagnostic.message,
                     group_id: diagnostic.group_id as usize,
                     code: diagnostic.code.map(lsp::NumberOrString::from_string),
+                    code_description: diagnostic
+                        .code_description
+                        .and_then(|s| lsp::Url::parse(&s).ok()),
                     is_primary: diagnostic.is_primary,
                     is_disk_based: diagnostic.is_disk_based,
                     is_unnecessary: diagnostic.is_unnecessary,

crates/markdown/src/markdown.rs 🔗

@@ -215,11 +215,16 @@ impl Markdown {
     }
 
     pub fn escape(s: &str) -> Cow<str> {
-        let count = s.bytes().filter(|c| c.is_ascii_punctuation()).count();
+        let count = s
+            .bytes()
+            .filter(|c| *c == b'\n' || c.is_ascii_punctuation())
+            .count();
         if count > 0 {
             let mut output = String::with_capacity(s.len() + count);
             for c in s.chars() {
-                if c.is_ascii_punctuation() {
+                if c == '\n' {
+                    output.push('\n')
+                } else if c.is_ascii_punctuation() {
                     output.push('\\')
                 }
                 output.push(c)

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -5950,6 +5950,29 @@ impl MultiBufferSnapshot {
         .map(|(range, diagnostic, _)| DiagnosticEntry { diagnostic, range })
     }
 
+    pub fn diagnostics_with_buffer_ids_in_range<'a, T>(
+        &'a self,
+        range: Range<T>,
+    ) -> impl Iterator<Item = (BufferId, DiagnosticEntry<T>)> + 'a
+    where
+        T: 'a
+            + text::ToOffset
+            + text::FromAnchor
+            + TextDimension
+            + Ord
+            + Sub<T, Output = T>
+            + fmt::Debug,
+    {
+        self.lift_buffer_metadata(range, move |buffer, buffer_range| {
+            Some(
+                buffer
+                    .diagnostics_in_range(buffer_range.start..buffer_range.end, false)
+                    .map(|entry| (entry.range, entry.diagnostic)),
+            )
+        })
+        .map(|(range, diagnostic, b)| (b.buffer_id, DiagnosticEntry { diagnostic, range }))
+    }
+
     pub fn syntax_ancestor<T: ToOffset>(
         &self,
         range: Range<T>,

crates/project/src/lsp_store.rs 🔗

@@ -8613,6 +8613,10 @@ impl LspStore {
                     diagnostic: Diagnostic {
                         source: diagnostic.source.clone(),
                         code: diagnostic.code.clone(),
+                        code_description: diagnostic
+                            .code_description
+                            .as_ref()
+                            .map(|d| d.href.clone()),
                         severity: diagnostic.severity.unwrap_or(DiagnosticSeverity::ERROR),
                         message: diagnostic.message.trim().to_string(),
                         group_id,
@@ -8631,6 +8635,10 @@ impl LspStore {
                                 diagnostic: Diagnostic {
                                     source: diagnostic.source.clone(),
                                     code: diagnostic.code.clone(),
+                                    code_description: diagnostic
+                                        .code_description
+                                        .as_ref()
+                                        .map(|c| c.href.clone()),
                                     severity: DiagnosticSeverity::INFORMATION,
                                     message: info.message.trim().to_string(),
                                     group_id,

crates/proto/proto/buffer.proto 🔗

@@ -270,6 +270,7 @@ message Diagnostic {
         Hint = 4;
     }
     optional string data = 12;
+    optional string code_description = 13;
 }
 
 message SearchQuery {