mirror of
https://github.com/zed-industries/zed.git
synced 2026-07-09 16:00:35 +00:00
Handle dynamic registration of semantic tokens capability (#60015)
In C# files nothing gets semantic highlighting — class fields and other identifiers are colored by tree-sitter only, so a private field looks the same as a plain local variable. The reason: Roslyn (the C# language server) doesn't declare `semanticTokensProvider` statically in its `initialize` response. It registers it **dynamically** (`client/registerCapability`), and only when the client advertises `textDocument.semanticTokens.dynamicRegistration = true`. Zed advertised `false` and didn't handle such a registration, so Roslyn never offered semantic tokens at all. (rust-analyzer/gopls are unaffected — they declare the capability statically.) This is the first of two PRs. This one makes Roslyn actually **send** semantic tokens. The companion PR (#60027) maps Roslyn's C#-specific token types to theme styles — without it the tokens arrive but most are dropped, since their types aren't in Zed's default rules. ## Solution - Advertise `textDocument.semanticTokens.dynamicRegistration = true`. - Handle the `textDocument/semanticTokens` registration and unregistration so the capability is stored, following the existing arms for `documentLink`, diagnostics, etc. ## Testing - Added a test that dynamically registers and unregisters `textDocument/semanticTokens` and checks the stored capability appears and is cleared. - Verified manually against Roslyn on a C# project: Zed now sends `textDocument/semanticTokens/full` and gets tokens back; before this change there was no semantic-token traffic at all. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes: - Support dynamic registration of the `textDocument/semanticTokens` capability. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
afaef857b9
commit
2a93ca53fd
5 changed files with 278 additions and 21 deletions
|
|
@ -572,6 +572,107 @@ mod tests {
|
|||
assert_eq!(full_counter.load(atomic::Ordering::Acquire), 2);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn lsp_semantic_tokens_dynamic_registration_requeries_open_document(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
update_test_language_settings(cx, &|language_settings| {
|
||||
language_settings.languages.0.insert(
|
||||
"Rust".into(),
|
||||
LanguageSettingsContent {
|
||||
semantic_tokens: Some(SemanticTokens::Full),
|
||||
..LanguageSettingsContent::default()
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// The server advertises no semantic tokens capability up front; it only
|
||||
// registers `textDocument/semanticTokens` dynamically, after the document
|
||||
// is already open (as Roslyn does).
|
||||
let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await;
|
||||
|
||||
let full_counter = Arc::new(AtomicUsize::new(0));
|
||||
let _full_request = cx
|
||||
.set_request_handler::<lsp::request::SemanticTokensFullRequest, _, _>({
|
||||
let full_counter = full_counter.clone();
|
||||
move |_, _, _| {
|
||||
full_counter.fetch_add(1, atomic::Ordering::Release);
|
||||
async move {
|
||||
Ok(Some(lsp::SemanticTokensResult::Tokens(
|
||||
lsp::SemanticTokens {
|
||||
data: vec![0, 3, 4, 0, 0],
|
||||
result_id: None,
|
||||
},
|
||||
)))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
cx.set_state("ˇfn main() {}");
|
||||
// Drain the refresh scheduled on open (while no capability exists yet), so a
|
||||
// later request can only come from the dynamic-registration refresh itself.
|
||||
cx.executor().advance_clock(Duration::from_millis(200));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
full_counter.load(atomic::Ordering::Acquire),
|
||||
0,
|
||||
"no semantic tokens should be requested before the capability is registered"
|
||||
);
|
||||
assert!(
|
||||
extract_semantic_highlights(&cx.editor, &cx).is_empty(),
|
||||
"no semantic highlights before the capability is registered"
|
||||
);
|
||||
|
||||
cx.lsp
|
||||
.request::<lsp::request::RegisterCapability>(
|
||||
lsp::RegistrationParams {
|
||||
registrations: vec![lsp::Registration {
|
||||
id: "semantic-tokens".to_string(),
|
||||
method: "textDocument/semanticTokens".to_string(),
|
||||
register_options: Some(
|
||||
serde_json::to_value(lsp::SemanticTokensRegistrationOptions {
|
||||
text_document_registration_options:
|
||||
lsp::TextDocumentRegistrationOptions {
|
||||
document_selector: None,
|
||||
},
|
||||
semantic_tokens_options: lsp::SemanticTokensOptions {
|
||||
legend: lsp::SemanticTokensLegend {
|
||||
token_types: vec!["function".into()],
|
||||
token_modifiers: Vec::new(),
|
||||
},
|
||||
full: Some(lsp::SemanticTokensFullOptions::Bool(true)),
|
||||
..lsp::SemanticTokensOptions::default()
|
||||
},
|
||||
static_registration_options: lsp::StaticRegistrationOptions {
|
||||
id: None,
|
||||
},
|
||||
})
|
||||
.unwrap(),
|
||||
),
|
||||
}],
|
||||
},
|
||||
lsp::DEFAULT_LSP_REQUEST_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
.into_response()
|
||||
.expect("register capability request failed");
|
||||
|
||||
cx.executor().advance_clock(Duration::from_millis(200));
|
||||
cx.run_until_parked();
|
||||
assert!(
|
||||
full_counter.load(atomic::Ordering::Acquire) >= 1,
|
||||
"dynamic registration should re-query semantic tokens for the open document"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
extract_semantic_highlights(&cx.editor, &cx),
|
||||
vec![MultiBufferOffset(3)..MultiBufferOffset(7)],
|
||||
"the open document should display semantic tokens after dynamic registration"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn lsp_semantic_tokens_full_none_result_id(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
|
|
|||
|
|
@ -934,7 +934,7 @@ impl LanguageServer {
|
|||
dynamic_registration: Some(true),
|
||||
}),
|
||||
semantic_tokens: Some(SemanticTokensClientCapabilities {
|
||||
dynamic_registration: Some(false),
|
||||
dynamic_registration: Some(true),
|
||||
requests: SemanticTokensClientCapabilitiesRequests {
|
||||
range: None,
|
||||
full: Some(SemanticTokensFullOptions::Delta { delta: Some(true) }),
|
||||
|
|
|
|||
|
|
@ -1153,26 +1153,11 @@ impl LocalLspStore {
|
|||
let request_id = request_id.clone();
|
||||
let mut cx = cx.clone();
|
||||
async move {
|
||||
lsp_store
|
||||
.update(&mut cx, |lsp_store, cx| {
|
||||
let request_id =
|
||||
Some(request_id.fetch_add(1, atomic::Ordering::AcqRel));
|
||||
cx.emit(LspStoreEvent::RefreshSemanticTokens {
|
||||
server_id,
|
||||
request_id,
|
||||
});
|
||||
lsp_store
|
||||
.downstream_client
|
||||
.as_ref()
|
||||
.map(|(client, project_id)| {
|
||||
client.send(proto::RefreshSemanticTokens {
|
||||
project_id: *project_id,
|
||||
server_id: server_id.to_proto(),
|
||||
request_id: request_id.map(|id| id as u64),
|
||||
})
|
||||
})
|
||||
})?
|
||||
.transpose()?;
|
||||
lsp_store.update(&mut cx, |lsp_store, cx| {
|
||||
let request_id =
|
||||
Some(request_id.fetch_add(1, atomic::Ordering::AcqRel));
|
||||
lsp_store.refresh_semantic_tokens(server_id, request_id, cx);
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -13213,6 +13198,23 @@ impl LspStore {
|
|||
notify_server_capabilities_updated(&server, cx);
|
||||
}
|
||||
}
|
||||
"textDocument/semanticTokens" => {
|
||||
if let Some(caps) = reg
|
||||
.register_options
|
||||
.map(serde_json::from_value::<lsp::SemanticTokensRegistrationOptions>)
|
||||
.transpose()?
|
||||
{
|
||||
server.update_capabilities(|capabilities| {
|
||||
capabilities.semantic_tokens_provider = Some(
|
||||
lsp::SemanticTokensServerCapabilities::SemanticTokensRegistrationOptions(caps),
|
||||
);
|
||||
});
|
||||
notify_server_capabilities_updated(&server, cx);
|
||||
// Re-query already-open buffers, which would otherwise keep
|
||||
// tree-sitter-only highlighting until edited.
|
||||
self.refresh_semantic_tokens(server_id, None, cx);
|
||||
}
|
||||
}
|
||||
_ => log::warn!("unhandled capability registration: {reg:?}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -13334,6 +13336,12 @@ impl LspStore {
|
|||
});
|
||||
notify_server_capabilities_updated(&server, cx);
|
||||
}
|
||||
"textDocument/semanticTokens" => {
|
||||
server.update_capabilities(|capabilities| {
|
||||
capabilities.semantic_tokens_provider = None;
|
||||
});
|
||||
notify_server_capabilities_updated(&server, cx);
|
||||
}
|
||||
"textDocument/didChange" => {
|
||||
server.update_capabilities(|capabilities| {
|
||||
let mut sync_options = Self::take_text_document_sync_options(capabilities);
|
||||
|
|
|
|||
|
|
@ -351,6 +351,29 @@ impl LspStore {
|
|||
}
|
||||
}
|
||||
|
||||
/// `request_id` orders per-server refreshes (a higher id invalidates the cache).
|
||||
/// Client-initiated refreshes (e.g. after dynamic registration) pass `None`.
|
||||
pub(crate) fn refresh_semantic_tokens(
|
||||
&mut self,
|
||||
server_id: LanguageServerId,
|
||||
request_id: Option<usize>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
cx.emit(LspStoreEvent::RefreshSemanticTokens {
|
||||
server_id,
|
||||
request_id,
|
||||
});
|
||||
if let Some((client, project_id)) = self.downstream_client.as_ref() {
|
||||
client
|
||||
.send(proto::RefreshSemanticTokens {
|
||||
project_id: *project_id,
|
||||
server_id: server_id.to_proto(),
|
||||
request_id: request_id.map(|id| id as u64),
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn handle_refresh_semantic_tokens(
|
||||
lsp_store: Entity<Self>,
|
||||
envelope: TypedEnvelope<proto::RefreshSemanticTokens>,
|
||||
|
|
|
|||
|
|
@ -2251,6 +2251,131 @@ async fn test_rescan_fs_change_is_reported_to_language_servers_as_changed(
|
|||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_dynamic_semantic_tokens_registration(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
path!("/the-root"),
|
||||
json!({
|
||||
"a.rs": "fn main() {}",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs.clone(), [path!("/the-root").as_ref()], cx).await;
|
||||
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
|
||||
language_registry.add(rust_lang());
|
||||
let mut fake_servers = language_registry.register_fake_lsp(
|
||||
"Rust",
|
||||
FakeLspAdapter {
|
||||
name: "the-language-server",
|
||||
// Crucially, no `semantic_tokens_provider` is advertised statically; the
|
||||
// server only offers it through dynamic registration (as Roslyn does).
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let _buffer = project
|
||||
.update(cx, |project, cx| {
|
||||
project.open_local_buffer_with_lsp(path!("/the-root/a.rs"), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let fake_server = fake_servers.next().await.unwrap();
|
||||
let server_id = fake_server.server.server_id();
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
let semantic_tokens_provider = |cx: &mut gpui::TestAppContext| {
|
||||
project.read_with(cx, |project, cx| {
|
||||
project
|
||||
.lsp_store()
|
||||
.read(cx)
|
||||
.lsp_server_capabilities
|
||||
.get(&server_id)
|
||||
.and_then(|capabilities| capabilities.semantic_tokens_provider.clone())
|
||||
})
|
||||
};
|
||||
|
||||
assert!(
|
||||
semantic_tokens_provider(cx).is_none(),
|
||||
"server should not advertise semantic tokens before dynamic registration"
|
||||
);
|
||||
|
||||
fake_server
|
||||
.request::<lsp::request::RegisterCapability>(
|
||||
lsp::RegistrationParams {
|
||||
registrations: vec![lsp::Registration {
|
||||
id: "semantic-tokens".to_string(),
|
||||
method: "textDocument/semanticTokens".to_string(),
|
||||
register_options: serde_json::to_value(
|
||||
lsp::SemanticTokensRegistrationOptions {
|
||||
text_document_registration_options:
|
||||
lsp::TextDocumentRegistrationOptions {
|
||||
document_selector: None,
|
||||
},
|
||||
semantic_tokens_options: lsp::SemanticTokensOptions {
|
||||
legend: lsp::SemanticTokensLegend {
|
||||
token_types: vec!["keyword".into(), "variable".into()],
|
||||
token_modifiers: vec![],
|
||||
},
|
||||
full: Some(lsp::SemanticTokensFullOptions::Bool(true)),
|
||||
..Default::default()
|
||||
},
|
||||
static_registration_options: lsp::StaticRegistrationOptions {
|
||||
id: None,
|
||||
},
|
||||
},
|
||||
)
|
||||
.ok(),
|
||||
}],
|
||||
},
|
||||
DEFAULT_LSP_REQUEST_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
.into_response()
|
||||
.unwrap();
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
let provider = semantic_tokens_provider(cx)
|
||||
.expect("semantic tokens provider should be set after dynamic registration");
|
||||
// The capability round-trips through capability-sync serialization, which may
|
||||
// normalize the registration options into plain options; either shape is fine
|
||||
// as long as the legend survives.
|
||||
let legend = match provider {
|
||||
lsp::SemanticTokensServerCapabilities::SemanticTokensOptions(options) => options.legend,
|
||||
lsp::SemanticTokensServerCapabilities::SemanticTokensRegistrationOptions(options) => {
|
||||
options.semantic_tokens_options.legend
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
legend.token_types,
|
||||
vec!["keyword".into(), "variable".into()],
|
||||
);
|
||||
|
||||
fake_server
|
||||
.request::<lsp::request::UnregisterCapability>(
|
||||
lsp::UnregistrationParams {
|
||||
unregisterations: vec![lsp::Unregistration {
|
||||
id: "semantic-tokens".to_string(),
|
||||
method: "textDocument/semanticTokens".to_string(),
|
||||
}],
|
||||
},
|
||||
DEFAULT_LSP_REQUEST_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
.into_response()
|
||||
.unwrap();
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
assert!(
|
||||
semantic_tokens_provider(cx).is_none(),
|
||||
"semantic tokens provider should be cleared after unregistration"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_reporting_fs_changes_to_language_servers(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue