feat: migrate LSP to opt-in native language servers

This commit is contained in:
Valdemar Lorenzen
2026-10-09 13:12:38 +02:00
parent 92679ecbe1
commit 30aa70672e
12 changed files with 2407 additions and 658 deletions
+24 -2
View File
@@ -896,8 +896,20 @@ impl Editor {
name: "core".into(),
required_capabilities: Vec::new(),
commands: [
"w", "q", "q!", "wq", "plugins", "edit", "buffers", "buffer", "bnext",
"bprev", "bdelete", "bdelete!",
"w",
"q",
"q!",
"wq",
"plugins",
"edit",
"buffers",
"buffer",
"bnext",
"bprev",
"bdelete",
"bdelete!",
"lsp_definition",
"lsp_diagnostics",
]
.into_iter()
.map(str::to_owned)
@@ -2384,6 +2396,16 @@ impl Editor {
});
}
match name {
"lsp_definition" | "lsp_diagnostics" if !args.is_empty() => {
self.set_message(format!("The {name} command takes no arguments"));
}
"lsp_definition" | "lsp_diagnostics" => {
return Some(Invocation {
plugin: "core".into(),
command: name.into(),
args: String::new(),
});
}
"w" | "wq" => {
if self.save(if args.is_empty() {
None
+2
View File
@@ -1,6 +1,8 @@
pub mod clipboard;
pub mod editor;
pub mod files;
pub mod lsp;
mod lsp_config;
pub mod plugin;
pub mod ruby;
pub mod session;
+1800
View File
File diff suppressed because it is too large Load Diff
+154
View File
@@ -0,0 +1,154 @@
use crate::lsp::ServerConfig;
use serde::Deserialize;
use serde_json::Value;
pub fn resolve(value: Value) -> Result<Vec<ServerConfig>, String> {
let Value::Object(languages) = value else {
return Err("LSP configuration must be a language-keyed object".into());
};
let mut servers = Vec::new();
for (language_id, value) in languages {
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 = match language_id.as_str() {
"rust" => Some(("rust-analyzer", "rs")),
"python" => Some(("pylsp", "py")),
_ => None,
};
let mut options = match value {
Value::Bool(false) => continue,
Value::Bool(true) => {
if preset.is_none() {
return Err(format!(
"LSP language {language_id:?} has no built-in preset; provide command and extensions"
));
}
serde_json::Map::new()
}
Value::Object(options) => options,
_ => {
return Err(format!(
"LSP language {language_id:?} must be true, false, or a configuration object"
));
}
};
for key in options.keys() {
if !matches!(
key.as_str(),
"command" | "extensions" | "initialization_options" | "settings"
) {
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, _)) => vec![command.into()],
None => {
return Err(format!(
"LSP language {language_id:?} requires command argv"
));
}
},
};
let 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());
}
}
Ok(servers)
}
pub(crate) fn deserialize<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<ServerConfig>, D::Error> {
resolve(Value::deserialize(deserializer)?).map_err(serde::de::Error::custom)
}
fn command(value: Value, language: &str) -> Result<Vec<String>, String> {
let error = || {
format!(
"LSP language {language:?} command must be nonempty argv with a nonempty executable and no NUL bytes"
)
};
let Value::Array(values) = value else {
return Err(error());
};
if values.is_empty() {
return Err(error());
}
let mut command = Vec::with_capacity(values.len());
for value in values {
let Value::String(argument) = value else {
return Err(error());
};
if argument.contains('\0') || (command.is_empty() && argument.is_empty()) {
return Err(error());
}
command.push(argument);
}
Ok(command)
}
fn extensions(value: Value, language: &str) -> Result<Vec<String>, String> {
let error = || {
format!(
"LSP language {language:?} extensions must be a nonempty array of suffixes without dots, separators, whitespace, or control characters"
)
};
let Value::Array(values) = value else {
return Err(error());
};
if values.is_empty() {
return Err(error());
}
let mut extensions = Vec::with_capacity(values.len());
for value in values {
let Value::String(extension) = value else {
return Err(error());
};
if extension.is_empty()
|| extension.chars().any(|character| {
matches!(character, '.' | '/' | '\\')
|| character.is_whitespace()
|| character.is_control()
})
{
return Err(error());
}
extensions.push(extension);
}
Ok(extensions)
}
fn object(value: Option<Value>, language: &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"
)),
}
}
+3 -2
View File
@@ -36,8 +36,9 @@ Buffers: :edit PATH, :buffers, :buffer N, :bnext, :bprev, :bdelete[!] [N]
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
Set VAMP_RUBY to choose the Ruby executable.
Optional LSP Ruby plugin: config.plugin PATH, options: { servers: [...] }
LSP commands when enabled: :lsp_definition, :lsp_diagnostics";
Native LSP is disabled by default: config.lsp[\"rust\"] = true enables a preset
Custom languages: config.lsp[\"my-language\"] = { command: [...], extensions: [...] }
LSP commands: :lsp_definition, :lsp_diagnostics";
struct Options {
file: Option<PathBuf>,
+215 -3
View File
@@ -19,6 +19,7 @@ const INVOKE_TIMEOUT: Duration = Duration::from_millis(500);
#[derive(Debug)]
pub struct Config {
pub plugins: Vec<PluginConfig>,
pub lsp: Vec<crate::lsp::ServerConfig>,
pub bindings: Vec<Binding>,
pub leader: String,
pub file_explorer_side: String,
@@ -52,6 +53,7 @@ impl Default for Config {
fn default() -> Self {
Self {
plugins: Vec::new(),
lsp: Vec::new(),
bindings: Vec::new(),
leader: "space".into(),
file_explorer_side: "right".into(),
@@ -71,6 +73,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
match response {
Response::Config {
plugins,
lsp,
bindings,
leader,
file_explorer_side,
@@ -92,6 +95,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
});
}
config.bindings = bindings;
config.lsp = lsp;
config.leader = leader;
config.file_explorer_side = file_explorer_side;
config.file_picker_view = file_picker_view;
@@ -375,6 +379,8 @@ enum Response {
Config {
version: u8,
plugins: Vec<PluginConfig>,
#[serde(deserialize_with = "crate::lsp_config::deserialize")]
lsp: Vec<crate::lsp::ServerConfig>,
bindings: Vec<Binding>,
leader: String,
file_explorer_side: String,
@@ -879,6 +885,7 @@ end
let defaults = Config::default();
assert_eq!(defaults.leader, "space");
assert!(defaults.plugins.is_empty() && defaults.bindings.is_empty());
assert!(defaults.lsp.is_empty());
let path = fixture.file("config.rb", "Vamp.configure { |_| }");
assert_eq!(load_config(&path).unwrap().leader, "space");
for (source, key) in [
@@ -900,14 +907,219 @@ end
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
}
for frame in [
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#,
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#,
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#,
] {
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
}
}
#[test]
fn lsp_configuration_is_disabled_unless_explicitly_enabled() {
let fixture = Fixture::new();
for source in [
"Vamp.configure { |_| }",
r#"Vamp.configure do |config|
config.lsp["rust"] = false
config.lsp["python"] = false
config.lsp["not-a-preset"] = false
end"#,
] {
let path = fixture.file("config.rb", source);
let config = load_config(&path).unwrap();
assert!(config.lsp.is_empty());
assert!(config.plugins.is_empty());
}
}
#[test]
fn lsp_presets_custom_languages_and_overrides_reach_native_configuration() {
let fixture = Fixture::new();
let path = fixture.file(
"config.rb",
r#"Vamp.configure do |config|
config.lsp["rust"] = true
config.lsp["python"] = true
end"#,
);
let config = load_config(&path).unwrap();
assert_eq!(config.lsp.len(), 2);
for (language, command, extension) in
[("rust", "rust-analyzer", "rs"), ("python", "pylsp", "py")]
{
let server = config
.lsp
.iter()
.find(|server| server.language_id == language)
.unwrap();
assert_eq!(server.command, [command]);
assert_eq!(server.extensions, [extension]);
assert_eq!(server.initialization_options, serde_json::json!({}));
assert_eq!(server.settings, serde_json::json!({}));
}
let path = fixture.file(
"config.rb",
r#"Vamp.configure do |config|
config.lsp["rust"] = {
extensions: ["rs", "ron"],
initialization_options: {cargo: {features: ["α"]}},
settings: {"rust-analyzer" => {check: {command: "clippy"}}}
}
config.lsp["python"] = {command: ["custom pylsp", "--stdio"]}
config.lsp["my-language.c++"] = {
command: ["custom server", "--flag", ""],
extensions: ["custom", "لغة"],
settings: {nested: {enabled: true, limit: 3, unset: nil}}
}
end"#,
);
let config = load_config(&path).unwrap();
assert_eq!(config.lsp.len(), 3);
let rust = config
.lsp
.iter()
.find(|server| server.language_id == "rust")
.unwrap();
assert_eq!(rust.command, ["rust-analyzer"]);
assert_eq!(rust.extensions, ["rs", "ron"]);
assert_eq!(
rust.initialization_options,
serde_json::json!({"cargo": {"features": ["α"]}})
);
assert_eq!(
rust.settings,
serde_json::json!({"rust-analyzer": {"check": {"command": "clippy"}}})
);
let python = config
.lsp
.iter()
.find(|server| server.language_id == "python")
.unwrap();
assert_eq!(python.command, ["custom pylsp", "--stdio"]);
assert_eq!(python.extensions, ["py"]);
assert_eq!(python.initialization_options, serde_json::json!({}));
assert_eq!(python.settings, serde_json::json!({}));
let custom = config
.lsp
.iter()
.find(|server| server.language_id == "my-language.c++")
.unwrap();
assert_eq!(custom.command, ["custom server", "--flag", ""]);
assert_eq!(custom.extensions, ["custom", "لغة"]);
assert_eq!(custom.initialization_options, serde_json::json!({}));
assert_eq!(
custom.settings,
serde_json::json!({"nested": {"enabled": true, "limit": 3, "unset": null}})
);
}
#[test]
fn lsp_configuration_rejects_invalid_keys_values_and_json_options() {
let fixture = Fixture::new();
for assignment in [
r#"config.lsp[:rust] = true"#,
r#"config.lsp[7] = false"#,
r#"config.lsp[""] = false"#,
r#"config.lsp["bad\nkey"] = false"#,
r#"config.lsp["\xFF".b] = false"#,
r#"config.lsp["rust"] = nil"#,
r#"config.lsp["rust"] = []"#,
r#"config.lsp["rust"] = "yes""#,
r#"config.lsp["custom"] = {}"#,
r#"config.lsp["custom"] = {command: ["server"]}"#,
r#"config.lsp["custom"] = {extensions: ["custom"]}"#,
r#"config.lsp["rust"] = {command: "server"}"#,
r#"config.lsp["rust"] = {command: []}"#,
r#"config.lsp["rust"] = {command: [""]}"#,
r#"config.lsp["rust"] = {command: ["server", 7]}"#,
r#"config.lsp["rust"] = {command: ["server", "bad\0argument"]}"#,
r#"config.lsp["rust"] = {extensions: "rs"}"#,
r#"config.lsp["rust"] = {extensions: []}"#,
r#"config.lsp["rust"] = {extensions: [""]}"#,
r#"config.lsp["rust"] = {extensions: [7]}"#,
r#"config.lsp["rust"] = {extensions: [".rs"]}"#,
r#"config.lsp["rust"] = {extensions: ["path/rs"]}"#,
r#"config.lsp["rust"] = {extensions: ["path\\rs"]}"#,
r#"config.lsp["rust"] = {extensions: ["r s"]}"#,
r#"config.lsp["rust"] = {extensions: ["r\u0001s"]}"#,
r#"config.lsp["rust"] = {initialization_options: []}"#,
r#"config.lsp["rust"] = {settings: nil}"#,
r#"config.lsp["rust"] = {settings: {bad: Object.new}}"#,
r#"config.lsp["rust"] = {settings: {bad: Float::NAN}}"#,
r#"config.lsp["rust"] = {settings: {7 => true}}"#,
r#"config.lsp["rust"] = {unknown: true}"#,
] {
let path = fixture.file(
"invalid.rb",
&format!("Vamp.configure {{ |config| {assignment} }}"),
);
assert!(load_config(&path).is_err(), "{assignment}");
}
let path = fixture.file(
"invalid.rb",
r#"Vamp.configure { |config| config.lsp["unlisted"] = true }"#,
);
let error = load_config(&path).unwrap_err();
assert!(
error.contains("unlisted") && error.contains("preset"),
"{error}"
);
}
#[test]
fn lsp_wire_configuration_is_validated_before_being_consumed() {
let mut frame = serde_json::json!({
"version": 1, "type": "config", "plugins": [], "lsp": {},
"bindings": [], "leader": "space", "file_explorer_side": "right",
"file_picker_view": "tree", "file_explorer_view": "tree",
"clipboard_mode": "system"
});
assert!(matches!(
serde_json::from_value::<Response>(frame.clone()).unwrap(),
Response::Config { lsp, .. } if lsp.is_empty()
));
frame["lsp"] = serde_json::json!({"rust": true});
assert!(matches!(
serde_json::from_value::<Response>(frame.clone()).unwrap(),
Response::Config { lsp, .. }
if lsp.len() == 1 && lsp[0].command == ["rust-analyzer"]
));
for value in [
serde_json::json!(null),
serde_json::json!([]),
serde_json::json!({"": false}),
serde_json::json!({"bad\u{0085}key": false}),
serde_json::json!({"rust": null}),
serde_json::json!({"unknown": true}),
serde_json::json!({"custom": {}}),
serde_json::json!({"rust": {"command": ["tool", "bad\0argument"]}}),
serde_json::json!({"rust": {"extensions": ["bad\nextension"]}}),
serde_json::json!({"rust": {"initialization_options": []}}),
serde_json::json!({"rust": {"settings": "not-an-object"}}),
serde_json::json!({"rust": {"unexpected": true}}),
] {
frame["lsp"] = value;
assert!(
serde_json::from_value::<Response>(frame.clone()).is_err(),
"{frame}"
);
}
let languages: serde_json::Map<String, serde_json::Value> = (0..17)
.map(|index| {
(
format!("custom-{index}"),
serde_json::json!({"command": ["server"], "extensions": ["custom"]}),
)
})
.collect();
frame["lsp"] = serde_json::Value::Object(languages);
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
frame.as_object_mut().unwrap().remove("lsp");
assert!(serde_json::from_value::<Response>(frame).is_err());
}
#[test]
fn crash_hang_callback_failure_and_fresh_host_recovery() {
let fixture = Fixture::new();
+19 -1
View File
@@ -81,8 +81,11 @@ module Vamp
Mode.private_class_method :new
class Config
attr_reader :lsp
def initialize
@plugins = []
@lsp = {}
@bindings = []
@leader = "space"
@file_explorer_side = "right"
@@ -159,8 +162,23 @@ module Vamp
end
def response
lsp = @lsp.to_h do |language, value|
language = Vamp.string!(language, "LSP language key")
if language.empty? || language.match?(/[[:cntrl:]]/)
raise ArgumentError, "LSP language keys must not be empty or contain control characters"
end
value = case value
when TrueClass, FalseClass
value
when Hash
Vamp.json_value!(value)
else
raise TypeError, "LSP language #{language.inspect} must be true, false, or a Hash"
end
[language, value]
end
{ "version" => 1, "type" => "config", "plugins" => @plugins,
"bindings" => @bindings, "leader" => @leader,
"lsp" => lsp, "bindings" => @bindings, "leader" => @leader,
"file_explorer_side" => @file_explorer_side,
"file_picker_view" => @file_picker_view,
"file_explorer_view" => @file_explorer_view,
+55 -3
View File
@@ -5,7 +5,8 @@ use std::{
};
use crate::{
editor::{Editor, Effect, Input, Invocation},
editor::{Cursor, Editor, Effect, Input, Invocation, Mode},
lsp::Lsp,
plugin::{
DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginEvent, PluginUpdate,
TextPosition, UpdateEffect,
@@ -37,6 +38,9 @@ pub struct EditorSession {
plugins: BTreeMap<String, RubyPlugin>,
documents: BTreeMap<usize, TrackedDocument>,
requests: BTreeMap<String, AsyncRequests>,
lsp: Lsp,
lsp_request_id: u64,
lsp_source_cursor: Option<Cursor>,
}
impl EditorSession {
@@ -117,6 +121,9 @@ impl EditorSession {
plugins,
documents: BTreeMap::new(),
requests: BTreeMap::new(),
lsp: Lsp::new(config.lsp),
lsp_request_id: 0,
lsp_source_cursor: None,
};
session.synchronize_documents();
Ok(session)
@@ -145,6 +152,31 @@ impl EditorSession {
}
fn dispatch(&mut self, invocation: Invocation) {
if invocation.plugin == "core" {
let result = match invocation.command.as_str() {
"lsp_definition" => {
if let Some(id) = self.lsp_request_id.checked_add(1) {
self.lsp_request_id = id;
let context = self.editor.context();
let source = InvocationSource {
buffer_id: context.buffer_id,
revision: context.revision,
};
self.requests.entry("core".into()).or_default().latest = Some((id, source));
self.lsp_source_cursor = Some(self.editor.cursor());
self.lsp.definition(&context, id)
} else {
Err("LSP request IDs exhausted".into())
}
}
"lsp_diagnostics" => self.lsp.diagnostics(self.editor.buffer_id()),
_ => unreachable!("session-dispatched core command"),
};
if let Err(error) = result {
self.editor.set_message(format!("LSP: {error}"));
}
return;
}
let context = self.editor.context();
let result = match self.plugins.get_mut(&invocation.plugin) {
Some(plugin) if plugin.is_async() => {
@@ -178,7 +210,7 @@ impl EditorSession {
}
fn synchronize_documents(&mut self) -> bool {
if !self.plugins.values().any(RubyPlugin::is_async) {
if !self.lsp.is_enabled() && !self.plugins.values().any(RubyPlugin::is_async) {
self.documents.clear();
return false;
}
@@ -250,6 +282,13 @@ impl EditorSession {
});
}
let mut failures = Vec::new();
let mut changed = false;
for event in &events {
if let Err(error) = self.lsp.notify_document(event) {
self.editor.set_message(format!("LSP: {error}"));
changed = true;
}
}
for (owner, plugin) in &mut self.plugins {
if !plugin.is_async() {
continue;
@@ -261,7 +300,7 @@ impl EditorSession {
}
}
}
let changed = !failures.is_empty();
changed |= !failures.is_empty();
for (owner, error) in failures {
self.retire(&owner, &error);
}
@@ -282,6 +321,9 @@ impl EditorSession {
) -> bool {
self.editor.buffer_id() == source.buffer_id
&& self.source_is_current(source)
&& (owner != "core"
|| (self.lsp_source_cursor == Some(self.editor.cursor())
&& self.editor.mode() == Mode::Normal))
&& self
.requests
.get(owner)
@@ -406,6 +448,16 @@ impl EditorSession {
/// Drains ready plugin work without waiting for command or server responses.
pub fn poll(&mut self) -> bool {
let mut changed = self.synchronize_documents();
for update in self.lsp.poll() {
match self.apply_update("core", update) {
Ok(applied) => changed |= applied,
Err(error) => {
self.editor.clear_plugin_diagnostics("core");
self.editor.set_message(format!("LSP: {error}"));
changed = true;
}
}
}
let mut ready = Vec::new();
for (owner, plugin) in &mut self.plugins {
if plugin.is_async() {