Merge pull request #115 from levineuwirth/shell-grammar
feat(highlight): shell/bash tree-sitter grammar for the shell family
This commit is contained in:
commit
9067eb3d0c
|
|
@ -2550,6 +2550,7 @@ dependencies = [
|
|||
"thiserror 2.0.18",
|
||||
"toml",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"tree-sitter-c",
|
||||
"tree-sitter-cpp",
|
||||
"tree-sitter-cuda",
|
||||
|
|
@ -3716,6 +3717,16 @@ dependencies = [
|
|||
"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]]
|
||||
name = "tree-sitter-c"
|
||||
version = "0.24.2"
|
||||
|
|
|
|||
13
Cargo.toml
13
Cargo.toml
|
|
@ -154,6 +154,19 @@ tree-sitter-cpp = "0.23"
|
|||
# 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-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
|
||||
# `_meta.format = "markdown"` get structured highlighting through the
|
||||
# same M4 path as rust/lua — no special-case painter in Lua.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
-- fallback and keeps the language id stable if that ever changes.
|
||||
pmacs.lsp.filetypes.lua = pmacs.lsp.filetypes.lua or "lua"
|
||||
-- Bash / shell (bash-language-server).
|
||||
pmacs.lsp.filetypes.sh = pmacs.lsp.filetypes.sh or "bash"
|
||||
pmacs.lsp.filetypes.bash = pmacs.lsp.filetypes.bash or "bash"
|
||||
-- Bash / shell (bash-language-server). pmacs bundles a bash grammar, so
|
||||
-- `language_for_path` already resolves these to `bash`; this map is the
|
||||
-- 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).
|
||||
pmacs.lsp.filetypes.toml = pmacs.lsp.filetypes.toml or "toml"
|
||||
-- Zig (zls). `.zon` is Zig Object Notation, handled by the same server.
|
||||
|
|
|
|||
107
src/syntax.rs
107
src/syntax.rs
|
|
@ -443,6 +443,24 @@ pub const BUILTIN_LANGUAGES: &[LanguageEntry] = &[
|
|||
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`])
|
||||
|
|
@ -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]
|
||||
fn byte_to_point_handles_first_line() {
|
||||
let src = b"hello world";
|
||||
|
|
|
|||
|
|
@ -5804,6 +5804,51 @@ fn m4_12_default_bundle_wires_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
|
||||
/// `didChange` notifications instead of sending one per keystroke
|
||||
/// (each send copies the whole buffer several times and writes
|
||||
|
|
|
|||
Loading…
Reference in New Issue