From 6fd7db81fe1910c057e4196551133748727a03f9 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 14 Jul 2026 13:50:21 +0100 Subject: [PATCH] feat(highlight): shell/bash tree-sitter grammar for the shell family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shell scripts already had LSP (bash-language-server + shellcheck/shfmt, wired in builtin/runtime/lsp.lua), but no tree-sitter grammar, so their text rendered without lexical color. Fill in the missing half. - Bundle tree-sitter-bash (0.25) as a BUILTIN_LANGUAGES entry. Unlike cuda, bash's highlights.scm is self-contained (no `; inherits:` delta), so a single fragment suffices. The crate exports LANGUAGE/HIGHLIGHT_QUERY over tree-sitter-language 0.1 — shared ABI crate, no second tree-sitter in the graph. - Extension set is wider than the `.sh`/`.bash` the LSP filetype map covered: `.zsh`/`.ksh`/`.ash` are close-enough dialects and `.bats` is bash. The grammar's language name is `bash`, matching the `pmacs.lsp.config.bash` key, so opening any of these also auto-attaches bash-language-server (shellcheck declines zsh, so `.zsh` diagnostics may be sparse; highlighting is unaffected). lsp.lua's filetype map is extended to the same set as the belt-and-suspenders fallback. - Extensionless shebang scripts (`#!/bin/sh`) and rc dotfiles (`.bashrc`) are intentionally NOT covered: detection is extension-keyed and shebang/filename sniffing is a separate, deferred feature. Bite-verified acceptance: - bash_grammar_loads_and_parses_script — the 0.25 grammar's ABI is accepted by the 0.26 core (set_language succeeds at runtime) and a representative script (shebang, set, parameter expansion, function, if) parses without error, rooting at `program`. - builtin_languages_include_bash / language_for_path_resolves_bash_ extensions — entry presence and detection across the wider set. - bash_highlights_compile_with_captures — the self-contained query compiles against the grammar with real capture classes. - m4_12_default_bundle_wires_bash — through the loaded runtime, config.bash targets bash-language-server and both grammar detection and the filetype fallback resolve the new extensions to `bash`. Gates green: fmt; clippy -D warnings; test --lib; --features crdt; m4_acceptance --skip basedpyright; GPU; full workspace sweep; git diff --check. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan --- Cargo.lock | 11 +++++ Cargo.toml | 13 +++++ builtin/runtime/lsp.lua | 12 +++-- src/syntax.rs | 107 ++++++++++++++++++++++++++++++++++++++++ tests/m4_acceptance.rs | 45 +++++++++++++++++ 5 files changed, 185 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c5f9f6f..0b133dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index a4b6516..fa13edc 100644 --- a/Cargo.toml +++ b/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. diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 16ec76a..6607423 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.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. diff --git a/src/syntax.rs b/src/syntax.rs index bd7e3b1..f3a1011 100644 --- a/src/syntax.rs +++ b/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"; diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index 50b3162..c648bcc 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -5804,6 +5804,51 @@ fn m4_12_default_bundle_wires_cuda() { assert_eq!(probe.get::("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::("cfg_cmd").unwrap(), + "bash-language-server", + "config.bash targets bash-language-server" + ); + assert_eq!(probe.get::("ft_zsh").unwrap(), "bash"); + assert_eq!(probe.get::("ft_bats").unwrap(), "bash"); + assert_eq!( + probe.get::("grammar_sh").unwrap(), + "bash", + "bundled grammar resolves `.sh` to bash" + ); + assert_eq!(probe.get::("grammar_zsh").unwrap(), "bash"); + assert_eq!(probe.get::("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