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
+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();