Merge pull request #1548 from zed-industries/elixir

Antonio Scandurra created

Add initial support for Elixir

Change summary

Cargo.lock                                     |  10 +
assets/settings/default.json                   |   3 
crates/project/src/project.rs                  |  53 +++-
crates/zed/Cargo.toml                          |   1 
crates/zed/src/languages.rs                    |   6 
crates/zed/src/languages/elixir.rs             | 195 ++++++++++++++++++++
crates/zed/src/languages/elixir/brackets.scm   |   5 
crates/zed/src/languages/elixir/config.toml    |  10 +
crates/zed/src/languages/elixir/highlights.scm | 155 +++++++++++++++
crates/zed/src/languages/elixir/indents.scm    |   8 
crates/zed/src/languages/elixir/outline.scm    |  16 +
11 files changed, 444 insertions(+), 18 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5868,6 +5868,15 @@ dependencies = [
  "tree-sitter",
 ]
 
+[[package]]
+name = "tree-sitter-elixir"
+version = "0.19.0"
+source = "git+https://github.com/elixir-lang/tree-sitter-elixir?rev=05e3631c6a0701c1fa518b0fee7be95a2ceef5e2#05e3631c6a0701c1fa518b0fee7be95a2ceef5e2"
+dependencies = [
+ "cc",
+ "tree-sitter",
+]
+
 [[package]]
 name = "tree-sitter-go"
 version = "0.19.1"
@@ -7056,6 +7065,7 @@ dependencies = [
  "tree-sitter",
  "tree-sitter-c",
  "tree-sitter-cpp",
+ "tree-sitter-elixir",
  "tree-sitter-go",
  "tree-sitter-json 0.20.0",
  "tree-sitter-markdown",

crates/project/src/project.rs 🔗

@@ -202,6 +202,7 @@ pub enum Event {
 pub enum LanguageServerState {
     Starting(Task<Option<Arc<LanguageServer>>>),
     Running {
+        language: Arc<Language>,
         adapter: Arc<CachedLspAdapter>,
         server: Arc<LanguageServer>,
     },
@@ -1969,7 +1970,7 @@ impl Project {
                     uri: lsp::Url::from_file_path(abs_path).unwrap(),
                 };
 
-                for (_, server) in self.language_servers_for_worktree(worktree_id) {
+                for (_, _, server) in self.language_servers_for_worktree(worktree_id) {
                     server
                         .notify::<lsp::notification::DidSaveTextDocument>(
                             lsp::DidSaveTextDocumentParams {
@@ -2004,15 +2005,18 @@ impl Project {
     fn language_servers_for_worktree(
         &self,
         worktree_id: WorktreeId,
-    ) -> impl Iterator<Item = (&Arc<CachedLspAdapter>, &Arc<LanguageServer>)> {
+    ) -> impl Iterator<Item = (&Arc<CachedLspAdapter>, &Arc<Language>, &Arc<LanguageServer>)> {
         self.language_server_ids
             .iter()
             .filter_map(move |((language_server_worktree_id, _), id)| {
                 if *language_server_worktree_id == worktree_id {
-                    if let Some(LanguageServerState::Running { adapter, server }) =
-                        self.language_servers.get(id)
+                    if let Some(LanguageServerState::Running {
+                        adapter,
+                        language,
+                        server,
+                    }) = self.language_servers.get(id)
                     {
-                        return Some((adapter, server));
+                        return Some((adapter, language, server));
                     }
                 }
                 None
@@ -2282,6 +2286,7 @@ impl Project {
                                 server_id,
                                 LanguageServerState::Running {
                                     adapter: adapter.clone(),
+                                    language,
                                     server: language_server.clone(),
                                 },
                             );
@@ -3314,10 +3319,14 @@ impl Project {
                     .worktree_for_id(worktree_id, cx)
                     .and_then(|worktree| worktree.read(cx).as_local())
                 {
-                    if let Some(LanguageServerState::Running { adapter, server }) =
-                        self.language_servers.get(server_id)
+                    if let Some(LanguageServerState::Running {
+                        adapter,
+                        language,
+                        server,
+                    }) = self.language_servers.get(server_id)
                     {
                         let adapter = adapter.clone();
+                        let language = language.clone();
                         let worktree_abs_path = worktree.abs_path().clone();
                         requests.push(
                             server
@@ -3331,6 +3340,7 @@ impl Project {
                                 .map(move |response| {
                                     (
                                         adapter,
+                                        language,
                                         worktree_id,
                                         worktree_abs_path,
                                         response.unwrap_or_default(),
@@ -3350,7 +3360,14 @@ impl Project {
                 };
                 let symbols = this.read_with(&cx, |this, cx| {
                     let mut symbols = Vec::new();
-                    for (adapter, source_worktree_id, worktree_abs_path, response) in responses {
+                    for (
+                        adapter,
+                        adapter_language,
+                        source_worktree_id,
+                        worktree_abs_path,
+                        response,
+                    ) in responses
+                    {
                         symbols.extend(response.into_iter().flatten().filter_map(|lsp_symbol| {
                             let abs_path = lsp_symbol.location.uri.to_file_path().ok()?;
                             let mut worktree_id = source_worktree_id;
@@ -3369,16 +3386,15 @@ impl Project {
                                 path: path.into(),
                             };
                             let signature = this.symbol_signature(&project_path);
-                            let language = this.languages.select_language(&project_path.path);
+                            let language = this
+                                .languages
+                                .select_language(&project_path.path)
+                                .unwrap_or(adapter_language.clone());
                             let language_server_name = adapter.name.clone();
                             Some(async move {
-                                let label = if let Some(language) = language {
-                                    language
-                                        .label_for_symbol(&lsp_symbol.name, lsp_symbol.kind)
-                                        .await
-                                } else {
-                                    None
-                                };
+                                let label = language
+                                    .label_for_symbol(&lsp_symbol.name, lsp_symbol.kind)
+                                    .await;
 
                                 Symbol {
                                     language_server_name,
@@ -5940,8 +5956,9 @@ impl Project {
             let key = (worktree_id, name);
 
             if let Some(server_id) = self.language_server_ids.get(&key) {
-                if let Some(LanguageServerState::Running { adapter, server }) =
-                    self.language_servers.get(server_id)
+                if let Some(LanguageServerState::Running {
+                    adapter, server, ..
+                }) = self.language_servers.get(server_id)
                 {
                     return Some((adapter, server));
                 }

crates/zed/Cargo.toml 🔗

@@ -91,6 +91,7 @@ toml = "0.5"
 tree-sitter = "0.20"
 tree-sitter-c = "0.20.1"
 tree-sitter-cpp = "0.20.0"
+tree-sitter-elixir = { git = "https://github.com/elixir-lang/tree-sitter-elixir", rev = "05e3631c6a0701c1fa518b0fee7be95a2ceef5e2" }
 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 = "137e1ce6a02698fc246cdb9c6b886ed1de9a1ed8" }
 tree-sitter-rust = "0.20.1"

crates/zed/src/languages.rs 🔗

@@ -5,6 +5,7 @@ use rust_embed::RustEmbed;
 use std::{borrow::Cow, str, sync::Arc};
 
 mod c;
+mod elixir;
 mod go;
 mod installation;
 mod json;
@@ -45,6 +46,11 @@ pub async fn init(languages: Arc<LanguageRegistry>, _executor: Arc<Background>)
             tree_sitter_cpp::language(),
             Some(CachedLspAdapter::new(c::CLspAdapter).await),
         ),
+        (
+            "elixir",
+            tree_sitter_elixir::language(),
+            Some(CachedLspAdapter::new(elixir::ElixirLspAdapter).await),
+        ),
         (
             "go",
             tree_sitter_go::language(),

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

@@ -0,0 +1,195 @@
+use super::installation::{latest_github_release, GitHubLspBinaryVersion};
+use anyhow::{anyhow, Context, Result};
+use async_trait::async_trait;
+use client::http::HttpClient;
+use futures::StreamExt;
+pub use language::*;
+use lsp::{CompletionItemKind, SymbolKind};
+use smol::fs::{self, File};
+use std::{any::Any, path::PathBuf, sync::Arc};
+use util::ResultExt;
+
+pub struct ElixirLspAdapter;
+
+#[async_trait]
+impl LspAdapter for ElixirLspAdapter {
+    async fn name(&self) -> LanguageServerName {
+        LanguageServerName("elixir-ls".into())
+    }
+
+    async fn fetch_latest_server_version(
+        &self,
+        http: Arc<dyn HttpClient>,
+    ) -> Result<Box<dyn 'static + Send + Any>> {
+        let release = latest_github_release("elixir-lsp/elixir-ls", http).await?;
+        let asset_name = "elixir-ls.zip";
+        let asset = release
+            .assets
+            .iter()
+            .find(|asset| asset.name == asset_name)
+            .ok_or_else(|| anyhow!("no asset found matching {:?}", asset_name))?;
+        let version = GitHubLspBinaryVersion {
+            name: release.name,
+            url: asset.browser_download_url.clone(),
+        };
+        Ok(Box::new(version) as Box<_>)
+    }
+
+    async fn fetch_server_binary(
+        &self,
+        version: Box<dyn 'static + Send + Any>,
+        http: Arc<dyn HttpClient>,
+        container_dir: PathBuf,
+    ) -> Result<PathBuf> {
+        let version = version.downcast::<GitHubLspBinaryVersion>().unwrap();
+        let zip_path = container_dir.join(format!("elixir-ls_{}.zip", version.name));
+        let version_dir = container_dir.join(format!("elixir-ls_{}", version.name));
+        let binary_path = version_dir.join("language_server.sh");
+
+        if fs::metadata(&binary_path).await.is_err() {
+            let mut response = http
+                .get(&version.url, Default::default(), true)
+                .await
+                .context("error downloading release")?;
+            let mut file = File::create(&zip_path)
+                .await
+                .with_context(|| format!("failed to create file {}", zip_path.display()))?;
+            if !response.status().is_success() {
+                Err(anyhow!(
+                    "download failed with status {}",
+                    response.status().to_string()
+                ))?;
+            }
+            futures::io::copy(response.body_mut(), &mut file).await?;
+
+            fs::create_dir_all(&version_dir)
+                .await
+                .with_context(|| format!("failed to create directory {}", version_dir.display()))?;
+            let unzip_status = smol::process::Command::new("unzip")
+                .arg(&zip_path)
+                .arg("-d")
+                .arg(&version_dir)
+                .output()
+                .await?
+                .status;
+            if !unzip_status.success() {
+                Err(anyhow!("failed to unzip clangd archive"))?;
+            }
+
+            if let Some(mut entries) = fs::read_dir(&container_dir).await.log_err() {
+                while let Some(entry) = entries.next().await {
+                    if let Some(entry) = entry.log_err() {
+                        let entry_path = entry.path();
+                        if entry_path.as_path() != version_dir {
+                            if let Ok(metadata) = fs::metadata(&entry_path).await {
+                                if metadata.is_file() {
+                                    fs::remove_file(&entry_path).await.log_err();
+                                } else {
+                                    fs::remove_dir_all(&entry_path).await.log_err();
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        Ok(binary_path)
+    }
+
+    async fn cached_server_binary(&self, container_dir: PathBuf) -> Option<PathBuf> {
+        (|| async move {
+            let mut last = None;
+            let mut entries = fs::read_dir(&container_dir).await?;
+            while let Some(entry) = entries.next().await {
+                last = Some(entry?.path());
+            }
+            last.ok_or_else(|| anyhow!("no cached binary"))
+        })()
+        .await
+        .log_err()
+    }
+
+    async fn label_for_completion(
+        &self,
+        completion: &lsp::CompletionItem,
+        language: &Language,
+    ) -> Option<CodeLabel> {
+        match completion.kind.zip(completion.detail.as_ref()) {
+            Some((_, detail)) if detail.starts_with("(function)") => {
+                let text = detail.strip_prefix("(function) ")?;
+                let filter_range = 0..text.find('(').unwrap_or(text.len());
+                let source = Rope::from(format!("def {text}").as_str());
+                let runs = language.highlight_text(&source, 4..4 + text.len());
+                return Some(CodeLabel {
+                    text: text.to_string(),
+                    runs,
+                    filter_range,
+                });
+            }
+            Some((_, detail)) if detail.starts_with("(macro)") => {
+                let text = detail.strip_prefix("(macro) ")?;
+                let filter_range = 0..text.find('(').unwrap_or(text.len());
+                let source = Rope::from(format!("defmacro {text}").as_str());
+                let runs = language.highlight_text(&source, 9..9 + text.len());
+                return Some(CodeLabel {
+                    text: text.to_string(),
+                    runs,
+                    filter_range,
+                });
+            }
+            Some((
+                CompletionItemKind::CLASS
+                | CompletionItemKind::MODULE
+                | CompletionItemKind::INTERFACE
+                | CompletionItemKind::STRUCT,
+                _,
+            )) => {
+                let filter_range = 0..completion
+                    .label
+                    .find(" (")
+                    .unwrap_or(completion.label.len());
+                let text = &completion.label[filter_range.clone()];
+                let source = Rope::from(format!("defmodule {text}").as_str());
+                let runs = language.highlight_text(&source, 10..10 + text.len());
+                return Some(CodeLabel {
+                    text: completion.label.clone(),
+                    runs,
+                    filter_range,
+                });
+            }
+            _ => {}
+        }
+
+        None
+    }
+
+    async fn label_for_symbol(
+        &self,
+        name: &str,
+        kind: SymbolKind,
+        language: &Language,
+    ) -> Option<CodeLabel> {
+        let (text, filter_range, display_range) = match kind {
+            SymbolKind::METHOD | SymbolKind::FUNCTION => {
+                let text = format!("def {}", name);
+                let filter_range = 4..4 + name.len();
+                let display_range = 0..filter_range.end;
+                (text, filter_range, display_range)
+            }
+            SymbolKind::CLASS | SymbolKind::MODULE | SymbolKind::INTERFACE | SymbolKind::STRUCT => {
+                let text = format!("defmodule {}", name);
+                let filter_range = 10..10 + name.len();
+                let display_range = 0..filter_range.end;
+                (text, filter_range, display_range)
+            }
+            _ => return None,
+        };
+
+        Some(CodeLabel {
+            runs: language.highlight_text(&text.as_str().into(), display_range.clone()),
+            text: text[display_range].to_string(),
+            filter_range,
+        })
+    }
+}

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

@@ -0,0 +1,10 @@
+name = "Elixir"
+path_suffixes = ["ex", "exs"]
+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 }
+]

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

@@ -0,0 +1,155 @@
+["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)
+
+(operator_identifier) @operator
+
+(unary_operator
+  operator: _ @operator)
+
+(binary_operator
+  operator: _ @operator)
+
+(dot
+  operator: _ @operator)
+
+(stab_clause
+  operator: _ @operator)
+
+[
+  (boolean)
+  (nil)
+] @constant
+
+[
+  (integer)
+  (float)
+] @number
+
+(alias) @type
+
+(call
+  target: (dot
+    left: (atom) @type))
+
+(char) @constant
+
+(interpolation "#{" @punctuation.special "}" @punctuation.special) @embedded
+
+(escape_sequence) @string.escape
+
+[
+  (atom)
+  (quoted_atom)
+  (keyword)
+  (quoted_keyword)
+] @string.special.symbol
+
+[
+  (string)
+  (charlist)
+] @string
+
+(sigil
+  (sigil_name) @__name__
+  quoted_start: _ @string
+  quoted_end: _ @string
+  (#match? @__name__ "^[sS]$")) @string
+
+(sigil
+  (sigil_name) @__name__
+  quoted_start: _ @string.regex
+  quoted_end: _ @string.regex
+  (#match? @__name__ "^[rR]$")) @string.regex
+
+(sigil
+  (sigil_name) @__name__
+  quoted_start: _ @string.special
+  quoted_end: _ @string.special) @string.special
+
+(call
+  target: [
+    (identifier) @function
+    (dot
+      right: (identifier) @function)
+  ])
+
+(call
+  target: (identifier) @keyword
+  (arguments
+    [
+      (identifier) @function
+      (binary_operator
+        left: (identifier) @function
+        operator: "when")
+    ])
+  (#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)
+
+(call
+  target: (identifier) @keyword
+  (#match? @keyword "^(def|defdelegate|defexception|defguard|defguardp|defimpl|defmacro|defmacrop|defmodule|defn|defnp|defoverridable|defp|defprotocol|defstruct)$"))
+
+(call
+  target: (identifier) @keyword
+  (#match? @keyword "^(alias|case|cond|else|for|if|import|quote|raise|receive|require|reraise|super|throw|try|unless|unquote|unquote_splicing|use|with)$"))
+
+(
+  (identifier) @constant.builtin
+  (#match? @constant.builtin "^(__MODULE__|__DIR__|__ENV__|__CALLER__|__STACKTRACE__)$")
+)
+
+(
+  (identifier) @comment.unused
+  (#match? @comment.unused "^_")
+)
+
+(comment) @comment
+
+[
+ "%"
+] @punctuation
+
+[
+ ","
+ ";"
+] @punctuation.delimiter
+
+[
+  "("
+  ")"
+  "["
+  "]"
+  "{"
+  "}"
+  "<<"
+  ">>"
+] @punctuation.bracket

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

@@ -0,0 +1,16 @@
+(call
+  target: (identifier) @context
+  (arguments (alias) @name)
+  (#match? @context "^(defmodule|defprotocol)$")) @item
+
+(call
+  target: (identifier) @context
+  (arguments
+    [
+      (identifier) @name
+      (call target: (identifier) @name)
+      (binary_operator
+        left: (call target: (identifier) @name)
+        operator: "when")
+    ])
+  (#match? @context "^(def|defp|defdelegate|defguard|defguardp|defmacro|defmacrop|defn|defnp)$")) @item