-- 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 = {} 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 -- 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 -- attach; the kill path clears the entry below if/when it lands. local highlighted_buffers = {} 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 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. local key = tostring(buf) 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(attach_for_active_buffer) 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() end) if not ok and pmacs.error then pmacs.error("syntax.after-switch: " .. tostring(err)) end 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 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 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) 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