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,7 +4,8 @@ pub mod rust_analyzer_ext;
use crate::{
CodeAction, Completion, CompletionResponse, CompletionSource, CoreCompletion, Hover, InlayHint,
LspAction, ProjectItem, ProjectPath, ProjectTransaction, ResolveState, Symbol, ToolchainStore,
LspAction, LspPullDiagnostics, ProjectItem, ProjectPath, ProjectTransaction, ResolveState,
Symbol, ToolchainStore,
buffer_store::{BufferStore, BufferStoreEvent},
environment::ProjectEnvironment,
lsp_command::{self, *},
@ -39,9 +40,9 @@ use http_client::HttpClient;
use itertools::Itertools as _;
use language::{
Bias, BinaryStatus, Buffer, BufferSnapshot, CachedLspAdapter, CodeLabel, Diagnostic,
DiagnosticEntry, DiagnosticSet, Diff, File as _, Language, LanguageName, LanguageRegistry,
LanguageToolchainStore, LocalFile, LspAdapter, LspAdapterDelegate, Patch, PointUtf16,
TextBufferSnapshot, ToOffset, ToPointUtf16, Transaction, Unclipped,
DiagnosticEntry, DiagnosticSet, DiagnosticSourceKind, Diff, File as _, Language, LanguageName,
LanguageRegistry, LanguageToolchainStore, LocalFile, LspAdapter, LspAdapterDelegate, Patch,
PointUtf16, TextBufferSnapshot, ToOffset, ToPointUtf16, Transaction, Unclipped,
language_settings::{
FormatOnSave, Formatter, LanguageSettings, SelectedFormatter, language_settings,
},
@ -252,6 +253,10 @@ impl LocalLspStore {
let this = self.weak.clone();
let pending_workspace_folders = pending_workspace_folders.clone();
let fs = self.fs.clone();
let pull_diagnostics = ProjectSettings::get_global(cx)
.diagnostics
.lsp_pull_diagnostics_debounce_ms
.is_some();
cx.spawn(async move |cx| {
let result = async {
let toolchains = this.update(cx, |this, cx| this.toolchain_store(cx))?;
@ -282,7 +287,8 @@ impl LocalLspStore {
}
let initialization_params = cx.update(|cx| {
let mut params = language_server.default_initialize_params(cx);
let mut params =
language_server.default_initialize_params(pull_diagnostics, cx);
params.initialization_options = initialization_options;
adapter.adapter.prepare_initialize_params(params, cx)
})??;
@ -474,8 +480,14 @@ impl LocalLspStore {
this.merge_diagnostics(
server_id,
params,
DiagnosticSourceKind::Pushed,
&adapter.disk_based_diagnostic_sources,
|diagnostic, cx| adapter.retain_old_diagnostic(diagnostic, cx),
|diagnostic, cx| match diagnostic.source_kind {
DiagnosticSourceKind::Other | DiagnosticSourceKind::Pushed => {
adapter.retain_old_diagnostic(diagnostic, cx)
}
DiagnosticSourceKind::Pulled => true,
},
cx,
)
.log_err();
@ -851,6 +863,28 @@ impl LocalLspStore {
})
.detach();
language_server
.on_request::<lsp::request::WorkspaceDiagnosticRefresh, _, _>({
let this = this.clone();
move |(), cx| {
let this = this.clone();
let mut cx = cx.clone();
async move {
this.update(&mut cx, |this, cx| {
cx.emit(LspStoreEvent::RefreshDocumentsDiagnostics);
this.downstream_client.as_ref().map(|(client, project_id)| {
client.send(proto::RefreshDocumentsDiagnostics {
project_id: *project_id,
})
})
})?
.transpose()?;
Ok(())
}
}
})
.detach();
language_server
.on_request::<lsp::request::ShowMessageRequest, _, _>({
let this = this.clone();
@ -1869,8 +1903,7 @@ impl LocalLspStore {
);
}
let uri = lsp::Url::from_file_path(abs_path)
.map_err(|()| anyhow!("failed to convert abs path to uri"))?;
let uri = file_path_to_lsp_url(abs_path)?;
let text_document = lsp::TextDocumentIdentifier::new(uri);
let lsp_edits = {
@ -1934,8 +1967,7 @@ impl LocalLspStore {
let logger = zlog::scoped!("lsp_format");
zlog::info!(logger => "Formatting via LSP");
let uri = lsp::Url::from_file_path(abs_path)
.map_err(|()| anyhow!("failed to convert abs path to uri"))?;
let uri = file_path_to_lsp_url(abs_path)?;
let text_document = lsp::TextDocumentIdentifier::new(uri);
let capabilities = &language_server.capabilities();
@ -2262,7 +2294,7 @@ impl LocalLspStore {
}
let abs_path = file.abs_path(cx);
let Some(uri) = lsp::Url::from_file_path(&abs_path).log_err() else {
let Some(uri) = file_path_to_lsp_url(&abs_path).log_err() else {
return;
};
let initial_snapshot = buffer.text_snapshot();
@ -3447,6 +3479,7 @@ pub enum LspStoreEvent {
edits: Vec<(lsp::Range, Snippet)>,
most_recent_edit: clock::Lamport,
},
RefreshDocumentsDiagnostics,
}
#[derive(Clone, Debug, Serialize)]
@ -3494,6 +3527,7 @@ impl LspStore {
client.add_entity_request_handler(Self::handle_register_buffer_with_language_servers);
client.add_entity_request_handler(Self::handle_rename_project_entry);
client.add_entity_request_handler(Self::handle_language_server_id_for_name);
client.add_entity_request_handler(Self::handle_refresh_documents_diagnostics);
client.add_entity_request_handler(Self::handle_lsp_command::<GetCodeActions>);
client.add_entity_request_handler(Self::handle_lsp_command::<GetCompletions>);
client.add_entity_request_handler(Self::handle_lsp_command::<GetHover>);
@ -3521,6 +3555,7 @@ impl LspStore {
client.add_entity_request_handler(
Self::handle_lsp_command::<lsp_ext_command::SwitchSourceHeader>,
);
client.add_entity_request_handler(Self::handle_lsp_command::<GetDocumentDiagnostics>);
}
pub fn as_remote(&self) -> Option<&RemoteLspStore> {
@ -4043,8 +4078,7 @@ impl LspStore {
.contains_key(&buffer.read(cx).remote_id())
{
if let Some(file_url) =
lsp::Url::from_file_path(&f.abs_path(cx))
.log_err()
file_path_to_lsp_url(&f.abs_path(cx)).log_err()
{
local.unregister_buffer_from_language_servers(
&buffer, &file_url, cx,
@ -4148,7 +4182,7 @@ impl LspStore {
if let Some(abs_path) =
File::from_dyn(buffer_file.as_ref()).map(|file| file.abs_path(cx))
{
if let Some(file_url) = lsp::Url::from_file_path(&abs_path).log_err() {
if let Some(file_url) = file_path_to_lsp_url(&abs_path).log_err() {
local_store.unregister_buffer_from_language_servers(
buffer_entity,
&file_url,
@ -5674,6 +5708,73 @@ impl LspStore {
}
}
pub fn pull_diagnostics(
&mut self,
buffer_handle: Entity<Buffer>,
cx: &mut Context<Self>,
) -> Task<Result<Vec<LspPullDiagnostics>>> {
let buffer = buffer_handle.read(cx);
let buffer_id = buffer.remote_id();
if let Some((client, upstream_project_id)) = self.upstream_client() {
let request_task = client.request(proto::MultiLspQuery {
buffer_id: buffer_id.into(),
version: serialize_version(&buffer_handle.read(cx).version()),
project_id: upstream_project_id,
strategy: Some(proto::multi_lsp_query::Strategy::All(
proto::AllLanguageServers {},
)),
request: Some(proto::multi_lsp_query::Request::GetDocumentDiagnostics(
GetDocumentDiagnostics {}.to_proto(upstream_project_id, buffer_handle.read(cx)),
)),
});
let buffer = buffer_handle.clone();
cx.spawn(async move |weak_project, cx| {
let Some(project) = weak_project.upgrade() else {
return Ok(Vec::new());
};
let responses = request_task.await?.responses;
let diagnostics = join_all(
responses
.into_iter()
.filter_map(|lsp_response| match lsp_response.response? {
proto::lsp_response::Response::GetDocumentDiagnosticsResponse(
response,
) => Some(response),
unexpected => {
debug_panic!("Unexpected response: {unexpected:?}");
None
}
})
.map(|diagnostics_response| {
GetDocumentDiagnostics {}.response_from_proto(
diagnostics_response,
project.clone(),
buffer.clone(),
cx.clone(),
)
}),
)
.await;
Ok(diagnostics
.into_iter()
.collect::<Result<Vec<_>>>()?
.into_iter()
.flatten()
.collect())
})
} else {
let all_actions_task = self.request_multiple_lsp_locally(
&buffer_handle,
None::<PointUtf16>,
GetDocumentDiagnostics {},
cx,
);
cx.spawn(async move |_, _| Ok(all_actions_task.await.into_iter().flatten().collect()))
}
}
pub fn inlay_hints(
&mut self,
buffer_handle: Entity<Buffer>,
@ -6218,7 +6319,7 @@ impl LspStore {
let worktree_id = file.worktree_id(cx);
let abs_path = file.as_local()?.abs_path(cx);
let text_document = lsp::TextDocumentIdentifier {
uri: lsp::Url::from_file_path(abs_path).log_err()?,
uri: file_path_to_lsp_url(&abs_path).log_err()?,
};
let local = self.as_local()?;
@ -6525,15 +6626,15 @@ impl LspStore {
path: relative_path.into(),
};
if let Some(buffer) = self.buffer_store.read(cx).get_by_path(&project_path, cx) {
if let Some(buffer_handle) = self.buffer_store.read(cx).get_by_path(&project_path, cx) {
let snapshot = self
.as_local_mut()
.unwrap()
.buffer_snapshot_for_lsp_version(&buffer, server_id, version, cx)?;
.buffer_snapshot_for_lsp_version(&buffer_handle, server_id, version, cx)?;
let buffer = buffer_handle.read(cx);
diagnostics.extend(
buffer
.read(cx)
.get_diagnostics(server_id)
.into_iter()
.flat_map(|diag| {
@ -6549,7 +6650,7 @@ impl LspStore {
);
self.as_local_mut().unwrap().update_buffer_diagnostics(
&buffer,
&buffer_handle,
server_id,
version,
diagnostics.clone(),
@ -7071,6 +7172,47 @@ impl LspStore {
.collect(),
})
}
Some(proto::multi_lsp_query::Request::GetDocumentDiagnostics(
get_document_diagnostics,
)) => {
let get_document_diagnostics = GetDocumentDiagnostics::from_proto(
get_document_diagnostics,
this.clone(),
buffer.clone(),
cx.clone(),
)
.await?;
let all_diagnostics = this
.update(&mut cx, |project, cx| {
project.request_multiple_lsp_locally(
&buffer,
None::<PointUtf16>,
get_document_diagnostics,
cx,
)
})?
.await
.into_iter();
this.update(&mut cx, |project, cx| proto::MultiLspQueryResponse {
responses: all_diagnostics
.map(|lsp_diagnostic| proto::LspResponse {
response: Some(
proto::lsp_response::Response::GetDocumentDiagnosticsResponse(
GetDocumentDiagnostics::response_to_proto(
lsp_diagnostic,
project,
sender_id,
&buffer_version,
cx,
),
),
),
})
.collect(),
})
}
None => anyhow::bail!("empty multi lsp query request"),
}
}
@ -7671,7 +7813,7 @@ impl LspStore {
PathEventKind::Changed => lsp::FileChangeType::CHANGED,
};
Some(lsp::FileEvent {
uri: lsp::Url::from_file_path(&event.path).ok()?,
uri: file_path_to_lsp_url(&event.path).log_err()?,
typ,
})
})
@ -7997,6 +8139,17 @@ impl LspStore {
Ok(proto::Ack {})
}
async fn handle_refresh_documents_diagnostics(
this: Entity<Self>,
_: TypedEnvelope<proto::RefreshDocumentsDiagnostics>,
mut cx: AsyncApp,
) -> Result<proto::Ack> {
this.update(&mut cx, |_, cx| {
cx.emit(LspStoreEvent::RefreshDocumentsDiagnostics);
})?;
Ok(proto::Ack {})
}
async fn handle_inlay_hints(
this: Entity<Self>,
envelope: TypedEnvelope<proto::InlayHints>,
@ -8719,12 +8872,14 @@ impl LspStore {
&mut self,
language_server_id: LanguageServerId,
params: lsp::PublishDiagnosticsParams,
source_kind: DiagnosticSourceKind,
disk_based_sources: &[String],
cx: &mut Context<Self>,
) -> Result<()> {
self.merge_diagnostics(
language_server_id,
params,
source_kind,
disk_based_sources,
|_, _| false,
cx,
@ -8735,6 +8890,7 @@ impl LspStore {
&mut self,
language_server_id: LanguageServerId,
mut params: lsp::PublishDiagnosticsParams,
source_kind: DiagnosticSourceKind,
disk_based_sources: &[String],
filter: F,
cx: &mut Context<Self>,
@ -8799,6 +8955,7 @@ impl LspStore {
range,
diagnostic: Diagnostic {
source: diagnostic.source.clone(),
source_kind,
code: diagnostic.code.clone(),
code_description: diagnostic
.code_description
@ -8825,6 +8982,7 @@ impl LspStore {
range,
diagnostic: Diagnostic {
source: diagnostic.source.clone(),
source_kind,
code: diagnostic.code.clone(),
code_description: diagnostic
.code_description