Essay

An Emacs 31 Configuration That Never Writes Into Itself

September 19, 20268 min read

An Emacs configuration built from an empty directory for LaTeX, Org and hardware work, with every generated file kept out of the repository and a test suite that fails on a warning.

Most of what I write is long technical prose with math in it: LaTeX books, Org notes, the occasional Markdown article. Most of the code I read is hardware description and assembly. I wanted one editor for all of it, and I wanted to understand every line of how that editor was set up. So I wrote an Emacs 31 configuration starting from an empty directory.

This post covers the decisions that turned out to matter. None of them is about which theme to use.

01.Why not start from a framework

Emacs frameworks such as Doom Emacs bundle hundreds of packages behind a module system. A module is a folder of Emacs Lisp that switches on one feature, such as LaTeX support or Git integration. The obvious shortcut is to copy the few modules you like into your own setup.

That shortcut fails, and the way it fails is worth knowing. Doom's modules are written against Doom's own macros. One module file can call use-package!, after!, map! and modulep!, and none of them exist in plain Emacs. A missing macro is at least loud: Emacs stops with an error the moment it meets one. The trouble starts if you load Doom's core library to get the macros. modulep! answers the question "is this other module enabled?" by reading state that only Doom's own build step produces. Without that state it answers no, every time. Every branch it guards then disappears without an error, and the copied module looks as if it loaded while quietly doing nothing.

So Doom contributed ideas and a handful of specific settings, never files. One small function did cross over, rewritten against plain Emacs, and it is credited in the section on LaTeX below.

02.Nothing generated lands in the configuration

Emacs writes a surprising number of files as you use it. The package manager downloads into a directory. Native compilation, which turns Emacs Lisp into machine code so it runs faster, leaves a cache. Minibuffer history, recently opened files, bookmarks, backups and auto-saves each get a file of their own. By default all of them go into the configuration directory, next to the code you wrote by hand.

Some of that is harmless clutter. Some of it is not. A finished game of M-x tetris writes a score file that records the player's full name and email address beside the score. If the configuration directory is a public Git repository, a moment of procrastination becomes a commit containing personal data.

Lock files are subtler. When you start modifying a file, Emacs creates a symbolic link beside it, named .# followed by the file's name, so that a second Emacs about to edit the same file can warn you. Edit init.el and a lock file appears inside the configuration.

The fix is to send every one of these somewhere else. This configuration puts them under ~/.local/state/emacs. That is where the XDG Base Directory specification, a convention from the Linux desktop that many command-line tools on macOS also follow, says a program's per-user state belongs. Each redirect is one line in this style:

Elisp
(setq ielm-history-file-name (ach/state "ielm-history.eld")
gamegrid-user-score-file-directory (ach/state "games/")
diary-file (ach/state "diary")
lock-file-name-transforms `((".*" ,(ach/state "lock/") t)))

ach/state is a three-line helper that joins a name onto the state directory. A package called no-littering does all of this automatically, and I chose not to use it. An automatic redirect is invisible, and when something does leak I want to find the one line that should have caught it.

Writing the redirects by hand has an obvious weakness: you can only redirect what you know about. Two things cover the gap. The .gitignore ignores everything and then names the hand-written files one by one, so a file nobody expected stays out of Git instead of being committed by accident. And a test, described next, asks Emacs itself where each library intends to write.

03.A test suite that fails on warnings

Mistakes in an Emacs configuration are easy to miss. The use-package macro, which most configurations use to install and set up each package, catches an error inside a package's setup and reports it as a warning. Emacs still starts. The warning lands in a buffer nobody opens, and the feature is simply absent.

This configuration therefore carries an ERT suite. ERT is the test framework built into Emacs. The suite loads everything the way a real start does and fails the run on any warning, as well as on any failed assertion.

Two of its tests do the containment work. The first loads a list of built-in libraries that the configuration never touches, then walks every customizable variable Emacs knows about and flags any whose value is a path inside the configuration directory. It is the test that fails on the day a library starts writing somewhere it should not. The second asks Emacs where the lock file for init.el would go, and checks that the answer is inside the state directory.

A wrapper script runs the suite against a throwaway state directory, so a test run never rewrites the history or bookmarks of the Emacs I am actually using. Afterwards it lists anything that appeared in the configuration directory. That includes empty directories, which Git cannot see at all, and any tracked file whose modification time moved while the tests ran.

04.Twelve menus and no more

Emacs reserves C-c followed by a plain letter for the user's own commands. Every personal command here sits under one of twelve such prefixes: files, buffers, search, Git, code, errors, notes, Org, projects, workspaces, toggles and help. The which-key package shows what each menu holds after a short pause, with an icon beside every entry.

Twelve is a closed set, and a test enforces it. Packages like to claim a C-c key for themselves when they load. The tabspaces workspace package, for instance, binds C-c TAB unless it is told not to, and a thirteenth menu arriving that way fails the suite. Another test reads the key hints printed at the foot of the start screen and fails if any of them names a key that nothing is bound to.

05.Languages through tree-sitter

Emacs 31 can parse source code with tree-sitter, a library that builds a real syntax tree instead of guessing at structure with regular expressions. Each language needs a grammar, which is a small compiled parser. This configuration tells Emacs to build a grammar the first time a file in that language opens, and to switch to the tree-sitter version of each editing mode whenever one exists.

Language servers are separate programs that answer questions such as "where is this defined?" and "what does this instruction do?". They connect through Eglot, which ships with Emacs. VHDL goes to vhdl_ls, SystemVerilog to Verible, and assembly to asm-lsp.

asm-lsp taught me the most. With no configuration file in a project, it assumes GNU assembler syntax and the architecture of the machine it is running on. On an Apple-silicon Mac, that architecture is ARM64. Open an x86-64 file written for NASM, hover over mov, and you get the ARM64 documentation for mov, with no error and no hint that anything is wrong. A GNU-syntax x86-64 file fares differently: it gets no documentation at all. The cure is a .asm-lsp.toml in each project that names the assembler and the instruction set.

06.Indenting a LaTeX list

AUCTeX is the LaTeX mode for Emacs. This configuration has it indent \item like any other line inside a list, four columns in from the \begin. Left to itself, AUCTeX then puts a wrapped line at the same column as the \item, so the continuation sits under the backslash rather than under the text it continues:

LaTeX
\begin{itemize}
\item The first line of a long item
that wraps here.
\end{itemize}

No AUCTeX setting changes that. The variable that sounds right, LaTeX-item-indent, moves the \item line and nothing else. The fix computes the column directly, adding the six characters of \item so the continuation lands under the first letter of the item's text:

LaTeX
\begin{itemize}
\item The first line of a long item
that wraps here.
\end{itemize}

A list nested inside an item starts from that same column, and refilling a paragraph with M-q follows the same rule. The function is a port of +latex-indent-item-fn from Doom Emacs, copyright Henrik Lissner and released under the MIT licence. Only the names changed, and the list of environments became a constant.

07.What a batch run cannot see

Everything above is checked by tests that run without a window. What they cannot check is anything that only exists on screen: whether an icon renders in the right font, whether a math preview in an Org buffer is legible against the theme, whether a PDF opens beside its LaTeX source at a sensible width. Those I still check by eye, and they are the next thing worth automating.

FeedbackBook mode
emacslisptoolsproductivity