lsp: Handle dynamic registration of textDocument/documentHighlight (#63029)

Zed did not advertise document highlight dynamic registration support or
handle `textDocument/documentHighlight` registrations. Dynamically
registered providers therefore fell through to the unhandled-capability
path, while servers with a fallback had to advertise a static provider
for every attached document.

Advertise dynamic registration support and track document highlight
registrations and unregistrations alongside the other selector-aware
dynamic text document capabilities. Refresh editors whenever the
effective document highlight registration state changes, including
selector-only changes that leave the merged provider unchanged, so
runtime registrations take effect immediately and unregistrations clear
stale or in-flight highlights.

Add coverage for the client capability, registration lifecycle with
multiple selectors sharing identical provider options, and editor
refresh behavior when an applicable selector is added or removed.

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed’s UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A

---------

Co-authored-by: GPT-5.6 Sol <noreply@openai.com>
Co-authored-by: Kirill Bulatov <kirill@zed.dev>
This commit is contained in:
authored and GitHub committed 2026-09-09 14:55:56 +00:00
1 parent 797fa3b3d9
commit 4ee80bcbbb
13 files changed
+787 -44

No files matched your search

+3
View File
@@ -437,6 +437,9 @@ impl Server {
broadcast_project_message_from_host::<proto::RefreshDocumentColors>,
)
.add_message_handler(broadcast_project_message_from_host::<proto::RefreshDocumentLinks>)
.add_message_handler(
broadcast_project_message_from_host::<proto::RefreshDocumentHighlights>,
)
.add_message_handler(broadcast_project_message_from_host::<proto::RefreshFoldingRanges>)
.add_message_handler(
broadcast_project_message_from_host::<proto::RefreshDocumentSymbols>,
+204 -1
View File
@@ -45,7 +45,7 @@ use rpc::RECEIVE_TIMEOUT;
use serde_json::json;
use settings::{
DocumentFoldingRanges, DocumentSymbols, InlayHintSettingsContent, InlineBlameSettings,
SemanticTokens, SettingsStore,
SemanticTokens, Settings as _, SettingsStore,
};
use std::{
collections::BTreeSet,
@@ -1606,6 +1606,209 @@ async fn test_remote_dynamic_call_hierarchy_followups_use_prepared_server(
assert_eq!(outgoing_request_count.load(atomic::Ordering::SeqCst), 1);
}
#[gpui::test]
async fn test_remote_dynamic_document_highlight_registration_refreshes_guest_editor(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(cx_a.executor()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
cx_b.update(editor::init);
let request_count = Arc::new(AtomicUsize::new(0));
client_a.language_registry().add(rust_lang());
let mut fake_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
capabilities: lsp::ServerCapabilities::default(),
initializer: Some(Box::new({
let request_count = request_count.clone();
move |fake_server| {
let request_count = request_count.clone();
fake_server
.set_request_handler::<lsp::request::DocumentHighlightRequest, _, _>(
move |_, _| {
request_count.fetch_add(1, atomic::Ordering::SeqCst);
async move {
Ok(Some(vec![lsp::DocumentHighlight {
range: lsp::Range::new(
lsp::Position::new(0, 0),
lsp::Position::new(0, 2),
),
kind: Some(lsp::DocumentHighlightKind::READ),
}]))
}
},
);
}
})),
..FakeLspAdapter::default()
},
);
client_b.language_registry().add(rust_lang());
client_b.language_registry().register_fake_lsp_adapter(
"Rust",
FakeLspAdapter {
capabilities: lsp::ServerCapabilities::default(),
..FakeLspAdapter::default()
},
);
client_a
.fs()
.insert_tree(path!("/dir"), json!({ "one.rs": "fn one() {}" }))
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/dir"), cx_a).await;
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let editor_b = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("one.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let fake_server = fake_servers.next().await.unwrap();
let debounce = Duration::from_millis(cx_b.update(|_, cx| {
editor::EditorSettings::get_global(cx)
.lsp_highlight_debounce
.0
}));
cx_a.executor().advance_clock(debounce);
cx_a.run_until_parked();
cx_b.run_until_parked();
assert_eq!(
request_count.load(atomic::Ordering::SeqCst),
0,
"expected no document highlight request before the capability is registered",
);
fake_server
.request::<lsp::request::RegisterCapability>(
lsp::RegistrationParams {
registrations: vec![lsp::Registration {
id: "rust-document-highlight".to_string(),
method: "textDocument/documentHighlight".to_string(),
register_options: Some(json!({
"documentSelector": [{ "language": "rust", "scheme": "file" }],
})),
}],
},
DEFAULT_LSP_REQUEST_TIMEOUT,
)
.await
.into_response()
.unwrap();
cx_a.executor().advance_clock(debounce);
cx_a.run_until_parked();
cx_b.run_until_parked();
assert_eq!(
request_count.load(atomic::Ordering::SeqCst),
1,
"expected the guest editor to request document highlights after the host registration",
);
assert!(editor_b.read_with(cx_b, |editor, _| {
editor.has_background_highlights(editor::HighlightKey::DocumentHighlightRead)
}));
fake_server
.request::<lsp::request::UnregisterCapability>(
lsp::UnregistrationParams {
unregisterations: vec![lsp::Unregistration {
id: "rust-document-highlight".to_string(),
method: "textDocument/documentHighlight".to_string(),
}],
},
DEFAULT_LSP_REQUEST_TIMEOUT,
)
.await
.into_response()
.unwrap();
cx_a.executor().advance_clock(debounce);
cx_a.run_until_parked();
cx_b.run_until_parked();
assert_eq!(
request_count.load(atomic::Ordering::SeqCst),
1,
"expected no document highlight request after the host unregistration",
);
assert!(
!editor_b.read_with(cx_b, |editor, _| {
editor.has_background_highlights(editor::HighlightKey::DocumentHighlightRead)
}),
"expected the guest editor to clear stale document highlights after the host unregistration",
);
fake_server
.request::<lsp::request::RegisterCapability>(
lsp::RegistrationParams {
registrations: vec![lsp::Registration {
id: "rust-document-highlight".to_string(),
method: "textDocument/documentHighlight".to_string(),
register_options: Some(json!({
"documentSelector": [{ "language": "rust", "scheme": "file" }],
})),
}],
},
DEFAULT_LSP_REQUEST_TIMEOUT,
)
.await
.into_response()
.unwrap();
cx_a.executor().advance_clock(debounce);
cx_a.run_until_parked();
cx_b.run_until_parked();
assert_eq!(request_count.load(atomic::Ordering::SeqCst), 2);
assert!(editor_b.read_with(cx_b, |editor, _| {
editor.has_background_highlights(editor::HighlightKey::DocumentHighlightRead)
}));
let server_id = fake_server.server.server_id();
let buffer_a = project_a
.update(cx_a, |project, cx| {
project.open_local_buffer(path!("/dir/one.rs"), cx)
})
.await
.unwrap();
project_a.update(cx_a, |project, cx| {
project.stop_language_servers_for_buffers(vec![buffer_a], HashSet::default(), cx);
});
cx_a.executor().advance_clock(debounce);
cx_a.run_until_parked();
cx_b.run_until_parked();
assert_eq!(
project_b.read_with(cx_b, |project, cx| {
project
.language_server_statuses(cx)
.map(|(server_id, _)| server_id)
.collect::<Vec<_>>()
}),
Vec::<lsp::LanguageServerId>::new(),
"expected the guest to drop the stopped server {server_id}",
);
assert_eq!(
request_count.load(atomic::Ordering::SeqCst),
2,
"expected no document highlight request after the host stopped the server",
);
assert!(
!editor_b.read_with(cx_b, |editor, _| {
editor.has_background_highlights(editor::HighlightKey::DocumentHighlightRead)
}),
"expected the guest editor to clear stale document highlights after the host stopped the server",
);
}
#[gpui::test]
async fn test_slow_lsp_server(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let mut server = TestServer::start(cx_a.executor()).await;
+32
View File
@@ -2080,6 +2080,9 @@ impl Editor {
project::Event::RefreshDocumentLinks { .. } => {
editor.refresh_document_links(None, cx);
}
project::Event::RefreshDocumentHighlights { server_id } => {
editor.refresh_document_highlights_for_server(*server_id, cx);
}
project::Event::RefreshFoldingRanges { .. } => {
editor.refresh_folding_ranges(None, window, cx);
}
@@ -2104,6 +2107,7 @@ impl Editor {
editor.refresh_runnables(None, window, cx);
editor.update_lsp_data(None, window, cx);
editor.refresh_inlay_hints(InlayHintRefreshReason::ServerRemoved, cx);
editor.refresh_document_highlights(cx);
}
project::Event::SnippetEdit(id, snippet_edits) => {
// todo(lw): Non singletons
@@ -3805,6 +3809,33 @@ impl Editor {
None
}
fn refresh_document_highlights_for_server(
&mut self,
server_id: Option<LanguageServerId>,
cx: &mut Context<Self>,
) {
let server_relevant = server_id.is_none_or(|server_id| {
let Some(project) = self.project.as_ref() else {
return false;
};
let cursor_position = self.selections.newest_anchor().head();
self.buffer
.read(cx)
.text_anchor_for_position(cursor_position, cx)
.is_some_and(|(cursor_buffer, _)| {
project
.read(cx)
.lsp_store()
.read(cx)
.relevant_server_ids_for_capability_check(&cursor_buffer, cx)
.contains(&server_id)
})
});
if server_relevant {
self.refresh_document_highlights(cx);
}
}
fn prepare_highlight_query_from_selection(
&mut self,
snapshot: &DisplaySnapshot,
@@ -10086,6 +10117,7 @@ impl Editor {
self.invalidate_semantic_tokens(Some(*buffer_id));
self.update_lsp_data(Some(*buffer_id), window, cx);
self.refresh_inlay_hints(InlayHintRefreshReason::ServerRemoved, cx);
self.refresh_document_highlights(cx);
}
jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx);
cx.emit(EditorEvent::Reparsed(*buffer_id));
+177
View File
@@ -11360,6 +11360,7 @@ async fn test_paste_shifts_block_by_first_line_delta(cx: &mut TestAppContext) {
));
cx.set_state("package test\n\nˇ");
cx.update_editor(|editor, window, cx| editor.paste(&Paste, window, cx));
cx.run_until_parked();
cx.assert_editor_state("package test\n\nfunc find() {\n\treturn 1\n}ˇ");
// The block moves by however far its first line moved, so a first line that
@@ -11367,6 +11368,7 @@ async fn test_paste_shifts_block_by_first_line_delta(cx: &mut TestAppContext) {
cx.write_to_clipboard(ClipboardItem::new_string(" foo()\n bar()".into()));
cx.set_state("func test() {\nˇ\n}");
cx.update_editor(|editor, window, cx| editor.paste(&Paste, window, cx));
cx.run_until_parked();
cx.assert_editor_state("func test() {\n foo()\nbar()ˇ\n}");
}
@@ -35502,6 +35504,150 @@ async fn test_bookmarks_tab_retries_failed_path_when_file_appears(cx: &mut TestA
);
}
#[gpui::test]
async fn test_dynamic_document_highlight_registration_refreshes_editor(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await;
let debounce = Duration::from_millis(
cx.update(|_, cx| EditorSettings::get_global(cx).lsp_highlight_debounce.0),
);
let project = cx.update_workspace(|workspace, _, _| workspace.project().clone());
let server_id = cx.lsp.server.server_id();
cx.set_state(indoc! {"
fn main() {
let foo = 1;
fˇoo;
}
"});
cx.executor().advance_clock(debounce);
cx.run_until_parked();
let request_count = Arc::new(AtomicUsize::new(0));
let _request_handler =
cx.set_request_handler::<lsp::request::DocumentHighlightRequest, _, _>({
let request_count = request_count.clone();
move |_, _, _| {
request_count.fetch_add(1, atomic::Ordering::SeqCst);
async move {
Ok(Some(vec![lsp::DocumentHighlight {
range: lsp::Range::new(lsp::Position::new(2, 4), lsp::Position::new(2, 7)),
kind: Some(lsp::DocumentHighlightKind::READ),
}]))
}
}
});
register_document_highlight_capability(&cx.lsp, "python-document-highlight", "python").await;
cx.executor().advance_clock(debounce);
cx.run_until_parked();
assert_eq!(
request_count.load(atomic::Ordering::SeqCst),
0,
"expected a registration for another language to not query the server",
);
assert_eq!(document_highlight_count(&mut cx), 0);
register_document_highlight_capability(&cx.lsp, "rust-document-highlight", "rust").await;
cx.executor().advance_clock(debounce);
cx.run_until_parked();
assert_eq!(
request_count.load(atomic::Ordering::SeqCst),
1,
"expected an applicable registration to refresh document highlights",
);
assert_eq!(document_highlight_count(&mut cx), 1);
cx.update_editor(|editor, _, cx| {
editor.refresh_document_highlights(cx);
});
for _ in 0..3 {
cx.executor().advance_clock(debounce / 4);
cx.run_until_parked();
cx.update(|_, cx| {
project.update(cx, |_, cx| {
cx.emit(project::Event::RefreshDocumentHighlights {
server_id: Some(LanguageServerId(server_id.0 + 1)),
});
});
});
cx.run_until_parked();
}
cx.executor().advance_clock(debounce / 4);
cx.run_until_parked();
assert_eq!(
request_count.load(atomic::Ordering::SeqCst),
2,
"expected refreshes of servers unrelated to the buffer to not restart the debounce",
);
cx.update(|_, cx| {
project.update(cx, |_, cx| {
cx.emit(project::Event::RefreshDocumentHighlights {
server_id: Some(server_id),
});
});
});
cx.executor().advance_clock(debounce);
cx.run_until_parked();
assert_eq!(
request_count.load(atomic::Ordering::SeqCst),
3,
"expected a refresh of the buffer's server to query document highlights",
);
cx.lsp
.request::<lsp::request::UnregisterCapability>(
lsp::UnregistrationParams {
unregisterations: vec![lsp::Unregistration {
id: "rust-document-highlight".to_string(),
method: "textDocument/documentHighlight".to_string(),
}],
},
DEFAULT_LSP_REQUEST_TIMEOUT,
)
.await
.into_response()
.unwrap();
cx.executor().advance_clock(debounce);
cx.run_until_parked();
assert_eq!(
request_count.load(atomic::Ordering::SeqCst),
3,
"expected no server query after the applicable registration is removed",
);
assert_eq!(
document_highlight_count(&mut cx),
0,
"expected stale document highlights to be cleared after unregistration",
);
register_document_highlight_capability(&cx.lsp, "rust-document-highlight", "rust").await;
cx.executor().advance_clock(debounce);
cx.run_until_parked();
assert_eq!(request_count.load(atomic::Ordering::SeqCst), 4);
assert_eq!(document_highlight_count(&mut cx), 1);
let buffer = cx.update_editor(|editor, _, cx| editor.buffer().read(cx).as_singleton().unwrap());
cx.update(|_, cx| {
project.update(cx, |project, cx| {
project.stop_language_servers_for_buffers(vec![buffer], HashSet::default(), cx);
});
});
cx.executor().advance_clock(debounce);
cx.run_until_parked();
assert_eq!(
request_count.load(atomic::Ordering::SeqCst),
4,
"expected no document highlight request after the server is stopped",
);
assert_eq!(
document_highlight_count(&mut cx),
0,
"expected stale document highlights to be cleared after the server is stopped",
);
}
#[gpui::test]
async fn test_rename_with_duplicate_edits(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@@ -46212,3 +46358,34 @@ async fn test_lsp_show_document_unsupported_uri(cx: &mut TestAppContext) {
.expect("show document request should not error");
assert_eq!(response, lsp::ShowDocumentResult { success: false });
}
async fn register_document_highlight_capability(
lsp: &lsp::FakeLanguageServer,
registration_id: &str,
language: &str,
) {
lsp.request::<lsp::request::RegisterCapability>(
lsp::RegistrationParams {
registrations: vec![lsp::Registration {
id: registration_id.to_string(),
method: "textDocument/documentHighlight".to_string(),
register_options: Some(json!({
"documentSelector": [{ "language": language, "scheme": "file" }],
})),
}],
},
DEFAULT_LSP_REQUEST_TIMEOUT,
)
.await
.into_response()
.unwrap();
}
fn document_highlight_count(cx: &mut EditorLspTestContext) -> usize {
cx.editor(|editor, _, _| {
editor
.background_highlights
.get(&HighlightKey::DocumentHighlightRead)
.map_or(0, |(_, ranges)| ranges.len())
})
}
+13
View File
@@ -946,6 +946,9 @@ impl LanguageServer {
link_support: Some(true),
dynamic_registration: Some(true),
}),
document_highlight: Some(DocumentHighlightClientCapabilities {
dynamic_registration: Some(true),
}),
code_action: Some(CodeActionClientCapabilities {
code_action_literal_support: Some(CodeActionLiteralSupport {
code_action_kind: CodeActionKindLiteralSupport {
@@ -2494,6 +2497,16 @@ mod tests {
let params = cx.update(|cx| server.default_initialize_params(false, false, cx));
assert_eq!(
params
.capabilities
.text_document
.as_ref()
.and_then(|capabilities| capabilities.document_highlight.as_ref())
.and_then(|capabilities| capabilities.dynamic_registration),
Some(true)
);
#[allow(deprecated)]
let root_uri = params.root_uri.expect("root_uri should be set");
#[allow(deprecated)]
+72 -25
View File
@@ -4642,6 +4642,9 @@ pub enum LspStoreEvent {
RefreshDocumentLinks {
server_id: Option<LanguageServerId>,
},
RefreshDocumentHighlights {
server_id: Option<LanguageServerId>,
},
RefreshFoldingRanges {
server_id: Option<LanguageServerId>,
},
@@ -4757,6 +4760,7 @@ impl LspStore {
client.add_entity_request_handler(Self::handle_refresh_code_lens);
client.add_entity_request_handler(Self::handle_refresh_document_colors);
client.add_entity_request_handler(Self::handle_refresh_document_links);
client.add_entity_request_handler(Self::handle_refresh_document_highlights);
client.add_entity_request_handler(Self::handle_refresh_folding_ranges);
client.add_entity_request_handler(Self::handle_refresh_document_symbols);
client.add_entity_request_handler(Self::handle_on_type_formatting);
@@ -5737,7 +5741,7 @@ impl LspStore {
)
}
fn relevant_server_ids_for_capability_check(
pub fn relevant_server_ids_for_capability_check(
&self,
buffer: &Entity<Buffer>,
cx: &App,
@@ -5810,32 +5814,44 @@ impl LspStore {
}
fn notify_server_capabilities_updated(&self, server: &LanguageServer, cx: &mut Context<Self>) {
if let Some(capabilities) = self.serialize_synced_server_capabilities(server).log_err() {
cx.emit(LspStoreEvent::LanguageServerUpdate {
language_server_id: server.server_id(),
name: Some(server.name()),
message: proto::update_language_server::Variant::MetadataUpdated(
proto::ServerMetadataUpdated {
capabilities: Some(capabilities),
binary: Some(proto::LanguageServerBinaryInfo {
path: server.binary().path.to_string_lossy().into_owned(),
arguments: server
.binary()
.arguments
.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect(),
}),
configuration: serde_json::to_string(server.configuration()).ok(),
workspace_folders: server
.workspace_folders()
.iter()
.map(|uri| uri.to_string())
.collect(),
},
),
let Some(capabilities) = self.serialize_synced_server_capabilities(server).log_err() else {
return;
};
let message =
proto::update_language_server::Variant::MetadataUpdated(proto::ServerMetadataUpdated {
capabilities: Some(capabilities),
binary: Some(proto::LanguageServerBinaryInfo {
path: server.binary().path.to_string_lossy().into_owned(),
arguments: server
.binary()
.arguments
.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect(),
}),
configuration: serde_json::to_string(server.configuration()).ok(),
workspace_folders: server
.workspace_folders()
.iter()
.map(|uri| uri.to_string())
.collect(),
});
if let Some((downstream_client, project_id)) = self.downstream_client.as_ref() {
downstream_client
.send(proto::UpdateLanguageServer {
project_id: *project_id,
server_name: Some(server.name().to_string()),
language_server_id: server.server_id().to_proto(),
variant: Some(message.clone()),
})
.context("sending server metadata downstream")
.log_err();
}
cx.emit(LspStoreEvent::LanguageServerUpdate {
language_server_id: server.server_id(),
name: Some(server.name()),
message,
});
}
pub(crate) fn insert_synced_server_capabilities(
@@ -5870,6 +5886,37 @@ impl LspStore {
}
}
fn refresh_document_highlights(
&mut self,
for_server: Option<LanguageServerId>,
cx: &mut Context<Self>,
) {
cx.emit(LspStoreEvent::RefreshDocumentHighlights {
server_id: for_server,
});
if let Some((downstream_client, project_id)) = self.downstream_client.as_ref() {
downstream_client
.send(proto::RefreshDocumentHighlights {
project_id: *project_id,
server_id: for_server.map(|server_id| server_id.to_proto()),
})
.context("sending refresh document highlights downstream")
.log_err();
}
}
async fn handle_refresh_document_highlights(
lsp_store: Entity<Self>,
envelope: TypedEnvelope<proto::RefreshDocumentHighlights>,
mut cx: AsyncApp,
) -> Result<proto::Ack> {
lsp_store.update(&mut cx, |lsp_store, cx| {
let server_id = envelope.payload.server_id.map(LanguageServerId::from_proto);
lsp_store.refresh_document_highlights(server_id, cx);
});
Ok(proto::Ack {})
}
fn remote_document_selector_context(
&self,
server_id: LanguageServerId,
@@ -102,26 +102,32 @@ impl LspStore {
.or_default();
// Guests track registrations by id, so notify on any id-visible change; caches only
// consume the (selector, options) value set, so refresh flags compare sets.
let registration_changed = registrations.get(&registration_id) != Some(&new_registration);
let applicable_set_changed;
if registrations.contains_key(&registration_id) {
let previous_registration = registrations.get(&registration_id);
let registration_changed = previous_registration != Some(&new_registration);
let applicable_set_changed = match previous_registration {
Some(previous_registration) if previous_registration == &new_registration => false,
Some(previous_registration) => {
let mut other_registrations = registrations
.iter()
.filter(|(id, _)| **id != registration_id)
.map(|(_, registration)| registration);
let previous_value_remains = other_registrations
.clone()
.any(|registration| registration == previous_registration);
let new_value_exists =
other_registrations.any(|registration| registration == &new_registration);
!previous_value_remains || !new_value_exists
}
None => !registrations
.values()
.any(|registration| registration == &new_registration),
};
if previous_registration.is_some() {
log::warn!(
"Received a duplicate {method} registration with ID {registration_id}, replacing the previous one"
);
let previous_values = registrations.values().cloned().collect::<Vec<_>>();
registrations.insert(registration_id, new_registration);
applicable_set_changed = !previous_values
.iter()
.all(|previous| registrations.values().any(|current| current == previous))
|| !registrations
.values()
.all(|current| previous_values.contains(current));
} else {
applicable_set_changed = !registrations
.values()
.any(|existing| existing == &new_registration);
registrations.insert(registration_id, new_registration);
}
registrations.insert(registration_id, new_registration);
let new_active_envelope = registrations
.last()
.map(|(_, registration)| registration.server_capabilities.clone());
@@ -617,6 +623,25 @@ impl LspStore {
|capabilities| &mut capabilities.definition_provider,
)?;
}
"textDocument/documentHighlight" => {
let document_selector =
parse_text_document_registration(reg.register_options.as_ref())?;
let options = parse_register_capabilities(reg.register_options)?;
if self
.register_dynamic_text_document_capability(
&server,
&reg.method,
reg.id,
options,
document_selector,
cx,
|capabilities| &mut capabilities.document_highlight_provider,
)?
.applicability_may_have_changed()
{
self.refresh_document_highlights(Some(server_id), cx);
}
}
"textDocument/completion" => {
if let Some(registration_options) = reg
.register_options
@@ -1038,6 +1063,19 @@ impl LspStore {
|capabilities| &mut capabilities.definition_provider,
)?;
}
"textDocument/documentHighlight" => {
if self
.unregister_dynamic_text_document_capability(
&server,
unreg,
cx,
|capabilities| &mut capabilities.document_highlight_provider,
)?
.is_some_and(CapabilityRegistrationChange::applicability_may_have_changed)
{
self.refresh_document_highlights(Some(server_id), cx);
}
}
"textDocument/completion" => {
if self
.unregister_dynamic_text_document_capability(
+28 -1
View File
@@ -417,6 +417,9 @@ pub enum Event {
RefreshDocumentLinks {
server_id: Option<LanguageServerId>,
},
RefreshDocumentHighlights {
server_id: Option<LanguageServerId>,
},
RefreshFoldingRanges {
server_id: Option<LanguageServerId>,
},
@@ -3710,6 +3713,20 @@ impl Project {
Event::SupplementaryLanguageServerAdded(*server_id, name.clone()),
),
LspStoreEvent::LanguageServerRemoved(server_id) => {
if self.is_local()
&& let Some(project_id) = self.remote_id()
{
self.collab_client
.send(proto::UpdateLanguageServer {
project_id,
server_name: None,
language_server_id: server_id.to_proto(),
variant: Some(proto::update_language_server::Variant::Removed(
proto::ServerRemoved {},
)),
})
.log_err();
}
cx.emit(Event::LanguageServerRemoved(*server_id))
}
LspStoreEvent::SupplementaryLanguageServerRemoved(server_id) => {
@@ -3748,6 +3765,11 @@ impl Project {
server_id: *server_id,
})
}
LspStoreEvent::RefreshDocumentHighlights { server_id } => {
cx.emit(Event::RefreshDocumentHighlights {
server_id: *server_id,
})
}
LspStoreEvent::RefreshFoldingRanges { server_id } => {
cx.emit(Event::RefreshFoldingRanges {
server_id: *server_id,
@@ -3779,7 +3801,12 @@ impl Project {
name,
message,
} => {
if self.is_local() {
if self.is_local()
&& !matches!(
message,
proto::update_language_server::Variant::MetadataUpdated(_)
)
{
self.enqueue_buffer_ordered_message(
BufferOrderedMessage::LanguageServerUpdate {
language_server_id: *language_server_id,
@@ -652,6 +652,156 @@ async fn test_inlay_hint_resolve_state_uses_matching_dynamic_registration(
);
}
#[gpui::test]
async fn test_dynamic_document_highlight_registration(cx: &mut gpui::TestAppContext) {
init_test(cx);
let (project, fake_server) =
setup_dynamic_registration_test(cx, lsp::ServerCapabilities::default()).await;
let server_id = fake_server.server.server_id();
let method = "textDocument/documentHighlight";
let (refresh_events, _refresh_events_subscription) = observe_refresh_events(&project, cx);
assert_eq!(
server_capabilities(&project, server_id, cx).document_highlight_provider,
None,
);
let registration_options = |language: &str| {
json!({
"documentSelector": [{ "language": language, "scheme": "file" }],
})
};
let registered_provider = Some(lsp::OneOf::Right(lsp::DocumentHighlightOptions {
work_done_progress_options: lsp::WorkDoneProgressOptions::default(),
}));
register_capability(
&fake_server,
method,
"python-document-highlight",
Some(registration_options("python")),
)
.await;
cx.executor().run_until_parked();
assert_eq!(
server_capabilities(&project, server_id, cx).document_highlight_provider,
registered_provider,
);
assert_eq!(
refresh_events.lock().drain(..).collect::<Vec<_>>(),
vec![format!("document_highlights({server_id})")],
"expected the first registration to refresh",
);
register_capability(
&fake_server,
method,
"rust-document-highlight",
Some(registration_options("rust")),
)
.await;
cx.executor().run_until_parked();
assert_eq!(
server_capabilities(&project, server_id, cx).document_highlight_provider,
registered_provider,
);
assert_eq!(
refresh_events.lock().drain(..).collect::<Vec<_>>(),
vec![format!("document_highlights({server_id})")],
"expected adding a distinct selector with identical provider options to refresh",
);
register_capability(
&fake_server,
method,
"rust-document-highlight-duplicate",
Some(registration_options("rust")),
)
.await;
cx.executor().run_until_parked();
assert_eq!(
refresh_events.lock().as_slice(),
&[] as &[String],
"expected a registration identical to an existing one to not refresh",
);
unregister_capabilities(&fake_server, method, &["rust-document-highlight-duplicate"]).await;
cx.executor().run_until_parked();
assert_eq!(
refresh_events.lock().as_slice(),
&[] as &[String],
"expected removing a duplicate registration to not refresh",
);
unregister_capabilities(&fake_server, method, &["rust-document-highlight"]).await;
cx.executor().run_until_parked();
assert_eq!(
server_capabilities(&project, server_id, cx).document_highlight_provider,
registered_provider,
);
assert_eq!(
refresh_events.lock().drain(..).collect::<Vec<_>>(),
vec![format!("document_highlights({server_id})")],
"expected removing a selector while another provider remains to refresh",
);
unregister_capabilities(&fake_server, method, &["python-document-highlight"]).await;
cx.executor().run_until_parked();
assert_eq!(
server_capabilities(&project, server_id, cx).document_highlight_provider,
None,
);
assert_eq!(
refresh_events.lock().drain(..).collect::<Vec<_>>(),
vec![format!("document_highlights({server_id})")],
"expected the last unregistration to clear the capability and refresh",
);
}
#[gpui::test]
async fn test_dynamic_registration_sends_metadata_downstream_before_refresh(
cx: &mut gpui::TestAppContext,
) {
init_test(cx);
let (project, fake_server) =
setup_dynamic_registration_test(cx, lsp::ServerCapabilities::default()).await;
let downstream = Arc::new(RecordingProtoClient::default());
project.update(cx, |project, cx| {
project.lsp_store().update(cx, |lsp_store, cx| {
lsp_store.shared(1, rpc::AnyProtoClient::new(downstream.clone()), cx);
});
});
cx.executor().run_until_parked();
downstream.sent.lock().clear();
register_capability(
&fake_server,
"textDocument/documentHighlight",
"rust-document-highlight",
Some(json!({
"documentSelector": [{ "language": "rust", "scheme": "file" }],
})),
)
.await;
cx.executor().run_until_parked();
assert_eq!(
downstream.sent.lock().drain(..).collect::<Vec<_>>(),
vec!["UpdateLanguageServer", "RefreshDocumentHighlights"],
"expected the capability update to reach guests before the refresh that depends on it",
);
unregister_capabilities(
&fake_server,
"textDocument/documentHighlight",
&["rust-document-highlight"],
)
.await;
cx.executor().run_until_parked();
assert_eq!(
downstream.sent.lock().drain(..).collect::<Vec<_>>(),
vec!["UpdateLanguageServer", "RefreshDocumentHighlights"],
);
}
#[gpui::test]
async fn test_multi_registration_inlay_hint(cx: &mut gpui::TestAppContext) {
init_test(cx);
@@ -2715,6 +2865,9 @@ fn observe_refresh_events(
label("document_colors", server_id)
}
Event::RefreshDocumentLinks { server_id } => label("document_links", server_id),
Event::RefreshDocumentHighlights { server_id } => {
label("document_highlights", server_id)
}
Event::RefreshFoldingRanges { server_id } => label("folding_ranges", server_id),
Event::RefreshDocumentSymbols { server_id } => {
label("document_symbols", server_id)
@@ -3092,3 +3245,40 @@ async fn fetch_all_lsp_data(
labels.sort();
labels
}
#[derive(Default)]
struct RecordingProtoClient {
sent: Mutex<Vec<&'static str>>,
handler_set: Mutex<rpc::ProtoMessageHandlerSet>,
}
impl rpc::ProtoClient for RecordingProtoClient {
fn request(
&self,
_: rpc::proto::Envelope,
_: &'static str,
) -> futures::future::BoxFuture<'static, Result<rpc::proto::Envelope>> {
unimplemented!()
}
fn send(&self, _: rpc::proto::Envelope, message_type: &'static str) -> Result<()> {
self.sent.lock().push(message_type);
Ok(())
}
fn send_response(&self, _: rpc::proto::Envelope, _: &'static str) -> Result<()> {
Ok(())
}
fn message_handler_set(&self) -> &Mutex<rpc::ProtoMessageHandlerSet> {
&self.handler_set
}
fn is_via_collab(&self) -> bool {
false
}
fn has_wsl_interop(&self) -> bool {
false
}
}
+5
View File
@@ -673,6 +673,11 @@ message RefreshDocumentLinks {
optional uint64 server_id = 2;
}
message RefreshDocumentHighlights {
uint64 project_id = 1;
optional uint64 server_id = 2;
}
message RefreshFoldingRanges {
uint64 project_id = 1;
optional uint64 server_id = 2;
+2 -1
View File
@@ -521,7 +521,8 @@ message Envelope {
LspExtExpandAbbreviationResponse lsp_ext_expand_abbreviation_response = 489;
LanguageServerShowDocumentRequest language_server_show_document_request = 490;
ExecuteLspCommand execute_lsp_command = 491;
ExecuteLspCommandResponse execute_lsp_command_response = 492; // current max
ExecuteLspCommandResponse execute_lsp_command_response = 492;
RefreshDocumentHighlights refresh_document_highlights = 493; // current max
}
reserved 87 to 88;
+3
View File
@@ -238,6 +238,7 @@ messages!(
(RefreshSemanticTokens, Background),
(RefreshDocumentColors, Background),
(RefreshDocumentLinks, Background),
(RefreshDocumentHighlights, Background),
(RefreshFoldingRanges, Background),
(RefreshDocumentSymbols, Background),
(RegisterBufferWithLanguageServers, Background),
@@ -521,6 +522,7 @@ request_messages!(
(RefreshCodeLens, Ack),
(RefreshDocumentColors, Ack),
(RefreshDocumentLinks, Ack),
(RefreshDocumentHighlights, Ack),
(RefreshFoldingRanges, Ack),
(RefreshDocumentSymbols, Ack),
(RejoinChannelBuffers, RejoinChannelBuffersResponse),
@@ -777,6 +779,7 @@ entity_messages!(
RefreshCodeLens,
RefreshDocumentColors,
RefreshDocumentLinks,
RefreshDocumentHighlights,
RefreshFoldingRanges,
RefreshDocumentSymbols,
ReloadBuffers,
@@ -456,6 +456,10 @@ impl HeadlessProject {
})
.log_err();
}
LspStoreEvent::LanguageServerUpdate {
message: proto::update_language_server::Variant::MetadataUpdated(_),
..
} => {}
LspStoreEvent::LanguageServerUpdate {
language_server_id,
name,