Emacs on macOS: Preserving the Correct Environment
How to correctly set environment variables when you launch Emacs from Finder or the dock
I have been using Doom Emacs in the past couple of years. It has served me quite well. But after upgrading to Emacs 29, I wanted to go back to vanilla Emacs and build my configuration from scratch. I wanted to figure out how everything works under the hood and have more control over the editing experience.
The first problem I encountered was that after starting Emacs I would see warnings like “libgccjit.so: error: error invoking gcc driver
”. My Emacs installation has native compilation enabled. The warning indicated that Emacs somehow was not able to invoke the gcc driver. However, it worked perfectly with Doom. It didn't take me long to figure out why: I compiled Emacs from source against dependencies installed by Homebrew, but now Emacs cannot find them because the environment variables are not set when Emacs is launched from Finder. Doom has a command doom env
that saves environment variables in a file. Every time I ran doom sync
or doom upgrade
, doom env
is automatically run. When Emacs is launched from Finder or the Dock, it doesn't inherit environment variables from the shell (which, unlike when running emacs
on the CLI, is not the parent process). However, Doom reads the file generated by doom env
earlier and set up everything as if Emacs were started from the shell. Without this, Emacs cannot find things installed outside the default system paths, including everything installed by Homebrew, asdf, RVM, and NVM etc.
There is a package called exec-path-from-shell aimed at solving this problem. It asks the shell to print the environment variables and then set them in Emacs. The advantage is that this is done every time Emacs starts, so the environment is up-to-date. The drawback is that launching the shell in a subprocess to print the variables can be slow, since the shell's own initialization file can be quite complex. My shell environment doesn't change very often, so I prefer Doom's method: optimize for startup speed. I could just re-generate the environment file when its staleness causes problem.
I created an Elisp script at ~/.emacs.d/scripts/gen-env-file.el
:
(defun gen-env-file (path)
"Save envvars to a file at PATH"
(let ((dirname (file-name-directory path)))
(make-directory dirname t))
(with-temp-file path
(setq-local coding-system-for-write 'utf-8-unix)
(insert
";; -*- mode: emacs-lisp -*-\n"
";; This file was automatically generated and will be overwritten.\n")
(insert (pp-to-string process-environment))))
(gen-env-file "~/.emacs.d/local/env.el")
Running it in the shell saves environment variables to ~/.emacs.d/local/env.el
.
emacs --quick --script ~/.emacs.d/scripts/gen-env-file.el
Then add the following to ~/.emacs.d/init.el
to read the file and set up the proper variables at Emacs startup:
(defun load-env-file (file)
"Read and set envvars from FILE."
(if (null (file-exists-p file))
(signal 'file-error
(list "No envvar file exists." file
"Run `emacs --script ~/.emacs.d/scripts/gen-env-file.el`."))
(with-temp-buffer
(insert-file-contents file)
(when-let (env (read (current-buffer)))
(let ((tz (getenv-internal "TZ")))
(setq-default
process-environment
(append env (default-value 'process-environment))
exec-path
(append (split-string (getenv "PATH") path-separator t)
(list exec-directory))
shell-file-name
(or (getenv "SHELL")
(default-value 'shell-file-name)))
(when-let (newtz (getenv-internal "TZ"))
(unless (equal tz newtz)
(set-time-zone-rule newtz))))
env))))
(load-env-file "~/.emacs.d/local/env.el")
I hope this article is useful for other Emacs users who like the doom env
way of handling environment but do not want to use the full doom distribution.