This repository is mostly an Emacs config.
Prefer to write plans in the plans/ directory.
After modifying any init/*.el files, always run the native compilation check
to verify your work:
./scripts/native-comp-all.sh
This catches syntax errors, invalid face references, unbalanced parens, obsolete functions, and other issues that only surface during byte/native compilation. Do not consider a change complete until this script passes cleanly.
A full Emacs source tree is checked out at extra/emacs/. This is useful for
looking up face definitions, understanding built-in mode internals, or checking
how upstream Emacs implements specific features. This directory is read-only
reference material and is not loaded by the running Emacs instance. Never modify
files under extra/emacs/; instead, use advice, function overrides, or other
mechanisms in init/*.el to change behavior.
Here are some strategies to obtain a reliable “dev loop” for validating formatter integrations.
To check a function's arguments or documentation without starting interactive Emacs:
- Get argument list (using
eglotas an example):emacs -Q --batch --eval "(require 'eglot)" --eval "(princ (help-function-arglist 'eglot))"
- Get function documentation (using
eglotas an example):emacs -Q --batch --eval "(require 'eglot)" --eval "(princ (documentation 'eglot))"
- Get variable documentation (using
eglot-server-programsas an example):emacs -Q --batch --eval "(require 'eglot)" --eval "(princ (documentation-property 'eglot-server-programs 'variable-documentation))"
- Check parentheses balance after edits:
emacs -Q --batch --eval '(progn (with-temp-buffer (insert-file-contents "init/shared-init.el") (check-parens)))'
Install all packages from a fresh or existing ~/.emacs.d/elpa:
emacs -Q --batch -l scripts/install-packages.el
This script:
- Installs missing packages (both ELPA and
:vc) - Reinstalls
:vcpackages whose configured source URL changed - Fixes any VC packages stuck on detached HEAD (from old
:last-releaseinstalls) by checking out the default branch - Runs
package-vc-upgrade-allto pull latest commits for all VC packages - Cleans stale
.elcfiles from upgraded VC packages
For a completely fresh install, move ~/.emacs.d/elpa aside first.
Run native compilation on all config files to catch warnings and errors:
./scripts/native-comp-all.sh
vcupp-native-comp sets vcupp-native-comp-active-p to t and enables
compile-angel-on-load-mode before loading the config, so packages and other
libraries loaded during init are byte-compiled and native-compiled
automatically. It then native-compiles the main config entry files after
loading. The batch wrapper bootstraps vcupp with use-package :vc :demand, so
it also works from a fresh package-user-dir.
Warnings to watch for:
- Obsolete macros/functions (e.g.,
defadvice,when-let,incf) - Unused lexical variables/arguments
- References to free variables (usually fine in
with-eval-after-loadblocks) - Unknown functions (usually fine when using autoloads or
with-eval-after-load) - Recursive require errors (usually from autoloaded
prognblocks that callrequireon themselves during batch load; guard withnoninteractive)
After upgrading VC packages, always re-run this script -- stale .elc files
from compile-angel can cause recursive load errors if the source changed.
Verify that all expected packages are installed, key features are loadable, and byte-compilation succeeded:
emacs --script scripts/test-packages.el
Exits non-zero on failure, printing a summary of passed/failed checks. When adding or removing a package, update the package lists in this script to match.
Measure config load time and deferred-task time:
emacs -Q --batch -l scripts/test-startup-time.el
Prints three timings: config load, deferred tasks, and total. Useful for
catching regressions when changing :defer or :demand settings in use-package
declarations. Not a pass/fail gate -- purely informational.
The user's ~/.emacs.d/early-init.el loads init/early-shared-init.el, and
~/.emacs.d/init.el loads init/shared-init.el.
To run the full user init headlessly:
emacs --batch -l ~/.emacs.d/early-init.el -l ~/.emacs.d/init.el
Note: --batch alone does not load user init files; you must pass them
explicitly with -l. Some errors only occur with a live display (e.g., color
resolution, frame parameters) and cannot be reproduced in --batch mode; ask
the user to run emacs --debug-init and share the backtrace in those cases.
To reproduce display-dependent early-init issues from the CLI (e.g., when
color-values returns nil before the GUI frame is ready), launch GUI Emacs with
an isolated init directory and capture stderr:
- Create a minimal repro under
tmp/, e.g.tmp/repro/early-init.el. - Symlink packages if needed:
ln -s ~/.emacs.d/elpa tmp/repro/elpa - Launch with nohup (macOS example):
EMACS_BIN="/opt/homebrew/Caskroom/emacs-plus-app/30.2-*/Emacs.app/Contents/MacOS/Emacs"nohup $EMACS_BIN --init-directory=tmp/repro --debug-init > tmp/repro/nohup.out 2>&1 &
- Check
tmp/repro/nohup.outfor the backtrace. - To probe state interactively, add
(setq debug-on-error t)to theearly-init.elso that Emacs drops into the debugger in the GUI window instead of just logging to stderr.
After fixing issues in init/*.el files, always recompile native code:
./scripts/native-comp-all.sh
Stale .eln files will keep running old code even after the .el source is
fixed. This is a common gotcha when the user reports "my fix didn't work."
When iterating on a small part of the config (a single function, hook, or
integration), prefer writing a tiny one-off .el file under tmp/ and running
it via emacs -Q --batch. This keeps the feedback loop fast without requiring a
full interactive Emacs session.
-
Example workflow:
- Create
tmp/<topic>-test.elthat loads just what you need (either by copying the relevant forms or by selectivelyload-file-ing a small file). - Run it:
emacs -Q --batch -l tmp/<topic>-test.el
- Print output with
princand exit non-zero with(kill-emacs 1)on failure.
- Create
-
When debugging errors that originate inside a third-party package, read the package source to find the exact function call sequence, then replicate that sequence in a batch harness. This isolates whether the bug is in the package's logic, the user config, or the interaction between them. Compare
emacs --batch -Q(vanilla) againstemacs --batch -l ~/.emacs.d/early-init.el -l ~/.emacs.d/init.el(user config) to narrow the cause.
This config uses several mwolson-authored packages installed via
use-package :vc. The canonical development checkouts live outside this repo.
The preferred workflow is: develop in the ~/devel/projects/ (or ~/vcupp/)
checkout, push changes, then update the submodule or :vc install here. Some
libraries have commented-out add-to-list 'load-path blocks in
init/shared-init.el to make it easy to test the local checkout directly.
| Library | Local checkout | Used in | Local-dev shortcut in shared-init |
|---|---|---|---|
| eglot-python-preset | ~/devel/projects/eglot-python-preset/ |
init/shared-init.el |
yes (line ~1324) |
| eglot-typescript-preset | ~/devel/projects/eglot-typescript-preset/ |
init/shared-init.el |
yes (line ~1119) |
| vcupp | ~/devel/projects/vcupp/ |
init/early-shared-init.el |
no |
Other mwolson projects in ~/devel/projects/ that are not currently used in
this config: gptel-fn-complete.
When working with JSON serialization issues (especially with LSP configurations):
-
Test individual components: Isolate problematic parts by testing JSON serialization of individual plists and values to identify the exact issue.
-
Use plists for JSON objects:
json-serializecan't handle lists containing other lists, but can handle plists:;; This fails: :args '("my-key-1" ("my-key-2" ("--format=unix" "--quiet" "%file"))) ;; This works: :args '(:my-key-1 (:my-key-2 ["--format=unix" "--quiet" "%file"]))
-
Use vectors for JSON arrays:
json-serializecan't handle lists containing strings, but can handle vectors:;; This fails: :args '("--format=unix" "--quiet" "%file") ;; This works: :args ["--format=unix" "--quiet" "%file"]
-
Keyword vs string handling:
json-serializeexpects keyword keys in plists, but values must be strings. When converting between formats, ensure proper type conversion:;; Convert keywords to strings for values: (substring (symbol-name key) 1) ; Remove leading :
-
Incremental testing: Build up the JSON structure piece by piece to isolate where serialization fails.
when-letandif-let: These are deprecated in favor ofwhen-let*andif-let*. Always use the starred versions.
When silencing byte-compilation warnings about unknown functions or variables,
prefer eval-when-compile with require over declare-function or defvar.
This ensures we catch deprecations and API changes during compilation.
Key points about eval-when-compile vs eval-and-compile:
- Both forms run at compile time even when placed inside a function body
eval-when-compile: runs at compile time only; the return value is baked into the compiled codeeval-and-compile: runs at both compile time and runtime
Use cases:
-
Macro expansion: Use
(eval-when-compile (require 'pkg))before code that uses macros frompkg. The macro will expand at compile time. If the code also calls functions frompkgat runtime, add a separate(require 'pkg). -
Top-level compile-time checks: Use
(eval-when-compile (require 'pkg))at the top-level of a file to catch deprecation warnings for packages used inwith-eval-after-loadblocks. This also helps compile-angel pick up these references for automatic compilation. -
Function-only calls: If code only calls functions (no macros), a plain
(require 'pkg)inside the function is sufficient - no compile-time require needed. -
Package initialization: Use
(eval-and-compile (package-initialize))or similar when the side effects must happen at both compile time and runtime. -
Settings file: Load the settings file (which contains package customizations) at compile time before requiring packages:
(eval-when-compile (load (concat my-emacs-path "init/settings") nil t) (require 'some-package))
-
Functions not in ELPA packages: Use
declare-functionfor functions from packages that aren't installed via ELPA and have compile-time issues:(declare-function my-fn "my-package")
Rarely, some packages define defcustoms with types like (repeat function) but
use default values containing symbols for packages that do not match the type
due to not being loaded (e.g., evil-goto-line). When using setopt with such
variables, the customize system validates the value and warns if any symbols
aren't recognized as functions.
To avoid these spurious warnings, use setq instead of setopt for such
variables, with a comment explaining why:
;; Use setq instead of setopt: pkg's defcustom type is (repeat function),
;; but its default includes symbols for packages not always loaded.
(setq pkg-some-variable modified-value)