Extract Ruby extension (#11360)

Vitaly Slobodin and Marshall Bowers created

This PR extracts Ruby and ERB support into an extension and removes the
built-in Ruby and Ruby support from Zed.

As part of this, the new extension is prepared for adding support for
the `Ruby LSP` which has some blockers. See
https://github.com/zed-industries/zed/pull/8613 I was thinking of adding
an initial support for Ruby LSP but I think it would be better to start
with extracting the Ruby extension for now.

The implementation, as the 1st step, matches the bundled version but
with 3 differences:

1. Added signature output to the completion popup. See my comment below.
![CleanShot 2024-05-04 at 09 17
37@2x](https://github.com/zed-industries/zed/assets/1894248/486b7a48-ea0c-44ce-b0c9-9f8f5d3ad42d)

3. Use the shell environment for starting the `solargraph` executable.
See my comment below.
4. Bumped the tree sitter version for Ruby to the latest available
version.

Additionally, I plan to tweak this extension a bit in the future but I
think we should do this bit by bit. Thanks!

Release Notes:

- Removed built-in support for Ruby, in favor of making it available as
an extension.

---------

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

Change summary

Cargo.lock                                         |   8 
Cargo.toml                                         |   1 
assets/settings/default.json                       |   3 
crates/extensions_ui/src/extension_suggest.rs      |   1 
crates/languages/Cargo.toml                        |   1 
crates/languages/src/lib.rs                        |   4 
crates/languages/src/ruby.rs                       | 205 ----------------
extensions/ruby/Cargo.toml                         |  16 +
extensions/ruby/LICENSE-APACHE                     |   1 
extensions/ruby/extension.toml                     |  15 +
extensions/ruby/languages/erb/config.toml          |   0 
extensions/ruby/languages/erb/highlights.scm       |   0 
extensions/ruby/languages/erb/injections.scm       |   0 
extensions/ruby/languages/ruby/brackets.scm        |   0 
extensions/ruby/languages/ruby/config.toml         |   0 
extensions/ruby/languages/ruby/embedding.scm       |   0 
extensions/ruby/languages/ruby/highlights.scm      |   0 
extensions/ruby/languages/ruby/indents.scm         |   0 
extensions/ruby/languages/ruby/outline.scm         |   0 
extensions/ruby/languages/ruby/overrides.scm       |   0 
extensions/ruby/src/language_servers.rs            |   3 
extensions/ruby/src/language_servers/solargraph.rs | 121 +++++++++
extensions/ruby/src/ruby.rs                        |  62 ++++
23 files changed, 230 insertions(+), 211 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5720,7 +5720,6 @@ dependencies = [
  "tree-sitter-proto",
  "tree-sitter-python",
  "tree-sitter-regex",
- "tree-sitter-ruby",
  "tree-sitter-rust",
  "tree-sitter-typescript",
  "tree-sitter-yaml",
@@ -13114,6 +13113,13 @@ dependencies = [
  "zed_extension_api 0.0.4",
 ]
 
+[[package]]
+name = "zed_ruby"
+version = "0.0.1"
+dependencies = [
+ "zed_extension_api 0.0.6 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
 [[package]]
 name = "zed_svelte"
 version = "0.0.1"

Cargo.toml 🔗

@@ -127,6 +127,7 @@ members = [
     "extensions/php",
     "extensions/prisma",
     "extensions/purescript",
+    "extensions/ruby",
     "extensions/svelte",
     "extensions/terraform",
     "extensions/toml",

assets/settings/default.json 🔗

@@ -633,6 +633,9 @@
     },
     "Prisma": {
       "tab_size": 2
+    },
+    "Ruby": {
+      "language_servers": ["solargraph", "..."]
     }
   },
   // Zed's Prettier integration settings.

crates/extensions_ui/src/extension_suggest.rs 🔗

@@ -58,6 +58,7 @@ const SUGGESTIONS_BY_EXTENSION_ID: &[(&str, &[&str])] = &[
     ("r", &["r", "R"]),
     ("racket", &["rkt"]),
     ("rescript", &["res", "resi"]),
+    ("ruby", &["rb", "erb"]),
     ("scheme", &["scm"]),
     ("scss", &["scss"]),
     ("sql", &["sql"]),

crates/languages/Cargo.toml 🔗

@@ -46,7 +46,6 @@ tree-sitter-markdown.workspace = true
 tree-sitter-proto.workspace = true
 tree-sitter-python.workspace = true
 tree-sitter-regex.workspace = true
-tree-sitter-ruby.workspace = true
 tree-sitter-rust.workspace = true
 tree-sitter-typescript.workspace = true
 tree-sitter-yaml.workspace = true

crates/languages/src/lib.rs 🔗

@@ -16,7 +16,6 @@ mod css;
 mod go;
 mod json;
 mod python;
-mod ruby;
 mod rust;
 mod tailwind;
 mod typescript;
@@ -50,7 +49,6 @@ pub fn init(
         ("proto", tree_sitter_proto::language()),
         ("python", tree_sitter_python::language()),
         ("regex", tree_sitter_regex::language()),
-        ("ruby", tree_sitter_ruby::language()),
         ("rust", tree_sitter_rust::language()),
         ("tsx", tree_sitter_typescript::language_tsx()),
         ("typescript", tree_sitter_typescript::language_typescript()),
@@ -156,8 +154,6 @@ pub fn init(
             node_runtime.clone(),
         ))]
     );
-    language!("ruby", vec![Arc::new(ruby::RubyLanguageServer)]);
-    language!("erb", vec![Arc::new(ruby::RubyLanguageServer),]);
     language!("regex");
     language!(
         "yaml",

crates/languages/src/ruby.rs 🔗

@@ -1,205 +0,0 @@
-use anyhow::{anyhow, Result};
-use async_trait::async_trait;
-use gpui::AsyncAppContext;
-use language::{LanguageServerName, LspAdapter, LspAdapterDelegate};
-use lsp::LanguageServerBinary;
-use project::project_settings::{BinarySettings, ProjectSettings};
-use settings::Settings;
-use std::{any::Any, ffi::OsString, path::PathBuf, sync::Arc};
-
-pub struct RubyLanguageServer;
-
-impl RubyLanguageServer {
-    const SERVER_NAME: &'static str = "solargraph";
-
-    fn server_binary_arguments() -> Vec<OsString> {
-        vec!["stdio".into()]
-    }
-}
-
-#[async_trait(?Send)]
-impl LspAdapter for RubyLanguageServer {
-    fn name(&self) -> LanguageServerName {
-        LanguageServerName(Self::SERVER_NAME.into())
-    }
-
-    async fn check_if_user_installed(
-        &self,
-        delegate: &dyn LspAdapterDelegate,
-        cx: &AsyncAppContext,
-    ) -> Option<LanguageServerBinary> {
-        let configured_binary = cx.update(|cx| {
-            ProjectSettings::get_global(cx)
-                .lsp
-                .get(Self::SERVER_NAME)
-                .and_then(|s| s.binary.clone())
-        });
-
-        if let Ok(Some(BinarySettings {
-            path: Some(path),
-            arguments,
-        })) = configured_binary
-        {
-            Some(LanguageServerBinary {
-                path: path.into(),
-                arguments: arguments
-                    .unwrap_or_default()
-                    .iter()
-                    .map(|arg| arg.into())
-                    .collect(),
-                env: None,
-            })
-        } else {
-            let env = delegate.shell_env().await;
-            let path = delegate.which(Self::SERVER_NAME.as_ref()).await?;
-            Some(LanguageServerBinary {
-                path,
-                arguments: Self::server_binary_arguments(),
-                env: Some(env),
-            })
-        }
-    }
-
-    async fn fetch_latest_server_version(
-        &self,
-        _: &dyn LspAdapterDelegate,
-    ) -> Result<Box<dyn 'static + Any + Send>> {
-        Ok(Box::new(()))
-    }
-
-    async fn fetch_server_binary(
-        &self,
-        _version: Box<dyn 'static + Send + Any>,
-        _container_dir: PathBuf,
-        _: &dyn LspAdapterDelegate,
-    ) -> Result<LanguageServerBinary> {
-        Err(anyhow!("solargraph must be installed manually"))
-    }
-
-    async fn cached_server_binary(
-        &self,
-        _: PathBuf,
-        _: &dyn LspAdapterDelegate,
-    ) -> Option<LanguageServerBinary> {
-        Some(LanguageServerBinary {
-            path: "solargraph".into(),
-            env: None,
-            arguments: Self::server_binary_arguments(),
-        })
-    }
-
-    fn can_be_reinstalled(&self) -> bool {
-        false
-    }
-
-    async fn installation_test_binary(&self, _: PathBuf) -> Option<LanguageServerBinary> {
-        None
-    }
-
-    async fn label_for_completion(
-        &self,
-        item: &lsp::CompletionItem,
-        language: &Arc<language::Language>,
-    ) -> Option<language::CodeLabel> {
-        let label = &item.label;
-        let grammar = language.grammar()?;
-        let highlight_id = match item.kind? {
-            lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method")?,
-            lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant")?,
-            lsp::CompletionItemKind::CLASS | lsp::CompletionItemKind::MODULE => {
-                grammar.highlight_id_for_name("type")?
-            }
-            lsp::CompletionItemKind::KEYWORD => {
-                if label.starts_with(':') {
-                    grammar.highlight_id_for_name("string.special.symbol")?
-                } else {
-                    grammar.highlight_id_for_name("keyword")?
-                }
-            }
-            lsp::CompletionItemKind::VARIABLE => {
-                if label.starts_with('@') {
-                    grammar.highlight_id_for_name("property")?
-                } else {
-                    return None;
-                }
-            }
-            _ => return None,
-        };
-        Some(language::CodeLabel {
-            text: label.clone(),
-            runs: vec![(0..label.len(), highlight_id)],
-            filter_range: 0..label.len(),
-        })
-    }
-
-    async fn label_for_symbol(
-        &self,
-        label: &str,
-        kind: lsp::SymbolKind,
-        language: &Arc<language::Language>,
-    ) -> Option<language::CodeLabel> {
-        let grammar = language.grammar()?;
-        match kind {
-            lsp::SymbolKind::METHOD => {
-                let mut parts = label.split('#');
-                let classes = parts.next()?;
-                let method = parts.next()?;
-                if parts.next().is_some() {
-                    return None;
-                }
-
-                let class_id = grammar.highlight_id_for_name("type")?;
-                let method_id = grammar.highlight_id_for_name("function.method")?;
-
-                let mut ix = 0;
-                let mut runs = Vec::new();
-                for (i, class) in classes.split("::").enumerate() {
-                    if i > 0 {
-                        ix += 2;
-                    }
-                    let end_ix = ix + class.len();
-                    runs.push((ix..end_ix, class_id));
-                    ix = end_ix;
-                }
-
-                ix += 1;
-                let end_ix = ix + method.len();
-                runs.push((ix..end_ix, method_id));
-                Some(language::CodeLabel {
-                    text: label.to_string(),
-                    runs,
-                    filter_range: 0..label.len(),
-                })
-            }
-            lsp::SymbolKind::CONSTANT => {
-                let constant_id = grammar.highlight_id_for_name("constant")?;
-                Some(language::CodeLabel {
-                    text: label.to_string(),
-                    runs: vec![(0..label.len(), constant_id)],
-                    filter_range: 0..label.len(),
-                })
-            }
-            lsp::SymbolKind::CLASS | lsp::SymbolKind::MODULE => {
-                let class_id = grammar.highlight_id_for_name("type")?;
-
-                let mut ix = 0;
-                let mut runs = Vec::new();
-                for (i, class) in label.split("::").enumerate() {
-                    if i > 0 {
-                        ix += "::".len();
-                    }
-                    let end_ix = ix + class.len();
-                    runs.push((ix..end_ix, class_id));
-                    ix = end_ix;
-                }
-
-                Some(language::CodeLabel {
-                    text: label.to_string(),
-                    runs,
-                    filter_range: 0..label.len(),
-                })
-            }
-            _ => return None,
-        }
-    }
-}

extensions/ruby/Cargo.toml 🔗

@@ -0,0 +1,16 @@
+[package]
+name = "zed_ruby"
+version = "0.0.1"
+edition = "2021"
+publish = false
+license = "Apache-2.0"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/ruby.rs"
+crate-type = ["cdylib"]
+
+[dependencies]
+zed_extension_api = "0.0.6"

extensions/ruby/extension.toml 🔗

@@ -0,0 +1,15 @@
+id = "ruby"
+name = "Ruby"
+description = "Ruby support."
+version = "0.0.1"
+schema_version = 1
+authors = ["Vitaly Slobodin <vitaliy.slobodin@gmail.com>"]
+repository = "https://github.com/zed-industries/zed"
+
+[language_servers.solargraph]
+name = "Solargraph"
+language = "Ruby"
+
+[grammars.ruby]
+repository = "https://github.com/tree-sitter/tree-sitter-ruby"
+commit = "9d86f3761bb30e8dcc81e754b81d3ce91848477e"

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

@@ -0,0 +1,121 @@
+use zed::lsp::{Completion, CompletionKind, Symbol, SymbolKind};
+use zed::{CodeLabel, CodeLabelSpan};
+use zed_extension_api::{self as zed, Result};
+
+pub struct Solargraph {}
+
+impl Solargraph {
+    pub const LANGUAGE_SERVER_ID: &'static str = "solargraph";
+
+    pub fn new() -> Self {
+        Self {}
+    }
+
+    pub fn server_script_path(&mut self, worktree: &zed::Worktree) -> Result<String> {
+        let path = worktree
+            .which("solargraph")
+            .ok_or_else(|| "solargraph must be installed manually".to_string())?;
+
+        Ok(path)
+    }
+
+    pub fn label_for_completion(&self, completion: Completion) -> Option<CodeLabel> {
+        match completion.kind? {
+            CompletionKind::Method => {
+                let highlight_name = match completion.kind? {
+                    CompletionKind::Class | CompletionKind::Module => "type",
+                    CompletionKind::Constant => "constant",
+                    CompletionKind::Method => "function.method",
+                    CompletionKind::Keyword => {
+                        if completion.label.starts_with(':') {
+                            "string.special.symbol"
+                        } else {
+                            "keyword"
+                        }
+                    }
+                    CompletionKind::Variable => {
+                        if completion.label.starts_with('@') {
+                            "property"
+                        } else {
+                            return None;
+                        }
+                    }
+                    _ => 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: if let Some(detail) = completion.detail {
+                        vec![
+                            name_span,
+                            CodeLabelSpan::literal(" ", None),
+                            CodeLabelSpan::literal(detail, None),
+                        ]
+                    } else {
+                        vec![name_span]
+                    },
+                    filter_range: (0..len).into(),
+                })
+            }
+            _ => None,
+        }
+    }
+
+    pub fn label_for_symbol(&self, symbol: Symbol) -> Option<CodeLabel> {
+        let name = &symbol.name;
+
+        return match symbol.kind {
+            SymbolKind::Method => {
+                let mut parts = name.split('#');
+                let container_name = parts.next()?;
+                let method_name = parts.next()?;
+
+                if parts.next().is_some() {
+                    return None;
+                }
+
+                let filter_range = 0..name.len();
+
+                let spans = vec![
+                    CodeLabelSpan::literal(container_name, Some("type".to_string())),
+                    CodeLabelSpan::literal("#", None),
+                    CodeLabelSpan::literal(method_name, Some("function.method".to_string())),
+                ];
+
+                Some(CodeLabel {
+                    code: name.to_string(),
+                    spans,
+                    filter_range: filter_range.into(),
+                })
+            }
+            SymbolKind::Class | SymbolKind::Module => {
+                let class = "class ";
+                let code = format!("{class}{name}");
+                let filter_range = 0..name.len();
+                let display_range = class.len()..class.len() + 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 🔗

@@ -0,0 +1,62 @@
+mod language_servers;
+
+use zed::lsp::{Completion, Symbol};
+use zed::{CodeLabel, LanguageServerId};
+use zed_extension_api::{self as zed, Result};
+
+use crate::language_servers::Solargraph;
+
+struct RubyExtension {
+    solargraph: Option<Solargraph>,
+}
+
+impl zed::Extension for RubyExtension {
+    fn new() -> Self {
+        Self { solargraph: None }
+    }
+
+    fn language_server_command(
+        &mut self,
+        language_server_id: &LanguageServerId,
+        worktree: &zed::Worktree,
+    ) -> Result<zed::Command> {
+        match language_server_id.as_ref() {
+            Solargraph::LANGUAGE_SERVER_ID => {
+                let solargraph = self.solargraph.get_or_insert_with(|| Solargraph::new());
+
+                Ok(zed::Command {
+                    command: solargraph.server_script_path(worktree)?,
+                    args: vec!["stdio".into()],
+                    env: worktree.shell_env(),
+                })
+            }
+            language_server_id => Err(format!("unknown language server: {language_server_id}")),
+        }
+    }
+
+    fn label_for_symbol(
+        &self,
+        language_server_id: &LanguageServerId,
+        symbol: Symbol,
+    ) -> Option<CodeLabel> {
+        match language_server_id.as_ref() {
+            Solargraph::LANGUAGE_SERVER_ID => self.solargraph.as_ref()?.label_for_symbol(symbol),
+            _ => None,
+        }
+    }
+
+    fn label_for_completion(
+        &self,
+        language_server_id: &LanguageServerId,
+        completion: Completion,
+    ) -> Option<CodeLabel> {
+        match language_server_id.as_ref() {
+            Solargraph::LANGUAGE_SERVER_ID => {
+                self.solargraph.as_ref()?.label_for_completion(completion)
+            }
+            _ => None,
+        }
+    }
+}
+
+zed::register_extension!(RubyExtension);