bash: Add built-in language server support (#52811)

Xin Zhao and Finn Evers created

This PR introduces native LSP support for Bash by integrating
`bash-language-server`. Combined with the existing Tree-sitter grammar,
Zed now provides a complete, out-of-the-box development experience for
shell scripting.

The implementation is very similar to other npm-managed language
servers. With `shellcheck` installed, standard LSP features—including
diagnostics, code actions, go-to-definition, find-references, and code
completion—work as expected.

Since I am not a frequent user of Bash, I have intentionally limited
this implementation to a standard, "out-of-the-box" setup. I lack the
hands-on experience to identify specific pain points or advanced LSP
features that might require custom integration, so I've avoided adding
any speculative or specialized configurations, especially within the
`LspAdapter` trait.

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [ ] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Closes #51917 

Release Notes:

- Added built-in language server support for Bash

---------

Co-authored-by: Finn Evers <finn@zed.dev>

Change summary

crates/extension_host/src/extension_host.rs |   2 
crates/languages/src/bash.rs                | 149 +++++++++++++++++++++++
crates/languages/src/lib.rs                 |   2 
docs/src/languages.md                       |   2 
docs/src/languages/bash.md                  |   4 
5 files changed, 155 insertions(+), 4 deletions(-)

Detailed changes

crates/extension_host/src/extension_host.rs 🔗

@@ -77,7 +77,7 @@ const CURRENT_SCHEMA_VERSION: SchemaVersion = SchemaVersion(1);
 ///
 /// These snippets should no longer be downloaded or loaded, because their
 /// functionality has been integrated into the core editor.
-const SUPPRESSED_EXTENSIONS: &[&str] = &["snippets", "ruff", "ty", "basedpyright"];
+const SUPPRESSED_EXTENSIONS: &[&str] = &["snippets", "ruff", "ty", "basedpyright", "basher"];
 
 /// Returns the [`SchemaVersion`] range that is compatible with this version of Zed.
 pub fn schema_version_range() -> RangeInclusive<SchemaVersion> {

crates/languages/src/bash.rs 🔗

@@ -1,5 +1,14 @@
+use anyhow::Result;
+use async_trait::async_trait;
+use collections::HashMap;
+use language::{LanguageServerName, LspAdapter, LspAdapterDelegate, LspInstaller, Toolchain};
+use lsp::LanguageServerBinary;
+use node_runtime::{NodeRuntime, VersionStrategy};
 use project::ContextProviderWithTasks;
+use semver::Version;
+use std::{path::PathBuf, vec};
 use task::{TaskTemplate, TaskTemplates, VariableName};
+use util::{ResultExt, maybe};
 
 pub(super) fn bash_task_context() -> ContextProviderWithTasks {
     ContextProviderWithTasks::new(TaskTemplates(vec![
@@ -17,6 +26,146 @@ pub(super) fn bash_task_context() -> ContextProviderWithTasks {
     ]))
 }
 
+pub struct BashLspAdapter {
+    node: NodeRuntime,
+}
+
+impl BashLspAdapter {
+    const PACKAGE_NAME: &str = "bash-language-server";
+    const NODE_MODULE_RELATIVE_SERVER_PATH: &str = "bash-language-server/out/cli.js";
+
+    pub fn new(node: NodeRuntime) -> Self {
+        Self { node }
+    }
+
+    async fn get_cached_server_binary(
+        container_dir: PathBuf,
+        env: HashMap<String, String>,
+        node: &NodeRuntime,
+    ) -> Option<lsp::LanguageServerBinary> {
+        maybe!(async {
+            let server_path = container_dir
+                .join("node_modules")
+                .join(Self::NODE_MODULE_RELATIVE_SERVER_PATH);
+            anyhow::ensure!(
+                server_path.exists(),
+                "missing executable in directory {server_path:?}"
+            );
+            Ok(LanguageServerBinary {
+                path: node.binary_path().await?,
+                env: Some(env),
+                arguments: vec![server_path.into(), "start".into()],
+            })
+        })
+        .await
+        .log_err()
+    }
+}
+
+impl LspInstaller for BashLspAdapter {
+    type BinaryVersion = Version;
+
+    async fn cached_server_binary(
+        &self,
+        container_dir: std::path::PathBuf,
+        delegate: &dyn LspAdapterDelegate,
+    ) -> Option<lsp::LanguageServerBinary> {
+        let env = delegate.shell_env().await;
+        Self::get_cached_server_binary(container_dir, env, &self.node).await
+    }
+
+    async fn check_if_user_installed(
+        &self,
+        delegate: &dyn LspAdapterDelegate,
+        _: Option<Toolchain>,
+        _: &gpui::AsyncApp,
+    ) -> Option<lsp::LanguageServerBinary> {
+        let path = delegate.which(Self::PACKAGE_NAME.as_ref()).await?;
+        let env = delegate.shell_env().await;
+
+        Some(LanguageServerBinary {
+            path,
+            env: Some(env),
+            arguments: vec!["start".into()],
+        })
+    }
+
+    async fn check_if_version_installed(
+        &self,
+        version: &Self::BinaryVersion,
+        container_dir: &PathBuf,
+        delegate: &dyn LspAdapterDelegate,
+    ) -> Option<lsp::LanguageServerBinary> {
+        let server_path = container_dir
+            .join("node_modules")
+            .join(Self::NODE_MODULE_RELATIVE_SERVER_PATH);
+
+        let should_install_language_server = self
+            .node
+            .should_install_npm_package(
+                Self::PACKAGE_NAME,
+                &server_path,
+                container_dir,
+                VersionStrategy::Latest(version),
+            )
+            .await;
+
+        if should_install_language_server {
+            None
+        } else {
+            let env = delegate.shell_env().await;
+            Some(LanguageServerBinary {
+                path: self.node.binary_path().await.ok()?,
+                env: Some(env),
+                arguments: vec![server_path.into(), "start".into()],
+            })
+        }
+    }
+
+    async fn fetch_latest_server_version(
+        &self,
+        _: &dyn LspAdapterDelegate,
+        _: bool,
+        _: &mut gpui::AsyncApp,
+    ) -> Result<Self::BinaryVersion> {
+        self.node
+            .npm_package_latest_version(Self::PACKAGE_NAME)
+            .await
+    }
+
+    async fn fetch_server_binary(
+        &self,
+        latest_version: Self::BinaryVersion,
+        container_dir: std::path::PathBuf,
+        delegate: &dyn LspAdapterDelegate,
+    ) -> Result<lsp::LanguageServerBinary> {
+        let server_path = container_dir
+            .join("node_modules")
+            .join(Self::NODE_MODULE_RELATIVE_SERVER_PATH);
+
+        self.node
+            .npm_install_packages(
+                &container_dir,
+                &[(Self::PACKAGE_NAME, &latest_version.to_string())],
+            )
+            .await?;
+
+        let env = delegate.shell_env().await;
+        Ok(LanguageServerBinary {
+            path: self.node.binary_path().await?,
+            env: Some(env),
+            arguments: vec![server_path.into(), "start".into()],
+        })
+    }
+}
+
+#[async_trait(?Send)]
+impl LspAdapter for BashLspAdapter {
+    fn name(&self) -> LanguageServerName {
+        LanguageServerName::new_static(Self::PACKAGE_NAME)
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use gpui::{AppContext as _, BorrowAppContext, Context, TestAppContext};

crates/languages/src/lib.rs 🔗

@@ -57,6 +57,7 @@ pub fn init(languages: Arc<LanguageRegistry>, fs: Arc<dyn Fs>, node: NodeRuntime
     #[cfg(feature = "load-grammars")]
     languages.register_native_grammars(grammars::native_grammars());
 
+    let bash_lsp_adapter = Arc::new(bash::BashLspAdapter::new(node.clone()));
     let c_lsp_adapter = Arc::new(c::CLspAdapter);
     let css_lsp_adapter = Arc::new(css::CssLspAdapter::new(node.clone()));
     let eslint_adapter = Arc::new(eslint::EsLintLspAdapter::new(node.clone(), fs.clone()));
@@ -88,6 +89,7 @@ pub fn init(languages: Arc<LanguageRegistry>, fs: Arc<dyn Fs>, node: NodeRuntime
         LanguageInfo {
             name: "bash",
             context: Some(Arc::new(bash::bash_task_context())),
+            adapters: vec![bash_lsp_adapter],
             ..Default::default()
         },
         LanguageInfo {

docs/src/languages.md 🔗

@@ -15,7 +15,7 @@ Some work out-of-the box and others rely on 3rd party extensions.
 - [Ansible](./languages/ansible.md)
 - [AsciiDoc](./languages/asciidoc.md)
 - [Astro](./languages/astro.md)
-- [Bash](./languages/bash.md)
+- [Bash](./languages/bash.md) \*
 - [Biome](./languages/biome.md)
 - [C](./languages/c.md) \*
 - [C++](./languages/cpp.md) \*

docs/src/languages/bash.md 🔗

@@ -5,14 +5,14 @@ description: "Configure Bash language support in Zed, including language servers
 
 # Bash
 
-Bash support is available through the [Bash extension](https://github.com/zed-extensions/bash).
+Bash support is available natively in Zed.
 
 - Tree-sitter: [tree-sitter/tree-sitter-bash](https://github.com/tree-sitter/tree-sitter-bash)
 - Language Server: [bash-lsp/bash-language-server](https://github.com/bash-lsp/bash-language-server)
 
 ## Configuration
 
-When `shellcheck` is available `bash-language-server` will use it internally to provide diagnostics.
+It is highly recommended to install `shellcheck`, as `bash-language-server` depends on it to provide diagnostics.
 
 ### Install `shellcheck`: