From 4d224f80c96f32cd9219b69fa7add9e96a3e5b97 Mon Sep 17 00:00:00 2001 From: Andrea Basile Date: Tue, 3 Nov 2026 23:43:31 +0100 Subject: [PATCH] feat(lsp): per-server features and code-action-kinds filter (#1099) A [[language-servers]] entry (or languageServers. in settings) can list features and code-action-kinds: capabilities outside features are dropped after initialize and from later registrations, publishDiagnostics and Inspect Code only use servers with diagnostics, and code actions are narrowed to the kept kinds (advertised kinds and answers both). No built-in definition uses them yet. First stage of #901: lets a refactoring-only server sit next to the language's own one. Co-authored-by: andreabasile <7715018+andreabasile@users.noreply.github.com> --- crates/ide-core/src/settings.rs | 12 ++ crates/ide-language/src/lib.rs | 10 ++ crates/ide-lsp/src/caps.rs | 259 ++++++++++++++++++++++++++- crates/ide-lsp/src/handler.rs | 4 + crates/ide-lsp/src/inspect.rs | 1 + crates/ide-lsp/src/lib.rs | 13 +- crates/ide-lsp/tests/mock_server.rs | 60 +++++++ crates/ide-lsp/tests/real_servers.rs | 6 + crates/ide-sdk/src/apply.rs | 12 +- crates/ide-sdk/src/tests.rs | 22 +++ src/bindings.ts | 20 +++ 11 files changed, 414 insertions(+), 5 deletions(-) diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index ebb97835..5991a79f 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -157,6 +157,16 @@ pub struct LanguageServerSettings { pub languages: Vec, /// Files marking a workspace root for a server defined here (`pyproject.toml`). pub root_markers: Vec, + /// Replaces the definition's `features`: what the server is used for + /// (`code-action`, `hover`, …); an empty list means everything. + #[serde(skip_serializing_if = "Option::is_none")] + #[specta(optional)] + pub features: Option>, + /// Replaces the definition's `code-action-kinds` (`refactor`); an empty + /// list keeps every kind. + #[serde(skip_serializing_if = "Option::is_none")] + #[specta(optional)] + pub code_action_kinds: Option>, } impl Default for LanguageServerSettings { @@ -170,6 +180,8 @@ impl Default for LanguageServerSettings { settings: None, languages: Vec::new(), root_markers: Vec::new(), + features: None, + code_action_kinds: None, } } } diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index cef38d97..7054d781 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -131,6 +131,16 @@ pub struct LanguageServerConfig { /// basedpyright): only used while none of those is installed. #[serde(default)] pub fallback: bool, + /// What the server is used for (`completion`, `code-action`, `diagnostics`, + /// … `FEATURES` in `ide-lsp/src/caps.rs`); empty means everything. The other + /// capabilities it announces are ignored, so a refactoring-only server next + /// to the language's own one adds no completion, hover or formatting. + #[serde(default)] + pub features: Vec, + /// Code action kinds kept from the server, matched like LSP's `only` + /// (`refactor` keeps `refactor.extract`); empty keeps every kind. + #[serde(default)] + pub code_action_kinds: Vec, /// Newest Java release the server runs on (kotlin-language-server: 21). A /// newer `JAVA_HOME` is replaced by the newest installed JDK that fits. #[serde(default)] diff --git a/crates/ide-lsp/src/caps.rs b/crates/ide-lsp/src/caps.rs index da53501b..a20296ef 100644 --- a/crates/ide-lsp/src/caps.rs +++ b/crates/ide-lsp/src/caps.rs @@ -183,6 +183,8 @@ pub struct ServerCaps { /// `Some(include_text)` when the server wants `didSave`. pub save: Option, pub semantic_legend: Option<(Vec, Vec)>, + /// Applied to what the server announces, now and in later registrations. + pub restriction: Restriction, } /// LSP request method → server capability key, for features and dynamic registration. @@ -215,6 +217,104 @@ pub fn capability_key(method: &str) -> Option<&'static str> { }) } +/// Names for a server's `features` → the capability keys each one keeps. +pub const FEATURES: &[(&str, &[&str])] = &[ + ("completion", &["completionProvider"]), + ("inline-completion", &["inlineCompletionProvider"]), + ("hover", &["hoverProvider"]), + ("signature-help", &["signatureHelpProvider"]), + ("definition", &["definitionProvider"]), + ("declaration", &["declarationProvider"]), + ("type-definition", &["typeDefinitionProvider"]), + ("implementation", &["implementationProvider"]), + ("references", &["referencesProvider"]), + ("document-highlight", &["documentHighlightProvider"]), + ("document-symbol", &["documentSymbolProvider"]), + ("workspace-symbol", &["workspaceSymbolProvider"]), + ("code-action", &["codeActionProvider", "executeCommandProvider"]), + ("code-lens", &["codeLensProvider", "executeCommandProvider"]), + ("rename", &["renameProvider"]), + ( + "formatting", + &["documentFormattingProvider", "documentRangeFormattingProvider", "documentOnTypeFormattingProvider"], + ), + ("inlay-hint", &["inlayHintProvider"]), + ("semantic-tokens", &["semanticTokensProvider"]), + ("document-link", &["documentLinkProvider"]), + ("call-hierarchy", &["callHierarchyProvider"]), + ("type-hierarchy", &["typeHierarchyProvider"]), + ("folding-range", &["foldingRangeProvider"]), + ("selection-range", &["selectionRangeProvider"]), + ("diagnostics", &["diagnosticProvider"]), +]; + +/// A server's `features` and `code-action-kinds`: the part of its capabilities +/// the IDE uses. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Restriction { + /// Allowed `*Provider` keys; `None` allows all. + keys: Option>, + /// Code action kind prefixes kept; empty keeps all. + action_kinds: Vec, +} + +impl Restriction { + /// Unknown feature names are logged and ignored. + pub fn new(server: &str, features: &[String], action_kinds: &[String]) -> Self { + let keys = (!features.is_empty()).then(|| { + let mut keys = Vec::new(); + for name in features { + match FEATURES.iter().find(|(n, _)| n == name) { + Some((_, k)) => keys.extend_from_slice(k), + None => tracing::warn!("{server}: unknown feature `{name}` in `features`"), + } + } + keys + }); + Self { keys, action_kinds: action_kinds.to_vec() } + } + + fn allows(&self, key: &str) -> bool { + self.keys.as_ref().is_none_or(|k| k.contains(&key)) + } + + /// Whether the server's `publishDiagnostics` are shown. + pub fn diagnostics(&self) -> bool { + self.allows("diagnosticProvider") + } + + /// Whether a code action of `kind` is kept; one without a kind only when + /// every kind is. + pub fn keeps_action(&self, kind: Option<&str>) -> bool { + self.action_kinds.is_empty() || kind.is_some_and(|k| self.action_kinds.iter().any(|p| kind_within(k, p))) + } + + /// The kinds the server can still return: those it lists that are kept, + /// narrowed to the kept prefixes below them (`refactor` → `refactor.extract`). + fn advertised_kinds(&self, listed: Option>) -> Vec { + let Some(listed) = listed else { return self.action_kinds.clone() }; + let mut out = Vec::new(); + for k in &listed { + let narrowed: Vec<&String> = if self.keeps_action(Some(k)) { + vec![k] + } else { + self.action_kinds.iter().filter(|p| kind_within(p, k)).collect() + }; + for k in narrowed { + if !out.contains(k) { + out.push(k.clone()); + } + } + } + out + } +} + +/// `kind` is `prefix` or below it (`refactor.extract` within `refactor`). +fn kind_within(kind: &str, prefix: &str) -> bool { + kind.strip_prefix(prefix).is_some_and(|rest| rest.is_empty() || rest.starts_with('.')) +} + fn truthy(v: Option<&Value>) -> bool { !matches!(v, None | Some(Value::Null) | Some(Value::Bool(false))) } @@ -244,7 +344,34 @@ impl ServerCaps { }; (strings("tokenTypes"), strings("tokenModifiers")) }); - Self { raw, encoding, open_close, sync, save, semantic_legend } + Self { raw, encoding, open_close, sync, save, semantic_legend, restriction: Restriction::default() } + } + + /// Drops the capabilities outside the server's `features`, and code action + /// kinds outside its `code-action-kinds`. Document sync, workspace options + /// and `experimental` stay. + pub fn restrict(&mut self, restriction: Restriction) { + if let Some(o) = self.raw.as_object_mut() { + o.retain(|key, _| !key.ends_with("Provider") || restriction.allows(key)); + } + if self.has("codeActionProvider") && !restriction.action_kinds.is_empty() { + let kinds = restriction.advertised_kinds(self.code_action_kinds()); + if kinds.is_empty() { + // It lists only kinds that aren't kept: nothing to ask it for. + if let Some(o) = self.raw.as_object_mut() { + o.remove("codeActionProvider"); + } + } else { + if !self.raw["codeActionProvider"].is_object() { + self.raw["codeActionProvider"] = json!({}); + } + self.raw["codeActionProvider"]["codeActionKinds"] = json!(kinds); + } + } + if !restriction.allows("semanticTokensProvider") { + self.semantic_legend = None; + } + self.restriction = restriction; } /// Whether the server statically advertises the capability under `key`. @@ -325,11 +452,18 @@ impl ServerCaps { self.raw["workspace"]["textDocumentContent"] = opts.clone(); return; } + if let Some(key) = capability_key(method) + && !self.restriction.allows(key) + { + return; + } if method == "textDocument/semanticTokens" && let Some(opts) = options { self.raw["semanticTokensProvider"] = opts.clone(); + let restriction = std::mem::take(&mut self.restriction); *self = Self::parse(std::mem::take(&mut self.raw)); + self.restriction = restriction; return; } if method == "textDocument/didSave" { @@ -349,6 +483,11 @@ impl ServerCaps { && !self.has(key) { self.raw[key] = options.cloned().filter(|o| o.is_object()).unwrap_or(Value::Bool(true)); + if key == "codeActionProvider" { + // Registered kinds go through the same narrowing as announced ones. + let restriction = std::mem::take(&mut self.restriction); + self.restrict(restriction); + } } } @@ -426,4 +565,122 @@ mod tests { c.unregister("workspace/willRenameFiles"); assert!(c.file_operation("willRename").is_none()); } + + fn restriction(features: &[&str], kinds: &[&str]) -> Restriction { + let owned = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::>(); + Restriction::new("test", &owned(features), &owned(kinds)) + } + + #[test] + fn features_drop_other_capabilities() { + let mut c = ServerCaps::parse(json!({ + "textDocumentSync": 2, + "completionProvider": {}, + "hoverProvider": true, + "documentFormattingProvider": true, + "foldingRangeProvider": true, + "codeActionProvider": { "resolveProvider": true }, + "executeCommandProvider": { "commands": ["rope.extract"] }, + "semanticTokensProvider": { "legend": { "tokenTypes": [], "tokenModifiers": [] }, "full": true }, + "workspace": { "fileOperations": { "willRename": { "filters": [] } } }, + "experimental": { "x": true } + })); + c.restrict(restriction(&["code-action", "nope"], &[])); + assert!(c.has("codeActionProvider") && c.has_sub("codeActionProvider", "resolveProvider")); + assert_eq!(c.commands(), ["rope.extract"]); + for key in ["completionProvider", "hoverProvider", "documentFormattingProvider", "foldingRangeProvider"] { + assert!(!c.has(key), "{key}"); + } + assert!(c.semantic_legend.is_none() && !c.semantic_tokens_full()); + assert_eq!((c.sync, c.open_close), (SyncKind::Incremental, true)); + assert!(c.file_operation("willRename").is_some() && c.raw["experimental"]["x"] == true); + assert!(!c.restriction.diagnostics()); + assert_eq!(c.code_action_kinds(), None, "no kind filter: the server may return any kind"); + + // Later registrations go through the same filter. + c.register("textDocument/hover", None); + c.register("textDocument/semanticTokens", Some(&json!({ "legend": {}, "full": true }))); + assert!(!c.has("hoverProvider") && !c.has("semanticTokensProvider")); + c.register("textDocument/didSave", None); + assert_eq!(c.save, Some(false)); + + let mut c = ServerCaps::parse(json!({ "hoverProvider": true, "semanticTokensProvider": { "full": true } })); + c.restrict(Restriction::default()); + assert!(c.has("hoverProvider") && c.restriction.diagnostics(), "no `features`: everything"); + c.restrict(restriction(&["semantic-tokens", "diagnostics"], &[])); + c.register("textDocument/semanticTokens", Some(&json!({ "legend": {}, "full": true }))); + assert!(!c.has("hoverProvider") && c.semantic_tokens_full() && c.restriction.diagnostics()); + c.register("textDocument/hover", None); + assert!(!c.has("hoverProvider"), "the restriction survives re-parsing semantic tokens"); + } + + #[test] + fn code_action_kinds_narrow_what_is_asked_and_kept() { + let r = restriction(&[], &["refactor", "source.fixAll"]); + assert!(r.keeps_action(Some("refactor")) && r.keeps_action(Some("refactor.extract.function"))); + assert!(r.keeps_action(Some("source.fixAll.ruff"))); + assert!(!r.keeps_action(Some("refactorx")) && !r.keeps_action(Some("source")) && !r.keeps_action(None)); + assert!(Restriction::default().keeps_action(None)); + + let kinds = |provider: Value| { + let mut c = ServerCaps::parse(json!({ "codeActionProvider": provider })); + c.restrict(r.clone()); + c.has("codeActionProvider").then(|| c.code_action_kinds().unwrap()) + }; + assert_eq!(kinds(json!(true)), Some(vec!["refactor".into(), "source.fixAll".into()])); + assert_eq!( + kinds(json!({ "codeActionKinds": ["quickfix", "refactor.extract", "source"] })), + Some(vec!["refactor.extract".into(), "source.fixAll".into()]), + ); + assert_eq!(kinds(json!({ "codeActionKinds": ["quickfix"] })), None, "nothing it lists is kept"); + + let mut c = ServerCaps::parse(json!({})); + c.restrict(r.clone()); + c.register("textDocument/codeAction", Some(&json!({ "codeActionKinds": ["quickfix", "refactor.inline"] }))); + assert_eq!(c.code_action_kinds(), Some(vec!["refactor.inline".into()])); + } + + #[test] + fn language_definitions_use_known_feature_names() { + for lang in ide_language::LanguageRegistry::load(None).unwrap().all() { + for server in &lang.language_servers { + for name in &server.features { + assert!(FEATURES.iter().any(|(n, _)| n == name), "{}: unknown feature `{name}`", server.name); + } + } + } + } + + #[test] + fn feature_names_cover_every_capability_the_ide_asks_for() { + let named: Vec<&str> = FEATURES.iter().flat_map(|(_, keys)| keys.iter().copied()).collect(); + for method in [ + "textDocument/completion", + "textDocument/hover", + "textDocument/signatureHelp", + "textDocument/definition", + "textDocument/declaration", + "textDocument/typeDefinition", + "textDocument/implementation", + "textDocument/references", + "textDocument/documentHighlight", + "textDocument/documentSymbol", + "workspace/symbol", + "textDocument/codeAction", + "workspace/executeCommand", + "textDocument/rename", + "textDocument/formatting", + "textDocument/rangeFormatting", + "textDocument/inlayHint", + "textDocument/inlineCompletion", + "textDocument/semanticTokens", + "textDocument/codeLens", + "textDocument/documentLink", + "textDocument/prepareCallHierarchy", + "textDocument/prepareTypeHierarchy", + ] { + let key = capability_key(method).unwrap(); + assert!(named.contains(&key), "{method} ({key}) has no feature name"); + } + } } diff --git a/crates/ide-lsp/src/handler.rs b/crates/ide-lsp/src/handler.rs index 11ab9696..006e4d09 100644 --- a/crates/ide-lsp/src/handler.rs +++ b/crates/ide-lsp/src/handler.rs @@ -111,6 +111,10 @@ impl ClientHandler { } fn publish_diagnostics(&self, mgr: &LspManager, params: Value) { + // Problems come only from servers used for `diagnostics`. + if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) { + return; + } let params: lsp::PublishDiagnosticsParams = match serde_json::from_value(params) { Ok(p) => p, Err(e) => { diff --git a/crates/ide-lsp/src/inspect.rs b/crates/ide-lsp/src/inspect.rs index 9fcf73e9..19c4e580 100644 --- a/crates/ide-lsp/src/inspect.rs +++ b/crates/ide-lsp/src/inspect.rs @@ -156,6 +156,7 @@ impl LspManager { .iter() .zip(ready) .filter_map(|(s, run)| Some(Target { server: s.clone(), run: run? })) + .filter(|t| t.caps().restriction.diagnostics()) .collect(); if targets.is_empty() { return Ok(None); diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 91de3dd3..97a94fc2 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -570,7 +570,10 @@ impl LspManager { if let Some(info) = result.get("serverInfo") { tracing::info!("{} initialized: {info}", server.name); } - ServerCaps::parse(result.get("capabilities").cloned().unwrap_or_else(|| json!({}))) + let mut caps = ServerCaps::parse(result.get("capabilities").cloned().unwrap_or_else(|| json!({}))); + let cfg = server.effective_config(); + caps.restrict(caps::Restriction::new(&server.name, &cfg.features, &cfg.code_action_kinds)); + caps } Err(e) => { tracing::warn!("{}: initialize failed: {e}", server.name); @@ -1593,8 +1596,14 @@ impl LspManager { Ok(actions) => { let conv = self.conv(t.enc()); let acc = out.value.get_or_insert_with(Vec::new); + let caps = t.caps(); acc.extend( - actions.unwrap_or_default().into_iter().map(|a| convert::code_action(&conv, a, t.server.id)), + actions + .unwrap_or_default() + .into_iter() + .map(|a| convert::code_action(&conv, a, t.server.id)) + // Servers may ignore `only`, and the kinds kept for this one. + .filter(|a| caps.restriction.keeps_action(a.kind.as_deref())), ); } Err(e) => out.fail(e), diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index fef3fb36..c9889ae7 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -225,6 +225,8 @@ fn server_cfg(name: &str) -> LanguageServerConfig { install_hint: Some(format!("install {name}")), connect: None, fallback: false, + features: Vec::new(), + code_action_kinds: Vec::new(), max_java: None, launch_error: None, tsserver_project: false, @@ -1749,6 +1751,64 @@ async fn code_action_query_without_listed_kinds() { assert_eq!(q.advertised_kinds, None, "`codeActionProvider` without `codeActionKinds` may return any kind"); } +#[tokio::test(flavor = "multi_thread")] +async fn features_limit_what_a_server_is_used_for() { + // A refactoring-only server listed first, announcing everything the main one does. + let h = harness_configured("mock", "mk", &[("rope", incremental_caps()), ("main", incremental_caps())], |cfg| { + if cfg.name == "rope" { + cfg.features = vec!["code-action".into()]; + cfg.code_action_kinds = vec!["refactor".into()]; + } + }); + let (doc, path) = h.open("a.mk", "let x = 1;\n"); + for name in ["rope", "main"] { + h.wait_status(name, ServerStatus::Ready).await; + } + let (rope, main) = (h.launcher.state("rope"), h.launcher.state("main")); + for s in [&rope, &main] { + s.respond("textDocument/hover", json!({ "contents": "x" })); + s.respond("textDocument/formatting", json!([])); + } + + h.mgr.hover(doc, Pos::new(0, 4), Default::default()).await.unwrap(); + assert_eq!(main.requested("textDocument/hover").len(), 1); + h.mgr + .format(doc, None, FormatOptions { tab_size: 2, insert_spaces: true, on_save: false, tool: None }) + .await + .unwrap(); + assert_eq!(main.requested("textDocument/formatting").len(), 1); + assert!(rope.requested("textDocument/hover").is_empty() && rope.requested("textDocument/formatting").is_empty()); + + rope.respond( + "textDocument/codeAction", + json!([ + { "title": "Organize imports", "kind": "source.organizeImports" }, + { "title": "Extract variable", "kind": "refactor.extract.variable" }, + { "title": "Run", "command": "mock.run" } + ]), + ); + main.respond("textDocument/codeAction", json!([{ "title": "Fix", "kind": "quickfix" }])); + let q = h.mgr.code_action_query(doc, rng(0, 8, 0, 9), vec![], ide_core::CancellationToken::new()).await.unwrap(); + let mut titles: Vec<&str> = q.actions.iter().map(|a| a.title.as_str()).collect(); + titles.sort(); + assert_eq!(titles, ["Extract variable", "Fix"], "rope's other kinds and bare commands are dropped"); + + let diag = + json!([{ "range": {"start":{"line":0,"character":4},"end":{"line":0,"character":5}}, "message": "unused" }]); + rope.peer() + .notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&path), "diagnostics": diag })) + .unwrap(); + main.peer() + .notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&path), "diagnostics": diag })) + .unwrap(); + eventually(|| { + h.events.lock().iter().any(|e| matches!(e, IdeEvent::Diagnostics { source, .. } if source == "main")) + }) + .await; + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::Diagnostics { source, .. } if source == "rope"))); +} + #[tokio::test(flavor = "multi_thread")] async fn shutdown_all_stops_gracefully() { let h = harness(&[("mock", incremental_caps())]); diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 3bd92275..35fa3314 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -129,6 +129,8 @@ async fn missing_binary_is_reported_with_install_hint() { install_hint: Some("npm i -g nope-ls".into()), connect: None, fallback: false, + features: Vec::new(), + code_action_kinds: Vec::new(), max_java: None, launch_error: None, tsserver_project: false, @@ -396,6 +398,8 @@ async fn typescript_language_server_end_to_end() { install_hint: None, connect: None, fallback: false, + features: Vec::new(), + code_action_kinds: Vec::new(), max_java: None, launch_error: None, tsserver_project: false, @@ -443,6 +447,8 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option LanguageServerConfi }), connect: None, fallback: false, + features: Vec::new(), + code_action_kinds: Vec::new(), max_java: None, launch_error: None, tsserver_project: false, @@ -562,11 +564,17 @@ pub fn server_launch_overrides(cfg: &mut LanguageServerConfig, s: &Settings) { } } -/// Environment, `initializationOptions` and `workspace/configuration` settings -/// from Settings › Language Servers; applied last so they win over derived values. +/// Environment, features, `initializationOptions` and `workspace/configuration` +/// settings from Settings › Language Servers; applied last so they win over derived values. pub fn server_protocol_overrides(cfg: &mut LanguageServerConfig, s: &Settings) { let Some(ls) = s.language_servers.get(&cfg.name) else { return }; cfg.env.extend(ls.env.iter().map(|(k, v)| (k.clone(), v.clone()))); + if let Some(features) = &ls.features { + cfg.features = features.clone(); + } + if let Some(kinds) = &ls.code_action_kinds { + cfg.code_action_kinds = kinds.clone(); + } if let Some(options) = &ls.initialization_options { merge_init_options(cfg, options.clone()); } diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 3117fdda..b3ad69e0 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -33,6 +33,8 @@ fn server(name: &str) -> LanguageServerConfig { install_hint: None, connect: None, fallback: false, + features: Vec::new(), + code_action_kinds: Vec::new(), max_java: None, launch_error: None, tsserver_project: false, @@ -329,6 +331,26 @@ fn language_server_settings_disable_add_and_override() { let init = serde_json::to_value(ra.initialization_options.unwrap()).unwrap(); assert_eq!(init, json!({ "checkOnSave": false, "check": { "command": "clippy" } })); assert_eq!(ra.settings, Some(json!({ "rust-analyzer": { "cargo": { "features": "all" } } }))); + assert!(ra.features.is_empty() && ra.code_action_kinds.is_empty(), "no setting keeps the definition's"); + + // `features` / `codeActionKinds` replace the definition's; empty lifts them. + let mut pylsp = server("pylsp"); + pylsp.features = vec!["code-action".into()]; + pylsp.code_action_kinds = vec!["refactor".into()]; + s.language_servers.insert( + "pylsp".into(), + LanguageServerSettings { + features: Some(vec!["code-action".into(), "hover".into()]), + code_action_kinds: Some(vec![]), + ..Default::default() + }, + ); + server_protocol_overrides(&mut pylsp, &s); + assert_eq!(pylsp.features, ["code-action", "hover"]); + assert!(pylsp.code_action_kinds.is_empty()); + let parsed: LanguageServerSettings = + serde_json::from_value(json!({ "features": ["code-action"], "codeActionKinds": ["refactor"] })).unwrap(); + assert_eq!(parsed.code_action_kinds, Some(vec!["refactor".to_string()])); } #[test] diff --git a/src/bindings.ts b/src/bindings.ts index b8048c35..5e89fae4 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -4319,6 +4319,16 @@ export type LanguageServerSettings_Deserialize = { languages?: string[], /** Files marking a workspace root for a server defined here (`pyproject.toml`). */ rootMarkers?: string[], + /** + * Replaces the definition's `features`: what the server is used for + * (`code-action`, `hover`, …); an empty list means everything. + */ + features?: string[] | null, + /** + * Replaces the definition's `code-action-kinds` (`refactor`); an empty + * list keeps every kind. + */ + codeActionKinds?: string[] | null, }; export type LanguageServerSettings_Serialize = { @@ -4347,6 +4357,16 @@ export type LanguageServerSettings_Serialize = { languages: string[], /** Files marking a workspace root for a server defined here (`pyproject.toml`). */ rootMarkers: string[], + /** + * Replaces the definition's `features`: what the server is used for + * (`code-action`, `hover`, …); an empty list means everything. + */ + features?: string[] | null, + /** + * Replaces the definition's `code-action-kinds` (`refactor`); an empty + * list keeps every kind. + */ + codeActionKinds?: string[] | null, }; export type LanguagesSettings = {