.jpg

Emacs, LSP, and Java, Together at Last

I like to use Java for small programming tasks. Without ceremony. Without a src/main/java directory tree. It works great with modern Java and VS Code. VS Code isn’t a complete IDE, but it relies on the Language Server Protocol (LSP) to talk to the Eclipse LSP server. Of course, VS Code is unremarkable as a text editor. It’s no Emacs. But the combination of Emacs, LSP, and Java can be a science project. After more than a year of fussing with it on and off, I finally got them to work together. Read on if you are one of the select few who care.

Why Emacs?

If you are a happy VS Code user, stay happy and move on. But perhaps you agree with Neal Stephenson who wrote: “Emacs outshines all other editing software in approximately the same way that the noonday sun does the stars. It is not just bigger and brighter; it simply makes everything else vanish.”

The Emacs ecosystem is so vast and varied that every Emacs user has their own reason on why they can’t do without. For me, it is HTML editing. I wrote my own editing mode...don’t ask. But this very blog post is a result of that effort. As are my books, thousands of pages. For others it is org-mode or magit, or vterm, or just the insane level of customizability of every part of the editing experience.

Once your fingers know Emacs, you want to use it for everything. Including Java files. At least for small, casual use. For bigger projects, of course, an IDE is a better choice.

The Language Server Protocol

No editor can know how to deal with every programming language out there. That is where the Language Server Protocol comes in. The LSP client in your editor feeds your code to a language-specific server which returns errors and warnings, and enables formatting, autocompletions, jumping to declarations, and so on. There are LSP servers for JavaScript, Python, bash, and dozens of other languages.

For Java, I am aware of three servers:

  1. The battle-tested Eclipse JDT language server, which is used by the Red Hat Java support in VS Code, as well as Emacs and vim
  2. A NetBeans-derived server that is used by the Oracle Java extension for VS Code
  3. George Fraser’s server based on the Java compiler API

Emacs has built-in support for LSP in the Eglot package. There is also a third-party LSP Mode package. Here is a comparison between the two by the author of Eglot. I am going with Eglot because it is built into Emacs and I generally try to favor the built-in packages.

With LSP Mode, each programming language has its own customized implementation, such as lsp-java or, for Scala, lsp-metals. In contrast, Eglot takes pride in an out-of-the-box experience for dozens of programming languages (and even an English grammar checker). There is an eglot-java package, but the Eglot author is astonished at its existence.

While eglot-java has a couple of nice features, it doesn’t really help with a big adoption hurdle: configuring the language server in a way that makes sense to Java programmers. Maybe some LSP servers are easy to configure, but Eclipse JDTLS is not, as you will soon see. Perhaps in the future eglot-java could take on more of that task, but in this article I’ll just look at basic Eglot.

Basic Configuration

Eglot comes with Emacs since version 29. Download JDTLS from https://www.eclipse.org/downloads/download.php?file=/jdtls/snapshots/jdt-language-server-latest.tar.gz, make a directory somewhere to hold it, uncompress it there, and make it so that the bin/jdtls file is on the PATH that Emacs sees. I make a symlink from ~/bin, but there are other ways, such as using exec-path-from-shell. And of course, Java needs to be on the PATH as well.

You also need Python on your system since jdtls is a Python script. Its only purpose is to build the lengthy command for invoking the LSP server (which is written in Java).

Now open a “Hello, World!” program and run M-x eglot. Introduce a typo, such as deleting a brace. You should get an error message.

.png

As shown in these animations, you can do the following:

That’s just what I need for working with small files.

If you always want to start Eglot with Java files, add this to your Emacs configuration:

(add-hook 'java-mode-hook 'eglot-ensure)

Setting JDTLS Options

So far so good. But now let’s say that you want something out of the ordinary. Such as a custom formatter. JDTLS will apply any Eclipse formatter. You can define the rules in Eclipse and save them as an XML file, or you can find files for common formatting styles such as this one.

Eglot needs to communicate with the LSP server to tell it about your preference, by sending some JSON. Your LSP server is supposed to document the format. JDTLS does that in its Wiki pages.

For example, here is how to set the formatter:

{
    "settings": {
        "java": {
            "format": {
                "enabled": true,
                "settings": {
                    "url": "file:///home/cay/bin/cay-eclipse.formatter.xml"
                    "profile": "cay",
                }
            }
        }
    }
}

Yes, settings within settings. Go ahead, match that up with the gobbledygook of the JDTLS documentation. You can then work backwards for other things that you might want to configure.

However, you can’t put compiler options into JSON. Instead place them into a separate file, and tell JDTLS where that file is:

{
    "settings": {
        "java": {
            "settings": {
                "url": "file:///home/cay/bin/eclipse-settings.prefs"
            }
        }
    }
}

In the file, you can add options like the following:

org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=enabled
org.eclipse.jdt.core.compiler.codegen.targetPlatform=24
org.eclipse.jdt.core.compiler.compliance=24
org.eclipse.jdt.core.compiler.source=24
org.eclipse.jdt.core.compiler.mickey=mouse

Of course, the last option has no effect, but it can help with troubleshooting.

Now you know how to configure JDTLS. Send it some JSON, and provide additional details in files.

Configuring the LSP Server

Eglot can send options to the LSP server in one of two ways: through the initialize handshake and the workspace/didChangeConfiguration notification. These are NOT the same. This matters because, as we will see later, due to a bug in JDTLS, the initialize request is not as useful for configuring the server. Unfortunately, most examples show the first way. Let’s instead start with the second.

Default Workspace Configuration

Workspace configuration is described as an advanced feature, but it is no harder than setting initialization options, and it works. Here is the configuration in my .emacs:

;; eglot
(with-eval-after-load 'eglot
  (setq-default eglot-workspace-configuration
                '(:java
                   (:settings
                    (:url
                     "file:///home/cay/data/bin/eclipse-settings.prefs")
                    :format
                    (:enabled
                     t
                     :settings
                     (:url
                      "file:///home/cay/data/bin/cay-eclipse.formatter.xml"
                      :profile
                      "cay"))
                    :configuration
                    (:runtimes
                     [
                      (:name
                       "JavaSE-24"
                       :path
                       "/home/cay/.sdkman/candidates/java/24-tem"
                       :default
                       t)])))))

Of course, you specify the configuration in Lisp, not JSON. These are nested property lists. If you squint hard enough, you can see how this format is equivalent to JSON. Minor weirdness: Use t for true, :json-false for false.

⚠️ With workspace configuration, do not include an enclosing :settings. That is only for initialization options!

⚠️ I cannot overemphasize how fussy these nested plists are. I made dozens of trivial mistakes with nesting those parentheses, all resulting in confusing server behavior, never with any sensible error messages. When your settings don’t work, first take a deep breath and check the parentheses!

Local Workspace Configuration

You can override the global workspace configuration by placing a file .dir-locals.el in the root of your project. When you just have a single source file without an Emacs project, it should be in the same directory as the source file.

The manual is not wrong when it states: “For some users, setting eglot-workspace-configuration is a somewhat daunting task. ”

Here is an example of what to place into that file:

((java-mode
  . ((eglot-workspace-configuration
      .
      (:java
       (:format
        (:enabled
         t
         :settings
         (:url
          "file:///home/cay/data/bin/cay-eclipse.formatter.xml"
          :profile
          "cay"))))))))

Again, note that there is no enclosing :settings.

The .dir-locals.el files replaces the global workspace configuration. It is not merged.

This is entirely optional. I don’t use it.

Initialization Options

Instead of using a workspace configuration, you can specify initialization options:

(with-eval-after-load 'eglot
  (add-to-list 'eglot-server-programs
                '(java-mode . ("jdtls"
                               :initializationOptions
                               (:settings
                                (:java
                                 (:format
                                  (:enabled
                                   t
                                   :settings
                                   (:url
                                    "file:///home/cay/data/bin/cay-eclipse.formatter.xml"
                                    :profile
                                    "cay")))))))))

Note that here you have :settings surrounding :java.

Unfortunately, when sending initialization options, they seem to be ignored, as noted here and here. In October 2023, I reported a bug with Eglot. The Eglot author blamed JDTLS. In March 2024, another aggrieved user filed a bug report with JDTLS.

Apparently, after the initialization handshake, the server asks about configuration changes. When nothing changes, VS Code resends the entire configuration, but Eglot sends an empty change. The latter is correct per the LSP spec, but JDTLS then wipes out the initialization options. Which is not correct.

There is a workaround, namely to turn off configuration changes:

(with-eval-after-load 'eglot
  (remove-hook 'eglot-connect-hook 'eglot-signal-didChangeConfiguration))

Obviously, this could lead to other problems, when this or another server needs to be notified of configuration changes.

Until the JDTLS bug is fixed, it seems best to use workspace configuration instead of initialization options.

Troubleshooting

Here are some tips for when things go wrong. First, call M-x eglot-show-workspace-configuration to see the workspace configuration as JSON. (This doesn’t show the initialization options.)

You can observe the traffic between Eglot and JDTLS in a buffer with a name such as *EGLOT (hello/(java-mode)) events*.

There, you can see the initialize and workspace/didChangeConfiguration messages.

Next, run this command in a terminal:

pgrep -a java | grep -E 'eclipse|jdt|equinox'

You will see the full command line that invoked JDTLS.

Now look at the -data argument in the command line. It specifies a directory such as /tmp/jdtls-9a5d56716d566997fa290054d161af96305aff9a. Change into that directory and run

grep "mickey" $(find . -name "*.prefs")

That tells you whether your compiler configuration file made it to the data directory.

Inside the data directory, you can find the JDTLS log at .metadata/.log. I have not found it useful, but you may be asked about it if you file a bug report.

⚠️ When you experiment, be sure to stop the server with M-x eglot-shutdown, remove the data directory , and restart with M-x eglot. Otherwise, the data directory may contain files from a previous configuration, which can be very confusing.

Conclusion

The marvel is not that the bear dances well, but that the bear dances at all.

With single-file Java programs for scripting tasks, an IDE seems overkill. That is where LSP comes in. A text editor such as VS Code, Emacs, or vim can give many of the comforts of an IDE by communicating with an LSP server.

The Emacs LSP client is bare bones and sparsely documented. The Eclipse LSP server is buggy and sparsely documented. Their maintainers are not working together. Troubleshooting is painful. After far too many attempts, I finally came up with a satisfactory configuration—hooray! Now I know more about LSP than I ever wanted to. Hopefully these notes make it easier for others.

Comments

With a Mastodon account (or any account on the fediverse), please visit this link to add a comment.

Thanks to Carl Schwann for the code for loading the comments.

Not on the fediverse yet? Comment below with Talkyard.