15 KiB
Modeline language detection — side quest
Status: Draft for user review, 2026-07-22. No implementation yet.
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. A candidate line truncated by
the 8 KiB boundary is not 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 optional
set/seand exactft=NAME,ft:NAME,filetype=NAME, orfiletype:NAMEassignments; - ignore all other option tokens rather than interpreting them;
- require a 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",
py = "python",
js = "javascript",
jsx = "javascriptreact",
ts = "typescript",
tsx = "typescriptreact",
yml = "yaml",
makefile = "make",
}
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, truncated candidate, or
unknown language 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.
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. - 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.
- Conflict order: overlapping edge windows are deduplicated and the last valid assignment in document order wins deterministically.
- Alias behavior: seeded aliases map
sh → bash,c++ → cpp, andtsx → typescriptreact; 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.
- Pinned 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.