Developing Java applications with Emacs using Nix

[Blog Index]​ [RSS]​ [Prev: Blogging with org-mode] [Next: Downloading Emacs packages ad-hoc with Nix]

Posted on 2024-08-30 in nix emacs java .

Introduction

In this document, I'll lay out how I'm writing Java code in Emacs using Nix to manage the dev environment. Emacs will use lsp-mode to talk to the Eclipse JDT language server to provide Java IDE features.

So far, only Maven is integrated into this environment. That is, all Maven dependencies will be built with Nix and made available to the system at runtime.

This document is intended for someone who knows how to use Nix flakes and wants to set up a Java development environment in Emacs. For someone familiar with Nix but unfamiliar with flakes, the TL;DR is that flakes standardize version-pinning Nix dependencies, which is what earlier iterations of the concept such as niv did.

My requirements

I want:

  • Per-directory Java development environments
  • Acceptable LSP support in Emacs, which includes at a minimum:
    • Autocomplete method names
    • Autocomplete class names from packages that haven't been imported + auto-import when selected
    • Jump to definition
    • Documentation on hover
  • Maven integration – the LSP should be aware of all maven dependencies.
  • All packages managed by nix to ensure reproducibility.

On JetBrains

In my experience, searching around for advice doing this turns up many people recommending that I use some JetBrains product for writing Java code and skipping the DIY route of cobbling together editors and LSP servers. To them, I say: "You're probably right and wiser than I am, but thanks for nothing."

Inspiration

I searched around for "emacs java nix" and found this blog post. I used that post as a starting point for this one. Thank you Dominik!

The main thing I wanted over that example is that I want to be able to copy/paste some files into an existing maven project and immediately open a java file and in emacs run M-x lsp. I want each project to have separate jdtls workspaces, and its own independent set of maven dependencies.

All of the advice in Dominik's post about overriding lsp-java--server-ls still applies since lsp-java's invocation is probably out of date, but with some slight modifications.

Things I'm using

These are the things I'm using to set up the development environment. This doesn't include the JDK, JRE, and Maven.

  • nix
  • direnv – direnv lets you have per-directory environments. This usually amounts to setting environment variables when entering a directory and unsetting them when leaving that directory.
  • emacs
  • jdtls – the LSP for Java (originally made for Eclipse)

The flake

The starting point is a project with a pom.xml. This is a file that Maven uses to define a Java project and how to build it.

Here is a minimal, cross-platform flake.nix that contains the package and a devShell that can be entered.

flake.nix:

{
  description = "Java project";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
  };

  outputs =
    { self, nixpkgs, ... }:
    let
      supportedSystems = [ "x86_64-linux" ];
      forEachSystem =
        f:
        nixpkgs.lib.genAttrs supportedSystems (
          system:
          f {
            pkgs = import nixpkgs {
              inherit system;
            };
            inherit system;
          }
        );
    in
    {
      packages = forEachSystem (
        { pkgs, ... }:
        {
          default = pkgs.callPackage ./package.nix { };
        }
      );

      devShells = forEachSystem (
        { system, pkgs }:
        {
          default = import ./shell.nix {
            inherit pkgs;
            mvnDeps = self.packages.${system}.default.fetchedMavenDeps;
          };
        }
      );
    };
}

package.nix

package.nix contains the actual package definition.

There's nothing special about this, and it just uses the nixpkgs-provided buildMavenPackage function.

package.nix:

{
  makeWrapper,
  maven,
  nix-gitignore,
  jre,
}:
let
  jarName = "sample-HEAD-SNAPSHOT.jar";
in
maven.buildMavenPackage rec {
  pname = "myMavenProject";
  version = "0.9.3";

  src = nix-gitignore.gitignoreSource [ "*.nix" ] ./.;

  mvnHash = "sha256-AL9iHDu2gz/JhEx8OUDodORITdEZuoOfT4QtbDy8T7E=";
  nativeBuildInputs = [ makeWrapper ];
  installPhase = ''
    mkdir -p $out/bin $out/share/${pname}
    install -Dm644 target/${jarName} $out/share/${pname}

    makeWrapper ${jre}/bin/java $out/bin/${pname} \
      --add-flags "-jar $out/share/${pname}/${jarName}"
  '';
}

shell.nix

shell.nix contains the definition of the development shell.

One might wonder why the shellHook needs to copy all of the maven dependencies into a temporary directory and not instead simply set the environment variables to point to the nix store. The reason for this is that jdtls expects the directory to be writable, and the nix store is read-only.

shell.nix:

{
  pkgs ? import (builtins.getFlake "nixpkgs") { system = builtins.currentSystem; },
  mvnDeps ? (pkgs.callPackage ./package.nix { }).fetchedMavenDeps,
}:
pkgs.mkShell {
  packages = with pkgs; [
    jdk11_headless
    jdt-language-server
    maven
  ];

  # Initialize .jdtls directory with stuff that jdt-language-server
  # needs. The directory should probably be added to .git/info/exclude
  # (or equivalent) of the enclosing project.
  shellHook = ''
    export TMPDIR=$PWD/.jdtls

    rm -rf $TMPDIR/
    mkdir -p $TMPDIR/{maven,config,workspace}

    # Copy maven repository into jdtls directory
    cp -dpR ${mvnDeps}/.m2 $TMPDIR/maven/
    export MAVEN_OPTS="-Dmaven.repo.local=$TMPDIR/maven/.m2"

    # Copy config into jdtls directory
    cp -dpR ${pkgs.jdt-language-server}/share/java/jdtls/config_linux $TMPDIR/config/
    export JDTLS_CONFIG="$TMPDIR/config/config_linux"

    # Set the workspace
    export JDTLS_WORKSPACE="$TMPDIR/workspace"

    # Mark everything writable to keep jdtls happy
    chmod +w -R $TMPDIR
  '';
}

This sets everything up in the working directory. If that's not desirable, the first line of the shellHook can be replaced with export TMPDIR=/tmp/javalsp/${PWD//\//_} – this will create a unique directory under /tmp/ for each project.

Direnv

I use direnv (and nix-direnv) paired with envrc-mode to get Emacs to pick up the shell environment. That's how I pass the environment variables JDTLS_CONFIG and JDTLS_WORKSPACE to jdtls.

$ echo use_flake > .envrc
$ direnv allow

Emacs config

How does Emacs read JDTLS_CONFIG and JDTLS_WORKSPACE from the environment and pass them on to jdtls? By overriding an internal lsp-java function. There is probably a much cleaner way to do this but I spent some time on it and couldn't come up with anything better.

This part is not in the Java project flake, but rather in my NixOS/home-manager configuration flake.

The reason this is at all needed is that lsp-java uses an invocation for jdtls that just doesn't work, at least on NixOS.

let
  lspJavaConfig = pkgs.writeText "lsp-java-config.el" ''
    (use-package lsp-java
      :ensure t
      :config
      (advice-add 'lsp-java--ls-command :override (lambda ()
        (list "${pkgs.jdt-language-server}/bin/jdtls"
              "-configuration" (getenv "JDTLS_CONFIG")
              "-data" (getenv "JDTLS_WORKSPACE")))))
  '';
in
{
  # ... 
  # incorporate `lspJavaConfig` into final emacs config
  # ...
}

Putting it all together

To set up a project with this:

  1. cd to a Java project with a pom.xml
  2. copy the flake, package, and shell nix files into the directory
  3. edit the package.nix file to match the actual Java package being built
  4. echo use_flake>.envrc and direnv allow

To start up Emacs and a LSP session in the project:

  1. in Emacs, open a Java file in the project
  2. run M-x envrc-reload (if not already loaded)
  3. run M-x lsp to start the LSP server

Troubleshooting tips

I had many issues setting this up. They mainly took the form of the LSP server not starting up, immediately exiting.

Sometimes, you can check the Emacs buffer called something like jdtls::stderr. There might be a nice Java exception thrown by jdtls for you to investigate.

Failing that, you might have to check the other LSP log. For some reason, this doesn't go to an Emacs buffer. To get this log, I resorted to running the LSP server command manually. To do this, you can run the following elisp in the Java buffer where the LSP fails to start:

(kill-new (string-join (lsp-java--ls-command) " "))

Then, you can M-x yank it into a terminal to run it. This will often show you an error that you couldn't see in the jdtls::stderr buffer.

[Blog Index]​ [RSS]​ [Prev: Blogging with org-mode] [Next: Downloading Emacs packages ad-hoc with Nix]