Emacs, Java, and Nix — An interesting journey
Mar 2, 2023 · 3 minute read · EmacsDo you want to use Emacs for Java development? I suggest using the language
server protocol with lsp-mode
and lsp-java
together with the Eclipse JDT
language server (jdtls
). And do you also want a declarative development
environment without surprises? Use Nix Direnv, envrc.el, and a Nix Flake! I
assume familiarity with these concepts. In the following, I will focus on the
Java-related Emacs setup.
The reason of this post is that I have stumbled upon problems when using a
declarative, project-specific configuration. In particular, lsp-java
uses a
global variable lsp-java-server-install-dir
which specifies the installation
directory of jdtls
. Further, it uses global workspace and configuration
directories which are jdtls
specific settings; we want those to be
project-specific.
But first things first. The following snippet defines a minimalist Nix Flake that provides a development environment for Java:
# File 'flake.nix'.
{
description = "Java development environment";
inputs.flake-utils.url = "github:numtide/flake-utils";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, flake-utils, nixpkgs }:
flake-utils.lib.eachDefaultSystem (system:
let pkgs = nixpkgs.legacyPackages.${system}; in
{
devShells.default = with pkgs; mkShell {
packages = [
# Gradle, Java development kit, and Java language server.
gradle
jdk
jdt-language-server
];
# Environment variable specifying the plugin directory of
# the language server 'jdtls'.
JDTLS_PATH = "${jdt-language-server}/share/java";
};
}
);
}
We also set up a directory environment file, and use it:
echo "use flake" > .envrc
direnv allow
direnv reload
However, the language server will not work yet. We need to tell lsp-java
about
the location of jdtls
and how to run it. This has proven to be difficult, if
not arduous. The solution, however, is pretty easy.
-
Use the wrapper script shipped with
jdtls
instead of a manualjava --lots-of-options
invocation like so:(after! lsp-java (defun lsp-java--ls-command () (list "jdt-language-server" "-configuration" "../config-linux" "-data" "../java-workspace")))
after!
is a Doom Emacs macro that executes code after loading a feature. You can use other constructs, if you like.- The function
lsp-java--ls-command
provides a list of strings which are concatenated and executed when running the language server. Here, we use the wrapper scriptjdt-language-server
, and only specify the project-specific configuration and workspace directories. We put them in the parent directory of the Java project, because, well, see this weird Stack Overflow answer.
-
Set
lsp-java-server-install-dir
in a hook using the environment variableJDTLS_PATH
set by the Nix Flake shell:(after! cc-mode (defun my-set-lsp-path () (setq lsp-java-server-install-dir (getenv "JDTLS_PATH"))) (add-hook 'java-mode-hook #'my-set-lsp-path))
Like so, everything works like a charm, and my experience with lsp-java
has
been great so far! We can have different versions of jdtls
for different
projects, and they do not even interfere with each other. Wow.
If you want, you can now set up a demo project from within Emacs using
lsp-java-spring-initializer
. After setting up the demo
project, the
directory structure is:
/home/dominik/Scratch/java
├── config-linux -- Created by =jdtls=, see above.
├── demo -- Demo project.
├── .direnv
├── .envrc
├── flake.lock
├── flake.nix
├── .git
├── .gitignore
└── java-workspace -- Created by =jdtls=, see above.
5 directories, 4 files
In conclusion, we have a project-specific, declarative Java development setup.
However, there is still some local state and cache created by Gradle
or
Maven
, depending on which build tool you use. For example, I do have a
~/.gradle
directory with lots of artifacts… If you know how to tell Gradle
or Maven to be project-specific, let me know!