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",
|
"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"
|
||||||
|
|
|
||||||
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
|
# 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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
107
src/syntax.rs
107
src/syntax.rs
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue