Use a dedicated test extension in extension tests (#13781)

Marshall Bowers created

This PR updates the `extension` crate's tests to use a dedicated test
extension for its tests instead of the real Gleam extension.

As the Gleam extension continues to evolve, it makes it less suitable to
use as a test fixture:

1. For a while now, the test has failed locally due to me having `gleam`
on my $PATH, which causes the extension's `get_language_server_command`
to go down a separate codepath.
2. With the addition of the `indexed_docs_providers` the test was
hanging indefinitely.

While these problems are likely solvable, it seems reasonable to have a
dedicated extension to use as a test fixture. That way we can do
whatever we need to exercise our test criteria.

The `test-extension` is a fork of the Gleam extension with some
additional functionality removed.

Release Notes:

- N/A

Change summary

Cargo.lock                                               |   7 
Cargo.toml                                               |   1 
crates/extension/src/extension_store_test.rs             |  13 
extensions/gleam/extension.toml                          |   2 
extensions/test-extension/Cargo.toml                     |  16 +
extensions/test-extension/LICENSE-APACHE                 |   1 
extensions/test-extension/README.md                      |   5 
extensions/test-extension/extension.toml                 |  15 
extensions/test-extension/languages/gleam/config.toml    |  12 
extensions/test-extension/languages/gleam/highlights.scm | 130 ++++++++
extensions/test-extension/languages/gleam/indents.scm    |   3 
extensions/test-extension/languages/gleam/outline.scm    |  31 +
extensions/test-extension/src/test_extension.rs          | 160 ++++++++++
13 files changed, 390 insertions(+), 6 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -13896,6 +13896,13 @@ dependencies = [
  "zed_extension_api 0.0.6",
 ]
 
+[[package]]
+name = "zed_test_extension"
+version = "0.1.0"
+dependencies = [
+ "zed_extension_api 0.0.6",
+]
+
 [[package]]
 name = "zed_toml"
 version = "0.1.1"

Cargo.toml 🔗

@@ -140,6 +140,7 @@ members = [
     "extensions/snippets",
     "extensions/svelte",
     "extensions/terraform",
+    "extensions/test-extension",
     "extensions/toml",
     "extensions/uiua",
     "extensions/vue",

crates/extension/src/extension_store_test.rs 🔗

@@ -446,7 +446,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
 }
 
 #[gpui::test]
-async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
+async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) {
     init_test(cx);
     cx.executor().allow_parking();
 
@@ -456,7 +456,8 @@ async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
         .parent()
         .unwrap();
     let cache_dir = root_dir.join("target");
-    let gleam_extension_dir = root_dir.join("extensions").join("gleam");
+    let test_extension_id = "test-extension";
+    let test_extension_dir = root_dir.join("extensions").join(test_extension_id);
 
     let fs = Arc::new(RealFs::default());
     let extensions_dir = temp_tree(json!({
@@ -596,7 +597,7 @@ async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
 
     extension_store
         .update(cx, |store, cx| {
-            store.install_dev_extension(gleam_extension_dir.clone(), cx)
+            store.install_dev_extension(test_extension_dir.clone(), cx)
         })
         .await
         .unwrap();
@@ -611,7 +612,8 @@ async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
         .unwrap();
 
     let fake_server = fake_servers.next().await.unwrap();
-    let expected_server_path = extensions_dir.join("work/gleam/gleam-v1.2.3/gleam");
+    let expected_server_path =
+        extensions_dir.join(format!("work/{test_extension_id}/gleam-v1.2.3/gleam"));
     let expected_binary_contents = language_server_version.lock().binary_contents.clone();
 
     assert_eq!(fake_server.binary.path, expected_server_path);
@@ -725,7 +727,8 @@ async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
 
     // The extension re-fetches the latest version of the language server.
     let fake_server = fake_servers.next().await.unwrap();
-    let new_expected_server_path = extensions_dir.join("work/gleam/gleam-v2.0.0/gleam");
+    let new_expected_server_path =
+        extensions_dir.join(format!("work/{test_extension_id}/gleam-v2.0.0/gleam"));
     let expected_binary_contents = language_server_version.lock().binary_contents.clone();
     assert_eq!(fake_server.binary.path, new_expected_server_path);
     assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]);

extensions/gleam/extension.toml 🔗

@@ -24,4 +24,4 @@ description = "Returns Gleam docs."
 requires_argument = true
 tooltip_text = "Insert Gleam docs"
 
-# [indexed_docs_providers.gleam-hexdocs]
+[indexed_docs_providers.gleam-hexdocs]

extensions/test-extension/Cargo.toml 🔗

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

extensions/test-extension/README.md 🔗

@@ -0,0 +1,5 @@
+# Test Extension
+
+This is a test extension that we use in the tests for the `extension` crate.
+
+Originally based off the Gleam extension.

extensions/test-extension/extension.toml 🔗

@@ -0,0 +1,15 @@
+id = "test-extension"
+name = "Test Extension"
+description = "An extension for use in tests."
+version = "0.1.0"
+schema_version = 1
+authors = ["Marshall Bowers <elliott.codes@gmail.com>"]
+repository = "https://github.com/zed-industries/zed"
+
+[language_servers.gleam]
+name = "Gleam LSP"
+language = "Gleam"
+
+[grammars.gleam]
+repository = "https://github.com/gleam-lang/tree-sitter-gleam"
+commit = "8432ffe32ccd360534837256747beb5b1c82fca1"

extensions/test-extension/languages/gleam/config.toml 🔗

@@ -0,0 +1,12 @@
+name = "Gleam"
+grammar = "gleam"
+path_suffixes = ["gleam"]
+line_comments = ["// ", "/// "]
+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, not_in = ["string", "comment"] },
+]
+tab_size = 2

extensions/test-extension/languages/gleam/highlights.scm 🔗

@@ -0,0 +1,130 @@
+; Comments
+(module_comment) @comment
+(statement_comment) @comment
+(comment) @comment
+
+; Constants
+(constant
+  name: (identifier) @constant)
+
+; Variables
+(identifier) @variable
+(discard) @comment.unused
+
+; Modules
+(module) @module
+(import alias: (identifier) @module)
+(remote_type_identifier
+  module: (identifier) @module)
+(remote_constructor_name
+  module: (identifier) @module)
+((field_access
+  record: (identifier) @module
+  field: (label) @function)
+ (#is-not? local))
+
+; Functions
+(unqualified_import (identifier) @function)
+(unqualified_import "type" (type_identifier) @type)
+(unqualified_import (type_identifier) @constructor)
+(function
+  name: (identifier) @function)
+(external_function
+  name: (identifier) @function)
+(function_parameter
+  name: (identifier) @variable.parameter)
+((function_call
+   function: (identifier) @function)
+ (#is-not? local))
+((binary_expression
+   operator: "|>"
+   right: (identifier) @function)
+ (#is-not? local))
+
+; "Properties"
+; Assumed to be intended to refer to a name for a field; something that comes
+; before ":" or after "."
+; e.g. record field names, tuple indices, names for named arguments, etc
+(label) @property
+(tuple_access
+  index: (integer) @property)
+
+; Attributes
+(attribute
+  "@" @attribute
+  name: (identifier) @attribute)
+
+(attribute_value (identifier) @constant)
+
+; Type names
+(remote_type_identifier) @type
+(type_identifier) @type
+
+; Data constructors
+(constructor_name) @constructor
+
+; Literals
+(string) @string
+((escape_sequence) @warning
+ ; Deprecated in v0.33.0-rc2:
+ (#eq? @warning "\\e"))
+(escape_sequence) @string.escape
+(bit_string_segment_option) @function.builtin
+(integer) @number
+(float) @number
+
+; Reserved identifiers
+; TODO: when tree-sitter supports `#any-of?` in the Rust bindings,
+; refactor this to use `#any-of?` rather than `#match?`
+((identifier) @warning
+ (#match? @warning "^(auto|delegate|derive|else|implement|macro|test|echo)$"))
+
+; Keywords
+[
+  (visibility_modifier) ; "pub"
+  (opacity_modifier) ; "opaque"
+  "as"
+  "assert"
+  "case"
+  "const"
+  ; DEPRECATED: 'external' was removed in v0.30.
+  "external"
+  "fn"
+  "if"
+  "import"
+  "let"
+  "panic"
+  "todo"
+  "type"
+  "use"
+] @keyword
+
+; Operators
+(binary_expression
+  operator: _ @operator)
+(boolean_negation "!" @operator)
+(integer_negation "-" @operator)
+
+; Punctuation
+[
+  "("
+  ")"
+  "["
+  "]"
+  "{"
+  "}"
+  "<<"
+  ">>"
+] @punctuation.bracket
+[
+  "."
+  ","
+  ;; Controversial -- maybe some are operators?
+  ":"
+  "#"
+  "="
+  "->"
+  ".."
+  "-"
+  "<-"
+] @punctuation.delimiter

extensions/test-extension/languages/gleam/outline.scm 🔗

@@ -0,0 +1,31 @@
+(external_type
+    (visibility_modifier)? @context
+    "type" @context
+    (type_name) @name) @item
+
+(type_definition
+    (visibility_modifier)? @context
+    (opacity_modifier)? @context
+    "type" @context
+    (type_name) @name) @item
+
+(data_constructor
+    (constructor_name) @name) @item
+
+(data_constructor_argument
+    (label) @name) @item
+
+(type_alias
+    (visibility_modifier)? @context
+    "type" @context
+    (type_name) @name) @item
+
+(function
+    (visibility_modifier)? @context
+    "fn" @context
+    name: (_) @name) @item
+
+(constant
+    (visibility_modifier)? @context
+    "const" @context
+    name: (_) @name) @item

extensions/test-extension/src/test_extension.rs 🔗

@@ -0,0 +1,160 @@
+use std::fs;
+use zed::lsp::CompletionKind;
+use zed::{CodeLabel, CodeLabelSpan, LanguageServerId};
+use zed_extension_api::{self as zed, Result};
+
+struct TestExtension {
+    cached_binary_path: Option<String>,
+}
+
+impl TestExtension {
+    fn language_server_binary_path(
+        &mut self,
+        language_server_id: &LanguageServerId,
+        _worktree: &zed::Worktree,
+    ) -> Result<String> {
+        if let Some(path) = &self.cached_binary_path {
+            if fs::metadata(path).map_or(false, |stat| stat.is_file()) {
+                return Ok(path.clone());
+            }
+        }
+
+        zed::set_language_server_installation_status(
+            &language_server_id,
+            &zed::LanguageServerInstallationStatus::CheckingForUpdate,
+        );
+        let release = zed::latest_github_release(
+            "gleam-lang/gleam",
+            zed::GithubReleaseOptions {
+                require_assets: true,
+                pre_release: false,
+            },
+        )?;
+
+        let (platform, arch) = zed::current_platform();
+        let asset_name = format!(
+            "gleam-{version}-{arch}-{os}.tar.gz",
+            version = release.version,
+            arch = match arch {
+                zed::Architecture::Aarch64 => "aarch64",
+                zed::Architecture::X86 => "x86",
+                zed::Architecture::X8664 => "x86_64",
+            },
+            os = match platform {
+                zed::Os::Mac => "apple-darwin",
+                zed::Os::Linux => "unknown-linux-musl",
+                zed::Os::Windows => "pc-windows-msvc",
+            },
+        );
+
+        let asset = release
+            .assets
+            .iter()
+            .find(|asset| asset.name == asset_name)
+            .ok_or_else(|| format!("no asset found matching {:?}", asset_name))?;
+
+        let version_dir = format!("gleam-{}", release.version);
+        let binary_path = format!("{version_dir}/gleam");
+
+        if !fs::metadata(&binary_path).map_or(false, |stat| stat.is_file()) {
+            zed::set_language_server_installation_status(
+                &language_server_id,
+                &zed::LanguageServerInstallationStatus::Downloading,
+            );
+
+            zed::download_file(
+                &asset.download_url,
+                &version_dir,
+                zed::DownloadedFileType::GzipTar,
+            )
+            .map_err(|e| format!("failed to download file: {e}"))?;
+
+            let entries =
+                fs::read_dir(".").map_err(|e| format!("failed to list working directory {e}"))?;
+            for entry in entries {
+                let entry = entry.map_err(|e| format!("failed to load directory entry {e}"))?;
+                if entry.file_name().to_str() != Some(&version_dir) {
+                    fs::remove_dir_all(&entry.path()).ok();
+                }
+            }
+        }
+
+        self.cached_binary_path = Some(binary_path.clone());
+        Ok(binary_path)
+    }
+}
+
+impl zed::Extension for TestExtension {
+    fn new() -> Self {
+        Self {
+            cached_binary_path: None,
+        }
+    }
+
+    fn language_server_command(
+        &mut self,
+        language_server_id: &LanguageServerId,
+        worktree: &zed::Worktree,
+    ) -> Result<zed::Command> {
+        Ok(zed::Command {
+            command: self.language_server_binary_path(language_server_id, worktree)?,
+            args: vec!["lsp".to_string()],
+            env: Default::default(),
+        })
+    }
+
+    fn label_for_completion(
+        &self,
+        _language_server_id: &LanguageServerId,
+        completion: zed::lsp::Completion,
+    ) -> Option<zed::CodeLabel> {
+        let name = &completion.label;
+        let ty = strip_newlines_from_detail(&completion.detail?);
+        let let_binding = "let a";
+        let colon = ": ";
+        let assignment = " = ";
+        let call = match completion.kind? {
+            CompletionKind::Function | CompletionKind::Constructor => "()",
+            _ => "",
+        };
+        let code = format!("{let_binding}{colon}{ty}{assignment}{name}{call}");
+
+        Some(CodeLabel {
+            spans: vec![
+                CodeLabelSpan::code_range({
+                    let start = let_binding.len() + colon.len() + ty.len() + assignment.len();
+                    start..start + name.len()
+                }),
+                CodeLabelSpan::code_range({
+                    let start = let_binding.len();
+                    start..start + colon.len()
+                }),
+                CodeLabelSpan::code_range({
+                    let start = let_binding.len() + colon.len();
+                    start..start + ty.len()
+                }),
+            ],
+            filter_range: (0..name.len()).into(),
+            code,
+        })
+    }
+}
+
+zed::register_extension!(TestExtension);
+
+/// Removes newlines from the completion detail.
+///
+/// The Gleam LSP can return types containing newlines, which causes formatting
+/// issues within the Zed completions menu.
+fn strip_newlines_from_detail(detail: &str) -> String {
+    let without_newlines = detail
+        .replace("->\n  ", "-> ")
+        .replace("\n  ", "")
+        .replace(",\n", "");
+
+    let comma_delimited_parts = without_newlines.split(',');
+    comma_delimited_parts
+        .map(|part| part.trim())
+        .collect::<Vec<_>>()
+        .join(", ")
+}