LunarVim as a Java IDE

chris@machine
9 min readMay 5, 2023

--

Java presents a unique challenge in terms of integration with LunarVim/Neovim, as it is arguably the most complex language I have encountered in terms of tooling and IDE setup. However, I believe that with LunarVim, it is entirely possible to achieve an exceptional IDE experience while working with Java. In this article we’ll go over:

  • nvim-jdtls plugin
  • syntax highlighting
  • LSP (jdtls)
  • formatting
  • managing different Java versions
  • codelens
  • lombok
  • testing
  • debugging

Plugins

Before we can get started with any config let’s first install nvim-jdtls. LunarVim will give you some basic LSP support for Java out of the box, but if you want debugging, testing and other advanced features you will need this plugin. If you’d like to read more about it check out this repo: nvim-jdtls repo. First open up your config.lua :

lvim ~/.config/lvim/config.lua

Next we’ll add the nvim-jdtls plugin:

lvim.plugins = {
"mfussenegger/nvim-jdtls",
}

The last thing we’ll do here is disable the builtin jdtls support:

vim.list_extend(lvim.lsp.automatic_configuration.skipped_servers, { "jdtls" })

Now let’s close LunarVim, reopen and run :LvimCacheReset to make sure the LunarVim’s builtin support is disabled.

Syntax highlighting

You might observe that the syntax highlighting appears somewhat limited or lacks the expected colors. To enhance the syntax highlighting, we will execute a command to install the treesitter language parser specifically for Java.

:TSInstall java

To be sure the java language parser is always installed when you setup your config on a new machine you can add the following to your config:

lvim.builtin.treesitter.ensure_installed = {
"java",
}

You can add all of the languages you use to this list to automatically install them when setting up LunarVim.

Ftplugin Setup

We’re going to be using the ftplugin directory for the rest of this setup. If you don’t know how this directory works that’s okay I’ll explain. In your lvim directory under ~/.config/lvim you can add a special directory called ftplugin just like in Neovim. We’ll also be adding a java.lua file under this directory as well. The filename is important, whatever code you add in java.lua will run whenever you open a java file.

mkdir ~/.config/lvim/ftplugin
cd ~/.config/lvim/ftplugin
touch java.lua
lvim java.lua

For the rest of this article we will be adding the configuration to the java.lua file.

JDTLS Setup

This is a pretty lengthy and involved setup, but I’ll do my best to explain what I can while keeping this post succinct. If you’d like to see the complete configuration you can checkout the following repo: java-ide-starter. Before we get started let’s install the necessary language server and debug adapter. By entering :Mason and installing the following:

  • jdtls
  • java-debug-adapter
  • java-test

To get started we’ll import jdtls:

local status, jdtls = pcall(require, "jdtls")
if not status then
return
end

We’ll need to setup a workspace for data about our java projects. You can use whatever directory you want, I like to keep it in ~/.local/share/lunarvim/jdtls-workspace.

local home = os.getenv "HOME"
local workspace_path = home .. "/.local/share/lunarvim/jdtls-workspace/"
local project_name = vim.fn.fnamemodify(vim.fn.getcwd(), ":p:h:t")
local workspace_dir = workspace_path .. project_name

There are different configs depending on what platform you’re working on, you can detect whether or not you’re on MacOS or Linux with the following:

local os_config = "linux"
if vim.fn.has "mac" == 1 then
os_config = "mac"
end

LunarVim and nvim-jdtls both provide some capabilities for the language server. You can set them up like this:

local capabilities = require("lvim.lsp").common_capabilities()
local extendedClientCapabilities = jdtls.extendedClientCapabilities
extendedClientCapabilities.resolveAdditionalTextEditsSupport = true

To support debugging and testing there is a concept called bundles so we’ll add this config as well:

lvim.builtin.dap.active = true
local bundles = {}
local mason_path = vim.fn.glob(vim.fn.stdpath "data" .. "/mason/")
vim.list_extend(bundles, vim.split(vim.fn.glob(mason_path .. "packages/java-test/extension/server/*.jar"), "\n"))
vim.list_extend(
bundles,
vim.split(
vim.fn.glob(mason_path .. "packages/java-debug-adapter/extension/server/com.microsoft.java.debug.plugin-*.jar"),
"\n"
)
)

Now we’re ready to use all of the values we defined above in our config. Make sure to swap out the paths in the runtimes section to whatever java versions you want support for.

lvim.builtin.dap.active = true
local config = {
cmd = {
"java",
"-Declipse.application=org.eclipse.jdt.ls.core.id1",
"-Dosgi.bundles.defaultStartLevel=4",
"-Declipse.product=org.eclipse.jdt.ls.core.product",
"-Dlog.protocol=true",
"-Dlog.level=ALL",
"-Xms1g",
"--add-modules=ALL-SYSTEM",
"--add-opens",
"java.base/java.util=ALL-UNNAMED",
"--add-opens",
"java.base/java.lang=ALL-UNNAMED",
"-javaagent:" .. home .. "/.local/share/nvim/mason/packages/jdtls/lombok.jar",
"-jar",
vim.fn.glob(home .. "/.local/share/nvim/mason/packages/jdtls/plugins/org.eclipse.equinox.launcher_*.jar"),
"-configuration",
home .. "/.local/share/nvim/mason/packages/jdtls/config_" .. os_config,
"-data",
workspace_dir,
},
root_dir = require("jdtls.setup").find_root { ".git", "mvnw", "gradlew", "pom.xml", "build.gradle" },
capabilities = capabilities,

settings = {
java = {
eclipse = {
downloadSources = true,
},
configuration = {
updateBuildConfiguration = "interactive",
runtimes = {
{
name = "JavaSE-11",
path = "~/.sdkman/candidates/java/11.0.17-tem",
},
{
name = "JavaSE-18",
path = "~/.sdkman/candidates/java/18.0.2-sem",
},
},
},
maven = {
downloadSources = true,
},
referencesCodeLens = {
enabled = true,
},
references = {
includeDecompiledSources = true,
},
inlayHints = {
parameterNames = {
enabled = "all", -- literals, all, none
},
},
format = {
enabled = false,
},
},
signatureHelp = { enabled = true },
extendedClientCapabilities = extendedClientCapabilities,
},
init_options = {
bundles = bundles,
},
}

Note: Make sure you have Java 17+ installed as the main java executable on your system, jdtls requires it. If you don’t know how to install Java or need to be able to support multiple Java versions check out my article on SDKMAN! here.

Next we’ll define an on attach function. This function will run every time the language server starts up. This function will setup codelens and the debug adapter.

config["on_attach"] = function(client, bufnr)
local _, _ = pcall(vim.lsp.codelens.refresh)
require("jdtls").setup_dap({ hotcodereplace = "auto" })
require("lvim.lsp").on_attach(client, bufnr)
local status_ok, jdtls_dap = pcall(require, "jdtls.dap")
if status_ok then
jdtls_dap.setup_dap_main_class_configs()
end
end

We’ll also want codelens to refresh every time we save (If you don’t know what codelens is that’s okay you’ll see how it works later in the article).

vim.api.nvim_create_autocmd({ "BufWritePost" }, {
pattern = { "*.java" },
callback = function()
local _, _ = pcall(vim.lsp.codelens.refresh)
end,
})

We’ll also use the the google-java-format formatter. If you’d like to use the builtin formatter go back to the config we setup earlier and set format enabled to true. Also make sure to install google-java-format with :Mason.

local formatters = require "lvim.lsp.null-ls.formatters"
formatters.setup {
{ command = "google_java_format", filetypes = { "java" } },
}

Finally we’ll pass the config to jdtls.

require("jdtls").start_or_attach(config)

The last piece of config we’ll add are some leader keymaps so that we can easily debug, run tests and run other helpful methods nvim-jdtls provides.

local status_ok, which_key = pcall(require, "which-key")
if not status_ok then
return
end

local opts = {
mode = "n",
prefix = "<leader>",
buffer = nil,
silent = true,
noremap = true,
nowait = true,
}

local vopts = {
mode = "v",
prefix = "<leader>",
buffer = nil,
silent = true,
noremap = true,
nowait = true,
}

local mappings = {
C = {
name = "Java",
o = { "<Cmd>lua require'jdtls'.organize_imports()<CR>", "Organize Imports" },
v = { "<Cmd>lua require('jdtls').extract_variable()<CR>", "Extract Variable" },
c = { "<Cmd>lua require('jdtls').extract_constant()<CR>", "Extract Constant" },
t = { "<Cmd>lua require'jdtls'.test_nearest_method()<CR>", "Test Method" },
T = { "<Cmd>lua require'jdtls'.test_class()<CR>", "Test Class" },
u = { "<Cmd>JdtUpdateConfig<CR>", "Update Config" },
},
}

local vmappings = {
C = {
name = "Java",
v = { "<Esc><Cmd>lua require('jdtls').extract_variable(true)<CR>", "Extract Variable" },
c = { "<Esc><Cmd>lua require('jdtls').extract_constant(true)<CR>", "Extract Constant" },
m = { "<Esc><Cmd>lua require('jdtls').extract_method(true)<CR>", "Extract Method" },
},
}

which_key.register(mappings, opts)
which_key.register(vmappings, vopts)
which_key.register(vmappings, vopts)

LSP Features Demo

If you don’t have any java code on hand you can clone the following repo to test out some of the features:

git clone https://github.com/ChristianChiarulli/java-http-client-examples
cd java-http-client-examples
lvim .

Completion

language aware completion

You should notice basic language aware completion. As well as snippet support.

snippet support for System.out.println()

Diagnostics

use gl to see a popup for the error message

You should notice diagnostics are enabled for things like errors, hints warnings etc..

Formatting

To format a file you can press space l f to have the language server or google-java-format format your code.

Different Java Runtime Support

Remember that currently jdtls requires version Java 17+ to work. If you want to support older or multiple versions of Java refer back to the config section on runtimes:

runtimes = {
{
name = "JavaSE-11",
path = "~/.sdkman/candidates/java/11.0.17-tem",
},
{
name = "JavaSE-18",
path = "~/.sdkman/candidates/java/18.0.2-sem",
},
},

Make sure you add the correct path to the version of java you have installed. I recommend installing multiple versions with sdkman. Also the name field is not arbitrary, it must match one of the following exectution environments found here.

enum ExecutionEnvironment {
J2SE_1_5 = 'J2SE-1.5',
JavaSE_1_6 = 'JavaSE-1.6',
JavaSE_1_7 = 'JavaSE-1.7',
JavaSE_1_8 = 'JavaSE-1.8',
JavaSE_9 = 'JavaSE-9',
JavaSE_10 = 'JavaSE-10',
JavaSE_11 = 'JavaSE-11',
JavaSE_12 = 'JavaSE-12',
JavaSE_13 = 'JavaSE-13',
JavaSE_14 = 'JavaSE-14',
JavaSE_15 = 'JavaSE-15',
JavaSE_16 = 'JavaSE-16',
JavaSE_17 = 'JavaSE-17',
JavaSE_18 = 'JavaSE-18',
JAVASE_19 = 'JavaSE-19'
}

The language server will choose what runtime to use based on config found in your pom.xml or build.gradle file. For instance for maven:

  <properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
</properties>

In this case jdtls will choose the JavaSE-11 runtime.

Codelens Support

codelens support for references

The jdtls language server supports codelens, which is a useful feature used to see things like the reference count for a particular function or interface. For instance you can see this function is referenced twice throughout our codebase:

Lombok Support

lombok completion

We also setup lombok earlier so our editor will be aware of how to use lombok provided it is added as a dependency, for instance in your pom.xml:

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.24</version>
<scope>provided</scope>
</dependency>

Testing

testing

Testing support is included via the bundles we added in the setup process. Just hover over the method you would like to test and press space C t to run your test. If you would like to test the entire file you can press space C T.

Debugging

debugging

To debug a test you can add a breakpoint with space d b and run the test the same way as above. You should notice that the test stops at the breakpoint and you can continue, step into, step over etc with the debugging commands builtin to LunarVim, just press space d to see the options:

debug menu

Conclusion

If you made it this far congratulations, setting up Java with LunarVim is a pretty complicated process and I doubt many people have actually set up an efficient Java IDE with Neovim. This is one language where I won’t blame you if you decide to just use Intellij with the vim plugin. That said I’m sure the process will become easier in the future as more plugins become available. Personally I was able to use this setup to be an effective Java Engineer professionally for ~4 years.

--

--