~edwargix/emacs.d

73c3cc40b82277bc473a90e182ac42b9e98161a9 — David Florness 2 months ago 82ac45b
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 <noreply@anthropic.com>
9 files changed, 44 insertions(+), 11 deletions(-)

M init.el
M lisp/dev.el
A lisp/env.el
M lisp/go.el
M lisp/java.el
M lisp/misc.el
M lisp/python.el
M lisp/scala.el
M straight/versions/default.el
M init.el => init.el +5 -0
@@ 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)

M lisp/dev.el => lisp/dev.el +1 -1
@@ 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)

A lisp/env.el => lisp/env.el +32 -0
@@ 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))

M lisp/go.el => lisp/go.el +1 -1
@@ 1,6 1,6 @@
(use-package go-mode
  :defer t
  :hook (go-mode . lsp))
  :hook (go-mode . lsp-deferred))

(require 'project)


M lisp/java.el => lisp/java.el +1 -1
@@ 1,5 1,5 @@
(use-package lsp-java
  :hook (java-mode . lsp))
  :hook (java-mode . lsp-deferred))

(use-package kotlin-mode
  :defer)

M lisp/misc.el => lisp/misc.el +0 -6
@@ 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

M lisp/python.el => lisp/python.el +1 -1
@@ 14,4 14,4 @@

(defun activate-python-lsp-hook ()
  (interactive)
  (add-hook 'python-mode-hook #'lsp))
  (add-hook 'python-mode-hook #'lsp-deferred))

M lisp/scala.el => lisp/scala.el +1 -1
@@ 1,5 1,5 @@
(use-package scala-mode
  :hook (scala-mode . lsp)
  :hook (scala-mode . lsp-deferred)
  :custom
  (scala-indent:step 4))


M straight/versions/default.el => straight/versions/default.el +2 -0
@@ 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")