Skip to content
Fran Gonzalez
← Back to blog
(updated Jul 16, 2026)·Clanker·10 min read

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.

Some matmuls wrote this slop, sorry. My goal with this content is to document some work I (a real human bean) do while poking the Clanker, and try to learn something along the way.

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:

  1. root_dir callback: per-buffer gate via on_dir. This is the primary defense.
  2. gradle.enabled = false: server-level preference, active on fresh boot. Stops a stray *.gradle in adjacent trees from being imported as a Gradle project before exclusions apply.
  3. 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.java shows jdtls attached with root_dir = .../<monorepo>/backend in :LspInfo. The root_dir callback called on_dir(backend_dir) for this buffer.
  • A buffer in web-app/android/app/src/main/java/.../Foo.java shows nothing in :LspInfo. The root_dir callback returned without calling on_dir for 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 a web-app/ subtree with its own pnpm-workspace.yaml. The find_toplevel_pnpm_workspace helper 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.
  • :messages no 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.config and vim.lsp.enable on 0.12.
  • vim.lsp.enable. The documentation shows the root_dir(bufnr, on_dir) callback signature and the on_dir() pattern used as the gate in this post.
  • vim.lsp.config. Merges a user config with the server’s shipped lsp/<name>.lua config. Overrides fields you set; preserves fields you leave out.
  • nvim-lspconfig jdtls config. The legacy lspconfig config, deprecated in favor of vim.lsp.config.

nvim-jdtls

  • nvim-jdtls. The Eclipse JDT language server wrapper for Neovim. Ships an lsp/jdtls.lua config 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. The start_or_attach fallback chain. Bypassed entirely when using vim.lsp.enable with a custom root_dir callback.
  • 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.java extra. The attach_jdtls function and FileType autocmd implement the same gate internally. Disabling this extra and owning the config directly is what this post describes.

Eclipse JDT-LS

Footnotes

  1. The Neovim 0.12 documentation describes the callback form: “The function form must call the on_dir callback to provide the root dir, or LSP will not be activated for the buffer.” See vim.lsp.enable().

  2. .git is the first of three markers in the fallback chain (.git, gradlew, mvnw), with a final vim.fn.getcwd() safety net. The monorepo has .git at the root, so the other two markers never come into play. See setup.lua for the full resolution chain.

  3. When root_dir is defined, root_markers is unused. The shipped root_markers = { ".git", "gradlew", "mvnw" } in nvim-jdtls’s lsp/jdtls.lua does not interfere with the custom root_dir callback. See lsp-config.

This post was written with AI assistance.