Add basic support for ruby

Max Brunsfeld and Kay Simmons created

Co-authored-by: Kay Simmons <kay@zed.dev>

Change summary

Cargo.lock                                   |  12 +
crates/language/Cargo.toml                   |   1 
crates/language/src/buffer.rs                |  16 +
crates/language/src/buffer_tests.rs          |  63 +++++++
crates/language/src/language.rs              |   4 
crates/zed/Cargo.toml                        |   1 
crates/zed/src/languages.rs                  |  10 +
crates/zed/src/languages/ruby/brackets.scm   |  14 +
crates/zed/src/languages/ruby/config.toml    |  11 +
crates/zed/src/languages/ruby/highlights.scm | 181 ++++++++++++++++++++++
crates/zed/src/languages/ruby/indents.scm    |  17 ++
crates/zed/src/languages/ruby/outline.scm    |  11 +
styles/src/styleTree/editor.ts               |  15 
13 files changed, 349 insertions(+), 7 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -3009,6 +3009,7 @@ dependencies = [
  "tree-sitter-javascript",
  "tree-sitter-json 0.19.0",
  "tree-sitter-python",
+ "tree-sitter-ruby",
  "tree-sitter-rust",
  "tree-sitter-typescript",
  "unindent",
@@ -6491,6 +6492,16 @@ dependencies = [
  "tree-sitter",
 ]
 
+[[package]]
+name = "tree-sitter-ruby"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ac30cbb1560363ae76e1ccde543d6d99087421e228cc47afcec004b86bb711a"
+dependencies = [
+ "cc",
+ "tree-sitter",
+]
+
 [[package]]
 name = "tree-sitter-rust"
 version = "0.20.3"
@@ -7712,6 +7723,7 @@ dependencies = [
  "tree-sitter-json 0.20.0",
  "tree-sitter-markdown",
  "tree-sitter-python",
+ "tree-sitter-ruby",
  "tree-sitter-rust",
  "tree-sitter-toml",
  "tree-sitter-typescript",

crates/language/Cargo.toml 🔗

@@ -71,4 +71,5 @@ tree-sitter-json = "*"
 tree-sitter-rust = "*"
 tree-sitter-python = "*"
 tree-sitter-typescript = "*"
+tree-sitter-ruby = "*"
 unindent = "0.1.7"

crates/language/src/buffer.rs 🔗

@@ -1764,6 +1764,7 @@ impl BufferSnapshot {
             .collect::<Vec<_>>();
 
         let mut indent_ranges = Vec::<Range<Point>>::new();
+        let mut outdent_positions = Vec::<Point>::new();
         while let Some(mat) = matches.peek() {
             let mut start: Option<Point> = None;
             let mut end: Option<Point> = None;
@@ -1777,6 +1778,8 @@ impl BufferSnapshot {
                     start = Some(Point::from_ts_point(capture.node.end_position()));
                 } else if Some(capture.index) == config.end_capture_ix {
                     end = Some(Point::from_ts_point(capture.node.start_position()));
+                } else if Some(capture.index) == config.outdent_capture_ix {
+                    outdent_positions.push(Point::from_ts_point(capture.node.start_position()));
                 }
             }
 
@@ -1797,6 +1800,19 @@ impl BufferSnapshot {
             }
         }
 
+        outdent_positions.sort();
+        for outdent_position in outdent_positions {
+            // find the innermost indent range containing this outdent_position
+            // set its end to the outdent position
+            if let Some(range_to_truncate) = indent_ranges
+                .iter_mut()
+                .filter(|indent_range| indent_range.contains(&outdent_position))
+                .last()
+            {
+                range_to_truncate.end = outdent_position;
+            }
+        }
+
         // Find the suggested indentation increases and decreased based on regexes.
         let mut indent_change_rows = Vec::<(u32, Ordering)>::new();
         self.for_each_line(

crates/language/src/buffer_tests.rs 🔗

@@ -1150,6 +1150,49 @@ fn test_autoindent_with_injected_languages(cx: &mut MutableAppContext) {
     });
 }
 
+#[gpui::test]
+fn test_autoindent_query_with_outdent_captures(cx: &mut MutableAppContext) {
+    let mut settings = Settings::test(cx);
+    settings.editor_defaults.tab_size = Some(2.try_into().unwrap());
+    cx.set_global(settings);
+    cx.add_model(|cx| {
+        let mut buffer = Buffer::new(0, "", cx).with_language(Arc::new(ruby_lang()), cx);
+
+        let text = r#"
+            class C
+            def a(b, c)
+            puts b
+            puts c
+            rescue
+            puts "errored"
+            exit 1
+            end
+            end
+        "#
+        .unindent();
+
+        buffer.edit([(0..0, text)], Some(AutoindentMode::EachLine), cx);
+
+        assert_eq!(
+            buffer.text(),
+            r#"
+                class C
+                  def a(b, c)
+                    puts b
+                    puts c
+                  rescue
+                    puts "errored"
+                    exit 1
+                  end
+                end
+            "#
+            .unindent()
+        );
+
+        buffer
+    });
+}
+
 #[gpui::test]
 fn test_serialization(cx: &mut gpui::MutableAppContext) {
     let mut now = Instant::now();
@@ -1497,6 +1540,26 @@ impl Buffer {
     }
 }
 
+fn ruby_lang() -> Language {
+    Language::new(
+        LanguageConfig {
+            name: "Ruby".into(),
+            path_suffixes: vec!["rb".to_string()],
+            ..Default::default()
+        },
+        Some(tree_sitter_ruby::language()),
+    )
+    .with_indents_query(
+        r#"
+            (class "end" @end) @indent
+            (method "end" @end) @indent
+            (rescue) @outdent
+            (then) @indent
+        "#,
+    )
+    .unwrap()
+}
+
 fn rust_lang() -> Language {
     Language::new(
         LanguageConfig {

crates/language/src/language.rs 🔗

@@ -312,6 +312,7 @@ struct IndentConfig {
     indent_capture_ix: u32,
     start_capture_ix: Option<u32>,
     end_capture_ix: Option<u32>,
+    outdent_capture_ix: Option<u32>,
 }
 
 struct OutlineConfig {
@@ -670,12 +671,14 @@ impl Language {
         let mut indent_capture_ix = None;
         let mut start_capture_ix = None;
         let mut end_capture_ix = None;
+        let mut outdent_capture_ix = None;
         get_capture_indices(
             &query,
             &mut [
                 ("indent", &mut indent_capture_ix),
                 ("start", &mut start_capture_ix),
                 ("end", &mut end_capture_ix),
+                ("outdent", &mut outdent_capture_ix),
             ],
         );
         if let Some(indent_capture_ix) = indent_capture_ix {
@@ -684,6 +687,7 @@ impl Language {
                 indent_capture_ix,
                 start_capture_ix,
                 end_capture_ix,
+                outdent_capture_ix,
             });
         }
         Ok(self)

crates/zed/Cargo.toml 🔗

@@ -102,6 +102,7 @@ tree-sitter-markdown = { git = "https://github.com/MDeiml/tree-sitter-markdown",
 tree-sitter-python = "0.20.2"
 tree-sitter-toml = { git = "https://github.com/tree-sitter/tree-sitter-toml", rev = "342d9be207c2dba869b9967124c679b5e6fd0ebe" }
 tree-sitter-typescript = "0.20.1"
+tree-sitter-ruby = "0.20.0"
 tree-sitter-html = "0.19.0"
 url = "2.2"
 

crates/zed/src/languages.rs 🔗

@@ -15,6 +15,15 @@ mod python;
 mod rust;
 mod typescript;
 
+// 1. Add tree-sitter-{language} parser to zed crate
+// 2. Create a language directory in zed/crates/zed/src/languages and add the language to init function below
+// 3. Add config.toml to the newly created language directory using existing languages as a template
+// 4. Copy highlights from tree sitter repo for the language into a highlights.scm file.
+//      Note: github highlights take the last match while zed takes the first
+// 5. Add indents.scm, outline.scm, and brackets.scm to implement indent on newline, outline/breadcrumbs,
+//    and autoclosing brackets respectively
+// 6. If the language has injections add an injections.scm query file
+
 #[derive(RustEmbed)]
 #[folder = "src/languages"]
 #[exclude = "*.rs"]
@@ -107,6 +116,7 @@ pub async fn init(languages: Arc<LanguageRegistry>, _executor: Arc<Background>)
             tree_sitter_html::language(),
             Some(CachedLspAdapter::new(html::HtmlLspAdapter).await),
         ),
+        ("ruby", tree_sitter_ruby::language(), None),
     ] {
         languages.add(language(name, grammar, lsp_adapter));
     }

crates/zed/src/languages/ruby/brackets.scm 🔗

@@ -0,0 +1,14 @@
+("[" @open "]" @close)
+("{" @open "}" @close)
+("\"" @open "\"" @close)
+("do" @open "end" @close)
+
+(block_parameters "|" @open "|" @close)
+(interpolation "#{" @open "}" @close)
+
+(if "if" @open "end" @close)
+(unless "unless" @open "end" @close)
+(begin "begin" @open "end" @close)
+(module "module" @open "end" @close)
+(_ . "def" @open "end" @close)
+(_ . "class" @open "end" @close)

crates/zed/src/languages/ruby/config.toml 🔗

@@ -0,0 +1,11 @@
+name = "Ruby"
+path_suffixes = ["rb", "Gemfile"]
+line_comment = "# "
+autoclose_before = ";:.,=}])>"
+brackets = [
+  { start = "{", end = "}", close = true, newline = true },
+  { start = "[", end = "]", close = true, newline = true },
+  { start = "(", end = ")", close = true, newline = true },
+  { start = "\"", end = "\"", close = true, newline = false },
+  { start = "'", end = "'", close = false, newline = false },
+]

crates/zed/src/languages/ruby/highlights.scm 🔗

@@ -0,0 +1,181 @@
+; Keywords
+
+[
+  "alias"
+  "and"
+  "begin"
+  "break"
+  "case"
+  "class"
+  "def"
+  "do"
+  "else"
+  "elsif"
+  "end"
+  "ensure"
+  "for"
+  "if"
+  "in"
+  "module"
+  "next"
+  "or"
+  "rescue"
+  "retry"
+  "return"
+  "then"
+  "unless"
+  "until"
+  "when"
+  "while"
+  "yield"
+] @keyword
+
+(identifier) @variable
+
+((identifier) @keyword
+ (#match? @keyword "^(private|protected|public)$"))
+
+; Function calls
+
+((identifier) @function.method.builtin
+ (#eq? @function.method.builtin "require"))
+
+"defined?" @function.method.builtin
+
+(call
+  method: [(identifier) (constant)] @function.method)
+
+; Function definitions
+
+(alias (identifier) @function.method)
+(setter (identifier) @function.method)
+(method name: [(identifier) (constant)] @function.method)
+(singleton_method name: [(identifier) (constant)] @function.method)
+
+; Identifiers
+
+[
+  (class_variable)
+  (instance_variable)
+] @property
+
+((identifier) @constant.builtin
+ (#match? @constant.builtin "^__(FILE|LINE|ENCODING)__$"))
+
+(file) @constant.builtin
+(line) @constant.builtin
+(encoding) @constant.builtin
+
+(hash_splat_nil
+  "**" @operator
+) @constant.builtin
+
+((constant) @constant
+ (#match? @constant "^[A-Z\\d_]+$"))
+
+(constant) @type
+
+(self) @variable.special
+(super) @variable.special
+
+; Literals
+
+[
+  (string)
+  (bare_string)
+  (subshell)
+  (heredoc_body)
+  (heredoc_beginning)
+] @string
+
+[
+  (simple_symbol)
+  (delimited_symbol)
+  (hash_key_symbol)
+  (bare_symbol)
+] @string.special.symbol
+
+(regex) @string.special.regex
+(escape_sequence) @escape
+
+[
+  (integer)
+  (float)
+] @number
+
+[
+  (nil)
+  (true)
+  (false)
+] @constant.builtin
+
+(interpolation
+  "#{" @punctuation.special
+  "}" @punctuation.special) @embedded
+
+(comment) @comment
+
+; Operators
+
+[
+  "!"
+  "~"
+  "+"
+  "-"
+  "**"
+  "*"
+  "/"
+  "%"
+  "<<"
+  ">>"
+  "&"
+  "|"
+  "^"
+  ">"
+  "<"
+  "<="
+  ">="
+  "=="
+  "!="
+  "=~"
+  "!~"
+  "<=>"
+  "||"
+  "&&"
+  ".."
+  "..."
+  "="
+  "**="
+  "*="
+  "/="
+  "%="
+  "+="
+  "-="
+  "<<="
+  ">>="
+  "&&="
+  "&="
+  "||="
+  "|="
+  "^="
+  "=>"
+  "->"
+  (operator)
+] @operator
+
+[
+  ","
+  ";"
+  "."
+] @punctuation.delimiter
+
+[
+  "("
+  ")"
+  "["
+  "]"
+  "{"
+  "}"
+  "%w("
+  "%i("
+] @punctuation.bracket

crates/zed/src/languages/ruby/indents.scm 🔗

@@ -0,0 +1,17 @@
+(method "end" @end) @indent
+(class "end" @end) @indent
+(module "end" @end) @indent
+(begin "end" @end) @indent
+(do_block "end" @end) @indent
+
+(then) @indent
+(call) @indent
+
+(ensure) @outdent
+(rescue) @outdent
+(else) @outdent
+
+
+(_ "[" "]" @end) @indent
+(_ "{" "}" @end) @indent
+(_ "(" ")" @end) @indent

crates/zed/src/languages/ruby/outline.scm 🔗

@@ -0,0 +1,11 @@
+(class
+    "class" @context
+    name: (_) @name) @item
+
+(method
+    "def" @context
+    name: (_) @name) @item
+
+(module
+    "module" @context
+    name: (_) @name) @item

styles/src/styleTree/editor.ts 🔗

@@ -1,10 +1,6 @@
 import { fontWeights } from "../common";
 import { withOpacity } from "../utils/color";
-import {
-  ColorScheme,
-  Layer,
-  StyleSets,
-} from "../themes/common/colorScheme";
+import { ColorScheme, Layer, StyleSets } from "../themes/common/colorScheme";
 import {
   background,
   border,
@@ -50,6 +46,11 @@ export default function editor(colorScheme: ColorScheme) {
       color: colorScheme.ramps.neutral(1).hex(),
       weight: fontWeights.normal,
     },
+    "variable.special": {
+      // Highlights for self, this, etc
+      color: colorScheme.ramps.blue(0.7).hex(),
+      weight: fontWeights.normal,
+    },
     comment: {
       color: colorScheme.ramps.neutral(0.71).hex(),
       weight: fontWeights.normal,
@@ -270,9 +271,9 @@ export default function editor(colorScheme: ColorScheme) {
         background: withOpacity(background(layer, "inverted"), 0.4),
         border: {
           width: 1,
-          color: borderColor(layer, 'variant'),
+          color: borderColor(layer, "variant"),
         },
-      }
+      },
     },
     compositionMark: {
       underline: {