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', '<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!