17 KiB
Modeline language detection — side quest
Status: Revision 2, approved for implementation by the user on 2026-07-22.
No implementation was present at approval.
Base: githubsucks/main at d5d9b9c; protocol v18.
Problem
Language inference currently uses four signals, in order:
- bundled grammar extension;
pmacs.lsp.filetypesextension;- exact basename;
- shebang.
That chain cannot classify a deliberately misleading or extensionless file when
its author supplied editor metadata such as -*- mode: python -*- or
vim: set ft=python:. Mode-system wiring #129 gives the result a persistent
per-buffer home and makes it observable through key dispatch, help, and the
statusline, but no modeline parser exists.
The goal is one bounded, non-executing modeline detector whose result drives the
same initial language used by syntax, LSP, and Buffer.major_mode. This is a
language-detection feature, not a general file-local settings system.
Ground truth
Detection is duplicated today
builtin/runtime/syntax.lua::resolve_active_language and
builtin/runtime/lsp.lua::buffer_language independently implement the same
extension → filetype → filename → shebang chain. They already have small but
important differences:
- syntax uses
buf:name(), preserving historical grammar-by-name behavior for pathless buffers; - LSP requires
buf:path(), because it cannot construct a URI or project root for a pathless buffer; - syntax pins the grammar in
parse_lang_by_bufferafter dispatch, while the public LSP language query re-sniffs mutable shebang text on every call.
Adding the fifth signal to both copies would create a third convention and let
syntax, LSP, mode initialization, auto-pairing, and comment commands disagree.
The implementation must consolidate the actual inference in syntax.lua and
leave only the LSP path-eligibility guard in lsp.lua.
Hook order is already useful
src/editor.rs loads syntax.lua before lsp.lua. Their buffer.after-load
callbacks therefore run in that registration order:
- syntax detects the language, initializes the major mode, and dispatches a grammar when one exists;
- LSP resolves the same buffer language and attaches a server when configured.
The modeline result must be computed and pinned by step 1 so step 2 cannot independently reinterpret the file.
Major mode and parser language have different later lifecycles
At first load, the detected language is the initial major-mode name. Afterward:
- explicit
pmacs.buffer.set_major_modechanges dispatch/statusline only; - syntax and LSP remain attached to their initially selected language;
buffer.after-switchrestores views without re-detecting;- edits do not change the selected grammar.
Modelines follow that same load-time contract. Editing a cookie is not a live
mode or parser switch. Close/reopen re-evaluates it. A future true reload that
fires buffer.after-load re-evaluates it as a fresh load; explicit mode
overrides and clears are not reload-persistent, matching the #129 framing.
Existing Lua reads are sufficient
BufferIdLua exposes byte-length and byte-slice operations. Detection can read
bounded prefix/suffix windows without copying the whole buffer or adding a Rust
binding. The rope is byte-addressed, so a bounded slice does not need UTF-8
boundary repair before Lua pattern matching.
Compatibility references
The supported subset follows the documented, non-evaluating pieces of:
- GNU Emacs, “Specifying File Variables”: https://www.gnu.org/software/emacs/manual/html_node/emacs/Specifying-File-Variables.html
- Vim,
:help modelineand:help 'modelines': https://vimhelp.org/options.txt.html#modeline
Compatibility is deliberately bounded below. pmacs does not become an Emacs
file-variable evaluator or a Vim :set interpreter.
Scope
In scope:
- Emacs
-*- mode: NAME -*-and mode-only-*- NAME -*-cookies; - Vim/Vi
ft=NAMEandfiletype=NAMEmodelines; - first/last-line scanning with fixed byte and line limits;
- canonical aliases for common external filetype names;
- modeline precedence over inferred path/shebang language;
- one shared, pinned per-buffer language decision;
- initial major mode, grammar, LSP, and language-aware Lua consumers agreeing;
- focused parser and end-to-end acceptance on both Lua backends.
Out of scope:
- Emacs
Local Variables:tail blocks; - variables other than Emacs
modeor Vimft/filetype; eval, Vim commands, option mutation, directory-local variables, or project trust prompts;- Vim
ex:markers, version predicates, escaped option values, and combined dotted filetypes; - live re-detection after edits, saves, renames, or buffer switches;
buffer.after-mode-change, minor modes, mode-scoped settings,describe-mode, or session persistence of explicit mode overrides;- a protocol change or frontend-specific work.
Decisions
Q#MD1 — Scan only bounded edge lines
Read at most 8 KiB from each end of the buffer and inspect:
- Emacs: line 1, or line 2 only when line 1 begins with
#!; - Vim/Vi: the first five and last five logical lines, matching Vim's default
'modelines'=5behavior.
When the prefix and suffix overlap, deduplicate lines before parsing. Strip one
trailing \r so CRLF and LF behave identically. Discard the suffix window's
leading fragment when its line begins before the 8 KiB boundary; that fragment
does not count toward the five complete logical lines counted backward from
buffer end. No truncated candidate is parsed. Detection therefore allocates at
most 16 KiB per fresh load, independent of file size, and an adversarial giant
edge line cannot force a whole-buffer copy.
The Emacs 3000-character tail Local Variables: mechanism is a separate parser
with comment-prefix/suffix rules and is excluded.
Q#MD2 — Recognize a conservative syntax subset
Emacs:
- require a complete pair of
-*-delimiters on the eligible line; - accept
mode: NAMEin a semicolon-separated property list; - accept a mode-only payload such as
-*- Lisp -*-; - ignore every property except
mode; - when a cookie contains multiple valid
modeproperties, the last wins.
Vim/Vi:
- accept
vim:,vi:, andVim:at line start or preceded by ASCII space or tab;Vim:requires thesetform, matching Vim; - accept only exact
ft=NAMEandfiletype=NAMEassignments;ft:NAMEandfiletype:NAMEare not modeline assignment forms and are rejected; - in the direct form, split option tokens on ASCII whitespace and
:, so the commonvim:ft=python:sw=4:form yieldsft=python; - in the
set/seform, end the option section at the first:and split only the preceding text on ASCII whitespace;vim: set sw=4: ft=pythontherefore contains no live filetype assignment; - ignore all other live option tokens rather than interpreting them;
- require that terminating colon for the
set/seform, so a comment suffix is never consumed as an option value; - reject
ex:, Vim version predicates, and marker substrings embedded in a word.
Across all eligible lines, the last valid mode assignment in document order wins. This matches Emacs's “final defined mode” behavior and ordinary sequential option assignment. A footer Vim modeline can intentionally override a header Emacs cookie; conflicting metadata does not depend on Lua table iteration.
Q#MD3 — Modeline names are data, never code
Trim ASCII edge whitespace, ASCII-lowercase the name, require
[a-z0-9][a-z0-9+_-]*, and cap it at 128 bytes. Empty, non-ASCII, control,
whitespace-containing, or overlong values are ignored silently.
The restriction is intentionally narrower than pmacs.buffer.set_major_mode,
which continues accepting arbitrary Lua strings for trusted configuration.
Untrusted file content receives no path to control characters, huge statusline
values, Lua evaluation, or option mutation.
Q#MD4 — Normalize common external names through one alias table
Expose a user-extensible Lua table:
pmacs.parse.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",
}
A normalized name absent from the table passes through unchanged. Users may
add, replace, or remove aliases in init.lua; defaults use or-style seeding
so preconfigured entries are not overwritten. Alias outputs must satisfy the
same 128-byte token rule before use.
This keeps canonical pmacs names stable without pretending that extension maps
and editor-mode names are the same namespace. Do not strip a trailing -mode:
GNU Emacs explicitly specifies the value without that suffix, and silent
stripping would make custom names ambiguous.
Q#MD5 — Explicit modelines override inference
The final fresh-load order is:
- modeline;
- bundled grammar extension;
pmacs.lsp.filetypesextension;- exact basename;
- shebang.
A modeline is explicit file metadata; the remaining signals are inference. Thus
a template.txt containing vim: set ft=python: selects python, and a
misnamed .py file containing -*- mode: lua -*- selects lua consistently
for mode, parser, and LSP.
This interprets the backlog's “fifth layer after extension → filetype → filename → shebang” as a layer added after that work, not as a lowest-priority fallback. Making explicit metadata lose to a suffix would defeat the feature's primary use case.
Q#MD6 — One resolver owns the effective language
syntax.lua owns:
pmacs.parse.language_from_modeline(buf): parse current content and return the normalized modeline language or nil;- the private fresh inference chain;
pmacs.parse.buffer_language(buf): return the language pinned for this buffer's current load, resolving once only for an unseen buffer.
The syntax buffer.after-load path forces a fresh inference, records either the
language or an explicit “resolved none” sentinel, then uses that same value for
mode initialization and grammar dispatch. buffer.after-switch consumes the
pin and resolves only the pre-existing hidden-buffer case that never received
an after-load event.
pmacs.lsp.buffer_language(buf) keeps its current path requirement, then
delegates to pmacs.parse.buffer_language(buf). The active-buffer wrapper stays
unchanged. This preserves pathless LSP behavior while deleting the duplicate
extension/filetype/filename/shebang chain.
The pin also closes an existing shebang inconsistency: editing #!/bin/sh to
#!/usr/bin/env lua no longer leaves a bash parse tree while making later
auto-pair/comment queries report Lua. Raw parser tests may call
language_from_modeline; behavior-driving consumers use the pin.
Q#MD7 — Initial mode, syntax, and LSP share one value
On buffer.after-load, set the buffer's major mode to the freshly resolved
language, including nil when no signal resolves. This replaces the current
“only if nil” guard and makes the already-documented reload contract exact:
a fresh after-load decision replaces an earlier explicit override or clear.
Then:
- dispatch a grammar only if
pmacs.parse._has_language(lang); - let LSP attach only if the same language has a configured server and a real file path;
- retain a valid unknown language as the major mode, while silently skipping grammar/LSP attachment.
After load, explicit set_major_mode remains independent: it immediately
changes mode key dispatch and statusline display but does not rewrite the
pinned parser/LSP language. Switches preserve both values.
Q#MD8 — Malformed or unsupported metadata is fail-closed and quiet
A malformed marker, invalid name, unsupported form, or truncated candidate
never raises from buffer.after-load and never emits an *errors* entry. The
detector returns nil and the existing inference chain continues.
A syntactically valid unknown name is different: it is a legitimate major mode
and is pinned, but _has_language and LSP config gates prevent a bogus parser
or server launch. This preserves #129's custom-mode capability without
executing file content.
A file can already select a configured language—and therefore which LSP server pmacs starts—through its extension or a content-sniffed shebang. Modelines add another bounded language selector within that existing capability class; they do not introduce automatic execution beyond what current language detection already permits.
No enable/disable setting is added. The supported input can only select a
bounded string already consumed as passive mode/language identity; it cannot
run hooks or set options. If a future buffer.after-mode-change hook makes mode
selection executable, modeline trust must be revisited in that feature's
framing.
Q#MD9 — No Rust or protocol surface is required
Expected implementation touch set:
builtin/runtime/syntax.lua— bounded parser, aliases, shared resolver, pin, and after-load initialization;builtin/runtime/lsp.lua— delegate language inference while retaining the path guard;tests/m4_acceptance.rs— parser, precedence, lifecycle, and end-to-end regression coverage;- this framing and the side-quest/handoff state documents when the feature lands.
No changes are expected in src/buffer.rs, Lua bindings, frontends,
pmacs-protocol, or protocol version 18.
Bets
- Sixteen KiB of edge text is sufficient. Real modelines are short; files with an 8 KiB first/last candidate line are better treated as malformed than copied wholesale during load.
- One canonical language should drive mode, parser, and LSP initially. A future distinction between editor mode and parser language needs a real consumer and an explicit mapping contract, not accidental divergence.
- ASCII-lowercased identifiers cover interoperable modelines. Trusted Lua remains available for arbitrary UTF-8 custom mode names.
- Load-time detection is enough. Live cookie edits would require orderly
parser teardown, LSP
didClose/didOpen, overlay replacement, mode-change notification, and failure rollback; that is not a one-shot detector. - Pathless LSP buffers stay ineligible. Syntax may still infer from a buffer name, but spawning a server without a URI/project root remains wrong.
Acceptance
All end-to-end fixtures clear pmacs.lsp.config unless the case intentionally
observes server selection, so opening a test file never starts a machine-local
language server.
- Emacs forms: first-line property and shorthand cookies resolve; a cookie
on line 2 resolves only after a shebang; unrelated properties are ignored;
the last
modeproperty wins. - Vim forms:
vim:/vi:direct andsetforms resolveftandfiletypein the first/last five lines; CRLF works;Vim:requiresset. The directvim:ft=sh:et:sw=2:form resolvessh, whilevim: set sw=4: ft=pythonignores the assignment after the terminating colon.ft:pythonandfiletype:pythonresolve nothing in either form. - Boundary rejection: sixth-line, sixth-from-end, middle-of-file, word-embedded, unterminated, truncated, invalid-character, and overlong candidates do not resolve and do not log errors. A partial line at the start of the suffix byte window is discarded without consuming one of the five complete tail-line slots.
- Conflict order: overlapping edge windows are deduplicated and the last valid assignment in document order wins deterministically.
- Alias behavior: seeded aliases map
sh/zsh → bash,c++ → cpp,js2 → javascript,tsx → typescriptreact, anddocker → dockerfile; a user override wins; an invalid alias output is ignored. - Explicit precedence: a
.pyfile with a Lua modeline yieldsluafrompmacs.parse.buffer_language,pmacs.lsp.active_buffer_language, the parse tree, andpmacs.buffer.major_mode. - Unknown valid mode: a
.txtfile withmode: prosereceives major modeprose, creates no parse view, starts no LSP server, and produces no error. - No-modeline regression: extension, filetype, filename, and shebang cases retain their present precedence and outputs.
- Shebang pin regression: open an extensionless
#!/bin/shfile, replace its shebang with#!/usr/bin/env lua, and assert the parse tree andpmacs.lsp.buffer_languageboth remainbash. - Pinned modeline lifecycle: changing a loaded modeline does not change the pinned language, parser, or major mode; switch-away/back remains stable; close and reopen re-evaluates the changed on-disk cookie.
- Explicit override independence:
set_major_modeafter load changes dispatch/statusline but notpmacs.parse.buffer_language; switches preserve the override. - Pathless preservation: syntax-by-buffer-name behavior remains, while
pmacs.lsp.buffer_languagestill returns nil without a backing path. - Backend parity: focused acceptance passes with default LuaJIT and
--no-default-features --features lua54; protocol remains v18.