Neovim as Java IDE

Last updated: Wed Feb 21 2024

A step by step guide to set up Neovim for Java development, capable of fully replacing IntelliJ. This is not a copy-paste solution but rather a series of instructions that you can adapt to your existing configuration. I have confirmed that it works for Linux and MacOS, and it may work for Windows too (with some minor tweaks).

You can find the minimal setup in this GitHub repository, I'll try to keep it up to date, but feel free to open an issue if I miss something.

  1. Install the package manager
  2. Set up the Language Server
  3. Get code completion
  4. Run tests
  5. Run and debug code
  6. Run and debug tests

1. Install the package manager

Mason.nvim allows you to manage third party dependencies such as Language Servers, linters, and other binaries that you will require through this guide. Follow the installation instructions and call the setup() function.

-- Plugin manager: add mason.nvim
{ 'williamboman/mason.nvim' }
-- init.lua
require'mason'.setup()

2. Setup the Language Server

Eclipse's JDT Language Server provides the language comprehension required to be notified of syntax errors, navigate through the code, autocompletion (fully set up in step 3), and code actions such as add missing imports. You can install the language server with Mason by running :MasonInstall jdtls, you will also need the nvim-jdtls plugin that is the Neovim client for that server.

-- Plugin manager: add nvim-jdtls
{ 'mfussenegger/nvim-jdtls' }

To set it up you need to call start_or_attach when a Java file is open, this can be achieved with an autocmd or java.lua file inside ftplugin/ directory. You need to populate the cmd property with the jdtls executable. If you've installed it using Mason, an executable script should be located in $HOME/.local/share/nvim/mason/bin/jdtls.

-- ftplugin/java.lua: call start_or_attach when a Java file is loaded
require'jdtls'.start_or_attach({
    cmd = {
        vim.fn.expand'$HOME/.local/share/nvim/mason/bin/jdtls',
    }
})

You should have a working client and server, open any Java project to see the Language Server loading. It may take a while but you should be able to see information about unused variables and syntax errors. There are also new commands available inside a Java file, you can type :Jdt and autocomplete the options.

Note: Python3 and Java 17+ is required for jdtls to work. If you run into any issues try executing the binary directly from the command line (I already gave you the path) or call :JdtShowLogs to get client logs.

2b. Add Lombok Support

Add the Java agent to JDTLS with the flag -javaagent pointing to the location of lombok.jar. If you used Mason.nvim, it already came with your JDTLS instalation. Update nvim-jdtls setup like this:

 -- ftplugin/java.lua: add arguments to jdtls script
 require'jdtls'.start_or_attach({
     cmd = {
         vim.fn.expand'$HOME/.local/share/nvim/mason/bin/jdtls',
+        ('--jvm-arg=-javaagent:%s'):format(vim.fn.expand'$HOME/.local/share/nvim/mason/packages/jdtls/lombok.jar')
     }
 })

3. Get code completion

The most popular completion plugin is nvim-cmp which has a couple dependencies, make sure to install them all including the source for LSP completion.

-- Plugin manager: install nvim-cmp, LuaSnip, cmp_luasnip, and cmp-nvim-lsp
{
    'hrsh7th/nvim-cmp',
    version = false, -- Ignore tags because nvim-cmp has a very old tag
    dependencies = {
        'L3MON4D3/LuaSnip',         -- Snippet engine
        'saadparwaiz1/cmp_luasnip', -- Snippet engine adapter
        'hrsh7th/cmp-nvim-lsp',     -- Source for LSP completion
    },
}
 -- Plugin manager: add cmp-nvim-lsp as dependency to nvim-jdtls
 {
     'mfussenegger/nvim-jdtls',
+    dependencies = 'hrsh7th/cmp-nvim-lsp',
 },

Next step is to set up nvim-cmp by calling setup() to configure the snippet engine, essential mappings, and the LSP completion source.

-- init.lua: setup nvim-cmp
require'cmp'.setup({
    snippet = {
        -- Exclusive to LuaSnip, check nvim-cmp documentation for usage with a different snippet engine
        expand = function(args)
            require'luasnip'.lsp_expand(args.body)
        end
    },
    mapping = {
        -- Sample but necessary mappings, read nvim-cmp documentation to customize them
        ['<C-c>'] = require'cmp'.mapping.abort(),
        ['<CR>'] = require'cmp'.mapping.confirm(),
        ['<C-n>'] = require'cmp'.mapping.select_next_item(),
        ['<C-p>'] = require'cmp'.mapping.select_prev_item(),
    },
    sources = {
        { name = 'nvim_lsp' },
    },
})

Finally, connect nvim-jdtls with nvim-cmp by adding completion capabilities.

 -- ftplugin/java.lua
 require'jdtls'.start_or_attach{
     cmd = {
         vim.fn.expand'$HOME/.local/share/nvim/mason/bin/jdtls',
         ('--jvm-arg=-javaagent:%s'):format(vim.fn.expand'$HOME/.local/share/nvim/mason/packages/jdtls/lombok.jar')
     },
+    capabilities = require'cmp_nvim_lsp'.default_capabilities()
 }

You should have completion working, open a Java file and start typing. You can cycle through the results with <C-n> and <C-P>, and select them with <CR>.

4. Run tests

Neotest provides a great interface for running tests, for it to work you need to install it alongside its dependencies including nvim-treesitter.

-- Plugin manager: install neotest with its necessary dependencies
{
    'nvim-neotest/neotest',
    dependencies = {
        'nvim-lua/plenary.nvim',
        'nvim-treesitter/nvim-treesitter',
        'antoinemadec/FixCursorHold.nvim',
        'rcasia/neotest-java',
    },
}
-- init.lua: setup neotest
require'neotest'.setup({
    adapters = {
        require'neotest-java',
    },
})

After installing neotest and its dependencies you are going to need the Java parser, install it by calling :TSInstall java (note: a C compiler is required to build the parser, you can use GCC or Clang).

You should be able to invoke the following commands to view and run tests. If you run into any trouble you can check neotest logs in ~/.local/state/nvim/neotest.log

require('neotest').output_panel.toggle()        -- Opens/closes test pannel
require('neotest').summary.toggle()             -- Toggle summary

require('neotest').run.run()                    -- Test nearest
require('neotest').run.run(vim.fn.expand('%'))  -- Test file
require('neotest').run.run(vim.loop.cwd())      -- Test project
require('neotest').run.stop()                   -- Stop testing

5. Run and debug code

To run and debug code you need a combination of nvim-jdtls, nvim-dap, and nvim-dap-ui. We also need to install java-debug.

Install the necessary plugins, you should already have nvim-jdtls from step 2 so you only need to add nvim-dap and its ui. There's no need to call setup() here.

-- Plugin manager: add nvim-dap-ui and nvim-dap
{
    'rcarriga/nvim-dap-ui',
    dependencies = 'mfussenegger/nvim-dap',
},

Install the debug server with :MasonInstall java-debug-adapter, and bundle the jar together with nvim-djtls by adding it to the bundles property. This property takes a list of paths to jar files.

 require'jdtls'.start_or_attach({
     cmd = {
         vim.fn.expand'$HOME/.local/share/nvim/mason/bin/jdtls',
         ('--jvm-arg=-javaagent:%s'):format(vim.fn.expand'$HOME/.local/share/nvim/mason/packages/jdtls/lombok.jar'),
     },
     capabilities = require'cmp_nvim_lsp'.default_capabilities(),
+    bundles = { vim.fn.expand'$HOME/.local/share/nvim/mason/packages/java-debug-adapter/extension/server/com.microsoft.java.debug.plugin-*.jar' },
 })

You should be able to open any Java project, run :JdtUpdateDebugConfigs, and access the following commands:

require'dap'.toggle_breakpoint()    -- Set or unset breakpoint
require'dap'.continue()             -- Start debuging or continue to next breakpoint
require'dap'.step_over()            -- Step over
require'dap'.step_into()            -- Step into
require'dap'.repl.open()            -- Open repl
require'dap'.restart()              -- Restart debugging session
require'dap'.close()                -- Close debugging session

require'dapui'.eval()               -- See runtime values of the variables under cursor
require'dapui'.toggle()             -- Open or close debugging UI

6. Debug tests

While we wait for neotest to support debugging we can rely on nvim-dap to debug tests.

A working language server and debug adapter are required. start by installing java-test with mason :MasonInstall java-test, and update your jdtls configuration to include a list of all jars from both java-debug and java-test. Because Mason installs them in similar paths you can use vim.fn.glob function to get a newline separated string containing all required jars that you can convert into a list usin vim.split. we can use vim.fn.glob to get a newline separated string containing al jars,

require'jdtls'.start_or_attach({
    cmd = {
        vim.fn.expand'$HOME/.local/share/nvim/mason/bin/jdtls',
        ('--jvm-arg=-javaagent:%s'):format(vim.fn.expand'$HOME/.local/share/nvim/mason/packages/jdtls/lombok.jar'),
    },
    capabilities = require'cmp_nvim_lsp'.default_capabilities(),
-    bundles = { vim.fn.expand'$HOME/.local/share/nvim/mason/packages/java-debug-adapter/extension/server/com.microsoft.java.debug.plugin-*.jar' },
+    bundles = vim.split(vim.fn.glob('$HOME/.local/share/nvim/mason/packages/java-*/extension/server/*.jar', 1), '\n'),
})

You should have access to two new functions that will help you debug tests, together with the previously mentioned commands you should be able to set breakpoints and debug normally.

require'jdtls'.test_class()             -- Run all tests in class
require'jdtls'.test_nearest_method()    -- Run test closest to cursor

That's it!

I hope this helps you set up your environment. I have made this guide to celebrate my javaniversary as today (February 17, 2024) is my third year developing Java on a daily basis, and I have been using Neovim for Java development since aproximately that much minus 2 months that took me to figure out how to set it up.

Additional resources:

Plugins used:

Packages used: