Modernizing My Emacs Config for Emacs 31
My Emacs config was a list of use-package blocks I had added over several years. But I still needed a couple of things from it: a faster startup and newer features, maybe. My first question was whether I needed to move to a newer Emacs. I audited the repo, and the first finding answered that question. Thanks to Homebrew, I was already running the latest release:
$ emacs --version
GNU Emacs 31.1
The config was written against older Emacs releases, though, and Homebrew's emacs bottle was built without native compilation:
(native-comp-available-p) ; => nil
So the work was to update the config for the Emacs I already had, then try a build with native compilation.
Bugs that had been there for years#
The audit loaded my init file in a batch Emacs against a copy of my elpa directory, with package installs stubbed out. Init finished without errors, but several blocks did nothing.
One block used :custom as if it were :config:
(use-package web-mode
:ensure t
:custom
(setq web-mode-css-indent-offset 2)
(setq web-mode-code-indent-offset 2))
:custom takes (variable value) pairs. Macroexpanding the block showed that use-package treated setq as the variable name and customized a variable called setq. My web-mode indent offsets, auto-pairing, and highlighting settings had never applied. The same mistake meant rubocop-mode was never added to ruby-mode-hook. The fix was to use :custom the way it is documented:
(use-package web-mode
:ensure t
:mode ("\\.erb\\'" "\\.html?\\'")
:custom
(web-mode-css-indent-offset 2)
(web-mode-code-indent-offset 2))
Other findings:
(setq tab-width 2)at the top level of init only sets the value in the buffer that is current while init runs. The default stayed at 8. It neededsetq-default.- I had my own
duplicate-linedefun. Emacs 29 added a built-induplicate-linethat does not touch the kill ring, and my version was replacing it. I deleted mine and boundC-c dto the built-in. - projectile-rails, lsp-treemacs, and which-key each had two
use-packageblocks. - My rspec-mode
:hookform was malformed, socompilation-filter-hookended up holdingrspec-modeinstead ofinf-ruby-auto-enter-and-focus. - I required a vendored
jsonrpc.elfrom~/.emacs.d/lisp, a directory that no longer existed. Emacs had been loading its built-in jsonrpc instead.
The iTerm copy mode problem#
While this was going on, I noticed that iTerm2 copy and paste stopped working normally whenever Emacs was open. Mouse selection behaved as if a copy mode had turned on. The cause was an Emacs 31 change, not iTerm. From the Emacs 31 NEWS file:
In compatible terminals, 'xterm-mouse-mode' is turned on by default. [...]
With 'xterm-mouse-mode' enabled, you must use Emacs keybindings to copy to
the OS selection instead of terminal-specific keybindings.
You can keep the old behavior by customizing 'xterm-mouse-mode' to nil.
Calling the mode function from the init file sets xterm-mouse-mode-called, and the terminal setup in term/xterm.el only turns the mode on when that variable is nil:
(xterm-mouse-mode -1)
Startup time#
I measured a baseline with M-x emacs-init-time: 0.763 seconds. The message disappears from the echo area quickly, but it is also saved in the *Messages* buffer.
Timing each package's require in a batch Emacs showed which ones loaded at startup even though nothing used them yet:
| package | require time |
|---|---|
| flycheck | 0.123s |
| magit | 0.116s |
| org-roam | 0.106s |
| projectile-rails | 0.041s |
The changes:
-
Moved from
~/.emacsto~/.emacs.d/init.eland added anearly-init.el. Emacs loadsearly-init.elbefore the package system and the first frame. I raised the garbage collection threshold there for the length of startup and set it back once startup finishes:(defconst gd-gc-cons-threshold-after-startup (* 100 1024 1024)) (setq gc-cons-threshold most-positive-fixnum) (add-hook 'emacs-startup-hook (lambda () (setq gc-cons-threshold gd-gc-cons-threshold-after-startup))) (setq package-quickstart t)Before this change, the 100MB threshold was set inside lsp-mode's
:initblock, partway through init. -
Removed
package-initializeandpackage-refresh-contents. Emacs has activated packages before loading the init file since Emacs 27. use-package's:ensurerefreshes the archive contents itself when it needs to install something. -
Turned on
package-quickstart. It combines the autoloads of every installed package into one precompiled file, so Emacs loads that one file instead of one autoloads file per package. Emacs regenerates it after package installs. I generated the first one withM-x package-quickstart-refresh. -
Deferred packages until they were needed. magit and org-roam got
:defer t, since their commands are autoloaded. flycheck moved fromglobal-flycheck-modeat startup to:hook (prog-mode . flycheck-mode). projectile-rails switched to turning on fromfind-file-hook.
I did not move anything to after-init-hook. startup.el records after-init-time before it runs after-init-hook, so loading a package from that hook takes it out of the emacs-init-time number without making Emacs ready any sooner.
The result was 0.249 seconds, down from 0.763.
Native compilation, an unexpected GUI, and a revert#
With the config cleaned up, I wanted an Emacs 31 build with native compilation. The emacs-plus tap built emacs-plus@31 with --with-native-compilation=aot by default:
brew trust d12frosted/emacs-plus
brew tap d12frosted/emacs-plus
brew uninstall emacs
brew install emacs-plus@31
The build took under two minutes on my M5, and native-comp-available-p returned t. Then I typed emacs and got a GUI window. I have used Emacs in a terminal for a long time and did not want that. emacs-plus installed bin/emacs as a small bash script that execs Emacs.app/Contents/MacOS/Emacs, and a Cocoa build opens a window by default.
The formula had a without-cocoa option that builds a terminal-only Emacs. Native compilation was configured separately from that option, so it stayed on:
brew uninstall emacs-plus@31
brew install emacs-plus@31 --without-cocoa
After the first few sessions, once Emacs had compiled each package to native code in the background, I measured again. emacs-init-time reported 0.867 seconds, slower than the 0.249 I had with the plain Homebrew install.
To isolate the cause, I timed init in a batch Emacs with the same config and the same compiled cache, changing only the load-no-native variable, which makes Emacs load .elc byte code instead of .eln native code:
| loading | init time, three runs |
|---|---|
.eln native code |
0.403s, 0.405s, 0.419s |
.elc byte code |
0.274s, 0.268s, 0.261s |
Two other details showed up in that build. native-comp-eln-load-path pointed at ~/workspace/native-lisp/, a path built from my current directory, instead of the installed lib/emacs/31.1/native-lisp/. Since Emacs could not find its precompiled files there, it recompiled about 100 built-in libraries into ~/.emacs.d/eln-cache. And the Lisp preloaded into the Emacs dump was byte code. normal-top-level was not a native function, even with --with-native-compilation=aot.
Slower startup could still be acceptable if runtime were faster, so I benchmarked that too, with native code and with byte code (times in seconds):
| work | native | byte code |
|---|---|---|
| fontify lsp-mode.el (10,304 lines) ×5 | 0.415 | 0.414 |
| indent lsp-mode.el ×3 | 0.165 | 0.164 |
| fontify a 525-line RSpec file ×5 | 0.040 | 0.040 |
| flex completion over every symbol ×5 | 0.028 | 0.029 |
| hash table and cl-lib loop, 300k keys ×3 | 0.522 | 0.545 |
Font-lock, indentation, and minibuffer completion are part of the preloaded dump, which was byte code in this build, so native compilation did not reach them. The only difference was about 4% on a plain Lisp loop.
I uninstalled emacs-plus and went back to the Homebrew install:
brew uninstall emacs-plus@31
brew install emacs
A build with a native dump might give a different result, but this one made startup slower and runtime about the same.
Emacs 31 built-ins#
Next I replaced packages with what Emacs 31 ships.
Tree-sitter. One setting turns on every tree-sitter major mode Emacs knows about:
(use-package treesit
:custom
(treesit-enabled-modes t))
Ruby, Rust, YAML, CSS, and TypeScript files then opened in ruby-ts-mode, rust-ts-mode, and the rest. The new treesit-auto-install-grammar option defaults to ask, so the first time Emacs opens a file in one of these modes, it asks to download and compile that language's grammar into ~/.emacs.d/tree-sitter. That needs git and a C compiler.
My Ruby hooks were on ruby-mode-hook, and ruby-ts-mode does not run that hook. Both modes derive from ruby-base-mode, so I moved robe, rubocop, and inf-ruby to ruby-base-mode-hook.
eglot and flymake. eglot replaced lsp-mode, lsp-ivy, and lsp-treemacs. flymake replaced flycheck.
(use-package eglot
:hook ((ruby-base-mode rust-ts-mode c-ts-mode c++-ts-mode swift-mode) . eglot-ensure))
(use-package flymake
:hook (prog-mode . flymake-mode))
which-key and editorconfig ship with Emacs 30 and later, so I removed their :ensure t.
Two problems came up.
The first was [eglot] -1: Server died when I opened a file in one of my Ruby projects. I reproduced it in a batch Emacs and read the server's stderr buffer:
rbenv: version `3.4.6' is not installed (set by .../console-blackjack-ruby/.ruby-version)
After rbenv install 3.4.6, the server exited with status 127 instead. The rbenv shim for ruby-lsp exists when any installed Ruby has the gem, and 3.4.6 did not have it yet. gem install ruby-lsp under 3.4.6 fixed it.
The second was CSS files opening in fundamental-mode. The error was Face inheritance results in inheritance cycle: gnus-group-news-low-empty. css-mode requires eww, eww requires gnus, and the gnus in Emacs 31 declares gnus-group-news-low with :inherit gnus-group-news-low-empty. The doom-themes release I had installed declared the reverse:
(gnus-group-news-low-empty :inherit 'gnus-group-news-low :weight 'normal)
Newer doom-themes source inherited from gnus-group-mail-1-empty instead. Upgrading doom-themes to 20260826.1413 fixed my CSS problem.
Completion#
vertico, orderless, and consult replaced ivy, counsel, and smex. corfu replaced company. marginalia was already in my config, and it was written for this set of packages. embark acts on the thing at point or the current completion candidate.
Emacs 31 supports child frames on terminal frames, and corfu uses them for its popup, so it works in iTerm2 without the corfu-terminal package. embark's README suggests C-., but terminals do not send that key, so I bound embark-act to C-c a. robe still provided Ruby completion through robe-complete-at-point, which corfu picked up.
Icons in a terminal#
My iTerm2's profile font is MesloLGS NF, a Nerd Font, so nerd-icons glyphs rendered. I turned on icons in centaur-tabs, in the dashboard, and in completion candidates with nerd-icons-completion. The dashboard needed one extra setting, because dashboard-display-icons-p defaults to display-graphic-p:
(setq dashboard-display-icons-p t)
A literate config#
I converted the config to an Org file, config.org, with each part under a heading. Saving it in Emacs writes init.el and early-init.el with org-babel-tangle:
(defun gd-tangle-config-on-save ()
"Write init.el and early-init.el when config.org is saved."
(when (and buffer-file-name
(string= (file-truename buffer-file-name) gd-config-org-file))
(org-babel-tangle)))
(add-hook 'org-mode-hook
(lambda () (add-hook 'after-save-hook #'gd-tangle-config-on-save nil t)))
Emacs still started from the generated init.el, so Org did not load at startup and startup time did not change. Loading Org from init.el with org-babel-load-file would have added Org's load time to every start.
Emacs writes Customize settings, including package-selected-packages after each install, to custom-file. I pointed that at a separate custom.el so Emacs would never edit the generated init.el. Before switching, I loaded the old and the generated init.el in batch and compared tab width, modes, faces, 14 key bindings, the theme, and the disabled commands. They all matched.
Old code brought back#
My config had a section of commented-out code. I turned it back on, with newer replacements where they existed:
- tempel replaced yasnippet. Its templates showed up as completion candidates in corfu.
- SLY replaced plain
inferior-lispfor SBCL. - My hand-written IELM history code went away. Emacs 30 added
ielm-history-file-name, which saves IELM history by default. - My cc-mode C style moved to
c-ts-mode, sincec-modewas remapped to it.
The C style was a bit of extra work. I wanted the linux style with case labels indented one level. c-ts-mode-indent-style accepts a function, and its docstring says the returned rules do not need the language symbol. The code assigns the return value directly to treesit-simple-indent-rules, which does need it, and my first version failed with wrong-type-argument listp standalone-parent. The working version returns ((c . RULES)):
(defun gd-c-ts-linux-style-with-indented-case ()
"Linux indent rules for c-ts-mode with case labels indented one level."
(let* ((language (if (derived-mode-p 'c++-ts-mode) 'cpp 'c))
(rules (alist-get language (c-ts-mode--simple-indent-rules language 'linux))))
`((,language ((node-is "case") standalone-parent c-ts-mode-indent-offset)
,@rules))))
Languages from the rest of my workspace#
Last, I counted file extensions across my ~/workspace, skipping dependency and build directories, and compared them with what my config at that point handled. Several file types had no mode:
| Files | Type | Added |
|---|---|---|
| 6,991 | Markdown | markdown-ts-mode, which ships with Emacs 31 without a file mapping |
| 640 | HAML | haml-mode |
| 324 | AsciiDoc | adoc-mode |
| 307 | Zig | zig-ts-mode |
| 115 | GDScript | gdscript-mode |
| 96 | Kotlin | kotlin-ts-mode |
| 72 | Swift | swift-mode |
| 18 | Clojure | clojure-ts-mode and cider |
Two of these needed more than :ensure t. kotlin-ts-mode registers neither a file mapping nor a grammar source, so I added both from its README. cider depends on clojure-mode, which claims .clj files, so clojure-ts-mode needed its own :mode entries.
clangd and sourcekit-lsp were already installed, so eglot started for C, C++, and Swift too. eglot has no default server entry for Swift, so I added one:
(add-to-list 'eglot-server-programs '(swift-mode . ("sourcekit-lsp")))
Go, Python, and Java already opened in tree-sitter modes, but I did not have gopls, a Python language server, or jdtls installed, so eglot did not start for them.
Startup stayed at about 0.25 seconds. My config is at GitHub if you want to take a look.