Files
zed/crates/project/tests/integration/lsp_command.rs
T
09440c85cb lsp: Add client support for LSP 3.18 markdown diagnostic messages (#61030)
LSP 3.18 allows servers to send [`MarkupContent` diagnostic
messages](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#diagnostic),
guarded by the client capability
`textDocument.diagnostic.markupMessageSupport`.

- Advertise `markup_message_support` only while pull diagnostics are
enabled because `textDocument.diagnostic` is the pull-model capability.
  LSP 3.18 currently has no equivalent capability for push diagnostics:

https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#diagnosticClientCapabilities
  https://github.com/microsoft/language-server-protocol/issues/2246
- Handle `string | MarkupContent` messages in both push and pull
diagnostic paths: markdown-kind messages populate the existing
`Diagnostic::markdown` field (rendered in hover popovers and the
diagnostics panel), plaintext-kind markup remains plain text, and plain
string messages keep going through the adapter's
`diagnostic_message_to_markdown`.
- Preserve server-provided markup for code actions sent back to the
originating server, avoid forwarding markup diagnostics to other
servers, and render diagnostic metadata without altering the
server-provided markdown.
- Carry the markup kind over RPC via a new optional
`LspDiagnostic.markup_message_kind` proto field so remote/collab
projects round-trip `MarkupContent` messages.

Depends on the lsp-types change that turns `Diagnostic.message` into a
`DiagnosticMessage` enum (`string | MarkupContent`):
https://github.com/zed-industries/lsp-types/pull/15
Once merged, Cargo.toml can point to the upstream commit.

Note that Zed already rendered some plain-string diagnostics as Markdown
through language-specific adapter hooks, including `rust-analyzer`. This
adds protocol-level support for diagnostics explicitly sent as LSP 3.18
`MarkupContent`, while preserving the existing adapter fallbacks.

As a result, this does not introduce a new diagnostics rendering
pipeline.
Server-provided Markdown is routed through the existing
`Diagnostic::markdown` path, with small fixes to handle Markdown-only
updates and block content correctly.

## Showcase

Example from [JETLS.jl](github.com/aviatesk/JETLS.jl) (a Julia language
server):

> Before
<img width="924" height="160" alt="Screenshot 2026-07-15 at 16 05 40"
src="https://github.com/user-attachments/assets/b19fe9e3-50e3-44e4-9b4d-ac8bd438d3d6"
/>

> After
<img width="924" height="160" alt="Screenshot 2026-07-15 at 14 13 05"
src="https://github.com/user-attachments/assets/359c3382-118d-4fa6-aa91-6bd3661d3d28"
/>

---

Release Notes:

- Added support for rendering LSP 3.18 Markdown diagnostic messages from
language servers.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Kirill Bulatov <mail4score@gmail.com>
2026-08-27 12:55:42 +00:00

166 lines
5.7 KiB
Rust

use std::str::FromStr;
use lsp::{DiagnosticSeverity, DiagnosticTag};
use project::lsp_command::*;
use rpc::proto::{self};
use serde_json::json;
#[test]
fn test_serialize_lsp_diagnostic() {
let lsp_diagnostic = lsp::Diagnostic {
range: lsp::Range {
start: lsp::Position::new(0, 1),
end: lsp::Position::new(2, 3),
},
severity: Some(DiagnosticSeverity::ERROR),
code: Some(lsp::NumberOrString::String("E001".to_string())),
source: Some("test-source".to_string()),
message: lsp::DiagnosticMessage::from("Test error message"),
related_information: None,
tags: Some(vec![DiagnosticTag::DEPRECATED]),
code_description: None,
data: Some(json!({"detail": "test detail"})),
};
let proto_diagnostic = GetDocumentDiagnostics::serialize_lsp_diagnostic(lsp_diagnostic)
.expect("Failed to serialize diagnostic");
let start = proto_diagnostic.start.unwrap();
let end = proto_diagnostic.end.unwrap();
assert_eq!(start.row, 0);
assert_eq!(start.column, 1);
assert_eq!(end.row, 2);
assert_eq!(end.column, 3);
assert_eq!(
proto_diagnostic.severity,
proto::lsp_diagnostic::Severity::Error as i32
);
assert_eq!(proto_diagnostic.code, Some("E001".to_string()));
assert_eq!(proto_diagnostic.source, Some("test-source".to_string()));
assert_eq!(proto_diagnostic.message, "Test error message");
}
#[test]
fn test_deserialize_lsp_diagnostic() {
let proto_diagnostic = proto::LspDiagnostic {
start: Some(proto::PointUtf16 { row: 0, column: 1 }),
end: Some(proto::PointUtf16 { row: 2, column: 3 }),
severity: proto::lsp_diagnostic::Severity::Warning as i32,
code: Some("ERR".to_string()),
source: Some("Prism".to_string()),
message: "assigned but unused variable - a".to_string(),
related_information: vec![],
tags: vec![],
code_description: None,
data: None,
markup_message_kind: None,
};
let lsp_diagnostic = GetDocumentDiagnostics::deserialize_lsp_diagnostic(proto_diagnostic)
.expect("Failed to deserialize diagnostic");
assert_eq!(lsp_diagnostic.range.start.line, 0);
assert_eq!(lsp_diagnostic.range.start.character, 1);
assert_eq!(lsp_diagnostic.range.end.line, 2);
assert_eq!(lsp_diagnostic.range.end.character, 3);
assert_eq!(lsp_diagnostic.severity, Some(DiagnosticSeverity::WARNING));
assert_eq!(
lsp_diagnostic.code,
Some(lsp::NumberOrString::String("ERR".to_string()))
);
assert_eq!(lsp_diagnostic.source, Some("Prism".to_string()));
assert_eq!(
lsp_diagnostic.message,
lsp::DiagnosticMessage::from("assigned but unused variable - a")
);
}
#[test]
fn test_related_information() {
let related_info = lsp::DiagnosticRelatedInformation {
location: lsp::Location {
uri: lsp::Uri::from_str("file:///test.rs").unwrap(),
range: lsp::Range {
start: lsp::Position::new(1, 1),
end: lsp::Position::new(1, 5),
},
},
message: "Related info message".to_string(),
};
let lsp_diagnostic = lsp::Diagnostic {
range: lsp::Range {
start: lsp::Position::new(0, 0),
end: lsp::Position::new(0, 1),
},
severity: Some(DiagnosticSeverity::INFORMATION),
code: None,
source: Some("Prism".to_string()),
message: lsp::DiagnosticMessage::from("assigned but unused variable - a"),
related_information: Some(vec![related_info]),
tags: None,
code_description: None,
data: None,
};
let proto_diagnostic = GetDocumentDiagnostics::serialize_lsp_diagnostic(lsp_diagnostic)
.expect("Failed to serialize diagnostic");
assert_eq!(proto_diagnostic.related_information.len(), 1);
let related = &proto_diagnostic.related_information[0];
assert_eq!(related.location_url, Some("file:///test.rs".to_string()));
assert_eq!(related.message, "Related info message");
}
#[test]
fn test_serialize_lsp_diagnostic_markup_message() {
let message = lsp::DiagnosticMessage::MarkupContent(lsp::MarkupContent {
kind: lsp::MarkupKind::Markdown,
value: "unused variable `a`\n\nsee [docs](https://example.com)".to_string(),
});
let lsp_diagnostic = lsp::Diagnostic {
range: lsp::Range {
start: lsp::Position::new(0, 1),
end: lsp::Position::new(2, 3),
},
severity: Some(DiagnosticSeverity::WARNING),
message: message.clone(),
..lsp::Diagnostic::default()
};
let proto_diagnostic = GetDocumentDiagnostics::serialize_lsp_diagnostic(lsp_diagnostic)
.expect("Failed to serialize diagnostic");
assert_eq!(
proto_diagnostic.message,
"unused variable `a`\n\nsee [docs](https://example.com)"
);
assert_eq!(
proto_diagnostic.markup_message_kind,
Some(proto::MarkupKind::Markdown as i32)
);
let lsp_diagnostic = GetDocumentDiagnostics::deserialize_lsp_diagnostic(proto_diagnostic)
.expect("Failed to deserialize diagnostic");
assert_eq!(lsp_diagnostic.message, message);
}
#[test]
fn test_invalid_ranges() {
let proto_diagnostic = proto::LspDiagnostic {
start: None,
end: Some(proto::PointUtf16 { row: 2, column: 3 }),
severity: proto::lsp_diagnostic::Severity::Error as i32,
code: None,
source: None,
message: "Test message".to_string(),
related_information: vec![],
tags: vec![],
code_description: None,
data: None,
markup_message_kind: None,
};
let result = GetDocumentDiagnostics::deserialize_lsp_diagnostic(proto_diagnostic);
assert!(result.is_err());
}