Improve syntax highlighting and outline view for Elixir (#2582)

Max Brunsfeld created

Fixes
https://linear.app/zed-industries/issue/Z-2208/outline-view-doesnt-differentiate-between-overloaded-functions
Fixes
https://linear.app/zed-industries/issue/Z-2205/elixir-syntax-highlighting-not-working-properly-for-doc-attributes-and

This PR improves syntax highlighting and outline view in Elixir. It's
common to overload elixir functions, with many different versions of the
function for different patterns of parameters, so I updated the outline
view to show functions' parameters in Elixir. But if we showed functions
the same way in the *breadcrumbs*, it would take up too much space.

So I added a new capture in languages' `outline` queries called
`@context.extra`, which is included in the outline view, but not in
breadcrumbs.

Release Notes:

- Improved syntax highlighting of doc attributes and special macros in
Elixir
- Updated the outline view in Elixir to display function parameters, to
allow differentiating between function overloads.

Change summary

Cargo.lock                                     |  4 
crates/language/src/buffer.rs                  | 13 ++++-
crates/language/src/buffer_tests.rs            | 46 +++++++++++++++++++
crates/language/src/language.rs                | 29 ++++-------
crates/zed/Cargo.toml                          |  2 
crates/zed/src/languages/elixir/highlights.scm | 47 ++++++++-----------
crates/zed/src/languages/elixir/indents.scm    |  4 -
crates/zed/src/languages/elixir/outline.scm    | 14 +++++
crates/zed/src/languages/typescript.rs         |  6 +-
9 files changed, 106 insertions(+), 59 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -7342,8 +7342,8 @@ dependencies = [
 
 [[package]]
 name = "tree-sitter-elixir"
-version = "0.19.0"
-source = "git+https://github.com/elixir-lang/tree-sitter-elixir?rev=05e3631c6a0701c1fa518b0fee7be95a2ceef5e2#05e3631c6a0701c1fa518b0fee7be95a2ceef5e2"
+version = "0.1.0"
+source = "git+https://github.com/elixir-lang/tree-sitter-elixir?rev=4ba9dab6e2602960d95b2b625f3386c27e08084e#4ba9dab6e2602960d95b2b625f3386c27e08084e"
 dependencies = [
  "cc",
  "tree-sitter",

crates/language/src/buffer.rs 🔗

@@ -2253,7 +2253,7 @@ impl BufferSnapshot {
     }
 
     pub fn outline(&self, theme: Option<&SyntaxTheme>) -> Option<Outline<Anchor>> {
-        self.outline_items_containing(0..self.len(), theme)
+        self.outline_items_containing(0..self.len(), true, theme)
             .map(Outline::new)
     }
 
@@ -2265,6 +2265,7 @@ impl BufferSnapshot {
         let position = position.to_offset(self);
         let mut items = self.outline_items_containing(
             position.saturating_sub(1)..self.len().min(position + 1),
+            false,
             theme,
         )?;
         let mut prev_depth = None;
@@ -2279,6 +2280,7 @@ impl BufferSnapshot {
     fn outline_items_containing(
         &self,
         range: Range<usize>,
+        include_extra_context: bool,
         theme: Option<&SyntaxTheme>,
     ) -> Option<Vec<OutlineItem<Anchor>>> {
         let mut matches = self.syntax.matches(range.clone(), &self.text, |grammar| {
@@ -2313,7 +2315,10 @@ impl BufferSnapshot {
                 let node_is_name;
                 if capture.index == config.name_capture_ix {
                     node_is_name = true;
-                } else if Some(capture.index) == config.context_capture_ix {
+                } else if Some(capture.index) == config.context_capture_ix
+                    || (Some(capture.index) == config.extra_context_capture_ix
+                        && include_extra_context)
+                {
                     node_is_name = false;
                 } else {
                     continue;
@@ -2340,10 +2345,12 @@ impl BufferSnapshot {
                 buffer_ranges.first().unwrap().0.start..buffer_ranges.last().unwrap().0.end,
                 true,
             );
+            let mut last_buffer_range_end = 0;
             for (buffer_range, is_name) in buffer_ranges {
-                if !text.is_empty() {
+                if !text.is_empty() && buffer_range.start > last_buffer_range_end {
                     text.push(' ');
                 }
+                last_buffer_range_end = buffer_range.end;
                 if is_name {
                     let mut start = text.len();
                     let end = start + buffer_range.len();

crates/language/src/buffer_tests.rs 🔗

@@ -592,6 +592,52 @@ async fn test_outline_nodes_with_newlines(cx: &mut gpui::TestAppContext) {
     );
 }
 
+#[gpui::test]
+async fn test_outline_with_extra_context(cx: &mut gpui::TestAppContext) {
+    let language = javascript_lang()
+        .with_outline_query(
+            r#"
+            (function_declaration
+                "function" @context
+                name: (_) @name
+                parameters: (formal_parameters
+                    "(" @context.extra
+                    ")" @context.extra)) @item
+            "#,
+        )
+        .unwrap();
+
+    let text = r#"
+        function a() {}
+        function b(c) {}
+    "#
+    .unindent();
+
+    let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(Arc::new(language), cx));
+    let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
+
+    // extra context nodes are included in the outline.
+    let outline = snapshot.outline(None).unwrap();
+    assert_eq!(
+        outline
+            .items
+            .iter()
+            .map(|item| (item.text.as_str(), item.depth))
+            .collect::<Vec<_>>(),
+        &[("function a()", 0), ("function b( )", 0),]
+    );
+
+    // extra context nodes do not appear in breadcrumbs.
+    let symbols = snapshot.symbols_containing(3, None).unwrap();
+    assert_eq!(
+        symbols
+            .iter()
+            .map(|item| (item.text.as_str(), item.depth))
+            .collect::<Vec<_>>(),
+        &[("function a", 0)]
+    );
+}
+
 #[gpui::test]
 async fn test_symbols_containing(cx: &mut gpui::TestAppContext) {
     let text = r#"

crates/language/src/language.rs 🔗

@@ -455,6 +455,7 @@ struct OutlineConfig {
     item_capture_ix: u32,
     name_capture_ix: u32,
     context_capture_ix: Option<u32>,
+    extra_context_capture_ix: Option<u32>,
 }
 
 struct InjectionConfig {
@@ -771,6 +772,7 @@ impl LanguageRegistry {
                                         }
                                     }
                                     Err(err) => {
+                                        log::error!("failed to load language {name} - {err}");
                                         let mut state = this.state.write();
                                         state.mark_language_loaded(id);
                                         if let Some(mut txs) = state.loading_languages.remove(&id) {
@@ -1059,34 +1061,22 @@ impl Language {
 
     pub fn with_queries(mut self, queries: LanguageQueries) -> Result<Self> {
         if let Some(query) = queries.highlights {
-            self = self
-                .with_highlights_query(query.as_ref())
-                .expect("failed to evaluate highlights query");
+            self = self.with_highlights_query(query.as_ref())?;
         }
         if let Some(query) = queries.brackets {
-            self = self
-                .with_brackets_query(query.as_ref())
-                .expect("failed to load brackets query");
+            self = self.with_brackets_query(query.as_ref())?;
         }
         if let Some(query) = queries.indents {
-            self = self
-                .with_indents_query(query.as_ref())
-                .expect("failed to load indents query");
+            self = self.with_indents_query(query.as_ref())?;
         }
         if let Some(query) = queries.outline {
-            self = self
-                .with_outline_query(query.as_ref())
-                .expect("failed to load outline query");
+            self = self.with_outline_query(query.as_ref())?;
         }
         if let Some(query) = queries.injections {
-            self = self
-                .with_injection_query(query.as_ref())
-                .expect("failed to load injection query");
+            self = self.with_injection_query(query.as_ref())?;
         }
         if let Some(query) = queries.overrides {
-            self = self
-                .with_override_query(query.as_ref())
-                .expect("failed to load override query");
+            self = self.with_override_query(query.as_ref())?;
         }
         Ok(self)
     }
@@ -1102,12 +1092,14 @@ impl Language {
         let mut item_capture_ix = None;
         let mut name_capture_ix = None;
         let mut context_capture_ix = None;
+        let mut extra_context_capture_ix = None;
         get_capture_indices(
             &query,
             &mut [
                 ("item", &mut item_capture_ix),
                 ("name", &mut name_capture_ix),
                 ("context", &mut context_capture_ix),
+                ("context.extra", &mut extra_context_capture_ix),
             ],
         );
         if let Some((item_capture_ix, name_capture_ix)) = item_capture_ix.zip(name_capture_ix) {
@@ -1116,6 +1108,7 @@ impl Language {
                 item_capture_ix,
                 name_capture_ix,
                 context_capture_ix,
+                extra_context_capture_ix,
             });
         }
         Ok(self)

crates/zed/Cargo.toml 🔗

@@ -106,7 +106,7 @@ tree-sitter = "0.20"
 tree-sitter-c = "0.20.1"
 tree-sitter-cpp = "0.20.0"
 tree-sitter-css = { git = "https://github.com/tree-sitter/tree-sitter-css", rev = "769203d0f9abe1a9a691ac2b9fe4bb4397a73c51" }
-tree-sitter-elixir = { git = "https://github.com/elixir-lang/tree-sitter-elixir", rev = "05e3631c6a0701c1fa518b0fee7be95a2ceef5e2" }
+tree-sitter-elixir = { git = "https://github.com/elixir-lang/tree-sitter-elixir", rev = "4ba9dab6e2602960d95b2b625f3386c27e08084e" }
 tree-sitter-embedded-template = "0.20.0"
 tree-sitter-go = { git = "https://github.com/tree-sitter/tree-sitter-go", rev = "aeb2f33b366fd78d5789ff104956ce23508b85db" }
 tree-sitter-json = { git = "https://github.com/tree-sitter/tree-sitter-json", rev = "40a81c01a40ac48744e0c8ccabbaba1920441199" }

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

@@ -1,20 +1,5 @@
 ["when" "and" "or" "not" "in" "not in" "fn" "do" "end" "catch" "rescue" "after" "else"] @keyword
 
-(unary_operator
-  operator: "@" @comment.doc
-  operand: (call
-    target: (identifier) @comment.doc.__attribute__
-    (arguments
-      [
-        (string) @comment.doc
-        (charlist) @comment.doc
-        (sigil
-          quoted_start: _ @comment.doc
-          quoted_end: _ @comment.doc) @comment.doc
-        (boolean) @comment.doc
-      ]))
-  (#match? @comment.doc.__attribute__ "^(moduledoc|typedoc|doc)$"))
-
 (unary_operator
   operator: "&"
   operand: (integer) @operator)
@@ -84,6 +69,11 @@
   quoted_start: _ @string.special
   quoted_end: _ @string.special) @string.special
 
+(
+  (identifier) @comment.unused
+  (#match? @comment.unused "^_")
+)
+
 (call
   target: [
     (identifier) @function
@@ -99,17 +89,12 @@
       (binary_operator
         left: (identifier) @function
         operator: "when")
+      (binary_operator
+        operator: "|>"
+        right: (identifier))
     ])
   (#match? @keyword "^(def|defdelegate|defguard|defguardp|defmacro|defmacrop|defn|defnp|defp)$"))
 
-(call
-  target: (identifier) @keyword
-  (arguments
-    (binary_operator
-      operator: "|>"
-      right: (identifier)))
-  (#match? @keyword "^(def|defdelegate|defguard|defguardp|defmacro|defmacrop|defn|defnp|defp)$"))
-
 (binary_operator
   operator: "|>"
   right: (identifier) @function)
@@ -127,10 +112,18 @@
   (#match? @constant.builtin "^(__MODULE__|__DIR__|__ENV__|__CALLER__|__STACKTRACE__)$")
 )
 
-(
-  (identifier) @comment.unused
-  (#match? @comment.unused "^_")
-)
+(unary_operator
+  operator: "@" @comment.doc
+  operand: (call
+    target: (identifier) @__attribute__ @comment.doc
+    (arguments
+      [
+        (string)
+        (charlist)
+        (sigil)
+        (boolean)
+      ] @comment.doc))
+  (#match? @__attribute__ "^(moduledoc|typedoc|doc)$"))
 
 (comment) @comment
 

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

@@ -8,9 +8,19 @@
   (arguments
     [
       (identifier) @name
-      (call target: (identifier) @name)
+      (call
+          target: (identifier) @name
+          (arguments
+              "(" @context.extra
+              _* @context.extra
+              ")" @context.extra))
       (binary_operator
-        left: (call target: (identifier) @name)
+        left: (call
+            target: (identifier) @name
+            (arguments
+                "(" @context.extra
+                _* @context.extra
+                ")" @context.extra))
         operator: "when")
     ])
   (#match? @context "^(def|defp|defdelegate|defguard|defguardp|defmacro|defmacrop|defn|defnp)$")) @item

crates/zed/src/languages/typescript.rs 🔗

@@ -327,10 +327,10 @@ mod tests {
                 .map(|item| (item.text.as_str(), item.depth))
                 .collect::<Vec<_>>(),
             &[
-                ("function a ( )", 0),
-                ("async function a2 ( )", 1),
+                ("function a()", 0),
+                ("async function a2()", 1),
                 ("let b", 0),
-                ("function getB ( )", 0),
+                ("function getB()", 0),
                 ("const d", 0),
             ]
         );