No More Broken LSPs: Bulletproof Elixir + Lexical Setup with Nix

Tame Lexical and ElixirLS once and for all with a reproducible Nix dev shell. Build, patch, and integrate your language server into Neovim. No surprises, just confidence.

Apr 18, 2025 by Josep Lluis Giralt D'Lacoste

elixir

lexical

elixirls

nix

flakes

neovim

beamops

devtools


At BEAMOps, we care deeply about making development environments repeatable, powerful, and user-friendly. We love the confidence of knowing that things will just work. That’s why we love environment integrity, single responsibility and DRY.

I think I started using language servers around six years ago — and if I’m honest, I never really got them to work consistently. Sometimes they’d break silently, sometimes loudly. I never stopped to ask myself why.

The answer? Everything matters: the Erlang version, the Elixir version, how the language server is built, how it’s wired into Neovim (or your editor of choice)… the tiniest mismatch turns your environment into a disaster, or maybe just mine because I am a disaster ;).

In this post, we’ll walk through our latest solution: a bulletproof Elixir setup using Nix, Flakes, Lexical and Neovim, shaped by weeks of trial and error and the sharp edges of broken tooling. Whether you’re an individual dev or part of a team, this guide aims to give you a dev environment that just works — every time.

Why Dev Shells Matter

I’ve lost count of how many times my language server just… didn’t work.

Sometimes it was the wrong Elixir version. Sometimes the wrong Erlang version. Sometimes it was a weird compatibility issue. And sometimes… it was just Tuesday.

A good friend and colleague hit me with some tough love a few months ago:

Your environment is shit (referring to the LSP, I hope). It’s always broken.

I can’t even be mad. He had a point. 😢

Just yesterday, I tried to jump to a module using an alias — and nothing happened. The language server was failing silently. That was my breaking point. I decided: this ends now.

So, I wanted to fix it for good.

I’ve always used ElixirLS, but there’s been a lot of buzz about Lexical. It’s sponsored by Dashbit, it’s focused on correctness and speed, and—if you know me—you know I’m a bit obsessed with José Valim, so anything he touches is basically gold.

I also work on several LiveView projects, and Lexical claims to have better support for it out of the box.

What I wanted was simple (in theory):

  • Figure out which language server to stick with
  • Build it consistently
  • Keep it up to date
  • Use it across projects without random compatibility issues

But most importantly: I wanted confidence that once I built a dev shell, it would work — or at least let me know when it wouldn’t.

That led me to fully commit to building a reproducible dev environment with Nix and Flakes. One where:

  • The language server is always built with the right Elixir & Erlang versions
  • Any incompatibility is surfaced immediately
  • And no warnings slip through the cracks

Building Lexical in a Reproducible Dev Shell

Before going all-in on Lexical, I first tried building ElixirLS. But here’s the catch: it doesn’t compile cleanly with --warnings-as-errors on the latest Elixir and Erlang.

Here’s what I ran:

 mix compile --warnings-as-errors

…and here’s what I got:

==> language_server
warning: Mix.Dep.load_on_environment/1 is undefined or private
...
warning: :dialyzer_plt.from_file/1 is undefined or private
...

So much red. 😬

That’s not what I want when compiling the LSP, let alone in a reproducible build.

Back to the mission: we want to guarantee that our language server works.

To do that, we’ll:

  • Build Erlang
  • Use that Erlang to build Elixir
  • Use that Elixir to build Lexical

Following the docs, it’s always best to align the versions used in your shell when building Lexical: Installation Caveats.

If you look at my personal dot-files…

You’ll find a dev_shell/ folder. This folder contains several flakes that I use across projects via .envrc and nix-direnv.

If you add the following to your .envrc:

echo "use flake github:gilacost/dot-files#elixir_latest_erlang_latest" >> .envrc

Then every time you cd into that project, Nix will load a fully pinned and reproducible Elixir + Erlang dev environment.

Think of it as asdf on steroids — but with way more control and flexibility.

Before we add Lexical to the mix, let’s review the current dev shell:

inputs:
let
  overlays = [
    (self: super: rec {
      erlang = super.beam.interpreters.${inputs.erlangInterpreter}.override {
        sha256 = inputs.erlangSha256;
        version = inputs.erlangVersion;
      };

      elixir = (super.beam.packagesWith erlang).elixir.override (
        {
          sha256 = inputs.elixirSha256;
          version = inputs.elixirVersion;
        }
        // (
          if inputs ? elixirEscriptPath then
            {
              escriptPath = inputs.elixirEscriptPath;
            }
          else
            { }
        )
      );
    })
  ];
  system = inputs.system;
  pkgs = import inputs.nixpkgs { inherit system overlays; };
in
pkgs.mkShell {
  buildInputs =
    with pkgs;
    let
      linuxPackages = lib.optionals (stdenv.isLinux) [
        inotify-tools
        libnotify
      ];
      darwinPackages = lib.optionals (stdenv.isDarwin) (
        with darwin.apple_sdk.frameworks;
        [
          terminal-notifier
          CoreFoundation
          CoreServices
        ]
      );
    in
    builtins.concatLists [
      [
        erlang
        elixir
      ]
      linuxPackages
      darwinPackages
    ];

  shellHook =
    let
      escript = ''
        Filepath = filename:join([
          code:root_dir(),
          "releases",
          erlang:system_info(otp_release),
          "OTP_VERSION"
        ]),
        {ok, Version} = file:read_file(Filepath),
        io:fwrite(Version),
        halt().
      '';
    in
    ''
      export OTP_VERSION="$(erl -eval '${escript}' -noshell | tr -d '\n')"
      export ELIXIR_VERSION="$(${pkgs.elixir}/bin/elixir --version | grep 'Elixir' | awk '{print $2}')"

      echo "🍎 Erlang OTP-$OTP_VERSION"
      echo "💧 Elixir $ELIXIR_VERSION"
    '';
}

Note: Nothing fancy here — we build Erlang, then use it to build Elixir. When you cd into a project using this shell, you should see something like:

🍎 Erlang OTP-27.3.2  
💧 Elixir 1.18.3

Adding Lexical to the Flake

Now that our base shell reliably builds and pins the right Elixir and Erlang versions, it’s time to add Lexical to the mix.

Lexical is written in Elixir and uses the standard Mix toolchain, so building it within Nix is very doable — but it still takes a bit of care. We want to ensure:

  • It’s built from a pinned Git revision
  • It compiles with –warnings-as-errors (to prevent the unexpected)
  • It packages correctly so we can reference it in our editor configuration
  • It works across environments without requiring manual setup

Below is a mkDerivation block that does exactly that. You can add it to your overlay or integrate it into your dev shell flake as a named output (lexical) and reference it in your PATH.

lexical = super.stdenv.mkDerivation {
  pname = "lexical";
  version = inputs.lexicalVersion;

  src = super.fetchFromGitHub {
    owner = "lexical-lsp";
    repo = "lexical";
    rev = inputs.lexicalVersion;
    sha256 = inputs.lexicalSha256;
  };

  buildInputs = [
    self.elixir
    super.git
    super.makeWrapper
  ];

  buildPhase = ''
    export HOME=$(mktemp -d)
    export PATH=$PATH:${super.git}/bin
    export SSL_CERT_FILE=${super.cacert}/etc/ssl/certs/ca-bundle.crt
    export GIT_SSL_CAINFO=${super.cacert}/etc/ssl/certs/ca-bundle.crt

    mix local.hex --force
    mix local.rebar --force
    mix deps.get
    mix deps.compile
    mix compile --warnings-as-errors
  '';

  installPhase = ''
    mix package --path "$out"
    chmod +x $out/bin/*.sh
  '';
};

Let’s walk through a few details:

  • We fetch Lexical from GitHub using a specific revision and hash, ensuring full reproducibility.
  • We override HOME to avoid polluting your real home directory during build time.
  • We set up Hex and Rebar non-interactively so the build doesn’t fail on missing tooling.
  • Finally, we run mix package which builds the LSP binaries inside $out folder, a common practice with nix.

Once that’s in place, you can include lexical in your buildInputs or export it as a binary path in your shellHook. Your editor (e.g., Neovim or VSCode) can then point to this compiled binary directly — and you’ll know that it was built against the exact same tool chain as the rest of your project.

Patching start_lexical and the Version Manager: I want Nix, please

In the last section, we built Lexical using Nix and packaged it nicely via mix package. However, if you try to run the resulting start_lexical.sh script inside the bin/ directory, you’ll likely hit this:

 ./_build/dev/package/lexical/bin/start_lexical.sh 
No activated version manager detected. Searching for version manager...
Could not activate a version manager. Trying system installation.

That message comes from the activate_version_manager.sh script, which is invoked automatically by start_lexical.sh. Its job is to detect tools like asdf, kiex, or exenv and ensure the correct Elixir version is active.

The problem? There’s no activation support for Nix (yet) — so the script bails out and falls back to the system installation, which isn’t what we want in a Nix-based dev shell.

Until someone submits a patch upstream to support Nix (👀 maybe me?), we’ll work around it by patching the generated scripts after the package is built.

Here’s the updated installPhase that does exactly that:

installPhase = ''
  mix package --path "$out"
  chmod +x $out/bin/*.sh

  # Create a wrapper script with version support
  mv "$out/bin/start_lexical.sh" "$out/bin/start_lexical.sh.orig"
  cat > "$out/bin/start_lexical.sh" << EOF
#!/bin/sh

if [ "\$1" = "--version" ] || [ "\$1" = "-v" ]; then
    echo "${inputs.lexicalVersion}"
    exit 0
fi

exec "$out/bin/start_lexical.sh.orig" "\$@"
EOF
  chmod +x "$out/bin/start_lexical.sh"

  # Add Nix detection to version manager script
  substituteInPlace "$out/bin/activate_version_manager.sh" \
    --replace-fail 'activate_version_manager() {' '
activate_version_manager() {
  if [ -n "$IN_NIX_SHELL" ]; then
    echo >&2 "Using Nix-provided Elixir: $(which elixir)"
    return 0
  fi'
'';

Let’s break down what’s going on:

  • We wrap the original start_lexical.sh with a small shim that supports a --version flag. This is useful for Neovim when using lsp config that query the version to verify the LSP.
  • We patch the activate_version_manager.sh script to check for the IN_NIX_SHELL environment variable. If present, it short-circuits the activation logic and just uses the Elixir version already provided by Nix.

Note: You don’t need to mess with asdf, kiex, or global installs. Nix already gives us reproducibility — we just need to convince Lexical to trust it.

This small patch is enough to get Lexical running smoothly inside a fully Nix-managed dev shell.

Making Lexical Available Globally

Now that we’ve built and patched Lexical, the final step is making it available system-wide — in a way that works seamlessly with your editor, no matter what shell or context you’re in.

I didn’t want to rely on setting vim.g.lsp_elixir_bin in my Neovim config, or exporting environment variables that might not persist outside a dev shell. That approach felt brittle — especially when switching between projects or rebuilding Lexical.

So, sticking with the Nix mindset, we reached for a reliable and reproducible trick: symbolic links.

In our shellHook, we symlink the current Lexical build into a known global location:

shellHook = ''
  export LEXICAL_PATH="${pkgs.lexical}/bin/lexical"

  export OTP_VERSION="$(erl -eval '${escript}' -noshell | tr -d '\n')"
  export ELIXIR_VERSION="$(${pkgs.elixir}/bin/elixir --version | grep 'Elixir' | awk '{print $2}')"

  echo "🍎 Erlang OTP-$OTP_VERSION"
  echo "💧 Elixir $ELIXIR_VERSION"
  echo "🧠 Lexical version: ${inputs.lexicalVersion}"
  echo ""

  # Remove existing directory/symlink if it exists
  rm -rf "$HOME/.elixir-lsp/lexical"

  # Link the correct lexical binary
  ln -sf "${pkgs.lexical}/" "$HOME/.elixir-lsp/lexical"
  echo "🔗 Symlinked Lexical to: $HOME/.elixir-lsp/lexical"
  echo "🧠 Lexical available at: ${pkgs.lexical}/bin/start_lexical.sh"
'';

Why This Works

  • Most editors (like Neovim with nvim-lspconfig) can automatically detect and run Lexical if it’s in a standard location like ~/.elixir-lsp/lexical/bin/start_lexical.sh.
  • The symbolic link keeps that path constant, even when you upgrade or rebuild Lexical with a new version.
  • You avoid needing to reload editor configs, fiddle with per-project paths, or export shell variables manually.
  • It also gives you a centralised place to inspect or test the current build.

A Nice Bonus

When you enter the shell, you’ll see helpful version info printed:

🍎 Erlang OTP-27.3.2
💧 Elixir 1.18.3
🧠 Lexical version: 0.6.0
🔗 Symlinked Lexical to: /Users/you/.elixir-lsp/lexical
🧠 Lexical available at: /nix/store/…-lexical-0.6.0/bin/start_lexical.sh

Neovim Integration

With the start_lexical.sh script reliably in place, I updated the Neovim config to use it without relying on dynamic environment variables:

local lsp = require('lspconfig')
...
local elixir_lsp_path = vim.fn.expand("~/.elixir-lsp/lexical/bin/start_lexical.sh")
...
lsp.lexical.setup {
  cmd = { elixir_lsp_path },
  filetypes = { "elixir", "eelixir", "heex" },
}

This setup works seamlessly with direnv, and since the path never changes (thanks to our symlink trick), there’s no need to reload Neovim when switching shells.

Using direnv + Flakes

If you’re new to Nix and want to try this shell setup, you’ll first need to install Nix and get direnv up and running.

Once that’s done, you can use the reusable dev shell in any Elixir project by adding this to the project’s .envrc file:

use flake github:gilacost/dot-files#elixir_latest_erlang_latest

You now have a fully pinned Elixir + Erlang + Lexical stack per project — including global LSP support — with no extra setup.


If you’d like help setting this up for your team, simplifying your Elixir tooling and CI pipelines, or reviewing and migrating your infrastructure, feel free to reach out.

📬 Email us at info@beamops.co.uk
📅 Or book a free 30-minute call — we’d love to hear what you’re working on and explore how we can help.