Skip to content

Commit 8587f26

Browse files
feat: add sorbet Ruby LSP server option (#104)
Redo for #9 (unable to reopen after force-pushing to the branch for the original PR). This adds Sorbet's LSP as an additional Ruby language server option and would take precedent over https://github.com/notchairmk/zed-sorbet. In it's current state issues like notchairmk/zed-sorbet#6 will still be present. In addition, without `get_executable_args` being worktree-aware (and the extension being able to supply default args) the sorbet binary for a repo missing config at `sorbet/config` will fail to initiate. The workaround in zed-sorbet was to just supply empty config if that file is missing and allow for file-local errors. These are the listed server capabilities ```json { "textDocumentSync": 1, "hoverProvider": true, "completionProvider": { "triggerCharacters": [ ".", ":", "@", "#" ] }, "definitionProvider": true, "typeDefinitionProvider": true, "implementationProvider": true, "referencesProvider": true, "documentHighlightProvider": true, "documentSymbolProvider": true, "workspaceSymbolProvider": true, "codeActionProvider": { "codeActionKinds": [ "quickfix", "source.fixAll.sorbet", "refactor.extract", "refactor.rewrite" ], "resolveProvider": true }, "documentFormattingProvider": false, "renameProvider": { "prepareProvider": true } } ``` <img width="802" alt="image" src="https://github.com/user-attachments/assets/b13e2cb0-b0f1-4d85-a51b-136435004263"> --------- Co-authored-by: Vitaly Slobodin <vitaliy.slobodin@gmail.com>
1 parent 8cf135d commit 8587f26

File tree

5 files changed

+222
-18
lines changed

5 files changed

+222
-18
lines changed

extension.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ languages = ["Ruby"]
2222
name = "Steep"
2323
languages = ["Ruby"]
2424

25+
[language_servers.sorbet]
26+
name = "Sorbet"
27+
languages = ["Ruby"]
28+
2529
[grammars.ruby]
2630
repository = "https://github.com/tree-sitter/tree-sitter-ruby"
2731
commit = "71bd32fb7607035768799732addba884a37a6210"

src/language_servers/language_server.rs

Lines changed: 55 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
#[cfg(test)]
2+
use std::collections::HashMap;
3+
14
use crate::{bundler::Bundler, command_executor::RealCommandExecutor, gemset::Gemset};
25
use zed_extension_api::{self as zed};
36

@@ -8,35 +11,52 @@ pub struct LanguageServerBinary {
811
pub env: Option<Vec<(String, String)>>,
912
}
1013

14+
#[derive(Clone, Debug, Default)]
15+
pub struct LspBinarySettings {
16+
#[allow(dead_code)]
17+
pub path: Option<String>,
18+
pub arguments: Option<Vec<String>>,
19+
}
20+
1121
pub trait WorktreeLike {
1222
#[allow(dead_code)]
1323
fn root_path(&self) -> String;
14-
1524
#[allow(dead_code)]
1625
fn shell_env(&self) -> Vec<(String, String)>;
17-
18-
#[allow(dead_code)]
1926
fn read_text_file(&self, path: &str) -> Result<String, String>;
27+
fn lsp_binary_settings(&self, server_id: &str) -> Result<Option<LspBinarySettings>, String>;
2028
}
2129

2230
impl WorktreeLike for zed::Worktree {
2331
fn root_path(&self) -> String {
24-
self.root_path()
32+
zed::Worktree::root_path(self)
2533
}
2634

2735
fn shell_env(&self) -> Vec<(String, String)> {
28-
self.shell_env()
36+
zed::Worktree::shell_env(self)
2937
}
3038

3139
fn read_text_file(&self, path: &str) -> Result<String, String> {
32-
self.read_text_file(path)
40+
zed::Worktree::read_text_file(self, path)
41+
}
42+
43+
fn lsp_binary_settings(&self, server_id: &str) -> Result<Option<LspBinarySettings>, String> {
44+
match zed::settings::LspSettings::for_worktree(server_id, self) {
45+
Ok(lsp_settings) => Ok(lsp_settings.binary.map(|b| LspBinarySettings {
46+
path: b.path,
47+
arguments: b.arguments,
48+
})),
49+
Err(e) => Err(e),
50+
}
3351
}
3452
}
3553

3654
#[cfg(test)]
3755
pub struct FakeWorktree {
3856
root_path: String,
3957
shell_env: Vec<(String, String)>,
58+
files: HashMap<String, Result<String, String>>,
59+
lsp_binary_settings_map: HashMap<String, Result<Option<LspBinarySettings>, String>>,
4060
}
4161

4262
#[cfg(test)]
@@ -45,11 +65,21 @@ impl FakeWorktree {
4565
FakeWorktree {
4666
root_path,
4767
shell_env: Vec::new(),
68+
files: HashMap::new(),
69+
lsp_binary_settings_map: HashMap::new(),
4870
}
4971
}
5072

51-
fn read_text_file(&self, _path: &str) -> Result<String, String> {
52-
Ok(String::new())
73+
pub fn add_file(&mut self, path: String, content: Result<String, String>) {
74+
self.files.insert(path, content);
75+
}
76+
77+
pub fn add_lsp_binary_setting(
78+
&mut self,
79+
server_id: String,
80+
settings: Result<Option<LspBinarySettings>, String>,
81+
) {
82+
self.lsp_binary_settings_map.insert(server_id, settings);
5383
}
5484
}
5585

@@ -64,7 +94,17 @@ impl WorktreeLike for FakeWorktree {
6494
}
6595

6696
fn read_text_file(&self, path: &str) -> Result<String, String> {
67-
self.read_text_file(path)
97+
self.files
98+
.get(path)
99+
.cloned()
100+
.unwrap_or_else(|| Err(format!("File not found in mock: {}", path)))
101+
}
102+
103+
fn lsp_binary_settings(&self, server_id: &str) -> Result<Option<LspBinarySettings>, String> {
104+
self.lsp_binary_settings_map
105+
.get(server_id)
106+
.cloned()
107+
.unwrap_or(Ok(None))
68108
}
69109
}
70110

@@ -104,11 +144,11 @@ pub trait LanguageServer {
104144
let lsp_settings =
105145
zed::settings::LspSettings::for_worktree(language_server_id.as_ref(), worktree)?;
106146

107-
if let Some(binary_settings) = lsp_settings.binary {
108-
if let Some(path) = binary_settings.path {
147+
if let Some(binary_settings) = &lsp_settings.binary {
148+
if let Some(path) = &binary_settings.path {
109149
return Ok(LanguageServerBinary {
110-
path,
111-
args: binary_settings.arguments,
150+
path: path.clone(),
151+
args: binary_settings.arguments.clone(),
112152
env: Some(worktree.shell_env()),
113153
});
114154
}
@@ -133,7 +173,7 @@ pub trait LanguageServer {
133173
Ok(_version) => {
134174
let bundle_path = worktree
135175
.which("bundle")
136-
.ok_or("Unable to find 'bundle' command: e")?;
176+
.ok_or_else(|| "Unable to find 'bundle' command".to_string())?;
137177

138178
Ok(LanguageServerBinary {
139179
path: bundle_path,
@@ -236,9 +276,7 @@ pub trait LanguageServer {
236276

237277
#[cfg(test)]
238278
mod tests {
239-
use crate::language_servers::language_server::FakeWorktree;
240-
241-
use super::{LanguageServer, WorktreeLike};
279+
use super::{FakeWorktree, LanguageServer, WorktreeLike};
242280

243281
struct TestServer {}
244282

src/language_servers/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ mod language_server;
22
mod rubocop;
33
mod ruby_lsp;
44
mod solargraph;
5+
mod sorbet;
56
mod steep;
67

78
pub use language_server::LanguageServer;
89
pub use rubocop::*;
910
pub use ruby_lsp::*;
1011
pub use solargraph::*;
12+
pub use sorbet::*;
1113
pub use steep::*;

src/language_servers/sorbet.rs

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
use super::{language_server::WorktreeLike, LanguageServer};
2+
3+
pub struct Sorbet {}
4+
5+
impl LanguageServer for Sorbet {
6+
const SERVER_ID: &str = "sorbet";
7+
const EXECUTABLE_NAME: &str = "srb";
8+
const GEM_NAME: &str = "sorbet";
9+
10+
fn get_executable_args<T: WorktreeLike>(&self, worktree: &T) -> Vec<String> {
11+
let binary_settings = worktree
12+
.lsp_binary_settings(Self::SERVER_ID)
13+
.unwrap_or_default();
14+
15+
let default_args = vec![
16+
"tc".to_string(),
17+
"--lsp".to_string(),
18+
"--enable-experimental-lsp-document-highlight".to_string(),
19+
];
20+
21+
// test if sorbet/config is present
22+
match worktree.read_text_file("sorbet/config") {
23+
Ok(_) => {
24+
// Config file exists, prefer custom arguments if available.
25+
binary_settings
26+
.and_then(|bs| bs.arguments)
27+
.unwrap_or(default_args)
28+
}
29+
Err(_) => {
30+
// gross, but avoid sorbet errors in a non-sorbet
31+
// environment by using an empty config
32+
vec![
33+
"tc".to_string(),
34+
"--lsp".to_string(),
35+
"--dir".to_string(),
36+
"./".to_string(),
37+
]
38+
}
39+
}
40+
}
41+
}
42+
43+
impl Sorbet {
44+
pub fn new() -> Self {
45+
Self {}
46+
}
47+
}
48+
49+
#[cfg(test)]
50+
mod tests {
51+
use crate::language_servers::{
52+
language_server::{FakeWorktree, LspBinarySettings},
53+
LanguageServer, Sorbet,
54+
};
55+
56+
#[test]
57+
fn test_server_id() {
58+
assert_eq!(Sorbet::SERVER_ID, "sorbet");
59+
}
60+
61+
#[test]
62+
fn test_executable_name() {
63+
assert_eq!(Sorbet::EXECUTABLE_NAME, "srb");
64+
}
65+
66+
#[test]
67+
fn test_executable_args_no_config_file() {
68+
let sorbet = Sorbet::new();
69+
let mut fake_worktree = FakeWorktree::new("/path/to/project".to_string());
70+
71+
fake_worktree.add_file(
72+
"sorbet/config".to_string(),
73+
Err("File not found".to_string()),
74+
);
75+
fake_worktree.add_lsp_binary_setting(Sorbet::SERVER_ID.to_string(), Ok(None));
76+
77+
let expected_args_no_config = vec![
78+
"tc".to_string(),
79+
"--lsp".to_string(),
80+
"--dir".to_string(),
81+
"./".to_string(),
82+
];
83+
assert_eq!(
84+
sorbet.get_executable_args(&fake_worktree),
85+
expected_args_no_config,
86+
"Should use fallback arguments when sorbet/config is not found"
87+
);
88+
}
89+
90+
#[test]
91+
fn test_executable_args_with_config_and_custom_settings() {
92+
let sorbet = Sorbet::new();
93+
let mut fake_worktree = FakeWorktree::new("/path/to/project".to_string());
94+
95+
fake_worktree.add_file("sorbet/config".to_string(), Ok("--dir\n.".to_string()));
96+
97+
let custom_args = vec!["--custom-arg1".to_string(), "value1".to_string()];
98+
fake_worktree.add_lsp_binary_setting(
99+
Sorbet::SERVER_ID.to_string(),
100+
Ok(Some(LspBinarySettings {
101+
path: None,
102+
arguments: Some(custom_args.clone()),
103+
})),
104+
);
105+
106+
assert_eq!(
107+
sorbet.get_executable_args(&fake_worktree),
108+
custom_args,
109+
"Should use custom arguments when config and settings are present"
110+
);
111+
}
112+
113+
#[test]
114+
fn test_executable_args_with_config_no_custom_settings() {
115+
let sorbet = Sorbet::new();
116+
let mut fake_worktree = FakeWorktree::new("/path/to/project".to_string());
117+
118+
fake_worktree.add_file("sorbet/config".to_string(), Ok("--dir\n.".to_string()));
119+
fake_worktree.add_lsp_binary_setting(Sorbet::SERVER_ID.to_string(), Ok(None));
120+
121+
let expected_default_args = vec![
122+
"tc".to_string(),
123+
"--lsp".to_string(),
124+
"--enable-experimental-lsp-document-highlight".to_string(),
125+
];
126+
assert_eq!(
127+
sorbet.get_executable_args(&fake_worktree),
128+
expected_default_args,
129+
"Should use default arguments when config is present but no custom settings"
130+
);
131+
}
132+
133+
#[test]
134+
fn test_executable_args_with_config_lsp_settings_is_empty_struct() {
135+
let sorbet = Sorbet::new();
136+
let mut fake_worktree = FakeWorktree::new("/path/to/project".to_string());
137+
138+
fake_worktree.add_file("sorbet/config".to_string(), Ok("--dir\n.".to_string()));
139+
fake_worktree.add_lsp_binary_setting(
140+
Sorbet::SERVER_ID.to_string(),
141+
Ok(Some(LspBinarySettings::default())),
142+
);
143+
144+
let expected_default_args = vec![
145+
"tc".to_string(),
146+
"--lsp".to_string(),
147+
"--enable-experimental-lsp-document-highlight".to_string(),
148+
];
149+
assert_eq!(
150+
sorbet.get_executable_args(&fake_worktree),
151+
expected_default_args,
152+
"Should use default arguments when config is present and LSP settings have no arguments"
153+
);
154+
}
155+
}

src/ruby.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@ mod command_executor;
33
mod gemset;
44
mod language_servers;
55

6-
use language_servers::{LanguageServer, Rubocop, RubyLsp, Solargraph, Steep};
6+
use language_servers::{LanguageServer, Rubocop, RubyLsp, Solargraph, Sorbet, Steep};
77
use zed_extension_api::{self as zed};
88

99
#[derive(Default)]
1010
struct RubyExtension {
1111
solargraph: Option<Solargraph>,
1212
ruby_lsp: Option<RubyLsp>,
1313
rubocop: Option<Rubocop>,
14+
sorbet: Option<Sorbet>,
1415
steep: Option<Steep>,
1516
}
1617

@@ -37,6 +38,10 @@ impl zed::Extension for RubyExtension {
3738
let rubocop = self.rubocop.get_or_insert_with(Rubocop::new);
3839
rubocop.language_server_command(language_server_id, worktree)
3940
}
41+
Sorbet::SERVER_ID => {
42+
let sorbet = self.sorbet.get_or_insert_with(Sorbet::new);
43+
sorbet.language_server_command(language_server_id, worktree)
44+
}
4045
Steep::SERVER_ID => {
4146
let steep = self.steep.get_or_insert_with(Steep::new);
4247
steep.language_server_command(language_server_id, worktree)

0 commit comments

Comments
 (0)