fix(highlight): PR #116 round 2 — pin grammar across switch, attached env -S

Two follow-ups from review, both in builtin/runtime/syntax.lua.

1. [P2] Buffer switching bypassed the pinned grammar. after-edit already
   reparsed the pinned language, but the after-switch reattach path
   (attach_for_active_buffer) re-resolved from scratch — so open an
   extensionless `#!/bin/sh` (bash), edit its shebang to lua, switch away
   and back, and the grammar flipped to lua while the LSP side kept its
   bash attachment (lsp.lua's after-switch reuses the existing record).
   attach_for_active_buffer now reuses the language pinned at first attach
   whenever a parse view already exists; only a first-seen buffer
   resolves. A language change still needs a close/reopen, matching both
   the after-edit behavior and how extensions work.

2. [P2] Attached `env -S`/`--split-string` forms failed. The walk skipped
   the whole option token, but for split-string the interpreter rides
   inside it: `-Spython3`, `-vSpython3` (after no-operand short flags
   i/v/0), and `--split-string=python3` all resolved to nil (the last was
   also eaten by the earlier `=` branch). The env walk now extracts the
   interpreter from the attached value (`^-[iv0]*S(.+)$` /
   `^--split-string=(.+)$`); the separated forms (`-S python3`) still work
   by walking on to the next token.

Tests (bite-verified against the round-1 syntax.lua — both fail there;
scripts/bite HEAD builtin/runtime/syntax.lua):
- m4_shebang_edit_keeps_pinned_grammar now adds a switch-away/back cycle
  (via pmacs.window.switch_buffer, which fires after-switch
  synchronously) and asserts the tree stays bash.
- m4_shebang_resolver_maps_interpreters adds the attached split-string
  cases (`-Spython3`, `--split-string=python3`, `-vSpython3`).

Gates: fmt; clippy -D warnings; m4_acceptance --skip basedpyright; GPU;
git diff --check green. Only-known-flake caveat as round 1
(editor::composition_overhead_under_ten_percent perf microbenchmark,
unrelated to this Lua change). 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:20:22 +01:00
parent f300b77533
commit 558d00020f
2 changed files with 64 additions and 12 deletions

View File

@ -99,21 +99,34 @@ function pmacs.parse.language_from_shebang(buf)
local seen_env = false
local skip_next = false
for tok in rest:gmatch("%S+") do
-- `-S`/`--split-string` introduces a string whose FIRST word is the
-- interpreter. GNU env accepts that value ATTACHED — `-Spython3`,
-- `-vSpython3` (after no-operand short flags i/v/0), or
-- `--split-string=python3` — where the interpreter rides inside the
-- option token. Extract it; the separated forms (`-S python3`) are
-- handled by simply walking on to the next token.
local split_attached =
tok:match("^%-[iv0]*S(.+)$") or tok:match("^%-%-split%-string=(.+)$")
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 split_attached then
local word = split_attached:match("^(%S+)")
base = word and (word:match("([^/]+)$") or word)
break
elseif tok == "-S" or tok == "--split-string" then
-- Separated split-string: the next token starts the string, i.e.
-- the interpreter — keep walking.
elseif tok:find("=", 1, true) then
-- `VAR=value` env assignment, or a `--long=value` option: both
-- `VAR=value` env assignment, or another `--long=value` option:
-- 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.
-- the interpreter. 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
@ -159,11 +172,18 @@ end
local function attach_for_active_buffer()
local buf = pmacs.window.buffer()
if not buf 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)
local key = tostring(buf)
-- Reuse the language pinned at first attach if this buffer already has
-- a parse view. A switch-away/back re-runs this hook (via after-switch);
-- re-resolving there would re-sniff a shebang the user has since edited
-- and silently swap the grammar — and diverge from the LSP side, which
-- keeps its existing attachment across the switch. A first-seen buffer
-- (no view yet) resolves normally. Gate dispatch on `_has_language`: the
-- resolution chain also yields languages with no grammar (python,
-- javascript), and dispatching one would raise "unknown language"
-- (caught, but noise) and never gives a wrong-grammar tree.
local lang = pmacs.parse._has_view(buf) and parse_lang_by_buffer[key]
or 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.
@ -174,8 +194,8 @@ local function attach_for_active_buffer()
-- table so we only push once per (buffer, after-load) cycle).
-- `tostring(buf)` is stable per BufferId (the metamethod
-- formats the wrapped id), so it's a safe table-key
-- replacement for a `:id()` method we don't have to expose.
local key = tostring(buf)
-- replacement for a `:id()` method we don't have to expose (`key` is
-- computed once at the top of this function).
if not highlighted_buffers[key] then
local ok = pmacs.parse._attach_highlight(buf, lang)
if ok then highlighted_buffers[key] = true 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"),
// Attached split-string forms carry the interpreter inside the
// option token.
("#!/usr/bin/env -Spython3 -u\n", "python"),
("#!/usr/bin/env --split-string=python3 -u\n", "python"),
("#!/usr/bin/env -vSpython3 -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"),
@ -6081,6 +6086,33 @@ fn m4_shebang_edit_keeps_pinned_grammar() {
Some("bash"),
"editing the shebang must not re-switch the pinned grammar"
);
// Switch away to another buffer and back: the after-switch reattach
// must reuse the pinned bash grammar rather than re-sniff the (now
// lua) shebang — otherwise grammar and LSP diverge, since the LSP side
// keeps its bash attachment across the switch. `switch_buffer` fires
// `buffer.after-switch` synchronously.
let other = dir.path().join("other.txt");
std::fs::write(&other, b"plain text\n").expect("write other");
let other_disp = other.display();
s.lua_host
.lua()
.load(format!(
"local pinned = pmacs.window.buffer()
pmacs.buffer.find_or_open('{other_disp}')
pmacs.window.switch_buffer(pinned)"
))
.exec()
.expect("switch away and back");
for _ in 0..64 {
s.tick_async();
std::thread::sleep(Duration::from_millis(2));
}
assert_eq!(
current_tree_language(&s).as_deref(),
Some("bash"),
"switch-away/back must reuse the pinned grammar, not re-sniff the edited shebang"
);
let errs: i64 = s
.lua_host
.lua()