fix(highlight): PR #116 round 1 — shebang precedence, pinned grammar, env operands

Three review findings, all in builtin/runtime/syntax.lua.

1. [P1] Syntax bypassed extension precedence and grammar availability.
   attach_for_active_buffer resolved `language_for_path or shebang`, but
   language_for_path knows only grammar-backed extensions — so a `.py`
   file opening with `#!/bin/sh` fell through to the shebang and got a
   bash parse tree, and an extensionless `#!/usr/bin/env python3` script
   dispatched "python" (no grammar) and raised "unknown language". A new
   resolve_active_language walks the full precedence chain — grammar
   extension -> LSP filetype map -> shebang — consulting the shebang only
   when the extension is unrecognized (a recognized non-grammar extension
   like .py is authoritative). Dispatch is then gated on
   pmacs.parse._has_language(lang), so grammarless languages are skipped
   silently. The extension parts stay keyed on buf:name() (unchanged from
   before), so path-less buffers that resolve a grammar by name — e.g.
   generated markdown buffers — are unaffected.

2. [P2] Editing an open script's shebang left parsing/highlighting stale.
   The after-edit path re-sniffed the mutable shebang: sh -> python
   raised "unknown language" while leaving the old bash tree, and
   sh -> lua swapped the parse tree under a highlight overlay still
   holding the original grammar's query. Reparse now uses the language
   pinned at first attach (parse_lang_by_buffer), never re-resolving —
   a language change needs a close/reopen, as it does for extensions.

3. [P2] `env` options with operands were mistaken for interpreters.
   `#!/usr/bin/env -u FOO python3` skipped `-u` but took `FOO`. The env
   walk now skips the operand of the operand-consuming GNU-env options
   (-u/--unset, -C/--chdir, -a/--argv0) before selecting the interpreter.
   -S/--split-string stays excluded (its string carries the interpreter).

Tests (bite-verified against pre-fix syntax.lua — each fails without its
fix; scripts/bite HEAD builtin/runtime/syntax.lua):
- m4_shebang_does_not_override_extension now also asserts _has_view is
  false (no bash grammar tree for a `.py` + `#!/bin/sh`), not only the
  LSP language.
- m4_shebang_extensionless_grammarless_language_is_silent — extensionless
  python resolves for LSP, gets no grammar view, and records no error.
- m4_shebang_edit_keeps_pinned_grammar — rewriting a `#!/bin/sh` script's
  shebang to lua keeps the bash tree and reports no error.
- m4_shebang_resolver_maps_interpreters — added the env-operand cases
  (`-u FOO`, `-C /tmp`, combined).

Gates: fmt; clippy -D warnings; m4_acceptance --skip basedpyright; GPU;
git diff --check all green. The only sweep failure is the pre-existing
editor::composition_overhead_under_ten_percent render microbenchmark
(ratio hovers at the 1.10 cutoff; flakes ~1/3 even isolated single-
threaded, already asserted-off on macOS) — a pure-Rust render loop this
Lua-only change cannot touch. 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 15:02:59 +01:00
parent 4a60e4c858
commit f300b77533
2 changed files with 182 additions and 17 deletions

View File

@ -96,11 +96,29 @@ function pmacs.parse.language_from_shebang(buf)
local base = first:match("([^/]+)$") or first
if base == "env" then
base = nil
local seen_env = false
local skip_next = false
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
if not seen_env then
seen_env = true -- the `env` path token itself
elseif skip_next then
skip_next = false -- the operand consumed by the previous option
elseif tok:find("=", 1, true) then
-- `VAR=value` env assignment, or a `--long=value` option: both
-- self-contained, skip.
elseif tok:sub(1, 1) == "-" then
-- An option. A few GNU-env short options and their long forms
-- consume the *next* token as an operand (`-u NAME`, `-C DIR`,
-- `-a NAME`); skip that operand too, or its value is mistaken for
-- the interpreter. `-S`/`--split-string` is deliberately absent:
-- the string it introduces contains the interpreter, which the
-- walk then picks up. An option with an attached operand
-- (`-uNAME`) is one self-contained token and needs no skip.
if tok == "-u" or tok == "-C" or tok == "-a"
or tok == "--unset" or tok == "--chdir" or tok == "--argv0" then
skip_next = true
end
else
base = tok:match("([^/]+)$") or tok
break
end
@ -116,14 +134,37 @@ end
-- attach; the kill path clears the entry below if/when it lands.
local highlighted_buffers = {}
-- Filetype-aware language resolution for the active buffer, in
-- precedence order: grammar extension → LSP filetype map → shebang. The
-- shebang is consulted ONLY when the extension is unrecognized (a known
-- non-grammar extension like `.py` must not fall through to a stray
-- `#!/bin/sh` and be misparsed as bash). Keyed on `buf:name()` for the
-- extension parts (matching the historical behavior — path-less buffers
-- that resolve a grammar by name keep working); the shebang reads buffer
-- content directly.
local function resolve_active_language(buf)
local name = buf:name()
if name then
local grammar = pmacs.parse.language_for_path(name)
if grammar then return grammar end
local ext = name:match("%.([%w_]+)$")
local by_ext = ext and pmacs.lsp and pmacs.lsp.filetypes and pmacs.lsp.filetypes[ext]
-- A recognized (even non-grammar) extension is authoritative; do not
-- consult the shebang for it.
if by_ext then return by_ext end
end
return pmacs.parse.language_from_shebang(buf)
end
local function attach_for_active_buffer()
local buf = pmacs.window.buffer()
if not buf then return end
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
-- Gate dispatch on `_has_language`: the chain above also resolves
-- languages with no grammar (python, javascript), and dispatching one
-- would raise "unknown language" (caught, but noise) — and an
-- extensionless script must never get a wrong-grammar parse tree.
local lang = resolve_active_language(buf)
if not lang or not pmacs.parse._has_language(lang) then return end
pmacs.parse._dispatch(buf, lang)
-- T M4.3: install the syntax-highlight overlay for this buffer.
-- Idempotent --- repeated calls for the same buffer are a no-op
@ -175,10 +216,14 @@ local function reparse_active_buffer_after_edit()
if not pmacs.parse._has_view(buf) then return end
local pending = pmacs.parse._pending_edits(buf)
if not pending or pending == 0 then return end
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)
-- Reparse with the language pinned when the view was first attached ---
-- never re-resolve from the path or (mutable) shebang. The Rust side is
-- "first language wins"; re-sniffing a shebang the user just edited
-- would either raise "unknown language" (sh → python) or swap the parse
-- tree to a new grammar while the highlight overlay still holds the
-- original grammar's query (sh → lua). Language changes need a
-- close/reopen, exactly as they do for a renamed extension.
local lang = parse_lang_by_buffer[tostring(buf)]
if not lang then return end
pmacs.parse._dispatch(buf, lang)
end

View File

@ -5878,6 +5878,11 @@ fn m4_shebang_resolver_maps_interpreters() {
("#!/usr/bin/env bash\n", "bash"),
("#!/usr/bin/env python3\n", "python"),
("#!/usr/bin/env -S python3 -u\n", "python"),
// GNU-env options that consume an operand must not have the
// operand mistaken for the interpreter.
("#!/usr/bin/env -u FOO python3\n", "python"),
("#!/usr/bin/env -C /tmp python3\n", "python"),
("#!/usr/bin/env -u FOO -C /tmp node\n", "javascript"),
("#!/usr/bin/node\n", "javascript"),
("#!/usr/bin/env lua\n", "lua"),
] {
@ -5957,17 +5962,132 @@ fn m4_shebang_does_not_override_extension() {
))
.exec()
.expect("open .py with a shell shebang");
let lang: Option<String> = s
// Both the LSP language *and* the grammar decision must respect the
// extension: python for LSP, and NO grammar parse view (python has no
// grammar) — not a bash tree installed from the `#!/bin/sh` line.
// `_has_view` is set synchronously by `_dispatch`, so no pump is
// needed; without the precedence fix the shebang would have dispatched
// bash and this would be true.
let (lang, has_view): (Option<String>, bool) = s
.lua_host
.lua()
.load("return pmacs.lsp.active_buffer_language()")
.load(
"return pmacs.lsp.active_buffer_language(),
pmacs.parse._has_view(pmacs.window.buffer())",
)
.eval()
.expect("language");
.expect("language + view");
assert_eq!(
lang.as_deref(),
Some("python"),
".py extension wins over a #!/bin/sh shebang"
".py extension wins over a #!/bin/sh shebang (LSP)"
);
assert!(
!has_view,
".py file must not get a grammar parse view from a #!/bin/sh line"
);
}
/// Finding-1 gate: an extensionless `#!/usr/bin/env python3` script
/// resolves to python for LSP, but python has no grammar — syntax must
/// skip it *silently*. Without the `_has_language` gate, `_dispatch`
/// raises "unknown language: python" (caught by the after-load pcall and
/// reported through `pmacs.error`), which we assert does NOT happen.
#[test]
fn m4_shebang_extensionless_grammarless_language_is_silent() {
use pmacs::editor::EditorState;
let s = EditorState::new();
let dir = tempfile::tempdir().expect("tempdir");
let f = dir.path().join("generate"); // no extension
std::fs::write(&f, b"#!/usr/bin/env python3\nprint('hi')\n").expect("write");
let f_disp = f.display();
s.lua_host
.lua()
.load(format!(
"pmacs.lsp.config = {{}}
_G.__errs = {{}}
local real = pmacs.error
pmacs.error = function(m) table.insert(_G.__errs, m) end
pmacs.buffer.find_or_open('{f_disp}')"
))
.exec()
.expect("open extensionless python script");
let (lang, has_view, errs): (Option<String>, bool, i64) = s
.lua_host
.lua()
.load(
"return pmacs.lsp.active_buffer_language(),
pmacs.parse._has_view(pmacs.window.buffer()),
#_G.__errs",
)
.eval()
.expect("probe");
assert_eq!(lang.as_deref(), Some("python"), "python resolves for LSP");
assert!(
!has_view,
"no grammar parse view for a grammarless language"
);
assert_eq!(errs, 0, "no 'unknown language' error reported");
}
/// Finding-2 pin: editing an open extensionless script's shebang must not
/// re-switch the parse grammar. A `#!/bin/sh` script attaches the bash
/// grammar; rewriting its shebang to lua and firing after-edit must keep
/// the bash tree (the pinned grammar) rather than swap in lua under the
/// stale highlight overlay — and must not error.
#[test]
fn m4_shebang_edit_keeps_pinned_grammar() {
use pmacs::editor::EditorState;
let mut s = EditorState::new();
let dir = tempfile::tempdir().expect("tempdir");
let hook = dir.path().join("deploy"); // no extension
std::fs::write(&hook, b"#!/bin/sh\necho one\n").expect("write");
let hook_disp = hook.display();
s.lua_host
.lua()
.load(format!(
"pmacs.lsp.config = {{}}
_G.__errs = {{}}
local real = pmacs.error
pmacs.error = function(m) table.insert(_G.__errs, m) end
pmacs.buffer.find_or_open('{hook_disp}')"
))
.exec()
.expect("open extensionless shell script");
pump_async(&mut s, |st| {
current_tree_language(st).as_deref() == Some("bash")
});
// Rewrite the first line to a lua shebang, then fire after-edit.
s.lua_host
.lua()
.load(
"local b = pmacs.window.buffer()
local text = b:slice(0, b:len())
local first_len = (text:find('\\n', 1, true) or 1) - 1
b:replace(0, first_len, '#!/usr/bin/env lua')
pmacs.hook.run('buffer.after-edit')",
)
.exec()
.expect("rewrite shebang to lua");
// Let the reparse settle (manual ticks: the tree stays bash with the
// pin, so a `pump_async` for a language *change* would time out).
for _ in 0..64 {
s.tick_async();
std::thread::sleep(Duration::from_millis(2));
}
assert_eq!(
current_tree_language(&s).as_deref(),
Some("bash"),
"editing the shebang must not re-switch the pinned grammar"
);
let errs: i64 = s
.lua_host
.lua()
.load("return #_G.__errs")
.eval()
.expect("errs");
assert_eq!(errs, 0, "reparse with the pinned grammar reports no error");
}
/// Typing-perf: the default bundle coalesces full-document