feat: preconfigure Python with Astral ty and Ruff
This commit is contained in:
+211
-72
@@ -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
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user