feat: preconfigure Python with Astral ty and Ruff

This commit is contained in:
2026-10-09 21:11:07 +02:00
parent 2c3c73e5b9
commit 548711980c
9 changed files with 1251 additions and 127 deletions
+211 -72
View File
@@ -1,5 +1,5 @@
use std::{
collections::{HashMap, VecDeque},
collections::{BTreeMap, HashMap, HashSet, VecDeque},
io::{Read, Write},
path::{Component, Path, PathBuf},
process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, Stdio},
@@ -39,7 +39,7 @@ pub struct ServerConfig {
pub settings: Value,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
struct ServerKey {
config: usize,
root: PathBuf,
@@ -47,12 +47,26 @@ struct ServerKey {
struct CachedDocument {
snapshot: Arc<DocumentSnapshot>,
server: Option<ServerKey>,
servers: Vec<ServerKey>,
diagnostics: HashMap<usize, Contribution>,
}
struct Contribution {
items: Vec<Diagnostic>,
bytes: usize,
}
enum Output {
Update(PluginUpdate),
Diagnostics {
snapshot: Arc<DocumentSnapshot>,
items: Vec<Diagnostic>,
},
}
struct Worker {
input: SyncSender<Input>,
output: Receiver<PluginUpdate>,
output: Receiver<Output>,
failure: Receiver<String>,
cancelled: Arc<AtomicBool>,
retired: bool,
@@ -98,8 +112,9 @@ pub struct Lsp {
configs: Vec<Arc<ServerConfig>>,
cwd: PathBuf,
documents: HashMap<usize, CachedDocument>,
servers: HashMap<ServerKey, Worker>,
servers: BTreeMap<ServerKey, Worker>,
updates: Vec<PluginUpdate>,
changed_diagnostics: HashSet<usize>,
}
impl Lsp {
@@ -108,8 +123,9 @@ impl Lsp {
configs: configs.into_iter().map(Arc::new).collect(),
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
documents: HashMap::new(),
servers: HashMap::new(),
servers: BTreeMap::new(),
updates: Vec::new(),
changed_diagnostics: HashSet::new(),
}
}
@@ -133,31 +149,65 @@ impl Lsp {
.map(|cached| Arc::clone(&cached.snapshot))
.unwrap_or_else(|| Arc::new(event.document.clone()));
let mut startup_error = None;
let key = snapshot.path.as_ref().and_then(|path| {
let extension = Path::new(path).extension()?.to_str()?;
let config = self.configs.iter().position(|config| {
config
.extensions
.iter()
.any(|candidate| candidate == extension)
})?;
Some(ServerKey {
config,
root: absolute(Path::new(&snapshot.root), &self.cwd),
})
});
if matches!(event.event, DocumentEventKind::Close) {
self.documents.remove(&snapshot.id);
} else {
self.changed_diagnostics.remove(&snapshot.id);
} else if self.documents.get(&snapshot.id).is_none_or(|cached| {
cached.snapshot.path != snapshot.path || cached.snapshot.root != snapshot.root
}) {
let keys = snapshot
.path
.as_ref()
.and_then(|path| {
let extension = Path::new(path).extension()?.to_str()?;
let matches = |config: &ServerConfig| {
config
.extensions
.iter()
.any(|candidate| candidate == extension)
};
let language = &self
.configs
.iter()
.find(|config| matches(config))?
.language_id;
let root = absolute(Path::new(&snapshot.root), &self.cwd);
Some(
self.configs
.iter()
.enumerate()
.filter(|(_, config)| {
config.language_id == *language && matches(config)
})
.map(|(config, _)| ServerKey {
config,
root: root.clone(),
})
.collect::<Vec<_>>(),
)
})
.unwrap_or_default();
self.documents.insert(
snapshot.id,
CachedDocument {
snapshot: Arc::clone(&snapshot),
server: key.clone(),
servers: keys,
diagnostics: HashMap::new(),
},
);
if let Some(key) = &key {
startup_error = self.start(key).err();
self.changed_diagnostics.insert(snapshot.id);
for index in 0..self.documents[&snapshot.id].servers.len() {
let key = self.documents[&snapshot.id].servers[index].clone();
if let Err(error) = self.start(&key) {
startup_error.get_or_insert(error);
}
}
} else {
let cached = self.documents.get_mut(&snapshot.id).unwrap();
if !Arc::ptr_eq(&cached.snapshot, &snapshot) {
cached.snapshot = Arc::clone(&snapshot);
cached.diagnostics.clear();
self.changed_diagnostics.insert(snapshot.id);
}
}
let mut failures = Vec::new();
@@ -170,7 +220,10 @@ impl Lsp {
.try_send(Input::Document {
snapshot: Arc::clone(&snapshot),
event: event.event,
served: key.as_ref() == Some(server_key),
served: self
.documents
.get(&snapshot.id)
.is_some_and(|doc| doc.servers.contains(server_key)),
})
.is_err()
{
@@ -245,13 +298,85 @@ impl Lsp {
for (key, reason) in failures {
self.retire(&key, reason);
}
let mut updates = std::mem::take(&mut self.updates);
for worker in self.servers.values() {
if !worker.retired {
updates.extend(worker.output.try_iter().take(QUEUE_SIZE));
// Ordered storage keeps attribution deterministic without per-poll sorting.
for (key, worker) in &mut self.servers {
if worker.retired {
continue;
}
let first_update = self.updates.len();
for _ in 0..QUEUE_SIZE {
let Ok(output) = worker.output.try_recv() else {
break;
};
match output {
Output::Update(update) => self.updates.push(update),
Output::Diagnostics { snapshot, items } => {
let Some(doc) = self.documents.get_mut(&snapshot.id).filter(|doc| {
doc.servers.contains(key) && Arc::ptr_eq(&doc.snapshot, &snapshot)
}) else {
continue;
};
let bytes = items.iter().map(|item| item.message.len()).sum::<usize>();
let siblings = doc
.diagnostics
.iter()
.filter(|(config, _)| **config != key.config)
.map(|(_, contribution)| contribution.bytes)
.sum::<usize>();
if bytes > MAX_BODY.saturating_sub(siblings) {
self.updates.truncate(first_update);
Self::retire_worker(
key,
worker,
&mut self.documents,
&mut self.changed_diagnostics,
&mut self.updates,
"LSP server stopped: combined diagnostic messages exceed 4 MiB"
.into(),
);
break;
}
if items.is_empty() {
doc.diagnostics.remove(&key.config);
} else {
doc.diagnostics
.insert(key.config, Contribution { items, bytes });
}
self.changed_diagnostics.insert(snapshot.id);
}
}
}
}
updates
let effects: Vec<_> = self
.changed_diagnostics
.drain()
.filter_map(|id| {
let doc = self.documents.get(&id)?;
let count = doc
.diagnostics
.values()
.map(|value| value.items.len())
.sum();
let mut items = Vec::with_capacity(count);
for key in &doc.servers {
if let Some(contribution) = doc.diagnostics.get(&key.config) {
items.extend(contribution.items.iter().cloned());
}
}
Some(UpdateEffect::Diagnostics {
buffer_id: id,
revision: doc.snapshot.revision,
items,
})
})
.collect();
if !effects.is_empty() {
self.updates.push(PluginUpdate {
request_id: None,
effects,
});
}
std::mem::take(&mut self.updates)
}
fn start(&mut self, key: &ServerKey) -> Result<(), String> {
@@ -271,7 +396,7 @@ impl Lsp {
let initial: Vec<_> = self
.documents
.values()
.map(|doc| (Arc::clone(&doc.snapshot), doc.server.as_ref() == Some(key)))
.map(|doc| (Arc::clone(&doc.snapshot), doc.servers.contains(key)))
.collect();
let started = thread::Builder::new()
.name(format!("lsp-{}", config.language_id))
@@ -328,7 +453,7 @@ impl Lsp {
let key = self
.documents
.get(&buffer_id)
.and_then(|doc| doc.server.clone());
.and_then(|doc| doc.servers.first().cloned());
if let Some(key) = key
&& let Some(worker) = self.servers.get(&key)
&& !worker.retired
@@ -353,23 +478,39 @@ impl Lsp {
let Some(worker) = self.servers.get_mut(key) else {
return;
};
Self::retire_worker(
key,
worker,
&mut self.documents,
&mut self.changed_diagnostics,
&mut self.updates,
reason,
);
}
fn retire_worker(
key: &ServerKey,
worker: &mut Worker,
documents: &mut HashMap<usize, CachedDocument>,
changed: &mut HashSet<usize>,
updates: &mut Vec<PluginUpdate>,
reason: String,
) {
if worker.retired {
return;
}
worker.retired = true;
worker.cancelled.store(true, Ordering::Release);
let mut effects: Vec<_> = self
.documents
.values()
.filter(|doc| doc.server.as_ref() == Some(key))
.map(|doc| clear(&doc.snapshot))
.collect();
effects.push(UpdateEffect::Message { text: reason });
// At most one retirement update per bounded worker, independently of the
// command-message queue, so a full queue cannot hide diagnostic cleanup.
self.updates.push(PluginUpdate {
for (id, doc) in documents {
if doc.servers.contains(key) {
doc.diagnostics.remove(&key.config);
changed.insert(*id);
}
}
// Retirement cleanup remains independent of the command-message queue.
updates.push(PluginUpdate {
request_id: None,
effects,
effects: vec![UpdateEffect::Message { text: reason }],
});
}
}
@@ -415,7 +556,7 @@ struct Runtime {
config: Arc<ServerConfig>,
root: PathBuf,
input: Receiver<Input>,
output: SyncSender<PluginUpdate>,
output: SyncSender<Output>,
cancelled: Arc<AtomicBool>,
transport: Transport,
snapshots: HashMap<usize, Arc<DocumentSnapshot>>,
@@ -434,7 +575,7 @@ impl Runtime {
config: Arc<ServerConfig>,
root: PathBuf,
input: Receiver<Input>,
output: SyncSender<PluginUpdate>,
output: SyncSender<Output>,
cancelled: Arc<AtomicBool>,
) -> Result<Self, String> {
let transport = Transport::spawn(&config.command, &root)?;
@@ -528,7 +669,22 @@ impl Runtime {
}
fn emit(&self, update: PluginUpdate) -> Result<(), String> {
self.output.try_send(update).map_err(|error| match error {
self.send(Output::Update(update))
}
fn emit_diagnostics(
&self,
snapshot: &Arc<DocumentSnapshot>,
items: Vec<Diagnostic>,
) -> Result<(), String> {
self.send(Output::Diagnostics {
snapshot: Arc::clone(snapshot),
items,
})
}
fn send(&self, output: Output) -> Result<(), String> {
self.output.try_send(output).map_err(|error| match error {
TrySendError::Full(_) => "LSP incoming update queue is full".into(),
TrySendError::Disconnected(_) => "LSP client disconnected".into(),
})
@@ -553,10 +709,7 @@ impl Runtime {
if !served {
if let Some(old) = old {
self.close(&old)?;
self.emit(PluginUpdate {
request_id: None,
effects: vec![clear(&snapshot)],
})?;
self.emit_diagnostics(&snapshot, Vec::new())?;
}
return Ok(());
}
@@ -587,10 +740,7 @@ impl Runtime {
saved: matches!(event, DocumentEventKind::Save),
};
if changed {
self.emit(PluginUpdate {
request_id: None,
effects: vec![clear(&doc.snapshot)],
})?;
self.emit_diagnostics(&doc.snapshot, Vec::new())?;
}
if self.ready {
if !doc.opened {
@@ -937,21 +1087,14 @@ impl Runtime {
),
});
}
self.emit(PluginUpdate {
request_id: None,
effects: vec![UpdateEffect::Diagnostics {
buffer_id: doc.snapshot.id,
revision: doc.snapshot.revision,
items: diagnostics,
}],
})
self.emit_diagnostics(&doc.snapshot, diagnostics)
}
fn definition_result(&self, source: Source, result: &Value) -> Result<(), String> {
if !self
if self
.documents
.get(&source.buffer_id)
.is_some_and(|doc| doc.snapshot.revision == source.revision)
.is_none_or(|doc| doc.snapshot.revision != source.revision)
{
return Ok(());
}
@@ -1084,7 +1227,11 @@ impl Runtime {
if self.transport.flush().is_err() || self.transport.read().is_err() {
break;
}
if exiting && self.transport.flushed() {
// A flushed exit notification is not proof the peer has consumed it.
if exiting
&& self.transport.flushed()
&& self.transport.child.try_wait().ok().flatten().is_some()
{
break;
}
match self.transport.frame() {
@@ -1651,14 +1798,6 @@ unsafe extern "C" {
fn kill(pid: i32, signal: i32) -> i32;
}
fn clear(snapshot: &DocumentSnapshot) -> UpdateEffect {
UpdateEffect::Diagnostics {
buffer_id: snapshot.id,
revision: snapshot.revision,
items: Vec::new(),
}
}
fn message(text: &str, request_id: Option<u64>) -> PluginUpdate {
PluginUpdate {
request_id,
+115 -38
View File
@@ -10,10 +10,10 @@ pub fn resolve(value: Value) -> Result<Vec<ServerConfig>, String> {
if language_id.is_empty() || language_id.chars().any(char::is_control) {
return Err("LSP language keys must not be empty or contain control characters".into());
}
let preset: Option<(&[&str], &str)> = match language_id.as_str() {
"rust" => Some((&["rust-analyzer"], "rs")),
"python" => Some((&["pylsp"], "py")),
"ruby" => Some((&["solargraph", "stdio"], "rb")),
let preset: Option<(&[&[&str]], &str)> = match language_id.as_str() {
"rust" => Some((&[&["rust-analyzer"]], "rs")),
"python" => Some((&[&["ty", "server"], &["ruff", "server"]], "py")),
"ruby" => Some((&[&["solargraph", "stdio"]], "rb")),
_ => None,
};
let mut options = match value {
@@ -36,55 +36,134 @@ pub fn resolve(value: Value) -> Result<Vec<ServerConfig>, String> {
for key in options.keys() {
if !matches!(
key.as_str(),
"command" | "extensions" | "initialization_options" | "settings"
"command" | "extensions" | "initialization_options" | "settings" | "servers"
) {
return Err(format!(
"LSP language {language_id:?} has unknown option {key:?}"
));
}
}
let command = match options.remove("command") {
Some(value) => command(value, &language_id)?,
None => match preset {
Some((command, _)) => command.iter().map(|argument| (*argument).into()).collect(),
None => {
return Err(format!(
"LSP language {language_id:?} requires command argv"
));
}
},
};
let extensions = match options.remove("extensions") {
let mut extensions = match options.remove("extensions") {
Some(value) => extensions(value, &language_id)?,
None => match preset {
Some((_, extension)) => vec![extension.into()],
None => return Err(format!("LSP language {language_id:?} requires extensions")),
},
};
let initialization_options = object(
options.remove("initialization_options"),
&language_id,
"initialization_options",
)?;
let settings = object(options.remove("settings"), &language_id, "settings")?;
servers.push(ServerConfig {
language_id,
command,
extensions,
initialization_options,
settings,
});
if servers.len() > 16 {
return Err("LSP configuration supports at most 16 enabled languages".into());
let explicit_servers = options.contains_key("servers");
let configurations = match options.remove("servers") {
Some(value) => {
if ["command", "initialization_options", "settings"]
.iter()
.any(|key| options.contains_key(*key))
{
return Err(format!(
"LSP language {language_id:?} servers cannot be combined with command, initialization_options, or settings"
));
}
let Value::Array(configurations) = value else {
return Err(format!(
"LSP language {language_id:?} servers must be a nonempty array of configuration objects"
));
};
if configurations.is_empty() {
return Err(format!(
"LSP language {language_id:?} servers must be a nonempty array of configuration objects"
));
}
configurations
}
None if options.contains_key("command") => vec![Value::Object(options)],
None => {
let Some((commands, _)) = preset else {
return Err(format!(
"LSP language {language_id:?} requires command argv or servers"
));
};
let mut configurations = Vec::with_capacity(commands.len());
for (index, command) in commands.iter().enumerate() {
let mut configuration = if index + 1 == commands.len() {
std::mem::take(&mut options)
} else {
options.clone()
};
configuration.insert(
"command".into(),
Value::Array(
command
.iter()
.map(|argument| Value::from(*argument))
.collect(),
),
);
if language_id == "python" && index == 0 {
configuration
.entry("initialization_options")
.or_insert_with(
|| serde_json::json!({"experimental": {"useUv": "off"}}),
);
}
configurations.push(Value::Object(configuration));
}
configurations
}
};
if servers.len() + configurations.len() > 16 {
return Err("LSP configuration supports at most 16 configured servers".into());
}
let configuration_count = configurations.len();
let mut language_id = language_id;
for (index, configuration) in configurations.into_iter().enumerate() {
let context = if explicit_servers {
format!("LSP language {language_id:?} servers[{index}]")
} else {
format!("LSP language {language_id:?}")
};
let Value::Object(mut configuration) = configuration else {
return Err(format!("{context} must be a configuration object"));
};
for key in configuration.keys() {
if !matches!(
key.as_str(),
"command" | "initialization_options" | "settings"
) {
return Err(format!("{context} has unknown option {key:?}"));
}
}
let command = match configuration.remove("command") {
Some(value) => command(value, &context)?,
None => return Err(format!("{context} requires command argv")),
};
let initialization_options = object(
configuration.remove("initialization_options"),
&context,
"initialization_options",
)?;
let settings = object(configuration.remove("settings"), &context, "settings")?;
servers.push(ServerConfig {
language_id: if index + 1 == configuration_count {
std::mem::take(&mut language_id)
} else {
language_id.clone()
},
command,
extensions: if index + 1 == configuration_count {
std::mem::take(&mut extensions)
} else {
extensions.clone()
},
initialization_options,
settings,
});
}
}
Ok(servers)
}
fn command(value: Value, language: &str) -> Result<Vec<String>, String> {
fn command(value: Value, context: &str) -> Result<Vec<String>, String> {
let error = || {
format!(
"LSP language {language:?} command must be nonempty argv with a nonempty executable and no NUL bytes"
"{context} command must be nonempty argv with a nonempty executable and no NUL bytes"
)
};
let Value::Array(values) = value else {
@@ -137,12 +216,10 @@ fn extensions(value: Value, language: &str) -> Result<Vec<String>, String> {
Ok(extensions)
}
fn object(value: Option<Value>, language: &str, option: &str) -> Result<Value, String> {
fn object(value: Option<Value>, context: &str, option: &str) -> Result<Value, String> {
match value {
None => Ok(Value::Object(serde_json::Map::new())),
Some(value) if value.is_object() => Ok(value),
Some(_) => Err(format!(
"LSP language {language:?} {option} must be a JSON object"
)),
Some(_) => Err(format!("{context} {option} must be a JSON object")),
}
}
+2 -1
View File
@@ -37,8 +37,9 @@ Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
Set VAMP_RUBY to choose the Ruby executable.
Native LSP is disabled by default: config.lsp[\"rust\"] = true enables a preset
Presets: rust -> rust-analyzer, python -> pylsp, ruby -> solargraph stdio
Presets: rust -> rust-analyzer, python -> ty server + ruff server, ruby -> solargraph stdio
Custom languages: config.lsp[\"my-language\"] = { command: [...], extensions: [...] }
Server stacks: config.lsp[\"python\"] = { servers: [{ command: [...] }, { command: [...] }] }
LSP commands: :lsp.definition, :lsp.diagnostics, :lsp.hover
Hover binding: config.bind \"K\", config.core.lsp.hover (no default key)";
+226 -5
View File
@@ -981,9 +981,9 @@ end"#,
end"#,
);
let config = load_config(&path).unwrap();
assert_eq!(config.lsp.len(), 4);
for (language, command, extension) in [
("rust", &["rust-analyzer"][..], "rs"),
("python", &["pylsp"][..], "py"),
("ruby", &["solargraph", "stdio"][..], "rb"),
] {
let server = config
@@ -996,6 +996,25 @@ end"#,
assert_eq!(server.initialization_options, serde_json::json!({}));
assert_eq!(server.settings, serde_json::json!({}));
}
let python: Vec<_> = config
.lsp
.iter()
.filter(|server| server.language_id == "python")
.collect();
assert_eq!(python.len(), 2);
for (server, command, initialization_options) in [
(
python[0],
["ty", "server"],
serde_json::json!({"experimental": {"useUv": "off"}}),
),
(python[1], ["ruff", "server"], serde_json::json!({})),
] {
assert_eq!(server.command, command);
assert_eq!(server.extensions, ["py"]);
assert_eq!(server.initialization_options, initialization_options);
assert_eq!(server.settings, serde_json::json!({}));
}
let path = fixture.file(
"config.rb",
@@ -1005,7 +1024,7 @@ end"#,
initialization_options: {cargo: {features: ["α"]}},
settings: {"rust-analyzer" => {check: {command: "clippy"}}}
}
config.lsp["python"] = {command: ["custom pylsp", "--stdio"]}
config.lsp["python"] = {command: ["custom-python-server", "--stdio"]}
config.lsp["ruby"] = {command: ["bundle", "exec", "solargraph", "stdio"]}
config.lsp["my-language.c++"] = {
command: ["custom server", "--flag", ""],
@@ -1035,10 +1054,18 @@ end"#,
.iter()
.find(|server| server.language_id == "python")
.unwrap();
assert_eq!(python.command, ["custom pylsp", "--stdio"]);
assert_eq!(python.command, ["custom-python-server", "--stdio"]);
assert_eq!(python.extensions, ["py"]);
assert_eq!(python.initialization_options, serde_json::json!({}));
assert_eq!(python.settings, serde_json::json!({}));
assert_eq!(
config
.lsp
.iter()
.filter(|server| server.language_id == "python")
.count(),
1
);
let ruby = config
.lsp
.iter()
@@ -1060,6 +1087,182 @@ end"#,
);
}
#[test]
fn lsp_python_common_options_and_ordered_server_overrides() {
let fixture = Fixture::new();
let path = fixture.file(
"config.rb",
r#"Vamp.configure do |config|
config.lsp["python"] = {
extensions: ["py", "pyi"],
initialization_options: {diagnosticMode: "workspace"},
settings: {shared: true}
}
end"#,
);
let config = load_config(&path).unwrap();
assert_eq!(config.lsp.len(), 2);
for (server, command) in config
.lsp
.iter()
.zip([["ty", "server"], ["ruff", "server"]])
{
assert_eq!(server.language_id, "python");
assert_eq!(server.command, command);
assert_eq!(server.extensions, ["py", "pyi"]);
assert_eq!(
server.initialization_options,
serde_json::json!({"diagnosticMode": "workspace"})
);
assert_eq!(server.settings, serde_json::json!({"shared": true}));
}
let path = fixture.file(
"config.rb",
r#"Vamp.configure do |config|
config.lsp["python"] = {servers: [
{command: ["type-server", "--stdio"], initialization_options: {experimental: {useUv: "off"}}, settings: {first: true}},
{command: ["lint-server"], initialization_options: {settings: {lint: {enable: true}}}}
]}
config.lsp["custom"] = {extensions: ["custom"], servers: [
{command: ["custom-first"]}, {command: ["custom-second"], settings: {second: true}}
]}
end"#,
);
let config = load_config(&path).unwrap();
for (language, commands, extensions) in [
(
"python",
[&["type-server", "--stdio"][..], &["lint-server"][..]],
["py"],
),
(
"custom",
[&["custom-first"][..], &["custom-second"][..]],
["custom"],
),
] {
let servers: Vec<_> = config
.lsp
.iter()
.filter(|server| server.language_id == language)
.collect();
assert_eq!(servers.len(), 2);
for (server, command) in servers.iter().zip(commands) {
assert_eq!(server.command, command);
assert_eq!(server.extensions, extensions);
}
}
let python: Vec<_> = config
.lsp
.iter()
.filter(|server| server.language_id == "python")
.collect();
assert_eq!(
python[0].initialization_options,
serde_json::json!({"experimental": {"useUv": "off"}})
);
assert_eq!(python[0].settings, serde_json::json!({"first": true}));
assert_eq!(
python[1].initialization_options,
serde_json::json!({"settings": {"lint": {"enable": true}}})
);
assert_eq!(python[1].settings, serde_json::json!({}));
let custom: Vec<_> = config
.lsp
.iter()
.filter(|server| server.language_id == "custom")
.collect();
assert_eq!(custom[0].initialization_options, serde_json::json!({}));
assert_eq!(custom[1].settings, serde_json::json!({"second": true}));
let path = fixture.file(
"config.rb",
r#"Vamp.configure { |config| config.lsp["python"] = {settings: {shared: true}} }"#,
);
let config = load_config(&path).unwrap();
assert_eq!(
config.lsp[0].initialization_options,
serde_json::json!({"experimental": {"useUv": "off"}})
);
assert_eq!(config.lsp[1].initialization_options, serde_json::json!({}));
}
#[test]
fn lsp_server_lists_reject_invalid_shapes_and_ambiguous_options_locally() {
let fixture = Fixture::new();
for (options, error) in [
("{servers: nil}", "nonempty array"),
("{servers: {command: ['server']}}", "nonempty array"),
("{servers: []}", "nonempty array"),
(
"{servers: [true]}",
"servers[0] must be a configuration object",
),
("{servers: [{}]}", "servers[0] requires command argv"),
("{servers: [{command: []}]}", "servers[0] command must be"),
(
"{servers: [{command: ['server'], extensions: ['py']}]}",
"servers[0] has unknown option",
),
(
"{servers: [{command: ['server'], servers: []}]}",
"servers[0] has unknown option",
),
(
"{servers: [{command: ['server'], initialization_options: []}]}",
"servers[0] initialization_options must be",
),
(
"{servers: [{command: ['server']}, {command: ['other'], settings: nil}]}",
"servers[1] settings must be",
),
(
"{servers: [{command: ['server']}], command: ['other']}",
"cannot be combined",
),
(
"{servers: [{command: ['server']}], initialization_options: {}}",
"cannot be combined",
),
(
"{servers: [{command: ['server']}], settings: {}}",
"cannot be combined",
),
] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.lsp[\"python\"] = {options} }}"),
);
let actual = load_config(&path).unwrap_err();
assert!(
actual.starts_with("Ruby config: LSP language \"python\""),
"{actual}"
);
assert!(actual.contains(error), "{options}: {actual}");
}
}
#[test]
fn lsp_server_limit_is_enforced_at_configuration_startup() {
let fixture = Fixture::new();
for count in [16, 17] {
let path = fixture.file(
"config.rb",
&format!(
"Vamp.configure {{ |config| config.lsp[\"python\"] = {{servers: Array.new({count}) {{ {{command: [\"server\"]}} }} }} }}"
),
);
let result = load_config(&path);
if count == 16 {
assert_eq!(result.unwrap().lsp.len(), 16);
} else {
assert_eq!(
result.unwrap_err(),
"Ruby config: LSP configuration supports at most 16 configured servers"
);
}
}
}
#[test]
fn lsp_configuration_rejects_invalid_keys_values_and_json_options() {
let fixture = Fixture::new();
@@ -1074,6 +1277,7 @@ end"#,
r#"config.lsp["rust"] = "yes""#,
r#"config.lsp["custom"] = {}"#,
r#"config.lsp["custom"] = {command: ["server"]}"#,
r#"config.lsp["custom"] = {servers: [{command: ["server"]}]}"#,
r#"config.lsp["custom"] = {extensions: ["custom"]}"#,
r#"config.lsp["rust"] = {command: "server"}"#,
r#"config.lsp["rust"] = {command: []}"#,
@@ -1146,6 +1350,7 @@ end"#,
serde_json::json!({"": false}),
serde_json::json!({"bad\u{0085}key": false}),
serde_json::json!({"rust": null}),
serde_json::json!({"python": []}),
serde_json::json!({"unknown": true}),
serde_json::json!({"custom": {}}),
serde_json::json!({"rust": {"command": ["tool", "bad\0argument"]}}),
@@ -1153,6 +1358,11 @@ end"#,
serde_json::json!({"rust": {"initialization_options": []}}),
serde_json::json!({"rust": {"settings": "not-an-object"}}),
serde_json::json!({"rust": {"unexpected": true}}),
serde_json::json!({"python": {"servers": []}}),
serde_json::json!({"python": {"servers": [null]}}),
serde_json::json!({"python": {"servers": [{}]}}),
serde_json::json!({"python": {"servers": [{"command": ["server"], "extensions": ["py"]}]}}),
serde_json::json!({"python": {"servers": [{"command": ["server"]}], "settings": {}}}),
] {
frame["lsp"] = value;
let Response::Config { lsp, .. } =
@@ -1162,7 +1372,7 @@ end"#,
};
assert!(crate::lsp_config::resolve(lsp).is_err(), "{frame}");
}
let languages: serde_json::Map<String, serde_json::Value> = (0..17)
let mut languages: serde_json::Map<String, serde_json::Value> = (0..14)
.map(|index| {
(
format!("custom-{index}"),
@@ -1170,13 +1380,24 @@ end"#,
)
})
.collect();
languages.insert("python".into(), serde_json::json!(true));
frame["lsp"] = serde_json::Value::Object(languages);
let Response::Config { lsp, .. } =
serde_json::from_value::<Response>(frame.clone()).unwrap()
else {
panic!("expected config frame");
};
assert!(crate::lsp_config::resolve(lsp).is_err());
assert_eq!(crate::lsp_config::resolve(lsp).unwrap().len(), 16);
frame["lsp"]["ruby"] = serde_json::json!(true);
let Response::Config { lsp, .. } =
serde_json::from_value::<Response>(frame.clone()).unwrap()
else {
panic!("expected config frame");
};
assert_eq!(
crate::lsp_config::resolve(lsp).unwrap_err(),
"LSP configuration supports at most 16 configured servers"
);
frame.as_object_mut().unwrap().remove("lsp");
assert!(serde_json::from_value::<Response>(frame).is_err());
}