From 73c3cc40b82277bc473a90e182ac42b9e98161a9 Mon Sep 17 00:00:00 2001 From: David Florness Date: Sun, 28 Jun 2026 15:00:36 -0400 Subject: [PATCH] integrate per-project environments into the Emacs daemon for LSP Add buffer-local, per-directory environment integration so the single Emacs daemon launches each project's tooling (language servers, etc.) with that project's environment, while terminal and GUI frames share the one daemon. This covers any direnv-managed environment -- Nix flake devShells, Python virtualenvs, project-local toolchains, plain .envrc exports -- where the relevant binaries are only on PATH when the project's environment is active. Introduces lisp/env.el holding the environment stack, in the order the variables must flow: exec-path-from-shell -> envrc -> inheritenv -> lsp-deferred - exec-path-from-shell: runs once at daemon startup to seed Emacs's global base environment from the login shell. The daemon does not inherit an interactive shell's PATH, so without this the tools that back the per-project layer (e.g. direnv) would not be on exec-path. This is the single base layer; everything else builds on it. Moved here from lisp/misc.el (keeping the GOPATH/SSH_AUTH_SOCK additions) so the whole environment stack lives in one file. - inheritenv: propagates a buffer's local environment into the temporary buffers where subprocesses are spawned. lsp-mode (and other tools) start processes from a temp buffer that would otherwise lose the buffer-local PATH; inheritenv carries process-environment/ exec-path across that spawn boundary. envrc also depends on it internally. - envrc + envrc-global-mode: buffer-local direnv integration. Each buffer gets its own project's environment layered on top of the base, keyed to the buffer's .envrc directory rather than to a frame or to a single global state. The buffer-local model is required here because one daemon hosts many projects/frames at once: a global environment (e.g. direnv.el's model) would be both slow under frequent project switching and incorrect with concurrent projects. envrc-global-mode is enabled last, in env.el loaded last from init.el, so it is the last globalized minor mode turned on and therefore runs first in each buffer's setup -- before lsp/flycheck read the environment. Within env.el, exec-path-from-shell runs before it so direnv is on PATH when envrc shells out. - lsp-deferred: convert every lsp activation hook from `lsp` to `lsp-deferred` (go, scala, java, python, ccls). A major mode runs its mode hook before after-change-major-mode-hook, where envrc activates the buffer-local environment; a bare `(lsp)` would spawn the server before that environment is in place. lsp-deferred waits until the buffer is visible, by which point envrc has run, so the server inherits the correct project PATH. Convention going forward: hook #'lsp-deferred, never bare `lsp`. (rustic is left as-is: it self-activates its LSP client with deferred startup.) Co-Authored-By: Claude Opus 4.8 --- init.el | 5 +++++ lisp/dev.el | 2 +- lisp/env.el | 32 ++++++++++++++++++++++++++++++++ lisp/go.el | 2 +- lisp/java.el | 2 +- lisp/misc.el | 6 ------ lisp/python.el | 2 +- lisp/scala.el | 2 +- straight/versions/default.el | 2 ++ 9 files changed, 44 insertions(+), 11 deletions(-) create mode 100644 lisp/env.el diff --git a/init.el b/init.el index 71858f2..bbb92fe 100644 --- a/init.el +++ b/init.el @@ -41,6 +41,11 @@ (load "~/.emacs.d/lisp/web") (load "~/.emacs.d/lisp/writing") +;; Load last so envrc-global-mode is the last globalized minor mode enabled +;; (so envrc-mode runs first in each buffer's setup), and so exec-path-from-shell +;; has seeded the base PATH (incl. direnv, and e.g. nix) before envrc shells out. +(load "~/.emacs.d/lisp/env") + ;; Start Emacs Daemon (require 'server) (unless (server-running-p) diff --git a/lisp/dev.el b/lisp/dev.el index a4a8115..74657cd 100644 --- a/lisp/dev.el +++ b/lisp/dev.el @@ -132,6 +132,6 @@ (use-package ccls :hook ((c-mode c++-mode objc-mode cuda-mode) . - (lambda () (require 'ccls) (lsp)))) + (lambda () (require 'ccls) (lsp-deferred)))) (use-package just-ts-mode) diff --git a/lisp/env.el b/lisp/env.el new file mode 100644 index 0000000..a5537d2 --- /dev/null +++ b/lisp/env.el @@ -0,0 +1,32 @@ +;; Seed the daemon's base environment from the login shell ONCE at startup. The +;; daemon does not inherit an interactive shell's PATH, so this puts the tools that +;; back the per-project layer (direnv, and e.g. nix) plus GOPATH/SSH_AUTH_SOCK on +;; PATH, both for buffers without a project environment and so envrc can shell out +;; to direnv. Must precede envrc below. +(use-package exec-path-from-shell + :demand t + :config + (add-to-list 'exec-path-from-shell-variables "GOPATH") + (add-to-list 'exec-path-from-shell-variables "SSH_AUTH_SOCK") + (exec-path-from-shell-initialize)) + +;; inheritenv must load before envrc: envrc declares it as a dependency and wraps +;; its own direnv subprocess calls with the `inheritenv' macro. Loading inheritenv +;; installs no global advice by itself -- it only provides the `inheritenv' macro +;; and `inheritenv-add-advice'/`inheritenv-apply' to opt individual commands in. +;; Commands that spawn with the file buffer current (e.g. lsp-mode via lsp-deferred) +;; already inherit the buffer-local environment directly; add advice only for a +;; command that spawns from a `with-temp-buffer' and thus loses the project PATH: +;; (inheritenv-add-advice 'the-leaky-command) +(use-package inheritenv + :demand t) + +(use-package envrc + :after inheritenv + :demand t + :config + ;; Enable as the LAST globalized minor mode so envrc-mode is positioned to run + ;; FIRST in each buffer's setup, before lsp/flycheck read the environment. + ;; Globalized modes prepend their per-buffer setup to after-change-major-mode-hook, + ;; so "enabled last" == "runs first per buffer". + (envrc-global-mode)) diff --git a/lisp/go.el b/lisp/go.el index a4d2c80..e452b10 100644 --- a/lisp/go.el +++ b/lisp/go.el @@ -1,6 +1,6 @@ (use-package go-mode :defer t - :hook (go-mode . lsp)) + :hook (go-mode . lsp-deferred)) (require 'project) diff --git a/lisp/java.el b/lisp/java.el index f20b852..8db01ff 100644 --- a/lisp/java.el +++ b/lisp/java.el @@ -1,5 +1,5 @@ (use-package lsp-java - :hook (java-mode . lsp)) + :hook (java-mode . lsp-deferred)) (use-package kotlin-mode :defer) diff --git a/lisp/misc.el b/lisp/misc.el index 66a1aaf..7ecb4a9 100644 --- a/lisp/misc.el +++ b/lisp/misc.el @@ -1,9 +1,3 @@ -(use-package exec-path-from-shell - :config - (add-to-list 'exec-path-from-shell-variables "GOPATH") - (add-to-list 'exec-path-from-shell-variables "SSH_AUTH_SOCK") - (exec-path-from-shell-initialize)) - ;; Winner mode: allows for undoing and redoing of windoow configurations (use-package winner :straight nil diff --git a/lisp/python.el b/lisp/python.el index b0e33bd..4cbc450 100644 --- a/lisp/python.el +++ b/lisp/python.el @@ -14,4 +14,4 @@ (defun activate-python-lsp-hook () (interactive) - (add-hook 'python-mode-hook #'lsp)) + (add-hook 'python-mode-hook #'lsp-deferred)) diff --git a/lisp/scala.el b/lisp/scala.el index 3daa190..718ed54 100644 --- a/lisp/scala.el +++ b/lisp/scala.el @@ -1,5 +1,5 @@ (use-package scala-mode - :hook (scala-mode . lsp) + :hook (scala-mode . lsp-deferred) :custom (scala-indent:step 4)) diff --git a/straight/versions/default.el b/straight/versions/default.el index f21c0ec..7b87b3e 100644 --- a/straight/versions/default.el +++ b/straight/versions/default.el @@ -45,6 +45,7 @@ ("emacsmirror-mirror" . "8bfb3b9bd054c2e44af4c6435b994f4fef9fef06") ("emacsql" . "d811bbefcb5e27841af55cae53aa939ba720de77") ("engine-mode" . "e7f317f1b284853b6df4dfd37ab7715b248e0ebd") + ("envrc" . "77e9dec1563bc204cc9e086cd8a7d3622196224c") ("evil" . "3b678a221ee99cc6a95b01d7a3129ce5efc4c3da") ("evil-collection" . "cf84caf44dd1588c10cd14a90223821a691099cc") ("evil-matchit" . "dd03aacd8602ffd2cd9b67d0072092f8d57d5e01") @@ -69,6 +70,7 @@ ("help-plus" . "405d525cffd25fd3f3c7976b4fdb0d0a3bccb64c") ("ht.el" . "1c49aad1c820c86f7ee35bf9fff8429502f60fef") ("hydra" . "59a2a45a35027948476d1d7751b0f0215b1e61aa") + ("inheritenv" . "b9e67cc20c069539698a9ac54d0e6cc11e616c6f") ("just-ts-mode.el" . "6b4b23fcc6f0f0d0903e84c51771d43c06a00771") ("kotlin-mode" . "fddd747e5b4736e8b27a147960f369b86179ddff") ("kubel" . "ceb35d50e7ff736a7a707e22407cd9542bf50ffd") -- 2.38.4