undefined behavior

tb;dr - too boring; don’t read

Recent All Posts Categories Tags About Search

LSP Setup for Java

Update on <2023-06-07 Wed>

Found a much easier way to configure this and tried again with the update-to-date lsp-java and it worked magically. Can’t recall why I had configuration issues before.

Simply it would be:

  1. Make sure JDK 17 is installed (According to the requirement of lsp-java).
  2. Create a lib folder and put .jar files into it (or symlinks). See this issue.
  3. Run lsp-restart-workspace.

Voila! Now LSP works as you would expect :).

Background

As a Java newbie I found that Java setup is quite different from C++.

Initially I added lsp-java along with the existing lsp-mode I’ve been using. With zero configuration it seemed to work fine. Completion is well supported for Java built-in libraries. However, when I tried visiting some symbols from some other places in the project that I’m working on, say JUnit, things getting complicated.

In C++’s world, with clangd, I can easily write a compile_flags.txt file and paste dependency header paths in it. But in Java, the code is organized as one big project and they are linked together in a “workspace”. I believe this is a concept from Eclipse. It doesn’t work like C++ where all your projects are loosely coupled and only bundled together during compile time. That’s why you can write compile_flags.txt for arbitrary projects because basically you’re writing compiler options.

So, how to make LSP work with external dependencies without copying them to the current working project. I searched a lot but it seemed like no one had discussed this problem. Maybe this is a very fundamental knowledge that every Java developer should know and not worth discussing. As a Java newbie, this really made me crazy. However, after several experiments it turned rather easy than I thought.

Solution

The lsp-java uses eclipse.jdt.ls as the LSP service provider. Whenever a project is opened, it creates a workspace folder under ~/.emacs.d/workspace.

For example, I have a project called awesome_app. When I open it in Emacs, lsp-java creates ~/.emacs.d/workspace/awesome_app_69131352 directory for me.

tree -a -L 3
awesome_app_69131352/
├── bin
│   ├── com
│   │   └── awesome
├── .classpath
├── .project
└── .settings
    ├── org.eclipse.core.resources.prefs
    └── org.eclipse.jdt.core.prefs

In the bin directory it caches all class files. Normally those files are stored in the project’s root directory along with the .classpath and .project files if it is opened by Eclipse. lsp-java configures it separately by default. I feel it kind neat and I like it.

To add other dependencies, simply modify the .classpath and .project.

By default, .project only contains the awesome_app project. The external dependencies need to be added under <linkedResources> section. It will look like this after modifications.

Note that the <type> should be 2 if this is a directory. Otherwise 1 for files. Details can be found in Eclipse document.

<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
	<name>awesome_app_69131352</name>
	<comment></comment>
	<projects>
	</projects>
	<buildSpec>
		<buildCommand>
			<name>org.eclipse.jdt.core.javabuilder</name>
			<arguments>
			</arguments>
		</buildCommand>
	</buildSpec>
	<natures>
		<nature>org.eclipse.jdt.core.javanature</nature>
	</natures>
	<linkedResources>
		<link>
			<name>_</name>
			<type>2</type>
			<location>/home/fang/awesome_app</location>
		</link>
		<link>
			<name>junit</name>
			<type>2</type>
			<location>/home/fang/external/junit</location>
		</link>
		<link>
			<name>mockito</name>
			<type>2</type>
			<location>/home/fang/external/mockito</location>
		</link>
	</linkedResources>
	<filteredResources>
		<filter>
			<id>1676757224502</id>
			<name></name>
			<type>30</type>
			<matcher>
				<id>org.eclipse.core.resources.regexFilterMatcher</id>
				<arguments>node_modules|.metadata|archetype-resources|META-INF/maven|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
			</matcher>
		</filter>
	</filteredResources>
</projectDescription>

Then modify the .classpath file to let JDT server cache dependency class files.

The dependency’s kind should be src and path should be name/path/to/root/of/package. The name in path is the name we specified in the .project for this dependency and the relative path should be the root where package’s fully qualified name starts.

<?xml version="1.0" encoding="UTF-8"?>
<classpath>
	<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"/>
	<classpathentry kind="src" path="_/src"/>
	<classpathentry kind="src" path="junit/src/main/java"/>
	<classpathentry kind="src" path="mockito/src/main/java"/>
	<classpathentry kind="output" path="bin"/>
</classpath>

Lastly, restart LSP workspace and see if cached class files appear in bin directory. It should have something like this.

awesome_app_69131352/
├── bin
│   ├── com
│   │   └── awesome
│   ├── junit
│   │   ├── extensions
│   │   ├── framework
│   │   ├── runner
│   │   └── textui
│   └── org
│       ├── junit
│       └── mockito
├── .classpath
├── .project
└── .settings
    ├── org.eclipse.core.resources.prefs
    └── org.eclipse.jdt.core.prefs

Hooray!