632 lines
24 KiB
Lua
632 lines
24 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 = {}
|
|
-- Fresh-load language decision, including an explicit false sentinel for
|
|
-- "resolved none". Syntax, LSP, pairing, comments, and initial major mode all
|
|
-- consume this pin rather than re-sniffing mutable file content independently.
|
|
local detected_language_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
|
|
|
|
-- Modeline → language detection ---------------------------------------------
|
|
|
|
local MODELINE_WINDOW_BYTES = 8 * 1024
|
|
local VIM_MODELINE_LINES = 5
|
|
local MODELINE_NAME_BYTES = 128
|
|
|
|
pmacs.parse.modeline_aliases = pmacs.parse.modeline_aliases or {}
|
|
local default_modeline_aliases = {
|
|
["c++"] = "cpp",
|
|
cxx = "cpp",
|
|
sh = "bash",
|
|
shell = "bash",
|
|
["shell-script"] = "bash",
|
|
zsh = "bash",
|
|
py = "python",
|
|
js = "javascript",
|
|
js2 = "javascript",
|
|
jsx = "javascriptreact",
|
|
ts = "typescript",
|
|
tsx = "typescriptreact",
|
|
yml = "yaml",
|
|
makefile = "make",
|
|
docker = "dockerfile",
|
|
}
|
|
for name, language in pairs(default_modeline_aliases) do
|
|
if pmacs.parse.modeline_aliases[name] == nil then
|
|
pmacs.parse.modeline_aliases[name] = language
|
|
end
|
|
end
|
|
|
|
local function normalize_modeline_name(name)
|
|
if type(name) ~= "string" then return nil end
|
|
name = name:gsub("^[ \t]+", ""):gsub("[ \t]+$", ""):lower()
|
|
if #name == 0 or #name > MODELINE_NAME_BYTES then return nil end
|
|
if not name:match("^[a-z0-9][a-z0-9+_-]*$") then return nil end
|
|
local alias = pmacs.parse.modeline_aliases[name]
|
|
if alias == nil then return name end
|
|
if type(alias) ~= "string" or #alias == 0 or #alias > MODELINE_NAME_BYTES then
|
|
return nil
|
|
end
|
|
if not alias:match("^[a-z0-9][a-z0-9+_-]*$") then return nil end
|
|
return alias
|
|
end
|
|
|
|
local function without_trailing_cr(line)
|
|
if line:sub(-1) == "\r" then return line:sub(1, -2) end
|
|
return line
|
|
end
|
|
|
|
-- Return the first five and last five complete logical lines. Each entry is
|
|
-- `{ text, offset }`, where offset is the zero-based buffer byte position.
|
|
-- The suffix's leading partial line is discarded and does not consume a slot.
|
|
local function modeline_edge_lines(buf)
|
|
local ok_len, length = pcall(function() return buf:len() end)
|
|
if not ok_len or not length or length <= 0 then return {}, {} end
|
|
|
|
local prefix_end = math.min(length, MODELINE_WINDOW_BYTES)
|
|
local ok_prefix, prefix = pcall(function() return buf:slice(0, prefix_end) end)
|
|
if not ok_prefix or type(prefix) ~= "string" then return {}, {} end
|
|
|
|
local front = {}
|
|
local pos = 1
|
|
while #front < VIM_MODELINE_LINES and pos <= #prefix do
|
|
local newline = prefix:find("\n", pos, true)
|
|
if not newline then
|
|
if prefix_end < length then break end
|
|
newline = #prefix + 1
|
|
end
|
|
front[#front + 1] = {
|
|
text = without_trailing_cr(prefix:sub(pos, newline - 1)),
|
|
offset = pos - 1,
|
|
}
|
|
if newline > #prefix then break end
|
|
pos = newline + 1
|
|
end
|
|
|
|
local tail_start = 0
|
|
local scan_start = 1
|
|
if length > MODELINE_WINDOW_BYTES then
|
|
-- Keep the one-byte line-boundary probe plus suffix content within the
|
|
-- same 8 KiB read budget.
|
|
tail_start = length - (MODELINE_WINDOW_BYTES - 1)
|
|
local ok_probe, probe =
|
|
pcall(function() return buf:slice(tail_start - 1, tail_start) end)
|
|
if not ok_probe or probe ~= "\n" then
|
|
scan_start = nil
|
|
end
|
|
end
|
|
|
|
local tail = prefix
|
|
if tail_start > 0 then
|
|
local ok_tail
|
|
ok_tail, tail = pcall(function() return buf:slice(tail_start, length) end)
|
|
if not ok_tail or type(tail) ~= "string" then return front, front end
|
|
end
|
|
if scan_start == nil then
|
|
local first_newline = tail:find("\n", 1, true)
|
|
if not first_newline then return front, front end
|
|
scan_start = first_newline + 1
|
|
end
|
|
|
|
local reverse_tail = {}
|
|
local line_end = #tail
|
|
if line_end >= scan_start and tail:byte(line_end) == 10 then
|
|
line_end = line_end - 1
|
|
end
|
|
while line_end >= scan_start and #reverse_tail < VIM_MODELINE_LINES do
|
|
local i = line_end
|
|
while i >= scan_start and tail:byte(i) ~= 10 do
|
|
i = i - 1
|
|
end
|
|
local line_start = i + 1
|
|
reverse_tail[#reverse_tail + 1] = {
|
|
text = without_trailing_cr(tail:sub(line_start, line_end)),
|
|
offset = tail_start + line_start - 1,
|
|
}
|
|
line_end = i - 1
|
|
end
|
|
|
|
local edges = {}
|
|
local seen = {}
|
|
for _, entry in ipairs(front) do
|
|
edges[#edges + 1] = entry
|
|
seen[entry.offset] = true
|
|
end
|
|
for i = #reverse_tail, 1, -1 do
|
|
local entry = reverse_tail[i]
|
|
if not seen[entry.offset] then
|
|
edges[#edges + 1] = entry
|
|
seen[entry.offset] = true
|
|
end
|
|
end
|
|
return front, edges
|
|
end
|
|
|
|
local function emacs_mode_on_line(entry, consider)
|
|
local line = entry.text
|
|
local search_from = 1
|
|
while true do
|
|
local open = line:find("-*-", search_from, true)
|
|
if not open then return end
|
|
local close = line:find("-*-", open + 3, true)
|
|
if not close then return end
|
|
local payload = line:sub(open + 3, close - 1)
|
|
if payload:find(":", 1, true) then
|
|
for part_at, part in payload:gmatch("()([^;]+)") do
|
|
local key, value =
|
|
part:match("^[ \t]*([%w_-]+)[ \t]*:[ \t]*(.-)[ \t]*$")
|
|
if key and key:lower() == "mode" then
|
|
local mode = normalize_modeline_name(value)
|
|
if mode then consider(mode, entry.offset + open + part_at) end
|
|
end
|
|
end
|
|
else
|
|
local mode = normalize_modeline_name(payload)
|
|
if mode then consider(mode, entry.offset + open) end
|
|
end
|
|
search_from = close + 3
|
|
end
|
|
end
|
|
|
|
local function vim_assignment(token)
|
|
return token:match("^ft=(.+)$") or token:match("^filetype=(.+)$")
|
|
end
|
|
|
|
local function vim_mode_at(entry, marker_at, marker, consider)
|
|
local line = entry.text
|
|
local rest_at = marker_at + #marker
|
|
local rest = line:sub(rest_at)
|
|
local full_set_at = rest:match("^[ \t]*set[ \t]+()")
|
|
local option_at = full_set_at
|
|
if not option_at and marker ~= "Vim:" then
|
|
option_at = rest:match("^[ \t]*se[ \t]+()")
|
|
end
|
|
if marker == "Vim:" and not full_set_at then return end
|
|
|
|
if option_at then
|
|
local terminator = rest:find(":", option_at, true)
|
|
if not terminator then return end
|
|
local options = rest:sub(option_at, terminator - 1)
|
|
for token_at, token in options:gmatch("()([^ \t]+)") do
|
|
local value = vim_assignment(token)
|
|
local mode = value and normalize_modeline_name(value)
|
|
if mode then
|
|
consider(mode, entry.offset + rest_at + option_at + token_at - 3)
|
|
end
|
|
end
|
|
return
|
|
end
|
|
|
|
for token_at, token in rest:gmatch("()([^ \t:]+)") do
|
|
local value = vim_assignment(token)
|
|
local mode = value and normalize_modeline_name(value)
|
|
if mode then consider(mode, entry.offset + rest_at + token_at - 2) end
|
|
end
|
|
end
|
|
|
|
local function vim_modes_on_line(entry, consider)
|
|
local line = entry.text
|
|
for _, marker in ipairs({ "vim:", "vi:", "Vim:" }) do
|
|
local search_from = 1
|
|
while true do
|
|
local marker_at = line:find(marker, search_from, true)
|
|
if not marker_at then break end
|
|
local previous = marker_at > 1 and line:sub(marker_at - 1, marker_at - 1)
|
|
if marker_at == 1 or previous == " " or previous == "\t" then
|
|
vim_mode_at(entry, marker_at, marker, consider)
|
|
end
|
|
search_from = marker_at + 1
|
|
end
|
|
end
|
|
end
|
|
|
|
function pmacs.parse.language_from_modeline(buf)
|
|
if not buf then return nil end
|
|
local front, edges = modeline_edge_lines(buf)
|
|
local mode
|
|
local mode_at = -1
|
|
local function consider(candidate, candidate_at)
|
|
if candidate_at >= mode_at then
|
|
mode = candidate
|
|
mode_at = candidate_at
|
|
end
|
|
end
|
|
|
|
if front[1] then emacs_mode_on_line(front[1], consider) end
|
|
if front[1] and front[1].text:sub(1, 2) == "#!" and front[2] then
|
|
emacs_mode_on_line(front[2], consider)
|
|
end
|
|
for _, entry in ipairs(edges) do
|
|
vim_modes_on_line(entry, consider)
|
|
end
|
|
return mode
|
|
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 = {}
|
|
|
|
-- Fresh language inference for a buffer, in precedence order: explicit
|
|
-- modeline → grammar extension → LSP filetype map → filename → shebang.
|
|
-- Path components intentionally come from `buf:name()` to preserve syntax's
|
|
-- historical grammar-by-name behavior for pathless buffers.
|
|
local function detect_buffer_language(buf)
|
|
local modeline = pmacs.parse.language_from_modeline(buf)
|
|
if modeline then return modeline end
|
|
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 refresh_buffer_language(buf)
|
|
local language = detect_buffer_language(buf)
|
|
detected_language_by_buffer[tostring(buf)] = language or false
|
|
return language
|
|
end
|
|
|
|
function pmacs.parse.buffer_language(buf)
|
|
if not buf then return nil end
|
|
local key = tostring(buf)
|
|
local language = detected_language_by_buffer[key]
|
|
if language ~= nil then return language or nil end
|
|
return refresh_buffer_language(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)
|
|
-- A genuine load refreshes every detection signal and replaces the initial
|
|
-- major mode, including with nil. Switches consume the pin: editing a
|
|
-- shebang/modeline cannot silently swap parser/LSP language, while a
|
|
-- registry-only hidden buffer still resolves when first visited.
|
|
local lang = initialize_mode and refresh_buffer_language(buf)
|
|
or pmacs.parse.buffer_language(buf)
|
|
if initialize_mode then pmacs.buffer.set_major_mode(buf, lang) end
|
|
-- The resolution chain can yield a valid mode with no grammar. Gate dispatch
|
|
-- so custom/server-only modes stay quiet rather than raising "unknown
|
|
-- language" from the parse worker.
|
|
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
|