feat(highlight): shebang-based language detection for extensionless scripts

Extension detection missed extensionless scripts — `scripts/deploy`, git
hooks, `configure`, and `scripts/bite` itself — so they got neither
highlighting nor an LSP server. Add a first-line shebang fallback.

- New `pmacs.parse.language_from_shebang(buf)` (builtin/runtime/syntax.lua):
  sniffs the first line (capped at 256 bytes), maps the interpreter's
  basename to a language, and resolves the `#!/usr/bin/env python3`
  indirection (skipping env's own `-S`/flags and `VAR=val` assignments).
  Backed by `pmacs.parse.shebangs`, a user-extensible map seeded with the
  interpreters pmacs can act on: sh-family -> bash, python* -> python,
  node -> javascript, lua* -> lua.
- Wired as a strict *fallback* on both resolution paths: syntax.lua's
  grammar attach (`language_for_path or language_from_shebang`) and
  lsp.lua's `buffer_language` (grammar -> filetypes -> shebang). A
  recognized extension always wins, so a `.py`/`.sh` file is never
  re-classified by a stray shebang.
- Cross-language, not shell-only: `#!/usr/bin/env python` /`node` /`lua`
  resolve too. Special filenames (`.bashrc`, `Dockerfile`, `Makefile`)
  are intentionally deferred until there are grammars behind them.

Bite-verified acceptance (tests/m4_acceptance.rs):
- m4_shebang_resolver_maps_interpreters — the mapping incl. env
  indirection and `env -S`; non-shebangs and unmapped interpreters
  (ruby) resolve to nil.
- m4_shebang_extensionless_script_resolves_bash — opening an
  extensionless `#!/bin/sh` script resolves to bash on BOTH paths:
  lsp.lua's `active_buffer_language()` and a settled bash parse tree
  (grammar attach). Reachable only via the shebang, since the file has
  no extension.
- m4_shebang_does_not_override_extension — a `.py` file opening with
  `#!/bin/sh` still resolves to python (extension precedence).

Gates green: fmt; clippy -D warnings; test --lib; --features crdt;
m4_acceptance --skip basedpyright; GPU; full workspace sweep;
git diff --check. Change is Lua-only plus the acceptance tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
This commit is contained in:
Levi Neuwirth 2026-07-14 14:33:06 +01:00
parent 9067eb3d0c
commit 4a60e4c858
3 changed files with 195 additions and 2 deletions

View File

@ -374,11 +374,15 @@ local function buffer_language(buf)
if not ok or not path then return nil end
-- Grammar-backed detection first (keeps rust/.rs etc. exactly as
-- before); fall back to the LSP-only filetype map so languages
-- with a server but no tree-sitter grammar (Python) still attach.
-- with a server but no tree-sitter grammar (Python) still attach;
-- finally, for an extensionless file, sniff a `#!interp` shebang so
-- e.g. an extensionless `#!/bin/sh` script still attaches its server.
local lang = pmacs.parse.language_for_path(path)
if lang then return lang end
local ext = path:match("%.([%w_]+)$")
return ext and pmacs.lsp.filetypes[ext] or nil
local by_ext = ext and pmacs.lsp.filetypes[ext]
if by_ext then return by_ext end
return pmacs.parse.language_from_shebang(buf)
end
-- Public: the per-buffer language chain. Auto-pairing resolves
-- relevance against the buffer its typed-edit record names — which a

View File

@ -44,6 +44,72 @@ function pmacs.parse._dispatch(buf, lang)
return job_id
end
-- Shebang → language detection ------------------------------------------
--
-- Extension detection (`language_for_path`) misses extensionless scripts
-- (`scripts/deploy`, git hooks, `configure`), which are the common case
-- for shell. This fills that gap by sniffing the first line: `#!interp`
-- maps the interpreter's basename to a language. It is a *fallback* —
-- every caller tries extension detection first, so a `.py`/`.sh` file is
-- never re-classified by a stray shebang. Deliberately does not cover
-- special filenames (`.bashrc`, `Dockerfile`): those wait on grammars for
-- the languages behind them.
--
-- The map is user-extensible from init.lua, e.g.
-- `pmacs.parse.shebangs.ruby = "ruby"`. Only interpreters whose language
-- pmacs can act on (grammar and/or LSP config) are seeded; an entry whose
-- language has neither is harmless but inert.
pmacs.parse.shebangs = pmacs.parse.shebangs or {
sh = "bash", bash = "bash", dash = "bash", ash = "bash",
ksh = "bash", mksh = "bash", zsh = "bash",
python = "python", python2 = "python", python3 = "python",
pypy = "python", pypy3 = "python",
node = "javascript", nodejs = "javascript",
lua = "lua", luajit = "lua",
}
-- First line of `buf` (up to 256 bytes), sans trailing newline, or nil.
-- 256 bytes is well past any real shebang and keeps the slice cheap on
-- the hot path (callers only reach here when extension detection missed).
local function first_line(buf)
local ok_len, n = pcall(function() return buf:len() end)
if not ok_len or not n or n <= 0 then return nil end
if n > 256 then n = 256 end
local ok, s = pcall(function() return buf:slice(0, n) end)
if not ok or type(s) ~= "string" or #s == 0 then return nil end
local nl = s:find("\n", 1, true)
if nl then s = s:sub(1, nl - 1) end
return s
end
-- Resolve the interpreter basename from a shebang line, resolving the
-- `env` indirection (`#!/usr/bin/env python3` → `python3`, skipping
-- `env`'s own options / `VAR=val` assignments). Returns the language
-- name from `pmacs.parse.shebangs`, or nil.
function pmacs.parse.language_from_shebang(buf)
if not buf then return nil end
local line = first_line(buf)
if not line or line:sub(1, 2) ~= "#!" then return nil end
local rest = line:sub(3):gsub("^%s+", "")
local first = rest:match("^(%S+)")
if not first then return nil end
local base = first:match("([^/]+)$") or first
if base == "env" then
base = nil
for tok in rest:gmatch("%S+") do
-- Skip the `env` path itself, its flags (`-S`, `--split-string`),
-- and inline `VAR=value` assignments; the first bare word left is
-- the real interpreter.
if tok ~= first and tok:sub(1, 1) ~= "-" and not tok:find("=", 1, true) then
base = tok:match("([^/]+)$") or tok
break
end
end
if not base then return nil end
end
return pmacs.parse.shebangs[base]
end
-- Set of buffer ids that already have a highlight overlay
-- attached, keyed by raw id (number). A buffer that opens, gets
-- highlights, gets killed, and is reopened needs a fresh overlay
@ -56,6 +122,7 @@ local function attach_for_active_buffer()
local path = buf:name()
if not path then return end
local lang = pmacs.parse.language_for_path(path)
or pmacs.parse.language_from_shebang(buf)
if not lang then return end
pmacs.parse._dispatch(buf, lang)
-- T M4.3: install the syntax-highlight overlay for this buffer.
@ -111,6 +178,7 @@ local function reparse_active_buffer_after_edit()
local path = buf:name()
if not path then return end
local lang = pmacs.parse.language_for_path(path)
or pmacs.parse.language_from_shebang(buf)
if not lang then return end
pmacs.parse._dispatch(buf, lang)
end

View File

@ -5849,6 +5849,127 @@ fn m4_12_default_bundle_wires_bash() {
assert_eq!(probe.get::<String>("grammar_bats").unwrap(), "bash");
}
/// Shebang detection: `pmacs.parse.language_from_shebang` maps the
/// interpreter basename (resolving the `#!/usr/bin/env` indirection) to a
/// language, and returns nil for non-shebangs and unmapped interpreters.
/// This is the fallback that lets extensionless scripts (`scripts/deploy`,
/// git hooks, `configure`) resolve a language at all — extension
/// detection misses them.
#[test]
fn m4_shebang_resolver_maps_interpreters() {
use pmacs::editor::EditorState;
let s = EditorState::new();
let resolve = |first_line: &str| -> Option<String> {
s.lua_host
.lua()
.load(format!(
"local b = pmacs.window.buffer()
if b:len() > 0 then b:delete(0, b:len()) end
b:insert(0, {first_line:?})
return pmacs.parse.language_from_shebang(b)"
))
.eval()
.expect("resolve shebang")
};
for (line, want) in [
("#!/bin/sh\n", "bash"),
("#!/bin/bash -e\n", "bash"),
("#! /bin/zsh\n", "bash"),
("#!/usr/bin/env bash\n", "bash"),
("#!/usr/bin/env python3\n", "python"),
("#!/usr/bin/env -S python3 -u\n", "python"),
("#!/usr/bin/node\n", "javascript"),
("#!/usr/bin/env lua\n", "lua"),
] {
assert_eq!(resolve(line).as_deref(), Some(want), "{line:?}");
}
for line in [
"echo hi\n",
"# just a comment\n",
"#!/usr/bin/env ruby\n", // interpreter not in the seeded map
"\n",
"",
] {
assert_eq!(resolve(line), None, "{line:?}");
}
}
/// End-to-end: opening an extensionless `#!/bin/sh` script resolves to
/// `bash` on both paths — `lsp.lua`'s `buffer_language` chain (so the
/// server would attach) and `syntax.lua`'s grammar attach (so a bash
/// parse tree is produced). `pmacs.lsp.config` is emptied first so the
/// real bash-language-server isn't spawned; grammar detection is
/// independent of the LSP config.
#[test]
fn m4_shebang_extensionless_script_resolves_bash() {
use pmacs::editor::EditorState;
let mut s = EditorState::new();
let dir = tempfile::tempdir().expect("tempdir");
let hook = dir.path().join("pre-commit"); // no extension
std::fs::write(&hook, b"#!/bin/sh\nset -e\necho building\n").expect("write");
let hook_disp = hook.display();
s.lua_host
.lua()
.load(format!(
"pmacs.lsp.config = {{}}
pmacs.buffer.find_or_open('{hook_disp}')"
))
.exec()
.expect("open extensionless shebang script");
let lsp_lang: Option<String> = s
.lua_host
.lua()
.load("return pmacs.lsp.active_buffer_language()")
.eval()
.expect("lsp language");
assert_eq!(
lsp_lang.as_deref(),
Some("bash"),
"extensionless #!/bin/sh resolves to bash for LSP"
);
pump_async(&mut s, |st| current_tree_language(st).is_some());
assert_eq!(
current_tree_language(&s).as_deref(),
Some("bash"),
"extensionless #!/bin/sh gets a bash parse tree"
);
}
/// Precedence: a recognized extension always wins over file content, so a
/// `.py` file that happens to open with `#!/bin/sh` still resolves to
/// python — the shebang is consulted only when extension detection misses.
#[test]
fn m4_shebang_does_not_override_extension() {
use pmacs::editor::EditorState;
let s = EditorState::new();
let dir = tempfile::tempdir().expect("tempdir");
let f = dir.path().join("tool.py");
std::fs::write(&f, b"#!/bin/sh\nprint('hi')\n").expect("write");
let f_disp = f.display();
s.lua_host
.lua()
.load(format!(
"pmacs.lsp.config = {{}}
pmacs.buffer.find_or_open('{f_disp}')"
))
.exec()
.expect("open .py with a shell shebang");
let lang: Option<String> = s
.lua_host
.lua()
.load("return pmacs.lsp.active_buffer_language()")
.eval()
.expect("language");
assert_eq!(
lang.as_deref(),
Some("python"),
".py extension wins over a #!/bin/sh shebang"
);
}
/// 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