https://ptrtojoel.dev/posts/so-you-want-to-write-java-in-neovim/ Home Posts Tags So You Want to Write Java in Neovim on 2024-12-28 Note: I plan on keeping this post updated if I need to add more content or change something alt text 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 alt text 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). alt text 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', 'df', "lua require('jdtls').test_class()", opts) vim.keymap.set('n', 'dn', "lua require('jdtls').test_nearest_method()", 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', '', "lua require('jdtls').organize_imports()", opts) vim.keymap.set('n', 'crv', "lua require('jdtls').extract_variable()", opts) vim.keymap.set('x', 'crv', "lua require('jdtls').extract_variable(true)", opts) vim.keymap.set('n', 'crc', "lua require('jdtls').extract_constant()", opts) vim.keymap.set('x', 'crc', "lua require('jdtls').extract_constant(true)", opts) vim.keymap.set('x', 'crm', "lua require('jdtls').extract_method(true)", opts) vim.keymap.set('n', 'pjp', "lua require('jdtls').javap()", 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', '', function() require('dap').continue() end) vim.keymap.set('n', '', function() require('dap').step_over() end) vim.keymap.set('n', '', function() require('dap').step_into() end) vim.keymap.set('n', '', function() require('dap').step_out() end) vim.keymap.set('n', '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', 'du', function() dapui.toggle() end) I hope this helps you get started working with Java in Neovim!