feat: detect language from modelines

Parse bounded Emacs and Vim modelines, normalize common aliases, and give
explicit file metadata precedence over inferred language. Pin one fresh-load
language decision for syntax, LSP, pairing, comments, and initial major mode,
while preserving the LSP path guard and explicit mode overrides.

Cover supported forms, rejection boundaries, precedence, unknown modes,
shebang and modeline pinning, reopen behavior, and pathless buffers.
This commit is contained in:
Levi Neuwirth 2026-07-22 10:02:20 -04:00
parent d5e59a9dcf
commit f8d05d2134
4 changed files with 613 additions and 56 deletions

View File

@ -471,33 +471,21 @@ end
local function buffer_language(buf)
local ok, path = pcall(function() return buf and buf:path() end)
if not ok or not path then return nil end
-- Grammar-backed detection first (keeps rust/.rs etc. exactly as
-- before); fall back to the LSP-only filetype map so languages with a
-- server but no tree-sitter grammar (Python) still attach; then the
-- basename map for filename-identified files (`Dockerfile`,
-- `CMakeLists.txt`); finally, for an extensionless file, sniff a
-- `#!interp` shebang so e.g. `#!/bin/sh` still attaches its server.
local lang = pmacs.parse.language_for_path(path)
if lang then return lang end
local ext = path:match("%.([%w_]+)$")
local by_ext = ext and pmacs.lsp.filetypes[ext]
if by_ext then return by_ext end
local by_name = pmacs.parse.language_from_filename(path)
if by_name then return by_name end
return pmacs.parse.language_from_shebang(buf)
-- Syntax owns the fresh-load inference and pin. LSP retains only its
-- path-eligibility rule: without a backing path it cannot construct a URI or
-- project root, even when syntax can infer a grammar from a buffer name.
return pmacs.parse.buffer_language(buf)
end
-- Public: the per-buffer language chain. Auto-pairing resolves
-- relevance against the buffer its typed-edit record names — which a
-- context-switching command may have left inactive by callback time —
-- so the parameterized form is the primitive and the active-buffer
-- form delegates.
-- Public: the pinned per-buffer language. Auto-pairing resolves relevance
-- against the buffer its typed-edit record names — which a context-switching
-- command may have left inactive by callback time — so the parameterized form
-- is the primitive and the active-buffer form delegates.
pmacs.lsp.buffer_language = buffer_language
local function active_buffer_language()
return buffer_language(pmacs.window.buffer())
end
-- Public: the comment-toggle module (and future language-aware Lua)
-- reuses this grammar+filetypes chain instead of replicating it.
-- Public: comment-toggle and other language-aware Lua reuse the same pin.
pmacs.lsp.active_buffer_language = active_buffer_language
-- Directory component of a path, or nil if it has none.
@ -714,7 +702,7 @@ local function attach_buffer(buf)
-- carries the full current text, superseding them.
pending_did_change[key] = nil
end
local language = active_buffer_language()
local language = buffer_language(buf)
if not language then return nil end
-- Path resolved before spawn so the server's `rootUri` can be
-- derived from the file's project (see `project_root_for`).

View File

@ -22,6 +22,10 @@ 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).
@ -200,21 +204,254 @@ function pmacs.parse.language_from_filename(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 = {}
-- 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)
-- 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)
@ -228,31 +465,34 @@ local function resolve_active_language(buf)
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)
-- 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
-- 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.

View File

@ -149,14 +149,16 @@ Emacs:
Vim/Vi:
- accept `vim:`, `vi:`, and `Vim:` at line start or preceded by ASCII space or
tab; `Vim:` requires the `set` form, matching Vim;
tab; uppercase `Vim:` requires literal `set` rather than abbreviated `se`,
matching Vim;
- accept only exact `ft=NAME` and `filetype=NAME` assignments; `ft:NAME` and
`filetype:NAME` are not modeline assignment forms and are rejected;
- in the direct form, split option tokens on ASCII whitespace and `:`, so the
common `vim:ft=python:sw=4:` form yields `ft=python`;
- in the `set` / `se` form, end the option section at the first `:` and split
only the preceding text on ASCII whitespace; `vim: set sw=4: ft=python`
therefore contains no live filetype assignment;
- in the `set` / `se` form (`se` is lowercase-marker-only), end the option
section at the first `:` and split only the preceding text on ASCII
whitespace; `vim: set sw=4: ft=python` therefore contains no live filetype
assignment;
- ignore all other live option tokens rather than interpreting them;
- require that terminating colon for the `set` / `se` form, so a comment suffix
is never consumed as an option value;

View File

@ -6101,6 +6101,17 @@ fn m4_shebang_edit_keeps_pinned_grammar() {
Some("bash"),
"editing the shebang must not re-switch the pinned grammar"
);
let lsp_language: Option<String> = s
.lua_host
.lua()
.load("return pmacs.lsp.buffer_language(pmacs.window.buffer())")
.eval()
.expect("pinned LSP language");
assert_eq!(
lsp_language.as_deref(),
Some("bash"),
"language-aware consumers must share the shebang pin"
);
// Switch away to another buffer and back: the after-switch reattach
// must reuse the pinned bash grammar rather than re-sniff the (now
@ -6137,6 +6148,322 @@ fn m4_shebang_edit_keeps_pinned_grammar() {
assert_eq!(errs, 0, "reparse with the pinned grammar reports no error");
}
/// Modeline smoke: explicit file metadata overrides a misleading extension,
/// and syntax, LSP language introspection, and initial major mode agree.
#[test]
fn m4_modeline_overrides_extension_end_to_end() {
use pmacs::editor::EditorState;
let mut s = EditorState::new();
let dir = tempfile::tempdir().expect("tempdir");
let file = dir.path().join("misleading.py");
std::fs::write(&file, b"-- -*- mode: lua -*-\nprint('ok')\n").expect("write");
let file_disp = file.display();
s.lua_host
.lua()
.load(format!(
"pmacs.lsp.config = {{}}
pmacs.buffer.find_or_open('{file_disp}')"
))
.exec()
.expect("open modeline fixture");
let (parsed, lsp, mode): (Option<String>, Option<String>, Option<String>) = s
.lua_host
.lua()
.load(
"local b = pmacs.window.buffer()
return pmacs.parse.buffer_language(b),
pmacs.lsp.active_buffer_language(),
pmacs.buffer.major_mode(b)",
)
.eval()
.expect("modeline language surfaces");
assert_eq!(parsed.as_deref(), Some("lua"));
assert_eq!(lsp.as_deref(), Some("lua"));
assert_eq!(mode.as_deref(), Some("lua"));
pump_async(&mut s, |st| {
current_tree_language(st).as_deref() == Some("lua")
});
}
#[test]
fn m4_modeline_parser_matches_supported_emacs_and_vim_forms() {
use pmacs::editor::EditorState;
let s = EditorState::new();
let resolve = |text: &str| -> Option<String> {
s.lua_host
.lua()
.load(format!(
"local b = pmacs.window.buffer()
if b:len() > 0 then b:delete(0, b:len()) end
b:insert(0, {text:?})
return pmacs.parse.language_from_modeline(b)"
))
.eval()
.expect("resolve modeline")
};
for (text, want) in [
("# -*- mode: Python; coding: utf-8 -*-\n", Some("python")),
("-- -*- Lua -*-\n", Some("lua")),
("#!/usr/bin/env python\n# -*- mode: Lua -*-\n", Some("lua")),
("# -*- mode: python; mode: lua -*-\n", Some("lua")),
("vim:ft=python:sw=4:\n", Some("python")),
("# vim: set ft=lua sw=2:\n", Some("lua")),
("# vi:filetype=yaml:et:\n", Some("yaml")),
("# Vim: set filetype=toml:\n", Some("toml")),
("one\ntwo\nthree\nfour\nfive\n# vim:ft=lua:\n", Some("lua")),
("# vim: set ft=python:\r\n", Some("python")),
] {
assert_eq!(resolve(text).as_deref(), want, "{text:?}");
}
for text in [
"plain\n# -*- mode: lua -*-\n", // line 2 needs a shebang
"# Vim:ft=lua:\n", // uppercase marker requires `set`
"# Vim: se ft=lua:\n", // uppercase marker requires literal `set`
"# vim: set sw=4: ft=python\n", // assignment follows the terminator
"# vim: set ft=python\n", // set form needs a terminator
"# vim:ft:python:\n", // colon is an option separator
"# vim: set ft:python :\n", // colon terminates the option section
] {
assert_eq!(resolve(text), None, "{text:?}");
}
}
#[test]
fn m4_modeline_parser_enforces_boundaries_aliases_and_conflicts() {
use pmacs::editor::EditorState;
let s = EditorState::new();
let resolve = |text: &str| -> Option<String> {
s.lua_host
.lua()
.load(format!(
"local b = pmacs.window.buffer()
if b:len() > 0 then b:delete(0, b:len()) end
b:insert(0, {text:?})
return pmacs.parse.language_from_modeline(b)"
))
.eval()
.expect("resolve modeline")
};
for (text, want) in [
("# vim:ft=zsh:\n", "bash"),
("# -*- mode: C++ -*-\n", "cpp"),
("# -*- mode: js2 -*-\n", "javascript"),
("# vim:ft=tsx:\n", "typescriptreact"),
("# vim:ft=docker:\n", "dockerfile"),
] {
assert_eq!(resolve(text).as_deref(), Some(want), "{text:?}");
}
let conflict = "# -*- mode: python; mode: yaml -*-\n2\n3\n4\n5\n# vim:ft=lua:\n";
assert_eq!(
resolve(conflict).as_deref(),
Some("lua"),
"last valid assignment in document order wins across overlapping edges"
);
let middle = "1\n2\n3\n4\n5\n# vim:ft=lua:\n7\n8\n9\n10\n11\n";
assert_eq!(
resolve(middle),
None,
"sixth line from both edges is outside the scan"
);
let partial_with_live_tail =
format!("{}\n# vim:ft=lua:\n1\n2\n3\n4", "x".repeat(8 * 1024 + 64));
assert_eq!(
resolve(&partial_with_live_tail).as_deref(),
Some("lua"),
"discarded suffix fragment does not consume a tail-line slot"
);
let marker_in_partial = format!("{} vim:ft=lua:\n1\n2\n3\n4\n5", "x".repeat(8 * 1024 + 64));
assert_eq!(
resolve(&marker_in_partial),
None,
"modeline in a truncated edge line is ignored"
);
let overlong = format!("# vim:ft={}:\n", "a".repeat(129));
for text in [
"prefixvim:ft=lua:\n".to_owned(),
"# vim:ft=lua!:\n".to_owned(),
overlong,
] {
assert_eq!(resolve(&text), None, "{text:?}");
}
s.lua_host
.lua()
.load("pmacs.parse.modeline_aliases.sh = 'lua'")
.exec()
.expect("override modeline alias");
assert_eq!(resolve("# vim:ft=sh:\n").as_deref(), Some("lua"));
s.lua_host
.lua()
.load("pmacs.parse.modeline_aliases.sh = 'BAD VALUE'")
.exec()
.expect("install invalid modeline alias");
assert_eq!(resolve("# vim:ft=sh:\n"), None);
s.lua_host
.lua()
.load("pmacs.parse.modeline_aliases.sh = 'bash'")
.exec()
.expect("restore modeline alias");
}
#[test]
fn m4_modeline_unknown_mode_is_quiet_and_parser_free() {
use pmacs::editor::EditorState;
let s = EditorState::new();
let dir = tempfile::tempdir().expect("tempdir");
let file = dir.path().join("notes.txt");
std::fs::write(&file, b"# vim:ft=prose:\nhello\n").expect("write");
let file_disp = file.display();
let (mode, language, has_view, errors): (Option<String>, Option<String>, bool, i64) = s
.lua_host
.lua()
.load(format!(
"pmacs.lsp.config = {{}}
_G.__modeline_errors = {{}}
local real_error = pmacs.error
pmacs.error = function(message)
table.insert(_G.__modeline_errors, message)
end
local b = pmacs.buffer.find_or_open('{file_disp}')
local mode = pmacs.buffer.major_mode(b)
local language = pmacs.parse.buffer_language(b)
local has_view = pmacs.parse._has_view(b)
local errors = #_G.__modeline_errors
pmacs.error = real_error
return mode, language, has_view, errors"
))
.eval()
.expect("open unknown modeline mode");
assert_eq!(mode.as_deref(), Some("prose"));
assert_eq!(language.as_deref(), Some("prose"));
assert!(!has_view, "unknown modeline must not dispatch a parser");
assert_eq!(errors, 0, "unknown modeline must not report an error");
}
#[test]
fn m4_modeline_language_is_pinned_until_reopen() {
use pmacs::editor::EditorState;
let mut s = EditorState::new();
let dir = tempfile::tempdir().expect("tempdir");
let file = dir.path().join("mutable.txt");
let other = dir.path().join("other.txt");
std::fs::write(&file, b"-- -*- mode: lua -*-\nprint('one')\n").expect("write");
std::fs::write(&other, b"other\n").expect("write other");
let file_disp = file.display();
let other_disp = other.display();
s.lua_host
.lua()
.load(format!(
"pmacs.lsp.config = {{}}
_G.MODELINE_BUFFER = pmacs.buffer.find_or_open('{file_disp}')"
))
.exec()
.expect("open mutable modeline fixture");
pump_async(&mut s, |st| {
current_tree_language(st).as_deref() == Some("lua")
});
let (language, mode): (Option<String>, Option<String>) = s
.lua_host
.lua()
.load(
"local b = MODELINE_BUFFER
local text = b:slice(0, b:len())
local start = assert(text:find('lua', 1, true)) - 1
b:replace(start, start + 3, 'python')
pmacs.hook.run('buffer.after-edit')
return pmacs.lsp.buffer_language(b), pmacs.buffer.major_mode(b)",
)
.eval()
.expect("edit loaded modeline");
assert_eq!(language.as_deref(), Some("lua"));
assert_eq!(mode.as_deref(), Some("lua"));
for _ in 0..64 {
s.tick_async();
std::thread::sleep(Duration::from_millis(2));
}
assert_eq!(current_tree_language(&s).as_deref(), Some("lua"));
let (language, mode): (Option<String>, Option<String>) = s
.lua_host
.lua()
.load(format!(
"pmacs.buffer.set_major_mode(MODELINE_BUFFER, 'markdown')
pmacs.buffer.find_or_open('{other_disp}')
pmacs.window.switch_buffer(MODELINE_BUFFER)
return pmacs.lsp.buffer_language(MODELINE_BUFFER),
pmacs.buffer.major_mode(MODELINE_BUFFER)"
))
.eval()
.expect("switch with explicit major-mode override");
assert_eq!(language.as_deref(), Some("lua"));
assert_eq!(mode.as_deref(), Some("markdown"));
s.lua_host
.lua()
.load(format!("pmacs.buffer.find_or_open('{other_disp}')"))
.exec()
.expect("switch away before removing old buffer");
std::fs::write(&file, b"# -*- mode: python -*-\nprint('two')\n").expect("rewrite");
let (new_id, language, mode): (String, Option<String>, Option<String>) = s
.lua_host
.lua()
.load(format!(
"local old = MODELINE_BUFFER
pmacs.buffer.remove(old)
local reopened = pmacs.buffer.find_or_open('{file_disp}')
return tostring(reopened), pmacs.lsp.buffer_language(reopened),
pmacs.buffer.major_mode(reopened)"
))
.eval()
.expect("reopen changed modeline fixture");
assert_ne!(
new_id,
s.lua_host
.lua()
.load("return tostring(MODELINE_BUFFER)")
.eval::<String>()
.unwrap(),
"reopen must allocate a fresh buffer id"
);
assert_eq!(language.as_deref(), Some("python"));
assert_eq!(mode.as_deref(), Some("python"));
pump_async(&mut s, |st| {
current_tree_language(st).as_deref() == Some("python")
});
}
#[test]
fn m4_modeline_shared_resolver_preserves_pathless_lsp_guard() {
use pmacs::editor::EditorState;
let s = EditorState::new();
let (syntax, lsp): (Option<String>, Option<String>) = s
.lua_host
.lua()
.load(
"local b = pmacs.buffer.create('scratch.lua')
return pmacs.parse.buffer_language(b), pmacs.lsp.buffer_language(b)",
)
.eval()
.expect("resolve pathless language");
assert_eq!(syntax.as_deref(), Some("lua"));
assert_eq!(lsp, None, "LSP requires a backing path");
}
/// Filename detection: `pmacs.parse.language_from_filename` maps a
/// basename (Dockerfile / Makefile / CMakeLists.txt / rc dotfiles) to a
/// language, resolving a full path too, and returns nil for a plain file.