Concept → IO ()

Linux Emacs Coding Music Links About Search

Emacs, Java, and Nix — An interesting journey

Do 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.
          # 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.

  1. Use the wrapper script shipped with jdtls instead of a manual java --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 script jdt-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.
  2. Set lsp-java-server-install-dir in a hook using the environment variable JDTLS_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:

├── 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!