Merge pull request #115 from levineuwirth/shell-grammar

feat(highlight): shell/bash tree-sitter grammar for the shell family
This commit is contained in:
Levi Neuwirth 2026-07-14 13:15:05 +00:00 committed by GitHub
commit 9067eb3d0c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 185 additions and 3 deletions

11
Cargo.lock generated
View File

@ -2550,6 +2550,7 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"toml", "toml",
"tree-sitter", "tree-sitter",
"tree-sitter-bash",
"tree-sitter-c", "tree-sitter-c",
"tree-sitter-cpp", "tree-sitter-cpp",
"tree-sitter-cuda", "tree-sitter-cuda",
@ -3716,6 +3717,16 @@ dependencies = [
"tree-sitter-language", "tree-sitter-language",
] ]
[[package]]
name = "tree-sitter-bash"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5ec769279cc91b561d3df0d8a5deb26b0ad40d183127f409494d6d8fc53062"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]] [[package]]
name = "tree-sitter-c" name = "tree-sitter-c"
version = "0.24.2" version = "0.24.2"

View File

@ -154,6 +154,19 @@ tree-sitter-cpp = "0.23"
# it shares the ABI crate with the other grammars — no second # it shares the ABI crate with the other grammars — no second
# `tree-sitter` in the graph (its own `tree-sitter` dep is dev-only). # `tree-sitter` in the graph (its own `tree-sitter` dep is dev-only).
tree-sitter-cuda = "0.21" tree-sitter-cuda = "0.21"
# Shell / bash. Lexical highlighting for `.sh`/`.bash` and the wider
# shell family (`.zsh`/`.ksh`/`.ash`/`.bats`) — the LSP half
# (bash-language-server + shellcheck/shfmt) was already wired in
# `builtin/runtime/lsp.lua`; this fills in the missing grammar. bash
# does not inherit another grammar, so `HIGHLIGHT_QUERY` (singular, the
# c/cpp/md idiom) is self-contained — no base-query composition like
# `cuda`. Exports `LANGUAGE` over `tree-sitter-language 0.1` (shared ABI
# crate, no second `tree-sitter` in the graph). Note: because the
# grammar's language name (`bash`) matches the `pmacs.lsp.config.bash`
# key, every extension claimed here also auto-attaches
# bash-language-server (shellcheck refuses zsh, so `.zsh` diagnostics
# may be sparse; highlighting is unaffected).
tree-sitter-bash = "0.25"
# T M9.7: markdown grammar so prompt result buffers with # T M9.7: markdown grammar so prompt result buffers with
# `_meta.format = "markdown"` get structured highlighting through the # `_meta.format = "markdown"` get structured highlighting through the
# same M4 path as rust/lua — no special-case painter in Lua. # same M4 path as rust/lua — no special-case painter in Lua.

View File

@ -215,9 +215,15 @@ pmacs.lsp.filetypes.jsx = pmacs.lsp.filetypes.jsx or "javascriptreact"
-- `language_for_path` resolves `.lua` first; this entry is the LSP -- `language_for_path` resolves `.lua` first; this entry is the LSP
-- fallback and keeps the language id stable if that ever changes. -- fallback and keeps the language id stable if that ever changes.
pmacs.lsp.filetypes.lua = pmacs.lsp.filetypes.lua or "lua" pmacs.lsp.filetypes.lua = pmacs.lsp.filetypes.lua or "lua"
-- Bash / shell (bash-language-server). -- Bash / shell (bash-language-server). pmacs bundles a bash grammar, so
pmacs.lsp.filetypes.sh = pmacs.lsp.filetypes.sh or "bash" -- `language_for_path` already resolves these to `bash`; this map is the
pmacs.lsp.filetypes.bash = pmacs.lsp.filetypes.bash or "bash" -- LSP-only fallback that keeps the language id stable if that grammar is
-- ever dropped (same role as the `lua`/`cuda` entries). The wider shell
-- family (`.zsh`/`.ksh`/`.ash`/`.bats`) rides the same server; shellcheck
-- declines zsh, so `.zsh` diagnostics may be sparse.
for _, ext in ipairs({ "sh", "bash", "zsh", "ksh", "ash", "bats" }) do
pmacs.lsp.filetypes[ext] = pmacs.lsp.filetypes[ext] or "bash"
end
-- TOML (taplo). -- TOML (taplo).
pmacs.lsp.filetypes.toml = pmacs.lsp.filetypes.toml or "toml" pmacs.lsp.filetypes.toml = pmacs.lsp.filetypes.toml or "toml"
-- Zig (zls). `.zon` is Zig Object Notation, handled by the same server. -- Zig (zls). `.zon` is Zig Object Notation, handled by the same server.

View File

@ -443,6 +443,24 @@ pub const BUILTIN_LANGUAGES: &[LanguageEntry] = &[
tree_sitter_cuda::HIGHLIGHTS_QUERY, tree_sitter_cuda::HIGHLIGHTS_QUERY,
], ],
}, },
// Shell / bash. Lexical highlighting for the shell family; the LSP
// half (bash-language-server) was already wired in `lsp.lua`. Unlike
// `cuda`, bash's `highlights.scm` is self-contained (no `; inherits:`
// delta), so a single fragment suffices. The extension set is wider
// than the `.sh`/`.bash` the LSP filetype map covered: `.zsh`/`.ksh`/
// `.ash` are close-enough dialects and `.bats` is bash. None collide
// with an entry above. Because the language name is `bash` — matching
// the `pmacs.lsp.config.bash` key — opening any of these also
// auto-attaches bash-language-server. Extensionless shebang scripts
// (`#!/bin/sh`) and rc dotfiles (`.bashrc`) are NOT covered: detection
// is extension-keyed, and shebang/filename sniffing is a separate
// (deferred) feature.
LanguageEntry {
name: "bash",
extensions: &["sh", "bash", "zsh", "ksh", "ash", "bats"],
loader: || tree_sitter_bash::LANGUAGE.into(),
highlights_query: &[tree_sitter_bash::HIGHLIGHT_QUERY],
},
]; ];
/// Registry that the Lua surface ([`crate::lua_bindings::install_parse`]) /// Registry that the Lua surface ([`crate::lua_bindings::install_parse`])
@ -953,6 +971,95 @@ mod tests {
); );
} }
#[test]
fn builtin_languages_include_bash() {
// Regression guard: the bash entry claims the wider shell family
// and ships a highlights query, so shell scripts get lexical color
// (they had LSP via bash-language-server but no grammar before).
let bash = BUILTIN_LANGUAGES
.iter()
.find(|l| l.name == "bash")
.expect("`bash` language entry must be present");
for ext in ["sh", "bash", "zsh", "ksh", "ash", "bats"] {
assert!(bash.extensions.contains(&ext), "`bash` claims `.{ext}`");
}
assert!(
!bash.highlights_query.is_empty(),
"`bash` ships a highlights query"
);
}
#[test]
fn bash_grammar_loads_and_parses_script() {
// ABI acceptance: the `tree-sitter-bash` 0.25 grammar must be
// accepted by our `tree-sitter` 0.26 core — `set_language`
// succeeds and a tree is produced. A representative script
// (shebang, `set`, parameter expansion, function, `if`) parses
// without error, so the entry wired a working grammar.
let reg = SyntaxRegistry::new();
let language = reg
.language("bash")
.expect("`bash` language loads from BUILTIN_LANGUAGES");
let mut buf = fresh_buffer("deploy.sh");
buf.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"#!/usr/bin/env bash\nset -euo pipefail\nname=${1:-world}\n\
greet() { echo \"hello, $name\"; }\nif [ -n \"$name\" ]; then greet; fi\n",
})
.unwrap();
let view = ParseView::new(&buf, language, "bash".to_owned());
let handle = view.handle();
let _vid = buf.attach_view(Box::new(view));
let bundle = parse_synchronously(&handle);
assert_eq!(
bundle.tree.root_node().kind(),
"program",
"bash grammar roots at `program`"
);
assert!(
!bundle.tree.root_node().has_error(),
"bash grammar parses a representative script without error"
);
}
#[test]
fn bash_highlights_compile_with_captures() {
// The self-contained bash `highlights.scm` must compile against
// the grammar and yield real capture classes (a crate bump that
// drifted query and grammar apart would surface here).
let reg = SyntaxRegistry::new();
let query = reg
.highlights_query("bash")
.expect("bash highlights compile");
assert!(
query.capture_names().len() >= 5,
"bash highlights resolve several capture classes; got {}",
query.capture_names().len()
);
}
#[test]
fn language_for_path_resolves_bash_extensions() {
// The whole shell family resolves to the `bash` grammar via
// extension detection; `.zsh`/`.ksh`/`.ash`/`.bats` are new here
// (only `.sh`/`.bash` were covered by the LSP filetype map before).
let reg = SyntaxRegistry::new();
for path in [
"deploy.sh",
"lib.bash",
"prompt.zsh",
"script.ksh",
"init.ash",
"test_cli.bats",
] {
assert_eq!(
reg.language_name_for_path(path).as_deref(),
Some("bash"),
"{path} resolves to bash"
);
}
}
#[test] #[test]
fn byte_to_point_handles_first_line() { fn byte_to_point_handles_first_line() {
let src = b"hello world"; let src = b"hello world";

View File

@ -5804,6 +5804,51 @@ fn m4_12_default_bundle_wires_cuda() {
assert_eq!(probe.get::<String>("grammar_cuh").unwrap(), "cuda"); assert_eq!(probe.get::<String>("grammar_cuh").unwrap(), "cuda");
} }
/// The default bundle wires the shell family: `pmacs.lsp.config.bash`
/// targets bash-language-server (pre-existing), the bundled bash grammar
/// resolves the wider extension set (`.sh`/`.zsh`/`.bats`) to `bash`
/// through `pmacs.parse.language_for_path`, and the LSP filetype fallback
/// maps the new extensions too (belt-and-suspenders if the grammar is
/// dropped).
#[test]
fn m4_12_default_bundle_wires_bash() {
use pmacs::editor::EditorState;
let s = EditorState::new();
let probe: mlua::Table = s
.lua_host
.lua()
.load(
r"
local out = {}
out.cfg_cmd = pmacs.lsp.config.bash and pmacs.lsp.config.bash.command
out.ft_zsh = pmacs.lsp.filetypes.zsh
out.ft_bats = pmacs.lsp.filetypes.bats
-- Grammar-backed detection resolves the wider set directly.
out.grammar_sh = pmacs.parse.language_for_path('deploy.sh')
out.grammar_zsh = pmacs.parse.language_for_path('prompt.zsh')
out.grammar_bats = pmacs.parse.language_for_path('test_cli.bats')
return out
",
)
.eval()
.expect("probe bash wiring");
assert_eq!(
probe.get::<String>("cfg_cmd").unwrap(),
"bash-language-server",
"config.bash targets bash-language-server"
);
assert_eq!(probe.get::<String>("ft_zsh").unwrap(), "bash");
assert_eq!(probe.get::<String>("ft_bats").unwrap(), "bash");
assert_eq!(
probe.get::<String>("grammar_sh").unwrap(),
"bash",
"bundled grammar resolves `.sh` to bash"
);
assert_eq!(probe.get::<String>("grammar_zsh").unwrap(), "bash");
assert_eq!(probe.get::<String>("grammar_bats").unwrap(), "bash");
}
/// Typing-perf: the default bundle coalesces full-document /// Typing-perf: the default bundle coalesces full-document
/// `didChange` notifications instead of sending one per keystroke /// `didChange` notifications instead of sending one per keystroke
/// (each send copies the whole buffer several times and writes /// (each send copies the whole buffer several times and writes