pmacs/builtin/runtime/syntax.lua

392 lines
17 KiB
Lua

-- builtin/runtime/syntax.lua --- T M4.2 auto-attach grammar by extension.
--
-- The Rust side (`crate::syntax::BUILTIN_LANGUAGES`) keeps a config
-- table of `name → loader + extensions`. This file is the Lua-side
-- glue that fires on every successful file load: it asks
-- `pmacs.parse.language_for_path` whether the loaded file maps to a
-- known grammar, and dispatches a parse if so. The settle path is
-- handled by an after-tick step that drains finished parse jobs and
-- installs them into the buffer's view.
--
-- Adding a new grammar requires only:
-- 1. A `tree-sitter-foo = "X.Y"` line in `Cargo.toml`.
-- 2. A new `LanguageEntry` in `crate::syntax::BUILTIN_LANGUAGES`.
-- Nothing in this file needs to change.
-- Set of dispatched-but-not-yet-installed parse job ids. Populated
-- when `pmacs.parse._dispatch` is called below; drained by the tick
-- step.
local pending_parse_jobs = {}
local parse_job_buffer_keys = {}
local inflight_parse_by_buffer = {}
local parse_buffer_by_key = {}
local parse_lang_by_buffer = {}
local reparse_requested_by_buffer = {}
-- Buffers already warned about hitting the injection layer cap (Q#IJ3);
-- keyed like the others so we warn once, and re-arm if the file stops
-- capping (an edit removed the excess regions).
local injection_cap_warned = {}
local raw_dispatch = pmacs.parse._dispatch
-- Wrap `_dispatch` so every dispatched parse job lands in our
-- pending set. Calls into `_dispatch` from outside this file (e.g.
-- a hand-rolled user script) get the same tracking for free.
function pmacs.parse._dispatch(buf, lang)
local key = tostring(buf)
parse_buffer_by_key[key] = buf
parse_lang_by_buffer[key] = lang
local inflight = inflight_parse_by_buffer[key]
if inflight then
reparse_requested_by_buffer[key] = true
return inflight
end
local job_id = raw_dispatch(buf, lang)
pending_parse_jobs[job_id] = true
parse_job_buffer_keys[job_id] = key
inflight_parse_by_buffer[key] = job_id
return job_id
end
-- Injection language aliases (framing Q#IJ4). The registry holds the
-- merged map (seeded with defaults on the Rust side), and each dispatch
-- snapshots it into the parse request so the worker can resolve dynamic
-- fence names (`py` → python, `ts` → typescript). Exposed as a
-- write-through proxy so users add fence-name aliases from init.lua:
-- pmacs.parse.injection_aliases.mylang = "rust"
-- Reads are not proxied (the canonical map lives Rust-side); this is a
-- write-only extension surface.
pmacs.parse.injection_aliases = setmetatable({}, {
__newindex = function(_, alias, lang)
pmacs.parse._register_injection_alias(alias, lang)
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
local seen_env = false
local skip_next = false
local tokens = {}
for tok in rest:gmatch("%S+") do
tokens[#tokens + 1] = tok
end
local i = 1
while i <= #tokens do
local tok = tokens[i]
i = i + 1
-- `-S`/`--split-string` introduces a complete env argument list,
-- not necessarily an interpreter first: it may begin with more env
-- options or `VAR=value` assignments. GNU env accepts that value
-- ATTACHED — `-Spython3`, `-vSpython3` (after no-operand short flags
-- i/v/0), or `--split-string=python3`. Put an attached payload back
-- into this token stream so it goes through the same option/operand/
-- assignment state machine as a separated payload.
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
table.insert(tokens, i, split_attached)
elseif tok == "-S" or tok == "--split-string" then
-- Separated split-string: the next token starts the string, i.e.
-- another env argument — keep walking.
elseif tok:find("=", 1, true) then
-- `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. 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
end
if not base then return nil end
end
return pmacs.parse.shebangs[base]
end
-- Filename → language detection --------------------------------------------
--
-- Some files are identified by their whole BASENAME, not an extension:
-- `Dockerfile`, `Makefile`, `CMakeLists.txt`, and rc dotfiles like
-- `.bashrc`/`PKGBUILD` (highlighted by the already-bundled bash grammar).
-- Consulted after extension detection (a recognized extension still wins)
-- and before the shebang — a `Makefile` never carries a shebang, and the
-- basename is the more reliable signal. User-extensible from init.lua,
-- e.g. `pmacs.parse.filenames["Vagrantfile"] = "ruby"`.
pmacs.parse.filenames = pmacs.parse.filenames or {
["Dockerfile"] = "dockerfile",
["Containerfile"] = "dockerfile",
["Makefile"] = "make",
["makefile"] = "make",
["GNUmakefile"] = "make",
["BSDmakefile"] = "make",
["CMakeLists.txt"] = "cmake",
-- Shell rc / config files → the bundled bash grammar.
[".bashrc"] = "bash",
[".bash_profile"] = "bash",
[".bash_logout"] = "bash",
[".profile"] = "bash",
[".zshrc"] = "bash",
[".zprofile"] = "bash",
[".zshenv"] = "bash",
["PKGBUILD"] = "bash",
}
-- Language for a path's basename, or nil. `name` may be a full path.
function pmacs.parse.language_from_filename(name)
if not name then return nil end
local base = name:match("([^/]+)$") or name
return pmacs.parse.filenames[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
-- 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 → filename → shebang. A
-- recognized extension is authoritative (a `.py` must not fall through to
-- a stray `#!/bin/sh` and be misparsed as bash); the basename map handles
-- extensionless `Dockerfile`/`Makefile`/rc-dotfiles, and only then does
-- the shebang (buffer content) get a look. Keyed on `buf:name()` for the
-- path parts (matching the historical behavior — path-less buffers that
-- resolve a grammar by name keep working).
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]
if by_ext then return by_ext end
local by_name = pmacs.parse.language_from_filename(name)
if by_name then return by_name end
end
return pmacs.parse.language_from_shebang(buf)
end
local function attach_for_active_buffer(initialize_mode)
local buf = pmacs.window.buffer()
if not buf then return end
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 can still yield a language with no grammar — a
-- shebang or filetype mapping to a server-only or unsupported language
-- (e.g. an init.lua `pmacs.parse.shebangs.ruby = "ruby"`) — 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)
-- The detected language is also the initial major-mode name. Do this
-- before grammar gating: a language supplied only by an LSP filetype or
-- shebang mapping is still a valid mode even when no parser is bundled.
-- Only after-load initializes it; after-switch must preserve explicit
-- overrides and explicit nil clears.
if initialize_mode and lang and pmacs.buffer.major_mode(buf) == nil then
pmacs.buffer.set_major_mode(buf, lang)
end
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
-- on our side, and the Rust side is also tolerant of double
-- attach (it pushes a fresh overlay each call, but `attach_for
-- _active_buffer` itself is gated by the `highlighted_buffers`
-- 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 (`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
end
end
pmacs.hook.add("buffer.after-load", function()
-- Best-effort: a missing grammar / re-entry / stale buffer
-- mustn't poison the rest of the after-load chain.
local ok, err = pcall(function() attach_for_active_buffer(true) end)
if not ok and pmacs.error then
pmacs.error("syntax.after-load: " .. tostring(err))
end
end)
pmacs.hook.add("buffer.after-switch", function()
-- Arc 1b: switching buffers clears the window's overlays
-- (`switch_active_buffer` resets window view state), so the
-- highlight view must be re-pushed for the now-active buffer.
-- Dropping the `highlighted_buffers` entry first lets
-- `attach_for_active_buffer` re-attach; the just-cleared window
-- makes that exactly-once per switch. Without this, C-x b /
-- panel navigation permanently stripped syntax color.
local ok, err = pcall(function()
local buf = pmacs.window.buffer()
if not buf then return end
highlighted_buffers[tostring(buf)] = nil
attach_for_active_buffer(false)
end)
if not ok and pmacs.error then
pmacs.error("syntax.after-switch: " .. tostring(err))
end
end)
-- Major mode is window-local presentation state because each split may show
-- a different buffer. The provider therefore reads ctx.buffer rather than
-- the focused buffer. Empty text omits the segment without tripping the
-- statusline failure latch.
pmacs.statusline.register {
name = "mode",
side = "left",
priority = 0,
face = "ui.modeline",
fn = function(ctx)
local mode = pmacs.buffer.major_mode(ctx.buffer)
if mode == nil then return "" end
return "(" .. mode .. ")"
end,
}
local function reparse_active_buffer_after_edit()
local buf = pmacs.window.buffer()
if not buf then return end
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
-- 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
pmacs.hook.add("buffer.after-edit", function()
-- `ParseView:on_edit` records incremental edits synchronously, but
-- highlight overlays only see new spans after a fresh parse settles.
local ok, err = pcall(reparse_active_buffer_after_edit)
if not ok and pmacs.error then
pmacs.error("syntax.after-edit: " .. tostring(err))
end
end)
local function dispatch_follow_up_if_dirty(key)
local buf = parse_buffer_by_key[key]
local lang = parse_lang_by_buffer[key]
if not buf or not lang then return end
local pending = pmacs.parse._pending_edits(buf)
local requested = reparse_requested_by_buffer[key]
reparse_requested_by_buffer[key] = nil
if requested or (pending and pending > 0) then
pmacs.parse._dispatch(buf, lang)
end
end
-- After-tick step: any parse job that has settled gets its bundle
-- installed into the buffer's view (and its pending entry drained).
-- Extension hook on top of the async runtime's tick rather than a
-- Rust-side tick callback because the install path is policy
-- (which view to install into), not mechanism.
local prior_tick = pmacs._async.tick
pmacs._async.tick = function(...)
local ret = prior_tick(...)
for job_id in pairs(pending_parse_jobs) do
if pmacs._async._is_complete(job_id) then
local key = parse_job_buffer_keys[job_id]
pmacs.parse._install_settled(job_id)
-- Surface the injection layer backstop (Q#IJ3) once per buffer rather
-- than dropping embedded regions silently. Best-effort: a missing
-- buffer or error here must not stall the settle loop.
local capped_buf = key and parse_buffer_by_key[key]
if capped_buf and pmacs.parse._injection_capped(capped_buf) then
if not injection_cap_warned[key] and pmacs.error then
injection_cap_warned[key] = true
pmacs.error(
"syntax: injection layer cap reached; some embedded regions are unhighlighted")
end
elseif key then
injection_cap_warned[key] = nil
end
pending_parse_jobs[job_id] = nil
parse_job_buffer_keys[job_id] = nil
if key and inflight_parse_by_buffer[key] == job_id then
inflight_parse_by_buffer[key] = nil
dispatch_follow_up_if_dirty(key)
end
end
end
return ret
end