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:
Artem Kalytiuk 2026-06-28 15:48:10 +02:00 committed by GitHub
parent afaef857b9
commit 2a93ca53fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 278 additions and 21 deletions

View file

@ -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, |_| {});

View file

@ -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) }),

View file

@ -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);

View file

@ -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>,

View file

@ -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);