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

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>
This commit is contained in:
Xin Zhao 2026-05-07 06:38:44 +08:00 committed by GitHub
parent fb3218e01e
commit 5dd9082d05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 155 additions and 4 deletions

View file

@ -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> {

View file

@ -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};

View file

@ -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 {

View file

@ -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) \*

View file

@ -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`: