Scoping nvim-jdtls to a Maven subproject in a polyglot LazyVim setup
How a root_dir callback gate scopes jdtls to a Maven backend in a polyglot pnpm monorepo on Neovim 0.12.
Opening a Capacitor Java file was kicking off a Gradle compile. After a few too many of those in a pnpm monorepo, I had to fix the configuration.
The fix uses the Neovim 0.12 native LSP API. The gate is a root_dir(bufnr, on_dir) callback that only calls on_dir for backend files. The callback signature is the key detail: in Neovim 0.11+, if on_dir is never called, the LSP client never starts.1
Unwanted Gradle trigger
I work in a pnpm monorepo where the Spring Boot backend lives under backend/ (Maven) and the mobile frontends live in web-app/android/ (Capacitor) and apps/<name>/android/ (Expo / React Native). I want LSP-backed Java editing for the backend and nothing else.
LazyVim ships a lang.java extra that configures nvim-jdtls. The extra’s root_dir walks up looking for build.gradle, build.gradle.kts, build.xml, pom.xml, settings.gradle, and settings.gradle.kts. When I open a Capacitor file at web-app/android/app/src/main/java/com/example/Foo.java, the walk finds web-app/android/build.gradle and jdtls starts an Eclipse JDT workspace rooted at web-app/android/. From there the importer walks the rest of the Android project and :messages starts showing Gradle build output.
The First Fix That Did Not Work
I tried to override root_dir with vim.fs.root(path, { "pom.xml" }). For the Capacitor path that walks up looking for pom.xml (there is none in web-app/) and returns nil, so jdtls should not start. In practice the override did not prevent jdtls from attaching. The reason is in nvim-jdtls’s setup.lua: start_or_attach has its own fallback when config.root_dir is falsy:
config.root_dir = (config.root_dir
or M.find_root({'.git', 'gradlew', 'mvnw'}, bufname)
or vim.fn.getcwd())
.git lives at the monorepo root, so the fallback revived jdtls with root_dir = <monorepo root>.2 With my settings (Gradle importer disabled, resourceFilters excluding the mobile trees) the Gradle build stopped, but :LspInfo still showed a jdtls client on every Java buffer in the monorepo. The plugin treats nil as “use the fallback” rather than “do not start”.
Why the 0.12 Native API Helps
Neovim 0.11 introduced the native LSP API and Neovim 0.12 continued that direction. The canonical way to set up a server is now vim.lsp.config('jdtls', { ... }) followed by vim.lsp.enable('jdtls'), and the require('lspconfig').jdtls.setup({}) form is deprecated.
The native API solves the monorepo gate in root_dir itself. In 0.11+, root_dir is an async callback with the signature root_dir(bufnr, on_dir). Call on_dir(root_path) to attach the server; do nothing and the client never starts. This is the gate.
The .git fallback in start_or_attach is irrelevant with this approach. vim.lsp.enable uses the native LSP client manager, which calls our root_dir callback directly. start_or_attach is never invoked, so its fallback chain never runs.3
The Pure Neovim 0.12 Solution
The full file is at ~/.config/nvim/lua/config/lsp/jdtls.lua. Three parts: the root_dir callback (the gate), defensive Eclipse JDT settings, and the cache directory for metadata.
1. root_dir(bufnr, on_dir) callback (the gate)
Two helpers, then the callback. The callback handles three cases: backend files attach to backend/, other monorepo files are gated (no on_dir call), and standalone projects fall through to standard markers.
The first helper finds the toplevel pnpm-workspace.yaml:
-- ~/.config/nvim/lua/config/lsp/jdtls.lua
local function find_toplevel_pnpm_workspace(start)
local dir = vim.fs.dirname(start)
local result = nil
while dir ~= "" and dir ~= "/" do
if vim.fn.filereadable(dir .. "/pnpm-workspace.yaml") == 1 then
result = dir
end
dir = vim.fs.dirname(dir)
end
return result
end
The custom walker is the part that took the most iteration. vim.fs.root(path, { "pnpm-workspace.yaml" }) returns the NEAREST ancestor with the marker, and the monorepo has nested pnpm-workspace.yaml files (some subprojects ship their own for supply chain policy). For a Capacitor file at web-app/node_modules/.../Foo.java the nearest pnpm-workspace.yaml is at web-app/, not the monorepo root, so the mono .. "/backend/pom.xml" check fails. The custom helper climbs all the way to the filesystem root and keeps the highest match.
The second helper uses the toplevel to classify the buffer into one of three cases:
-- ~/.config/nvim/lua/config/lsp/jdtls.lua
local function resolve_backend_dir(fname)
if fname == "" then
return nil, false
end
local mono = find_toplevel_pnpm_workspace(fname)
if mono == nil then
return nil, false -- outside monorepo: use standard markers
end
if vim.fn.filereadable(mono .. "/backend/pom.xml") ~= 1 then
return nil, true -- in monorepo, no backend: gate
end
if vim.startswith(fname, mono .. "/backend/") then
return mono .. "/backend", true -- backend file: attach
end
return nil, true -- in monorepo, not backend: gate
end
The root_dir callback ties it together. This is where on_dir is either called or withheld:
-- ~/.config/nvim/lua/config/lsp/jdtls.lua
vim.lsp.config("jdtls", {
cmd = {
"jdtls",
"--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false",
},
root_dir = function(bufnr, on_dir)
local fname = vim.api.nvim_buf_get_name(bufnr)
if fname == "" then
return
end
local backend_dir, in_mono = resolve_backend_dir(fname)
-- Backend files in the monorepo: attach to backend/.
if backend_dir then
on_dir(backend_dir)
return
end
-- Other files inside the monorepo: gate.
-- Do not call on_dir; jdtls never attaches.
if in_mono then
return
end
-- Standalone Java projects: use standard JDTLS markers.
local root = vim.fs.root(fname, {
"build.gradle",
"build.gradle.kts",
"build.xml",
"pom.xml",
"settings.gradle",
"settings.gradle.kts",
})
if root then
on_dir(root)
end
end,
settings = {}, -- see next section
})
-- Auto-activate jdtls for java buffers. root_dir above is the gate.
vim.lsp.enable("jdtls")
The callback takes bufnr (a buffer number), not a file path. The first line extracts the path with vim.api.nvim_buf_get_name(bufnr). This was the bug that took the longest to find: I originally wrote root_dir = function(path) assuming a string argument and return-ing the root. Neovim 0.11+ expects root_dir(bufnr, on_dir) and a callback invocation. With the return-value pattern the client never started because on_dir was never called.
2. Defensive import settings
The gate in root_dir stops jdtls from spawning, but in case the gate ever fails open I keep the workspace scan from picking up *.gradle files in adjacent trees. I disabled the Gradle importer, disabled first-time auto-import, and added the build and output directories to the exclusion globs:
-- ~/.config/nvim/lua/config/lsp/jdtls.lua (settings excerpt)
settings = {
java = {
import = {
exclusions = {
"**/android/**",
"**/.gradle/**",
"**/build/**",
"**/dist/**",
"**/ios/**",
"**/node_modules/**",
"**/capacitor-cordova-android-plugins/**",
"**/.gitlab-ci-local/**",
},
gradle = { enabled = false },
maven = { enabled = true },
},
project = {
importOnFirstTimeStartup = "disabled",
resourceFilters = {
"node_modules", ".git", "android", "ios",
"build", "dist", ".gradle", ".gitlab-ci-local",
"web-app", "apps", "packages",
},
},
server = { launchMode = "LightWeight" },
jdt = { ls = { androidSupport = { enabled = "off" } } },
},
},
Three layers of defense, ordered by when each activates:
root_dircallback: per-buffer gate viaon_dir. This is the primary defense.gradle.enabled = false: server-level preference, active on fresh boot. Stops a stray*.gradlein adjacent trees from being imported as a Gradle project before exclusions apply.import.exclusions+project.resourceFilters: post-init only. Eclipse JDT-LS ignores them on the first workspace init, so they protect the next re-import, not the first boot.
gradle.enabled = false is the most important defensive toggle. The Eclipse JDT Gradle importer respects it: even if a stray build.gradle is found, jdtls refuses to import it as a Gradle project.
3. Metadata stays in the cache
This line keeps jdtls from writing .project, .classpath, .settings, and .factorypath into the source tree:
-- ~/.config/nvim/lua/config/lsp/jdtls.lua
cmd = {
"jdtls",
"--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false",
},
The metadata now lives at ~/.cache/nvim/jdtls/<project>/workspace/.
Verified behavior
After restarting Neovim:
- A buffer in
backend/src/main/java/.../Foo.javashowsjdtlsattached withroot_dir = .../<monorepo>/backendin:LspInfo. Theroot_dircallback calledon_dir(backend_dir)for this buffer. - A buffer in
web-app/android/app/src/main/java/.../Foo.javashows nothing in:LspInfo. Theroot_dircallback returned without callingon_dirfor this buffer, so the LSP client was never created. - A buffer in
web-app/node_modules/.pnpm/.../Foo.java(a Capacitor source) has no jdtls client, even though it lives in aweb-app/subtree with its ownpnpm-workspace.yaml. Thefind_toplevel_pnpm_workspacehelper climbs past the nested file to the real monorepo root. find <monorepo> -name ".project" -o -name ".classpath" -o -name ".settings" -o -name ".factorypath"returns nothing.:messagesno longer shows Gradle compile output when picking a mobile file.
Limitations
I spent time on two dead ends before finding the real solution.
The first dead end was the root_dir return-value override. nvim-jdtls has a .git fallback in start_or_attach that revives the client with the monorepo root when root_dir returns nil. Returning nil triggers the fallback instead of stopping the client.
The second dead end was a FileType autocmd that called vim.lsp.enable per buffer. This was closer to the right idea, but it still required root_dir to work. At the time my root_dir used a return-value signature (function(path) return root end), which Neovim 0.11+ ignores. The client never started because the async callback on_dir was never called.
The fix was reading the vim.lsp.enable documentation carefully. The example shows root_dir = function(bufnr, on_dir) and a call to on_dir(). With the correct callback signature, root_dir alone is both the gate and the root resolver. The FileType autocmd and per-buffer vim.lsp.enable were unnecessary.
The ftplugin/java.lua path from the nvim-jdtls README is the documented alternative for users who call jdtls.start_or_attach(config) directly. That path still has the .git fallback problem. The native vim.lsp.enable path bypasses start_or_attach entirely, so the fallback is irrelevant.
References
Tooling
- pnpm workspaces. The monorepo workspace configuration where this problem occurs.
Neovim 0.12 native LSP
- Native LSP in Neovim 0.12. A focused guide to
vim.lsp.configandvim.lsp.enableon 0.12. vim.lsp.enable. The documentation shows theroot_dir(bufnr, on_dir)callback signature and theon_dir()pattern used as the gate in this post.vim.lsp.config. Merges a user config with the server’s shippedlsp/<name>.luaconfig. Overrides fields you set; preserves fields you leave out.- nvim-lspconfig
jdtlsconfig. The legacylspconfigconfig, deprecated in favor ofvim.lsp.config.
nvim-jdtls
- nvim-jdtls. The Eclipse JDT language server wrapper for Neovim. Ships an
lsp/jdtls.luaconfig for the native API. The “Via lsp.config” and “Via ftplugin” sections in the README are the two documented setup paths. - nvim-jdtls
setup.lua. Thestart_or_attachfallback chain. Bypassed entirely when usingvim.lsp.enablewith a customroot_dircallback. vim.fs.root. The filesystem-walk helper used in the standalone-project fallback.
LazyVim
- LazyVim. The Neovim distribution used in the original setup.
- LazyVim
lang.javaextra. Theattach_jdtlsfunction andFileTypeautocmd implement the same gate internally. Disabling this extra and owning the config directly is what this post describes.
Eclipse JDT-LS
java.import.exclusions. Import exclusion globs consumed by the language server.java.project.resourceFilters. Workspace-scan filter list (defaults tonode_modulesand.git).java.import.gradle.enabled. Gradle importer toggle.java.import.generatesMetadataFilesAtProjectRoot. JVM system property (read viaSystem.getProperty) controlling whether.project,.classpath,.factorypath, and.settings/land in the source tree. The jdtls default istrue; pass-Djava.import.generatesMetadataFilesAtProjectRoot=falseto redirect them.java.jdt.ls.androidSupport.enabled. Experimental Android project import toggle (auto/on/off).
Footnotes
-
The Neovim 0.12 documentation describes the callback form: “The function form must call the
on_dircallback to provide the root dir, or LSP will not be activated for the buffer.” Seevim.lsp.enable(). ↩ -
.gitis the first of three markers in the fallback chain (.git,gradlew,mvnw), with a finalvim.fn.getcwd()safety net. The monorepo has.gitat the root, so the other two markers never come into play. Seesetup.luafor the full resolution chain. ↩ -
When
root_diris defined,root_markersis unused. The shippedroot_markers = { ".git", "gradlew", "mvnw" }in nvim-jdtls’slsp/jdtls.luadoes not interfere with the customroot_dircallback. Seelsp-config. ↩
This post was written with AI assistance.