lsp: Implement support for the textDocument/diagnostic command (#19230)

Closes [#13107](https://github.com/zed-industries/zed/issues/13107)

Enabled pull diagnostics by default, for the language servers that
declare support in the corresponding capabilities.

```
"diagnostics": {
    "lsp_pull_diagnostics_debounce_ms": null
}
```
settings can be used to disable the pulling.

Release Notes:

- Added support for the LSP `textDocument/diagnostic` command.

# Brief

This is draft PR that implements the LSP `textDocument/diagnostic`
command. The goal is to receive your feedback and establish further
steps towards fully implementing this command. I tried to re-use
existing method and structures to ensure:

1. The existing functionality works as before
2. There is no interference between the diagnostics sent by a server and
the diagnostics requested by a client.

The current implementation is done via a new LSP command
`GetDocumentDiagnostics` that is sent when a buffer is saved and when a
buffer is edited. There is a new method called `pull_diagnostic` that is
called for such events. It has debounce to ensure we don't spam a server
with commands every time the buffer is edited. Probably, we don't need
the debounce when the buffer is saved.

All in all, the goal is basically to get your feedback and ensure I am
on the right track. Thanks!


## References

1.
https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_pullDiagnostics

## In action

You can clone any Ruby repo since the `ruby-lsp` supports the pull
diagnostics only.

Steps to reproduce:

1. Clone this repo https://github.com/vitallium/stimulus-lsp-error-zed
2. Install Ruby (via `asdf` or `mise).
4. Install Ruby gems via `bundle install`
5. Install Ruby LSP with `gem install ruby-lsp`
6. Check out this PR and build Zed
7. Open any file and start editing to see diagnostics in realtime.



https://github.com/user-attachments/assets/0ef6ec41-e4fa-4539-8f2c-6be0d8be4129

---------

Co-authored-by: Kirill Bulatov <mail4score@gmail.com>
Co-authored-by: Kirill Bulatov <kirill@zed.dev>
This commit is contained in:
Vitaly Slobodin 2025-06-05 21:42:52 +02:00 committed by GitHub
parent 04cd3fcd23
commit 7aa70a4858
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1408 additions and 124 deletions

View file

@ -4,14 +4,15 @@ use crate::{
CodeAction, CompletionSource, CoreCompletion, CoreCompletionResponse, DocumentHighlight,
DocumentSymbol, Hover, HoverBlock, HoverBlockKind, InlayHint, InlayHintLabel,
InlayHintLabelPart, InlayHintLabelPartTooltip, InlayHintTooltip, Location, LocationLink,
LspAction, MarkupContent, PrepareRenameResponse, ProjectTransaction, ResolveState,
LspAction, LspPullDiagnostics, MarkupContent, PrepareRenameResponse, ProjectTransaction,
PulledDiagnostics, ResolveState,
lsp_store::{LocalLspStore, LspStore},
};
use anyhow::{Context as _, Result};
use async_trait::async_trait;
use client::proto::{self, PeerId};
use clock::Global;
use collections::HashSet;
use collections::{HashMap, HashSet};
use futures::future;
use gpui::{App, AsyncApp, Entity, Task};
use language::{
@ -23,14 +24,18 @@ use language::{
range_from_lsp, range_to_lsp,
};
use lsp::{
AdapterServerCapabilities, CodeActionKind, CodeActionOptions, CompletionContext,
CompletionListItemDefaultsEditRange, CompletionTriggerKind, DocumentHighlightKind,
LanguageServer, LanguageServerId, LinkedEditingRangeServerCapabilities, OneOf, RenameOptions,
ServerCapabilities,
AdapterServerCapabilities, CodeActionKind, CodeActionOptions, CodeDescription,
CompletionContext, CompletionListItemDefaultsEditRange, CompletionTriggerKind,
DocumentHighlightKind, LanguageServer, LanguageServerId, LinkedEditingRangeServerCapabilities,
OneOf, RenameOptions, ServerCapabilities,
};
use serde_json::Value;
use signature_help::{lsp_to_proto_signature, proto_to_lsp_signature};
use std::{cmp::Reverse, mem, ops::Range, path::Path, sync::Arc};
use std::{
cmp::Reverse, collections::hash_map, mem, ops::Range, path::Path, str::FromStr, sync::Arc,
};
use text::{BufferId, LineEnding};
use util::{ResultExt as _, debug_panic};
pub use signature_help::SignatureHelp;
@ -45,7 +50,7 @@ pub fn lsp_formatting_options(settings: &LanguageSettings) -> lsp::FormattingOpt
}
}
pub(crate) fn file_path_to_lsp_url(path: &Path) -> Result<lsp::Url> {
pub fn file_path_to_lsp_url(path: &Path) -> Result<lsp::Url> {
match lsp::Url::from_file_path(path) {
Ok(url) => Ok(url),
Err(()) => anyhow::bail!("Invalid file path provided to LSP request: {path:?}"),
@ -254,6 +259,9 @@ pub(crate) struct LinkedEditingRange {
pub position: Anchor,
}
#[derive(Clone, Debug)]
pub(crate) struct GetDocumentDiagnostics {}
#[async_trait(?Send)]
impl LspCommand for PrepareRename {
type Response = PrepareRenameResponse;
@ -3656,3 +3664,627 @@ impl LspCommand for LinkedEditingRange {
BufferId::new(message.buffer_id)
}
}
impl GetDocumentDiagnostics {
fn deserialize_lsp_diagnostic(diagnostic: proto::LspDiagnostic) -> Result<lsp::Diagnostic> {
let start = diagnostic.start.context("invalid start range")?;
let end = diagnostic.end.context("invalid end range")?;
let range = Range::<PointUtf16> {
start: PointUtf16 {
row: start.row,
column: start.column,
},
end: PointUtf16 {
row: end.row,
column: end.column,
},
};
let data = diagnostic.data.and_then(|data| Value::from_str(&data).ok());
let code = diagnostic.code.map(lsp::NumberOrString::String);
let related_information = diagnostic
.related_information
.into_iter()
.map(|info| {
let start = info.location_range_start.unwrap();
let end = info.location_range_end.unwrap();
lsp::DiagnosticRelatedInformation {
location: lsp::Location {
range: lsp::Range {
start: point_to_lsp(PointUtf16::new(start.row, start.column)),
end: point_to_lsp(PointUtf16::new(end.row, end.column)),
},
uri: lsp::Url::parse(&info.location_url.unwrap()).unwrap(),
},
message: info.message.clone(),
}
})
.collect::<Vec<_>>();
let tags = diagnostic
.tags
.into_iter()
.filter_map(|tag| match proto::LspDiagnosticTag::from_i32(tag) {
Some(proto::LspDiagnosticTag::Unnecessary) => Some(lsp::DiagnosticTag::UNNECESSARY),
Some(proto::LspDiagnosticTag::Deprecated) => Some(lsp::DiagnosticTag::DEPRECATED),
_ => None,
})
.collect::<Vec<_>>();
Ok(lsp::Diagnostic {
range: language::range_to_lsp(range)?,
severity: match proto::lsp_diagnostic::Severity::from_i32(diagnostic.severity).unwrap()
{
proto::lsp_diagnostic::Severity::Error => Some(lsp::DiagnosticSeverity::ERROR),
proto::lsp_diagnostic::Severity::Warning => Some(lsp::DiagnosticSeverity::WARNING),
proto::lsp_diagnostic::Severity::Information => {
Some(lsp::DiagnosticSeverity::INFORMATION)
}
proto::lsp_diagnostic::Severity::Hint => Some(lsp::DiagnosticSeverity::HINT),
_ => None,
},
code,
code_description: match diagnostic.code_description {
Some(code_description) => Some(CodeDescription {
href: lsp::Url::parse(&code_description).unwrap(),
}),
None => None,
},
related_information: Some(related_information),
tags: Some(tags),
source: diagnostic.source.clone(),
message: diagnostic.message,
data,
})
}
fn serialize_lsp_diagnostic(diagnostic: lsp::Diagnostic) -> Result<proto::LspDiagnostic> {
let range = language::range_from_lsp(diagnostic.range);
let related_information = diagnostic
.related_information
.unwrap_or_default()
.into_iter()
.map(|related_information| {
let location_range_start =
point_from_lsp(related_information.location.range.start).0;
let location_range_end = point_from_lsp(related_information.location.range.end).0;
Ok(proto::LspDiagnosticRelatedInformation {
location_url: Some(related_information.location.uri.to_string()),
location_range_start: Some(proto::PointUtf16 {
row: location_range_start.row,
column: location_range_start.column,
}),
location_range_end: Some(proto::PointUtf16 {
row: location_range_end.row,
column: location_range_end.column,
}),
message: related_information.message,
})
})
.collect::<Result<Vec<_>>>()?;
let tags = diagnostic
.tags
.unwrap_or_default()
.into_iter()
.map(|tag| match tag {
lsp::DiagnosticTag::UNNECESSARY => proto::LspDiagnosticTag::Unnecessary,
lsp::DiagnosticTag::DEPRECATED => proto::LspDiagnosticTag::Deprecated,
_ => proto::LspDiagnosticTag::None,
} as i32)
.collect();
Ok(proto::LspDiagnostic {
start: Some(proto::PointUtf16 {
row: range.start.0.row,
column: range.start.0.column,
}),
end: Some(proto::PointUtf16 {
row: range.end.0.row,
column: range.end.0.column,
}),
severity: match diagnostic.severity {
Some(lsp::DiagnosticSeverity::ERROR) => proto::lsp_diagnostic::Severity::Error,
Some(lsp::DiagnosticSeverity::WARNING) => proto::lsp_diagnostic::Severity::Warning,
Some(lsp::DiagnosticSeverity::INFORMATION) => {
proto::lsp_diagnostic::Severity::Information
}
Some(lsp::DiagnosticSeverity::HINT) => proto::lsp_diagnostic::Severity::Hint,
_ => proto::lsp_diagnostic::Severity::None,
} as i32,
code: diagnostic.code.as_ref().map(|code| match code {
lsp::NumberOrString::Number(code) => code.to_string(),
lsp::NumberOrString::String(code) => code.clone(),
}),
source: diagnostic.source.clone(),
related_information,
tags,
code_description: diagnostic
.code_description
.map(|desc| desc.href.to_string()),
message: diagnostic.message,
data: diagnostic.data.as_ref().map(|data| data.to_string()),
})
}
}
#[async_trait(?Send)]
impl LspCommand for GetDocumentDiagnostics {
type Response = Vec<LspPullDiagnostics>;
type LspRequest = lsp::request::DocumentDiagnosticRequest;
type ProtoRequest = proto::GetDocumentDiagnostics;
fn display_name(&self) -> &str {
"Get diagnostics"
}
fn check_capabilities(&self, server_capabilities: AdapterServerCapabilities) -> bool {
server_capabilities
.server_capabilities
.diagnostic_provider
.is_some()
}
fn to_lsp(
&self,
path: &Path,
_: &Buffer,
language_server: &Arc<LanguageServer>,
_: &App,
) -> Result<lsp::DocumentDiagnosticParams> {
let identifier = match language_server.capabilities().diagnostic_provider {
Some(lsp::DiagnosticServerCapabilities::Options(options)) => options.identifier,
Some(lsp::DiagnosticServerCapabilities::RegistrationOptions(options)) => {
options.diagnostic_options.identifier
}
None => None,
};
Ok(lsp::DocumentDiagnosticParams {
text_document: lsp::TextDocumentIdentifier {
uri: file_path_to_lsp_url(path)?,
},
identifier,
previous_result_id: None,
partial_result_params: Default::default(),
work_done_progress_params: Default::default(),
})
}
async fn response_from_lsp(
self,
message: lsp::DocumentDiagnosticReportResult,
_: Entity<LspStore>,
buffer: Entity<Buffer>,
server_id: LanguageServerId,
cx: AsyncApp,
) -> Result<Self::Response> {
let url = buffer.read_with(&cx, |buffer, cx| {
buffer
.file()
.and_then(|file| file.as_local())
.map(|file| {
let abs_path = file.abs_path(cx);
file_path_to_lsp_url(&abs_path)
})
.transpose()?
.with_context(|| format!("missing url on buffer {}", buffer.remote_id()))
})??;
let mut pulled_diagnostics = HashMap::default();
match message {
lsp::DocumentDiagnosticReportResult::Report(report) => match report {
lsp::DocumentDiagnosticReport::Full(report) => {
if let Some(related_documents) = report.related_documents {
process_related_documents(
&mut pulled_diagnostics,
server_id,
related_documents,
);
}
process_full_diagnostics_report(
&mut pulled_diagnostics,
server_id,
url,
report.full_document_diagnostic_report,
);
}
lsp::DocumentDiagnosticReport::Unchanged(report) => {
if let Some(related_documents) = report.related_documents {
process_related_documents(
&mut pulled_diagnostics,
server_id,
related_documents,
);
}
process_unchanged_diagnostics_report(
&mut pulled_diagnostics,
server_id,
url,
report.unchanged_document_diagnostic_report,
);
}
},
lsp::DocumentDiagnosticReportResult::Partial(report) => {
if let Some(related_documents) = report.related_documents {
process_related_documents(
&mut pulled_diagnostics,
server_id,
related_documents,
);
}
}
}
Ok(pulled_diagnostics.into_values().collect())
}
fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetDocumentDiagnostics {
proto::GetDocumentDiagnostics {
project_id,
buffer_id: buffer.remote_id().into(),
version: serialize_version(&buffer.version()),
}
}
async fn from_proto(
message: proto::GetDocumentDiagnostics,
_: Entity<LspStore>,
buffer: Entity<Buffer>,
mut cx: AsyncApp,
) -> Result<Self> {
buffer
.update(&mut cx, |buffer, _| {
buffer.wait_for_version(deserialize_version(&message.version))
})?
.await?;
Ok(Self {})
}
fn response_to_proto(
response: Self::Response,
_: &mut LspStore,
_: PeerId,
_: &clock::Global,
_: &mut App,
) -> proto::GetDocumentDiagnosticsResponse {
let pulled_diagnostics = response
.into_iter()
.filter_map(|diagnostics| match diagnostics {
LspPullDiagnostics::Default => None,
LspPullDiagnostics::Response {
server_id,
uri,
diagnostics,
} => {
let mut changed = false;
let (diagnostics, result_id) = match diagnostics {
PulledDiagnostics::Unchanged { result_id } => (Vec::new(), Some(result_id)),
PulledDiagnostics::Changed {
result_id,
diagnostics,
} => {
changed = true;
(diagnostics, result_id)
}
};
Some(proto::PulledDiagnostics {
changed,
result_id,
uri: uri.to_string(),
server_id: server_id.to_proto(),
diagnostics: diagnostics
.into_iter()
.filter_map(|diagnostic| {
GetDocumentDiagnostics::serialize_lsp_diagnostic(diagnostic)
.context("serializing diagnostics")
.log_err()
})
.collect(),
})
}
})
.collect();
proto::GetDocumentDiagnosticsResponse { pulled_diagnostics }
}
async fn response_from_proto(
self,
response: proto::GetDocumentDiagnosticsResponse,
_: Entity<LspStore>,
_: Entity<Buffer>,
_: AsyncApp,
) -> Result<Self::Response> {
let pulled_diagnostics = response
.pulled_diagnostics
.into_iter()
.filter_map(|diagnostics| {
Some(LspPullDiagnostics::Response {
server_id: LanguageServerId::from_proto(diagnostics.server_id),
uri: lsp::Url::from_str(diagnostics.uri.as_str()).log_err()?,
diagnostics: if diagnostics.changed {
PulledDiagnostics::Unchanged {
result_id: diagnostics.result_id?,
}
} else {
PulledDiagnostics::Changed {
result_id: diagnostics.result_id,
diagnostics: diagnostics
.diagnostics
.into_iter()
.filter_map(|diagnostic| {
GetDocumentDiagnostics::deserialize_lsp_diagnostic(diagnostic)
.context("deserializing diagnostics")
.log_err()
})
.collect(),
}
},
})
})
.collect();
Ok(pulled_diagnostics)
}
fn buffer_id_from_proto(message: &proto::GetDocumentDiagnostics) -> Result<BufferId> {
BufferId::new(message.buffer_id)
}
}
fn process_related_documents(
diagnostics: &mut HashMap<lsp::Url, LspPullDiagnostics>,
server_id: LanguageServerId,
documents: impl IntoIterator<Item = (lsp::Url, lsp::DocumentDiagnosticReportKind)>,
) {
for (url, report_kind) in documents {
match report_kind {
lsp::DocumentDiagnosticReportKind::Full(report) => {
process_full_diagnostics_report(diagnostics, server_id, url, report)
}
lsp::DocumentDiagnosticReportKind::Unchanged(report) => {
process_unchanged_diagnostics_report(diagnostics, server_id, url, report)
}
}
}
}
fn process_unchanged_diagnostics_report(
diagnostics: &mut HashMap<lsp::Url, LspPullDiagnostics>,
server_id: LanguageServerId,
uri: lsp::Url,
report: lsp::UnchangedDocumentDiagnosticReport,
) {
let result_id = report.result_id;
match diagnostics.entry(uri.clone()) {
hash_map::Entry::Occupied(mut o) => match o.get_mut() {
LspPullDiagnostics::Default => {
o.insert(LspPullDiagnostics::Response {
server_id,
uri,
diagnostics: PulledDiagnostics::Unchanged { result_id },
});
}
LspPullDiagnostics::Response {
server_id: existing_server_id,
uri: existing_uri,
diagnostics: existing_diagnostics,
} => {
if server_id != *existing_server_id || &uri != existing_uri {
debug_panic!(
"Unexpected state: file {uri} has two different sets of diagnostics reported"
);
}
match existing_diagnostics {
PulledDiagnostics::Unchanged { .. } => {
*existing_diagnostics = PulledDiagnostics::Unchanged { result_id };
}
PulledDiagnostics::Changed { .. } => {}
}
}
},
hash_map::Entry::Vacant(v) => {
v.insert(LspPullDiagnostics::Response {
server_id,
uri,
diagnostics: PulledDiagnostics::Unchanged { result_id },
});
}
}
}
fn process_full_diagnostics_report(
diagnostics: &mut HashMap<lsp::Url, LspPullDiagnostics>,
server_id: LanguageServerId,
uri: lsp::Url,
report: lsp::FullDocumentDiagnosticReport,
) {
let result_id = report.result_id;
match diagnostics.entry(uri.clone()) {
hash_map::Entry::Occupied(mut o) => match o.get_mut() {
LspPullDiagnostics::Default => {
o.insert(LspPullDiagnostics::Response {
server_id,
uri,
diagnostics: PulledDiagnostics::Changed {
result_id,
diagnostics: report.items,
},
});
}
LspPullDiagnostics::Response {
server_id: existing_server_id,
uri: existing_uri,
diagnostics: existing_diagnostics,
} => {
if server_id != *existing_server_id || &uri != existing_uri {
debug_panic!(
"Unexpected state: file {uri} has two different sets of diagnostics reported"
);
}
match existing_diagnostics {
PulledDiagnostics::Unchanged { .. } => {
*existing_diagnostics = PulledDiagnostics::Changed {
result_id,
diagnostics: report.items,
};
}
PulledDiagnostics::Changed {
result_id: existing_result_id,
diagnostics: existing_diagnostics,
} => {
if result_id.is_some() {
*existing_result_id = result_id;
}
existing_diagnostics.extend(report.items);
}
}
}
},
hash_map::Entry::Vacant(v) => {
v.insert(LspPullDiagnostics::Response {
server_id,
uri,
diagnostics: PulledDiagnostics::Changed {
result_id,
diagnostics: report.items,
},
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use lsp::{DiagnosticSeverity, DiagnosticTag};
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: "Test error message".to_string(),
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.clone())
.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,
};
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, "assigned but unused variable - a");
}
#[test]
fn test_related_information() {
let related_info = lsp::DiagnosticRelatedInformation {
location: lsp::Location {
uri: lsp::Url::parse("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: "assigned but unused variable - a".to_string(),
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_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,
};
let result = GetDocumentDiagnostics::deserialize_lsp_diagnostic(proto_diagnostic);
assert!(result.is_err());
}
}