So You Want to Write Java in Neovim
Note: I plan on keeping this post updated if I need to add more content or change something
I have been doing Java in Neovim for quite a while at work, and it’s been a very pleasant experience. As Neovim usage grows (especially amongst the younger crowd), I want to share how I do it.
I think historically it's been considered a painful experience, but with guidance, it can be very straightforward!
I’ll preface this by saying that if Neovim isn’t your primary editor, you should first try an IDE specifically for Java (they should all have a Vim plugin):
- Eclipse
- IntelliJ
- Apache Netbeans
If Neovim is your primary editor, you probably hate opening *insert IDE that turns you into snail* for a specific language, and so did I.
LSP
Java has one LSP option for Neovim, and that’s JDTLS (Java Development Tools Language Server) by Eclipse. You should read the project README for a high-level overview on it (including features): JDTLS GitHub
It’s a great LSP for Java, and I think it’s all you need to work with Java projects. My personal workflow usually involves one tmux window with a project open and another window handling the compiling, testing etc
To use JDTLS in Neovim, there are two plugins you can choose from, and which you decide on depends on your preferences.
YOU USE A DISTRO
If you're happy accepting out-of-the-box, all-in-one setups, then nvim-java might be for you. It attempts to be a comprehensive solution with popular defaults, and be hassle-free when it comes to LSP, debugging, testing setups. It’s not completely flexible, so if you need more control, you should try the next option.
YOU READ THE FRIENDLY MANUAL
I expect the majority to fit here, and nvim-jdtls is the go-to Java plugin for LSP support in Neovim. You have full access to configure JDTLS, and I highly recommend reading through the available options.
Just remember to install JDTLS via Mason.
Sometimes you will need to provide a reference JAR that the LSP can hook into loading. I downloaded a Lombok JAR and added it at the JDTLS install path (you will see this in my nvim-jdtls config below), and I at least know this had to be done for Playwright under 'referencedLibraries'.
DEBUGGING
Debugging can be done inside Neovim, but again, keep in mind that you may have a better experience in a Java-focused IDE.
I recommend installing nvim-dap and nvim-dap-ui.
You will need to install java-debug-adapter from Mason OR download it and reference it in the lsp config (for nvim-jdtls only).
TESTING
Working with tests inside Neovim is also possible, follows a similar setup to the above.
You will need to install java-test from Mason OR download it and reference it in the lsp config (for nvim-jdtls only).
MY SETUP
I will show what I have as a point of reference:
I imagine you are also using treesitter, lspzero etc.
JDTLS
local java_cmds = vim.api.nvim_create_augroup('java_cmds', { clear = true })
local cache_vars = {}
local root_files = {
'.git',
'mvnw',
'gradlew',
'pom.xml',
'build.gradle',
'build.sbt'
}
local features = {
-- change this to `true` to enable codelens
codelens = true,
-- change this to `true` if you have `nvim-dap`,
-- `java-test` and `java-debug-adapter` installed
debugger = true,
}
local function get_jdtls_paths()
if cache_vars.paths then
return cache_vars.paths
end
local path = {}
path.data_dir = vim.fn.stdpath('cache') .. '/nvim-jdtls'
local jdtls_install = require('mason-registry')
.get_package('jdtls')
:get_install_path()
path.java_agent = jdtls_install .. '/lombok.jar'
path.launcher_jar = vim.fn.glob(jdtls_install .. '/plugins/org.eclipse.equinox.launcher_*.jar')
if vim.fn.has('mac') == 1 then
path.platform_config = jdtls_install .. '/config_mac'
elseif vim.fn.has('unix') == 1 then
path.platform_config = jdtls_install .. '/config_linux'
elseif vim.fn.has('win32') == 1 then
path.platform_config = jdtls_install .. '/config_win'
end
path.bundles = {}
---
-- Include java-test bundle if present
---
local java_test_path = require('mason-registry')
.get_package('java-test')
:get_install_path()
local java_test_bundle = vim.split(
vim.fn.glob(java_test_path .. '/extension/server/*.jar'),
'\n'
)
if java_test_bundle[1] ~= '' then
vim.list_extend(path.bundles, java_test_bundle)
end
---
-- Include java-debug-adapter bundle if present
---
local java_debug_path = require('mason-registry')
.get_package('java-debug-adapter')
:get_install_path()
local java_debug_bundle = vim.split(
vim.fn.glob(java_debug_path .. '/extension/server/com.microsoft.java.debug.plugin-*.jar'),
'\n'
)
if java_debug_bundle[1] ~= '' then
vim.list_extend(path.bundles, java_debug_bundle)
end
---
-- Useful if you're starting jdtls with a Java version that's
-- different from the one the project uses.
---
path.runtimes = {
-- Note: the field `name` must be a valid `ExecutionEnvironment`,
-- you can find the list here:
-- https://github.com/eclipse/eclipse.jdt.ls/wiki/Running-the-JAVA-LS-server-from-the-command-line#initialize-request
--
-- This example assume you are using sdkman: https://sdkman.io
{
name = 'JavaSE-21',
path = vim.fn.expand('~/.sdkman/candidates/java/21.0.2-tem'),
},
{
name = 'JavaSE-23',
path = vim.fn.expand('~/.sdkman/candidates/java/23-tem'),
}
}
cache_vars.paths = path
return path
end
local function enable_codelens(bufnr)
pcall(vim.lsp.codelens.refresh)
vim.api.nvim_create_autocmd('BufWritePost', {
buffer = bufnr,
group = java_cmds,
desc = 'refresh codelens',
callback = function()
pcall(vim.lsp.codelens.refresh)
end,
})
end
local function enable_debugger(bufnr)
require('jdtls').setup_dap({ hotcodereplace = 'auto' })
require('jdtls.dap').setup_dap_main_class_configs()
local opts = { buffer = bufnr }
vim.keymap.set('n', '<leader>df', "<cmd>lua require('jdtls').test_class()<cr>", opts)
vim.keymap.set('n', '<leader>dn', "<cmd>lua require('jdtls').test_nearest_method()<cr>", opts)
end
local function jdtls_on_attach(client, bufnr)
--vim.lsp.inlay_hint(bufnr, true)
if features.debugger then
enable_debugger(bufnr)
end
if features.codelens then
enable_codelens(bufnr)
end
-- The following mappings are based on the suggested usage of nvim-jdtls
-- https://github.com/mfussenegger/nvim-jdtls#usage
local opts = { buffer = bufnr }
vim.keymap.set('n', '<A-o>', "<cmd>lua require('jdtls').organize_imports()<cr>", opts)
vim.keymap.set('n', 'crv', "<cmd>lua require('jdtls').extract_variable()<cr>", opts)
vim.keymap.set('x', 'crv', "<esc><cmd>lua require('jdtls').extract_variable(true)<cr>", opts)
vim.keymap.set('n', 'crc', "<cmd>lua require('jdtls').extract_constant()<cr>", opts)
vim.keymap.set('x', 'crc', "<esc><cmd>lua require('jdtls').extract_constant(true)<cr>", opts)
vim.keymap.set('x', 'crm', "<esc><Cmd>lua require('jdtls').extract_method(true)<cr>", opts)
vim.keymap.set('n', '<leader>pjp', "<cmd>lua require('jdtls').javap()<cr>", opts)
end
local function jdtls_setup(event)
local jdtls = require('jdtls')
local extendedClientCapabilities = jdtls.extendedClientCapabilities;
extendedClientCapabilities.onCompletionItemSelectedCommand = "editor.action.triggerParameterHints"
local path = get_jdtls_paths()
local data_dir = path.data_dir .. '/' .. vim.fn.fnamemodify(vim.fn.getcwd(), ':p:h:t')
if cache_vars.capabilities == nil then
jdtls.extendedClientCapabilities.resolveAdditionalTextEditsSupport = true
local ok_cmp, cmp_lsp = pcall(require, 'cmp_nvim_lsp')
cache_vars.capabilities = vim.tbl_deep_extend(
'force',
vim.lsp.protocol.make_client_capabilities(),
ok_cmp and cmp_lsp.default_capabilities() or {}
)
end
-- The command that starts the language server
-- See: https://github.com/eclipse/eclipse.jdt.ls#running-from-the-command-line
local 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',
'-javaagent:' .. path.java_agent,
'-Xms1g',
'--add-modules=ALL-SYSTEM',
'--add-opens',
'java.base/java.util=ALL-UNNAMED',
'--add-opens',
'java.base/java.lang=ALL-UNNAMED',
-- 💀
'-jar',
path.launcher_jar,
-- 💀
'-configuration',
path.platform_config,
-- 💀
'-data',
data_dir,
}
local lsp_settings = {
java = {
-- jdt = {
-- ls = {
-- vmargs = "-XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Dsun.zip.disableMemoryMapping=true -Xmx1G -Xms100m"
-- }
-- },
project = {
referencedLibraries = {
-- add any library jars here for the lsp to pick them up
},
},
eclipse = {
downloadSources = true,
},
configuration = {
updateBuildConfiguration = 'interactive',
runtimes = path.runtimes,
},
maven = {
downloadSources = true,
},
implementationsCodeLens = {
enabled = true,
},
referencesCodeLens = {
enabled = true,
},
references = {
includeDecompiledSources = true,
},
inlayHints = {
enabled = true,
--parameterNames = {
-- enabled = 'all' -- literals, all, none
--}
},
format = {
enabled = true,
-- settings = {
-- profile = 'asdf'
-- },
}
},
signatureHelp = {
enabled = true,
},
completion = {
favoriteStaticMembers = {
'org.hamcrest.MatcherAssert.assertThat',
'org.hamcrest.Matchers.*',
'org.hamcrest.CoreMatchers.*',
'org.junit.jupiter.api.Assertions.*',
'java.util.Objects.requireNonNull',
'java.util.Objects.requireNonNullElse',
'org.mockito.Mockito.*',
},
},
contentProvider = {
preferred = 'fernflower',
},
extendedClientCapabilities = jdtls.extendedClientCapabilities,
sources = {
organizeImports = {
starThreshold = 9999,
staticStarThreshold = 9999,
}
},
codeGeneration = {
toString = {
template = '${object.className}{${member.name()}=${member.value}, ${otherMembers}}',
},
useBlocks = true,
},
}
-- This starts a new client & server,
-- or attaches to an existing client & server depending on the `root_dir`.
jdtls.start_or_attach({
cmd = cmd,
settings = lsp_settings,
on_attach = jdtls_on_attach,
capabilities = cache_vars.capabilities,
root_dir = jdtls.setup.find_root(root_files),
flags = {
allow_incremental_sync = true,
},
init_options = {
bundles = path.bundles,
extendedClientCapabilities = extendedClientCapabilities,
},
})
end
vim.api.nvim_create_autocmd('FileType', {
group = java_cmds,
pattern = { 'java' },
desc = 'Setup jdtls',
callback = jdtls_setup,
})
DAP
local dap = require('dap')
dap.configurations.java = {
{
type = 'java',
request = 'launch',
name = 'Launch Java Program'
},
}
vim.fn.sign_define('DapBreakpoint',
{
text = '🔴',
texthl = 'DapBreakpointSymbol',
linehl = 'DapBreakpoint',
numhl = 'DapBreakpoint'
})
vim.fn.sign_define('DapStopped',
{
texthl = 'DapStoppedSymbol',
linehl = 'CursorLine',
numhl = 'DapBreakpoint'
})
vim.keymap.set('n', '<F5>', function() require('dap').continue() end)
vim.keymap.set('n', '<F10>', function() require('dap').step_over() end)
vim.keymap.set('n', '<F11>', function() require('dap').step_into() end)
vim.keymap.set('n', '<F12>', function() require('dap').step_out() end)
vim.keymap.set('n', '<Leader>b', function() require('dap').toggle_breakpoint() end)
local dapui = require('dapui')
dapui.setup()
dap.listeners.before.attach.dapui_config = function()
dapui.open()
end
dap.listeners.before.launch.dapui_config = function()
dapui.open()
end
dap.listeners.before.event_terminated.dapui_config = function()
--dapui.close()
end
dap.listeners.before.event_exited.dapui_config = function()
--dapui.close()
end
vim.keymap.set('n', '<Leader>du', function() dapui.toggle() end)
I hope this helps you get started working with Java in Neovim!