Project Specific Tooling in Emacs with Nix Flakes and Direnv
Tags: programming.nix
One use-case for nix is to describe the tools/dependencies used on a per-project basis. – This is similar to VSCode’s Remote Containers, but without the containers.
We can achieve this in Emacs by leveraging direnv, nix-direnv, and emacs-direnv.
direnv provides a neat way of specifying environment variables in a per-directory basis.
Since direnv runs arbitrary bash code in the .envrc
files, it’s possible to
use direnv to automatically load programs specified in a nix file into the
enviroment.
Using nix-direnv
With the new flake-aware nix
cli commands, a flake.nix
file is preferred
over shell.nix
or default.nix
. The
nix-direnv project integrates
direnv with nix flakes.
Example .envrc
and flake.nix
files are given in nix-direnv’s template/
directory.
These can also be generated by running:
nix flake new -t github:nix-community/nix-direnv .
(and direnv allow
to grant direnv permission to run the file).
But for the sake of saving a click, it’s something like:
if ! has nix_direnv_version || ! nix_direnv_version 1.6.0; then
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/1.6.0/direnvrc" "sha256-FqqbUyxL8MZdXe5LkMgtNo95raZFbegFpl5k2+PrCow="
fi
use flake
{
description = "A basic flake with a shell";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs = { self, nixpkgs, flake-utils }:
-utils.lib.eachDefaultSystem (system: let
flakepkgs = nixpkgs.legacyPackages.${system};
in {
devShell = pkgs.mkShell {
nativeBuildInputs = [ pkgs.bashInteractive ];
buildInputs = [ ];
};
});
}
emacs-direnv is a package which
will load the environment from direnv .envrc
files.
Since emacs-direnv
loads the .envrc
file, and the .envrc
file loads the
environment with the devShell
from the flake.nix
, Emacs will use the
programs from the devShell
. (As can be verified by evaluating (getenv "PATH")
or (executable-find "bash")
, or whatever).
With recent versions of direnv, the .envrc
can be as simple as:
use flake
and I often find it useful to refer to a devShell
output of a flake elsewhere:
use flake github:rgoulter/nix-user-repository#python_3_10
Example: Go-lang
e.g. for Go-lang, we want go
and gopls
, so our devShell
is defined in the
flake.nix
as:
{
devShell = pkgs.mkShell packages = with pkgs; [
go
gopls];
};
Example: Terraform
e.g. for Terraform:
{
devShell = pkgs.mkShell packages = with pkgs; [
terraform
terraform-ls
tflint];
};
But, using terraform-ls
instead of terraform-lsp
is a bit more involved. In
order to get the LSP working, I needed to fill .dir-locals.el
with:
((terraform-mode . ((eval . (progn
(direnv-update-environment)
(setq-local lsp-terraform-server `("terraform-ls" "serve")))))))
Downsides
Some tooling (linting, LSP) can be a bit finicky with editors depending on the load order of the direnv plugin, and the load order of the tool. This can sometimes be mitigated by launching the editor from the direnv environment.
For code intelligence with LSP, often the code for the dependecies is outside of the project directory.
- e.g. in a Rust project, the source code for crates ends up under the home directory. – So, if you navigate to some function’s declaration, but that’s outside the project directory, then direnv unloads the LSP no longer provides the server on the path (and the editor LSP plugin is not able to start the LSP server in this outside-of-project-directory folder).
And obviously it takes a bit of effort to write the above.