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.
- nix-direnv – lets you use a nix flake
- emacs
- envrc-mode – emacs' per-buffer direnv integration
- lsp-mode
- lsp-java –
lsp-mode
integration withjdtls
- 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.
{ 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.
{ 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.
{ 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:
- cd to a Java project with a pom.xml
- copy the flake, package, and shell nix files into the directory
- edit the
package.nix
file to match the actual Java package being built echo use_flake>.envrc
anddirenv allow
To start up Emacs and a LSP session in the project:
- in Emacs, open a Java file in the project
- run
M-x envrc-reload
(if not already loaded) - 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]