ruby: Add ruby-lsp as an experimental language server (#11768)

Vitaly Slobodin and Marshall Bowers created

Adds [ruby-lsp](https://shopify.github.io/ruby-lsp/) as an alternative
LS for Ruby language.
While support for fully functional `ruby-lsp` is limited due to some
limitations (see https://github.com/zed-industries/zed/pull/8613) I
think it's OK to add it but disable by default. Thanks!

Resolves #4834.

Release Notes:

- N/A

### Some screenshots

Completion support
![CleanShot 2024-05-13 at 22 58
23@2x](https://github.com/zed-industries/zed/assets/1894248/d5047baa-c58f-465d-ae31-a7045aa56adf)

Symbol search
![CleanShot 2024-05-13 at 23 03
59@2x](https://github.com/zed-industries/zed/assets/1894248/0cb6320a-b000-4a0c-85eb-f8d1a8f6936e)

---------

Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>

Change summary

assets/settings/default.json                     |  2 
extensions/ruby/extension.toml                   |  4 
extensions/ruby/src/language_servers.rs          |  2 
extensions/ruby/src/language_servers/ruby_lsp.rs | 85 ++++++++++++++++++
extensions/ruby/src/ruby.rs                      | 41 ++++++++
5 files changed, 130 insertions(+), 4 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -648,7 +648,7 @@
       "tab_size": 2
     },
     "Ruby": {
-      "language_servers": ["solargraph", "..."]
+      "language_servers": ["solargraph", "!ruby-lsp", "..."]
     }
   },
   // Zed's Prettier integration settings.

extensions/ruby/extension.toml 🔗

@@ -10,6 +10,10 @@ repository = "https://github.com/zed-industries/zed"
 name = "Solargraph"
 language = "Ruby"
 
+[language_servers.ruby-lsp]
+name = "Ruby LSP"
+language = "Ruby"
+
 [grammars.ruby]
 repository = "https://github.com/tree-sitter/tree-sitter-ruby"
 commit = "9d86f3761bb30e8dcc81e754b81d3ce91848477e"

extensions/ruby/src/language_servers/ruby_lsp.rs 🔗

@@ -0,0 +1,85 @@
+use zed::{
+    lsp::{Completion, CompletionKind, Symbol, SymbolKind},
+    CodeLabel, CodeLabelSpan,
+};
+use zed_extension_api::{self as zed, Result};
+
+pub struct RubyLsp {}
+
+impl RubyLsp {
+    pub const LANGUAGE_SERVER_ID: &'static str = "ruby-lsp";
+
+    pub fn new() -> Self {
+        Self {}
+    }
+
+    pub fn server_script_path(&mut self, worktree: &zed::Worktree) -> Result<String> {
+        let path = worktree.which("ruby-lsp").ok_or_else(|| {
+            "ruby-lsp must be installed manually. Install it with `gem install ruby-lsp`."
+                .to_string()
+        })?;
+
+        Ok(path)
+    }
+
+    pub fn label_for_completion(&self, completion: Completion) -> Option<CodeLabel> {
+        let highlight_name = match completion.kind? {
+            CompletionKind::Class | CompletionKind::Module => "type",
+            CompletionKind::Constant => "constant",
+            CompletionKind::Method => "function.method",
+            CompletionKind::Reference => "function.method",
+            CompletionKind::Keyword => "keyword",
+            _ => return None,
+        };
+
+        let len = completion.label.len();
+        let name_span = CodeLabelSpan::literal(completion.label, Some(highlight_name.to_string()));
+
+        Some(CodeLabel {
+            code: Default::default(),
+            spans: vec![name_span],
+            filter_range: (0..len).into(),
+        })
+    }
+
+    pub fn label_for_symbol(&self, symbol: Symbol) -> Option<CodeLabel> {
+        let name = &symbol.name;
+
+        return match symbol.kind {
+            SymbolKind::Method => {
+                let code = format!("def {name}; end");
+                let filter_range = 0..name.len();
+                let display_range = 4..4 + name.len();
+
+                Some(CodeLabel {
+                    code,
+                    spans: vec![CodeLabelSpan::code_range(display_range)],
+                    filter_range: filter_range.into(),
+                })
+            }
+            SymbolKind::Class | SymbolKind::Module => {
+                let code = format!("class {name}; end");
+                let filter_range = 0..name.len();
+                let display_range = 6..6 + name.len();
+
+                Some(CodeLabel {
+                    code,
+                    spans: vec![CodeLabelSpan::code_range(display_range)],
+                    filter_range: filter_range.into(),
+                })
+            }
+            SymbolKind::Constant => {
+                let code = name.to_uppercase().to_string();
+                let filter_range = 0..name.len();
+                let display_range = 0..name.len();
+
+                Some(CodeLabel {
+                    code,
+                    spans: vec![CodeLabelSpan::code_range(display_range)],
+                    filter_range: filter_range.into(),
+                })
+            }
+            _ => None,
+        };
+    }
+}

extensions/ruby/src/ruby.rs 🔗

@@ -1,18 +1,23 @@
 mod language_servers;
 
 use zed::lsp::{Completion, Symbol};
-use zed::{CodeLabel, LanguageServerId};
+use zed::serde_json::json;
+use zed::{serde_json, CodeLabel, LanguageServerId};
 use zed_extension_api::{self as zed, Result};
 
-use crate::language_servers::Solargraph;
+use crate::language_servers::{RubyLsp, Solargraph};
 
 struct RubyExtension {
     solargraph: Option<Solargraph>,
+    ruby_lsp: Option<RubyLsp>,
 }
 
 impl zed::Extension for RubyExtension {
     fn new() -> Self {
-        Self { solargraph: None }
+        Self {
+            solargraph: None,
+            ruby_lsp: None,
+        }
     }
 
     fn language_server_command(
@@ -30,6 +35,15 @@ impl zed::Extension for RubyExtension {
                     env: worktree.shell_env(),
                 })
             }
+            RubyLsp::LANGUAGE_SERVER_ID => {
+                let ruby_lsp = self.ruby_lsp.get_or_insert_with(|| RubyLsp::new());
+
+                Ok(zed::Command {
+                    command: ruby_lsp.server_script_path(worktree)?,
+                    args: vec![],
+                    env: worktree.shell_env(),
+                })
+            }
             language_server_id => Err(format!("unknown language server: {language_server_id}")),
         }
     }
@@ -41,6 +55,7 @@ impl zed::Extension for RubyExtension {
     ) -> Option<CodeLabel> {
         match language_server_id.as_ref() {
             Solargraph::LANGUAGE_SERVER_ID => self.solargraph.as_ref()?.label_for_symbol(symbol),
+            RubyLsp::LANGUAGE_SERVER_ID => self.ruby_lsp.as_ref()?.label_for_symbol(symbol),
             _ => None,
         }
     }
@@ -54,9 +69,29 @@ impl zed::Extension for RubyExtension {
             Solargraph::LANGUAGE_SERVER_ID => {
                 self.solargraph.as_ref()?.label_for_completion(completion)
             }
+            RubyLsp::LANGUAGE_SERVER_ID => self.ruby_lsp.as_ref()?.label_for_completion(completion),
             _ => None,
         }
     }
+
+    fn language_server_initialization_options(
+        &mut self,
+        language_server_id: &LanguageServerId,
+        _worktree: &zed::Worktree,
+    ) -> Result<Option<serde_json::Value>> {
+        match language_server_id.as_ref() {
+            // We disable diagnostics because ruby-lsp uses pull-based diagnostics,
+            // which Zed doesn't support yet.
+            RubyLsp::LANGUAGE_SERVER_ID => Ok(Some(json!({
+                "enabledFeatures": {
+                  "diagnostics": false
+                },
+                "experimentalFeaturesEnabled": true
+            }))),
+
+            _ => Ok(None),
+        }
+    }
 }
 
 zed::register_extension!(RubyExtension);