Asynchronous Emacs Direnv Devshells

Figure 1: The Meeting of San Carlo Borromeo and San Filippo Neri ; emacs logo licensed under GPLv3 from Wikimedia

On the bad side of town underneath the freeway overpass with a needle full of lisp is a wild-eyed man who likes to abuse emacs. That man is me.

My eccentric technology tastes take on a particularly esoteric flavor when I start setting up language servers. In short:

• I sandbox every language (and language server) in its own Nix devshell. I have no system-wide python or node, for example.

• I pair these devshells with direnv so that my environments automatically appear when I’m inside of a project directory.

• I live inside of emacs and use the envrc package to make my environments appear in emacs in cases where they’re critical. For example, eglot needs its language servers in my $PATH.

• I keep my environments moderately up-to-date, so sometimes (not usually) my devshell can take a while to appear.

• Emacs is, as all elisp cult members know, singly-threaded. Out of the box, long direnv exports can freeze the editor.

• To address the previous bullet point, I run a fork of envrc-mode that asynchronously sources the environment.

String all of that mental illness together and you get one annoying side-effect: if I want eglot-ensure hooks to find my sandboxed language servers, it should always wait for direnv to finish before running, otherwise my executables may not be there. Emacs being what it is, reacting to asynchronous events is unpaved territory!

Glimpsing the Sin

This bothered me for a long time until I decided to take action to fix it. There currently isn’t any “blessed” way to deal with this weird situation. Although the “run a function after entering a mode” use case is well-understood, the “run a function after happens at a later time” has no officially sanctioned method.

So I had to dream up a big messy solution.

There is one signal we can key onto which helps identify when an asynchronous direnv export from the envrc package is done: it will call envrc--apply in all cases (whether there’s new environment variables to export or not):

elisp

“Okay,” I thought to myself. We can abuse this.

Sloppy, But Not Slop

I wrote a global minor mode that looks like this:

elisp

The mode uses one of my favorite cursed elisp functions: advice-add (or add-function):

The rest of the docstring explains ways to change the targeted function. My favorite resource to understand advice-add isthis article that includes visuals to understand them.

We use two in that code snippet plus one bit of porcelain:

1. :around on eglot-ensure to replace it with our own function, which is given the old function’s definition at the time our replacement function gets called.

2. :after on envrc--apply to call our function after envrc--apply is called.

3. A small mode-line widget that will emit a string when we’re waiting in envrc purgatory.

That’s enough to make our disaster work!

First: our shim that dethrones eglot-ensure with a different function. Recall that original-fn here will be the eglot-ensure function at call time:

elisp

The comments are self-explanatory, but to torture you a little more, this will hold off calling elgot-ensure by detecting if we expect envrc-mode to run and, if so, saving the original function into a buffer-local variable that we’ll use later. As an extra goodie, we do this for all sibling buffers in the current project we expect to undergo the same treatment (same major mode, also see the original function in their list of hooks, also have envrc active.)

Recall that we advice’d the following function :after envrc--apply. It’ll use that direnv-defer--fn buffer-local variable we saved:

elisp

Note that the call site for envrc--apply is already in a loop iterating through all of the buffers that envrc-mode thinks should get updated so we don’t need to deal with that in our code: just invoke the deferred function if it was held. I suppose this could cause problems if you apply this strategy to functions that behave differently than eglot-ensure but we’re cowboys doing whatever the hell we want here, baby.

Seconds of Glory

What does our toil buy? Here’s a contrived example I recorded opening an example .nix file with an artificial five second delay introduced to the devshell. Watch the modeline on the lower right indicate that the devshell is loading via direnv along with an “eglot is pending” modeline segment before it finishes and fires eglot-ensure after:

Ooh la la, aren’t we fancy?

You Aren’t Getting These 3 Minutes of Reading Back

I hope you enjoyed this brief adventure. As you can tell from the fact that I overengineered this into a minor mode (and then overthought it into a blog post), maybe you could extend it to defer other functions until after direnv completes. I guess.

Don’t let anybody tell you what you can and can’t do with your computer and your geriatric lisp machine.

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论