Setting up per-project RLS for Emacs with Nix and Direnv
One of my favorite things about Nix is using the nix-shell to provision the development tooling for a project
without infecting the rest of the system. Even if the project itself isn't built with Nix, I will often have a
shell.nix
just to provision tools like Cargo and SBT. This becomes especially helpful with Rust
where each of my Rust projects can have a different rustc
version without needing to switch my rustup toolchain.
To that effect, I've setup my Emacs to use the Direnv integration emacs-direnv to provision the buffer's environment using the project's Nix shell whenever I open a file in that project --- if you're not familiar with Direnv I encourage you to read about it here. That combined with Emacs Language Server Protocol (LSP) integration via lsp-mode and the Rust Language Server (RLS) and I've got a setup that allows me to switch between Rust projects using varying Rust versions without hassle.
This setup took a bit of fiddling to work so I thought I'd share it here for the public and to document for myself so I don't forget.
Setting up Emacs
The core plugins I use to get this setup working are direnv, lsp-mode, lsp-ui, and company-lsp. For direnv, besides installing
the Emacs plugin, make sure to install Direnv itself on your system as well. For the LSP plugins, I've setup my Emacs to use
"recent" versions of the plugins since LSP integration is ever-evolving --- I haven't bothered to test what the
oldest working versions of these plugins are, but at time of writing the versions I'm using are 20191016.1813
for lsp-mode,
20191016.1644
for lsp-ui, and 20190612.1553
for company-lsp. For those managing their Emacs plugins with Nix,
you can try doing what I do in my Emacs Nix overlay to get versions newer than upstream Nixpkgs may provide.
From there my Emacs setup is largely taken from the Metals (Scala Language Server) Emacs tutorial, with some additions to hook in direnv.
(use-package direnv
:init
(add-hook 'prog-mode-hook #'direnv-update-environment)
:config
(direnv-mode))
(use-package company-lsp
:defer t)
(use-package lsp-mode
:after (direnv evil)
:config
; We want LSP
(setq lsp-prefer-flymake nil)
; Optional, I don't like this feature
(setq lsp-enable-snippet nil)
; LSP will watch all files in the project
; directory by default, so we eliminate some
; of the irrelevant ones here, most notable
; the .direnv folder which will contain *a lot*
; of Nix-y noise we don't want indexed.
(setq lsp-file-watch-ignored '(
"[/\\\\]\\.direnv$"
; SCM tools
"[/\\\\]\\.git$"
"[/\\\\]\\.hg$"
"[/\\\\]\\.bzr$"
"[/\\\\]_darcs$"
"[/\\\\]\\.svn$"
"[/\\\\]_FOSSIL_$"
; IDE tools
"[/\\\\]\\.idea$"
"[/\\\\]\\.ensime_cache$"
"[/\\\\]\\.eunit$"
"[/\\\\]node_modules$"
"[/\\\\]\\.fslckout$"
"[/\\\\]\\.tox$"
"[/\\\\]\\.stack-work$"
"[/\\\\]\\.bloop$"
"[/\\\\]\\.metals$"
"[/\\\\]target$"
; Autotools output
"[/\\\\]\\.deps$"
"[/\\\\]build-aux$"
"[/\\\\]autom4te.cache$"
"[/\\\\]\\.reference$")))
Setting up the project
Once Emacs is ready to roll we need a shell.nix
to provision an environment with Nix and
an .envrc
to tell direnv to use said shell.nix
when entering the project.
While Nixpkgs has some Rust integration, it does not provide many knobs for us
to turn in terms of the Rust environment we want, like the compiler version or toolchain
extensions like rust-src
. Thankfully, the kind folks at Mozilla published a
Nix overlay that makes it much more ergonomic to work with Rust in Nix.
Most of the shell.nix
then is boilerplate to pull and setup this overlay. From there
it's just a matter of specifying the Rust version we want, along with the extensions we want
for RLS.
let
rust-version = "1.40.0";
nixpkgs = fetchGit {
url = "https://github.com/NixOS/nixpkgs.git";
rev = "a3070689aef665ba1f5cc7903a205d3eff082ce9";
ref = "release-19.09";
};
mozilla-overlay =
import (builtins.fetchTarball https://github.com/mozilla/nixpkgs-mozilla/archive/master.tar.gz);
pkgs = import nixpkgs {
overlays = [ mozilla-overlay ];
};
rust-channel = pkgs.rustChannelOf {
channel = rust-version;
};
rust = rust-channel.rust.override {
extensions = [ "rust-src" ];
};
cargo = rust-channel.cargo;
in
pkgs.mkShell {
name = "rust-dev";
buildInputs = [ rust cargo ];
}
As for the .envrc
, Direnv comes with Nix bindings so all we need in
that file is:
use_nix
Now we just need to direnv allow
to whitelist the project for Direnv,
open a file in the project, and reap the rewards --- you should see a little
"LSP :: Connected to [rls:XXX status:starting]" diagnostic in the
minibuffer indicating great success. There may be some lag when you open the project
for the first time as Nix is pulling the dependencies, or before any diagnostics appear as
RLS is working in the background to download Rust dependencies and compiling the project. To
make the former a bit more tolerable, I will run nix-shell
in a terminal outside of
Emacs so I can actually see the download progress instead of staring at a locked Emacs session.
For the latter, lsp-mode and RLS have the lsp-log
, rls
, and rls::stderr
buffers you can
open to see progress or debug any issues you may encounter.
Other languages
Much of this setup translates readily to other languages or other Emacs language modes. Likely
all you will need to do is get a shell.nix
that provisions the correct environment with any
tools your language mode needs and you're off to the races. Keep in mind though that some languages
(e.g. Scala) and their corresponding language servers (e.g. SBT + Metals) already have native support
for project-specific compiler versions so the only win you may get from mimicking this setup in
those cases is consistency.