diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index c162d34f..f9f89bae 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -20,7 +20,7 @@ off work to one of these:
| [`nvim-neotest/neotest`](https://github.com/nvim-neotest/neotest) | Yes | The framework this *is* an adapter for — defines `neotest.Adapter`, `RunSpec`, `Result`, `Tree`, and the `lib.files`/`lib.treesitter` helpers reused directly in `core/`. |
| [`nvim-neotest/nvim-nio`](https://github.com/nvim-neotest/nvim-nio) | Yes | Async runtime (`nio`) — lets LSP requests and process calls happen off the main thread without blocking Neovim. |
| [`nvim-treesitter`](https://github.com/nvim-treesitter/nvim-treesitter) + `tree-sitter-java` | Yes | Parses Java source so `positions_discoverer.lua` can find classes and test methods (see [flow 1](#1-test-discovery)). |
-| A Java language server (e.g. [`nvim-jdtls`](https://github.com/mfussenegger/nvim-jdtls)/JDT.LS) | Yes, at runtime | neotest-java doesn't bundle or configure a Java LSP itself — the `JavaLanguageServer` gateway (`core/language_server/`, jdtls-backed by default: three single-purpose resolvers for java-home, classpath, and compile fronted by one seam) resolves the `java` binary, the classpath, and triggers workspace compiles (see [flow 2](#2-spec-building)). No compatible client attached means spec building can't proceed. |
+| A Java language server (e.g. [`nvim-jdtls`](https://github.com/mfussenegger/nvim-jdtls)/JDT.LS or [`intellij-lsp.nvim`](https://github.com/Rishabh-Pathak/intellij-lsp.nvim)) | Yes, at runtime | neotest-java doesn't bundle or configure a Java LSP itself — the `JavaLanguageServer` gateway (`core/language_server/`, backed by JDTLS or IntelliJ LSP: single-purpose resolvers for java-home, classpath, and compile fronted by one seam) resolves the `java` binary, the classpath, and triggers workspace compiles (see [flow 2](#2-spec-building)). No compatible client attached means spec building can't proceed. |
| [`mfussenegger/nvim-dap`](https://github.com/mfussenegger/nvim-dap) | Only for debugging | Only required if you run tests with `strategy = "dap"`; both `core/spec_builder/init.lua` and `build_tool/launcher.lua` `pcall`-require it and fail gracefully/loudly if it's missing. |
| [JUnit Platform Console Standalone](https://mvnrepository.com/artifact/org.junit.platform/junit-platform-console-standalone) (a jar, not a plugin) | Yes | The actual test runner — downloaded by `install.lua` and invoked as a subprocess by the command built in [flow 2](#2-spec-building). |
@@ -83,7 +83,7 @@ sequenceDiagram
participant neotest
participant sb as spec_builder
participant proj as root_finder /
build_tool / Project
- participant lsp as JDT.LS (via LSP)
+ participant lsp as Java LSP
(JDT.LS / IntelliJ)
participant cb as junit_command_builder
neotest->>sb: build_spec(args)
@@ -105,7 +105,7 @@ sequenceDiagram
figures out which module the test belongs to (Maven or Gradle,
single-module or multi-module — see [root
finding](#4-finding-the-project-root)), asks the `JavaLanguageServer`
-gateway (jdtls-backed by default)
+gateway (dispatching to JDTLS or IntelliJ LSP based on the active client)
for the `java` binary path and the project's classpath, triggers a
workspace compile, and hands everything to
`command/junit_command_builder.lua`, which assembles the actual
@@ -185,11 +185,11 @@ A quick map for "which file do I touch?":
spec building, result parsing.
- **`core/language_server/`** — the single seam for all Java-LSP
interaction: `init.lua` exposes `JavaLanguageServer`
- (`get_java_home`/`get_classpath`/`compile`) backed by three
- single-purpose jdtls resolvers (`jdtls_java_home`,
- `jdtls_classpath`, `jdtls_compile`). `Binaries`,
- `ClasspathProvider`, and `LspCompiler` are thin delegates of this
- gateway, so a future server only replaces the gateway.
+ (`get_java_home`/`get_classpath`/`compile`) dispatching dynamically to
+ single-purpose resolvers for JDTLS (`jdtls_java_home`, `jdtls_classpath`, `jdtls_compile`)
+ or IntelliJ LSP (`intellij_java_home`, `intellij_classpath`, `intellij_compile`,
+ assisted by `document_uri`). `Binaries`, `ClasspathProvider`, and `LspCompiler`
+ are thin delegates of this gateway, so upstream callers never change.
- **`core/spec_builder/`** — orchestrates building the command to run
tests: build-tool detection, classpath via the gateway, compile-on-run.
- **`core/position_ids/`** — computes stable IDs for classes/methods so
diff --git a/lua/neotest-java/core/language_server/document_uri.lua b/lua/neotest-java/core/language_server/document_uri.lua
new file mode 100644
index 00000000..09310375
--- /dev/null
+++ b/lua/neotest-java/core/language_server/document_uri.lua
@@ -0,0 +1,61 @@
+local Path = require("neotest-java.model.path")
+
+--- Resolves a representative test document URI for language server requests.
+---
+--- Architectural Rationale:
+--- Unlike Eclipse JDTLS which accepts workspace root paths and an explicit `{ scope = "test" }`
+--- parameter, IntelliJ LSP (intellij-lsp.nvim) derives project scope strictly from the document URI
+--- passed to commands like `intellij.java.resolveLaunch` and `intellij.java.resolveBuildCommand`.
+---
+--- When passed a directory or project root URI, IntelliJ LSP defaults to production scope:
+--- - Classpath only contains `target/classes` and runtime dependencies (missing test libraries).
+--- - Build command defaults to production compile (e.g. `mvn compile`).
+---
+--- When passed an actual test file document URI (e.g. under `src/test/**/*.java`), IntelliJ LSP correctly
+--- resolves test scope:
+--- - Classpath includes `target/test-classes` and all test dependencies (JUnit, Mockito, etc.).
+--- - Build command compiles test sources (e.g. `mvn test-compile`).
+---
+--- @param base_dir neotest-java.Path Target directory or Java file path
+--- @param globpath? fun(dir: string, pattern: string, nosuf: boolean, list: boolean): string[] Injectable globpath function for unit testing
+--- @return string uri Valid file URI formatted via vim.uri_from_fname
+local function resolve_test_document_uri(base_dir, globpath)
+ local path_str = base_dir:to_string()
+ if path_str:match("%.java$") then
+ return vim.uri_from_fname(path_str)
+ end
+
+ -- Use nio.fn.globpath where available to avoid Neovim E5560 (fast-event context)
+ -- errors when resolving files within an async/coroutine execution context.
+ local function safe_glob(pattern)
+ if globpath then
+ return globpath(path_str, pattern, false, true)
+ end
+ local ok, nio = pcall(require, "nio")
+ if ok and nio.fn and nio.fn.globpath then
+ local success, res = pcall(nio.fn.globpath, path_str, pattern, false, true)
+ if success and type(res) == "table" then
+ return res
+ end
+ end
+ local success, res = pcall(vim.fn.globpath, path_str, pattern, false, true)
+ if success and type(res) == "table" then
+ return res
+ end
+ return {}
+ end
+
+ local test_files = safe_glob("src/test/**/*.java")
+ if test_files and #test_files > 0 then
+ return vim.uri_from_fname(test_files[1])
+ end
+
+ local any_tests = safe_glob("**/*Test*.java")
+ if any_tests and #any_tests > 0 then
+ return vim.uri_from_fname(any_tests[1])
+ end
+
+ return vim.uri_from_fname(path_str)
+end
+
+return resolve_test_document_uri
diff --git a/lua/neotest-java/core/language_server/init.lua b/lua/neotest-java/core/language_server/init.lua
index 88584c7d..cf2eb1d9 100644
--- a/lua/neotest-java/core/language_server/init.lua
+++ b/lua/neotest-java/core/language_server/init.lua
@@ -1,19 +1,34 @@
local JdtlsJavaHome = require("neotest-java.core.language_server.jdtls_java_home")
local JdtlsClasspath = require("neotest-java.core.language_server.jdtls_classpath")
local JdtlsCompile = require("neotest-java.core.language_server.jdtls_compile")
+local IntellijJavaHome = require("neotest-java.core.language_server.intellij_java_home")
+local IntellijClasspath = require("neotest-java.core.language_server.intellij_classpath")
+local IntellijCompile = require("neotest-java.core.language_server.intellij_compile")
---- Single seam for all Java language-server interaction.
---- A future server (e.g. the IntelliJ LSP from issue #314) replaces this
---- whole table with its own three methods; callers never change.
+--- Single gateway seam for all Java language-server interactions.
+---
+--- Architectural details:
+--- Provides a unified interface (`JavaLanguageServer`) fronting both Eclipse JDTLS and IntelliJ LSP.
+--- Upstream callers (`Binaries`, `ClasspathProvider`, `LspCompiler`) interact solely with this seam,
+--- completely decoupled from specific LSP implementations, protocols, and quirks.
+---
+--- Dispatch mechanism:
+--- For each operation, the active client is checked via `client_provider(cwd)`.
+--- - If `client.name == "intellij"`, delegates to the IntelliJ resolvers (`intellij_java_home`,
+--- `intellij_classpath`, `intellij_compile`).
+--- - Otherwise (default / `client.name == "jdtls"`), delegates to the JDTLS resolvers.
+---
--- @class neotest-java.JavaLanguageServer
--- @field get_java_home fun(cwd: neotest-java.Path): neotest-java.Path
--- @field get_classpath fun(base_dir: neotest-java.Path, additional_classpath_entries?: neotest-java.Path[]): neotest-java.Classpath | string runtime + test + extra entries, in order. Custom providers may return a plain string; callers coerce via tostring().
--- @field compile fun(opts: { base_dir: neotest-java.Path, compile_mode: "full" | "incremental" })
--- @class neotest-java.JavaLanguageServerDeps
---- @field client_provider fun(cwd: neotest-java.Path): vim.lsp.Client Shared jdtls client provider (see compiler/client_provider.lua).
+--- @field client_provider fun(cwd: neotest-java.Path): vim.lsp.Client Shared client provider (see compiler/client_provider.lua).
--- @field schedule? fun(fn: fun()) Defaults to vim.schedule. Inject a synchronous pass-through in tests.
--- @field path_separator? string Defaults to ";" on Windows, ":" on Unix. Inject in tests to avoid platform-dependent behavior.
+--- @field system? fun(cmd: string[], opts?: table): table Injectable for testing vim.system.
+--- @field globpath? fun(dir: string, pattern: string, nosuf: boolean, list: boolean): string[]
--- @param deps neotest-java.JavaLanguageServerDeps
--- @return neotest-java.JavaLanguageServer
@@ -21,27 +36,71 @@ local function JavaLanguageServer(deps)
deps = deps or {}
assert(deps.client_provider, "JavaLanguageServer requires a client_provider")
- local java_home = JdtlsJavaHome({
- client_provider = deps.client_provider,
- schedule = deps.schedule,
- })
- local classpath = JdtlsClasspath({
- client_provider = deps.client_provider,
- schedule = deps.schedule,
- path_separator = deps.path_separator,
- })
- local compiler = JdtlsCompile({
- client_provider = deps.client_provider,
- schedule = deps.schedule,
- })
+ local jdtls = {
+ java_home = JdtlsJavaHome({
+ client_provider = deps.client_provider,
+ schedule = deps.schedule,
+ }),
+ classpath = JdtlsClasspath({
+ client_provider = deps.client_provider,
+ schedule = deps.schedule,
+ path_separator = deps.path_separator,
+ }),
+ compiler = JdtlsCompile({
+ client_provider = deps.client_provider,
+ schedule = deps.schedule,
+ }),
+ }
+
+ local intellij = {
+ java_home = IntellijJavaHome({
+ client_provider = deps.client_provider,
+ schedule = deps.schedule,
+ }),
+ classpath = IntellijClasspath({
+ client_provider = deps.client_provider,
+ schedule = deps.schedule,
+ path_separator = deps.path_separator,
+ globpath = deps.globpath,
+ }),
+ compiler = IntellijCompile({
+ client_provider = deps.client_provider,
+ schedule = deps.schedule,
+ system = deps.system,
+ globpath = deps.globpath,
+ }),
+ }
+
+ local function get_backend(cwd)
+ local client = deps.client_provider(cwd)
+ if client and client.name == "intellij" then
+ return {
+ get_java_home = intellij.java_home.get_java_home,
+ get_classpath = intellij.classpath.get_classpath,
+ compile = intellij.compiler.compile,
+ }
+ end
+ return {
+ get_java_home = jdtls.java_home.get_java_home,
+ get_classpath = jdtls.classpath.get_classpath,
+ compile = jdtls.compiler.compile,
+ }
+ end
return {
- get_java_home = java_home.get_java_home,
- get_classpath = classpath.get_classpath,
- compile = compiler.compile,
+ get_java_home = function(cwd)
+ return get_backend(cwd).get_java_home(cwd)
+ end,
+ get_classpath = function(base_dir, additional_classpath_entries)
+ return get_backend(base_dir).get_classpath(base_dir, additional_classpath_entries)
+ end,
+ compile = function(opts)
+ return get_backend(opts.base_dir).compile(opts)
+ end,
}
end
return {
new = JavaLanguageServer,
+ jdtls = JavaLanguageServer,
}
diff --git a/lua/neotest-java/core/language_server/intellij_classpath.lua b/lua/neotest-java/core/language_server/intellij_classpath.lua
new file mode 100644
index 00000000..e2be08ab
--- /dev/null
+++ b/lua/neotest-java/core/language_server/intellij_classpath.lua
@@ -0,0 +1,83 @@
+local nio = require("nio")
+local Classpath = require("neotest-java.model.classpath")
+local resolve_document_uri = require("neotest-java.core.language_server.document_uri")
+
+-- Classpath separator: ";" on Windows, ":" on Unix.
+local DEFAULT_PATH_SEPARATOR = vim.fn.has("win32") == 1 and ";" or ":"
+
+--- @class neotest-java.IntellijClasspathDeps
+--- @field client_provider fun(cwd: neotest-java.Path): vim.lsp.Client
+--- @field schedule? fun(fn: fun()) Defaults to vim.schedule. Inject a synchronous pass-through in tests.
+--- @field path_separator? string Defaults to ";" on Windows, ":" on Unix. Inject in tests to avoid platform-dependent behavior.
+--- @field globpath? fun(dir: string, pattern: string, nosuf: boolean, list: boolean): string[]
+
+--- Resolves the complete execution classpath via IntelliJ LSP.
+---
+--- Architectural details:
+--- IntelliJ LSP provides a single command `intellij.java.resolveLaunch` which, when invoked
+--- with a representative test document URI, computes the full runtime classpath and modulepath
+--- needed to run test code (including `target/test-classes` and test dependencies like JUnit).
+--- Both `classpath` and `modulePath` arrays are combined alongside any caller-supplied additional
+--- classpath entries into a single `Classpath` value object.
+---
+--- @param deps neotest-java.IntellijClasspathDeps
+--- @return { get_classpath: fun(base_dir: neotest-java.Path, additional_classpath_entries?: neotest-java.Path[]): neotest-java.Classpath }
+local function IntellijClasspath(deps)
+ deps = deps or {}
+ assert(deps.client_provider, "IntellijClasspath requires a client_provider")
+ local schedule = deps.schedule or vim.schedule
+ local path_separator = deps.path_separator or DEFAULT_PATH_SEPARATOR
+
+ --- @param base_dir neotest-java.Path
+ --- @param additional_classpath_entries? neotest-java.Path[]
+ --- @return neotest-java.Classpath
+ local function get_classpath(base_dir, additional_classpath_entries)
+ additional_classpath_entries = additional_classpath_entries or {}
+
+ local doc_uri = resolve_document_uri(base_dir, deps.globpath)
+ local client = deps.client_provider(base_dir)
+
+ local future = nio.control.future()
+
+ schedule(function()
+ ---@diagnostic disable-next-line: undefined-field
+ client:request("workspace/executeCommand", {
+ command = "intellij.java.resolveLaunch",
+ arguments = { { uri = doc_uri } },
+ }, function(err, result)
+ if err then
+ future.set_error(err)
+ else
+ future.set(result or {})
+ end
+ end)
+ end)
+
+ local res = future.wait()
+ local cp_list = res.classpath or {}
+ local mp_list = res.modulePath or {}
+
+ local additional_strings = vim.iter(additional_classpath_entries)
+ --- @param path neotest-java.Path
+ :map(function(path)
+ return path:to_string()
+ end)
+ :totable()
+
+ local entries = vim.iter({
+ cp_list,
+ mp_list,
+ additional_strings,
+ })
+ :flatten()
+ :totable()
+
+ return Classpath(entries, { separator = path_separator })
+ end
+
+ return {
+ get_classpath = get_classpath,
+ }
+end
+
+return IntellijClasspath
diff --git a/lua/neotest-java/core/language_server/intellij_compile.lua b/lua/neotest-java/core/language_server/intellij_compile.lua
new file mode 100644
index 00000000..317c4e98
--- /dev/null
+++ b/lua/neotest-java/core/language_server/intellij_compile.lua
@@ -0,0 +1,95 @@
+local logger = require("neotest-java.logger")
+local nio = require("nio")
+local resolve_document_uri = require("neotest-java.core.language_server.document_uri")
+
+---@diagnostic disable-next-line: undefined-doc-name
+--- @class neotest-java.IntellijCompileDeps
+--- @field client_provider fun(cwd: neotest-java.Path): vim.lsp.Client
+--- @field schedule? fun(fn: fun()) Defaults to vim.schedule. Inject a synchronous pass-through in tests.
+--- @field system? fun(cmd: string[], opts?: table): table Injectable for testing vim.system.
+--- @field globpath? fun(dir: string, pattern: string, nosuf: boolean, list: boolean): string[]
+
+--- Compiles test sources via the build command resolved by IntelliJ LSP.
+---
+--- Architectural details:
+--- Unlike Eclipse JDTLS which compiles directly within its language server process,
+--- IntelliJ LSP delegates compilation to the project's native build tool (Maven/Gradle).
+--- Calling `intellij.java.resolveBuildCommand` with the test document URI yields the
+--- appropriate CLI invocation (e.g. `mvnw test-compile` or `gradle testClasses`).
+--- This command is executed using `vim.system`, waiting for completion if running
+--- inside an active coroutine (via `coroutine.running()`).
+---
+--- @param deps neotest-java.IntellijCompileDeps
+--- @return { compile: fun(opts: { base_dir: neotest-java.Path, compile_mode: "full" | "incremental" }) }
+local function IntellijCompile(deps)
+ deps = deps or {}
+ assert(deps.client_provider, "IntellijCompile requires a client_provider")
+ local schedule = deps.schedule or vim.schedule
+ local run_system = deps.system or vim.system
+
+ --- @param opts { base_dir: neotest-java.Path, compile_mode: "full" | "incremental" }
+ local function compile(opts)
+ local client = deps.client_provider(opts.base_dir)
+ if not client or not client.initialized then
+ return
+ end
+
+ logger.debug(("IntelliJ LSP compilation in %s mode"):format(opts.compile_mode))
+
+ local target_uri = resolve_document_uri(opts.base_dir, deps.globpath)
+ local future = nio.control.future()
+
+ schedule(function()
+ --- @diagnostic disable-next-line: undefined-field
+ client:request("workspace/executeCommand", {
+ command = "intellij.java.resolveBuildCommand",
+ arguments = { { uri = target_uri } },
+ }, function(err, result)
+ if err then
+ logger.error("IntelliJ LSP resolveBuildCommand failed: " .. vim.inspect(err))
+ future.set(false)
+ return
+ end
+
+ if result and result.supported ~= false and type(result.command) == "table" and #result.command > 0 then
+ logger.debug("Executing build command: " .. table.concat(result.command, " "))
+ local spawn_ok, proc = pcall(run_system, result.command, {
+ cwd = result.cwd or opts.base_dir:to_string(),
+ })
+ if not spawn_ok then
+ logger.error("Failed to spawn build command: " .. tostring(proc))
+ future.set(false)
+ return
+ end
+
+ local completed = proc:wait()
+ if completed.code == 0 then
+ logger.debug("IntelliJ LSP build command succeeded")
+ else
+ logger.warn(("IntelliJ LSP build command failed with exit code %d"):format(completed.code))
+ end
+ future.set(completed.code == 0)
+ elseif result and result.reason then
+ logger.warn("IntelliJ LSP build skipped: " .. tostring(result.reason))
+ future.set(true)
+ else
+ future.set(true)
+ end
+ end)
+ end)
+
+ -- Only wait when inside a coroutine (e.g. nio async task) to avoid
+ -- attempting to yield across C-call boundaries.
+ if coroutine.running() then
+ future.wait()
+ end
+
+ logger.debug("compilation complete!")
+ end
+
+ return {
+ compile = compile,
+ }
+end
+
+return IntellijCompile
diff --git a/lua/neotest-java/core/language_server/intellij_java_home.lua b/lua/neotest-java/core/language_server/intellij_java_home.lua
new file mode 100644
index 00000000..f7bfe4b3
--- /dev/null
+++ b/lua/neotest-java/core/language_server/intellij_java_home.lua
@@ -0,0 +1,89 @@
+local Path = require("neotest-java.model.path")
+local logger = require("neotest-java.logger")
+local nio = require("nio")
+
+---@diagnostic disable: undefined-doc-name
+--- @class neotest-java.IntellijJavaHomeDeps
+--- @field client_provider fun(cwd: neotest-java.Path): vim.lsp.Client
+--- @field schedule? fun(fn: fun()) Defaults to vim.schedule. Inject a synchronous pass-through in tests.
+
+--- Resolves the JDK home directory for IntelliJ LSP.
+---
+--- Architectural details:
+--- IntelliJ LSP returns `javaExec` pointing to the exact Java binary configured for the project
+--- (e.g. `/bin/java` on Unix or `\bin\java.exe` on Windows).
+--- Downstream consumers (`neotest-java.command.binaries`) expect the root JDK directory so they
+--- can locate both `bin/java` (for test execution) and `bin/javap` (for method signature disambiguation).
+--- Calling `:parent():parent()` traverses from binary -> `bin` directory -> JDK root.
+--- Results are cached per directory to avoid redundant LSP roundtrips.
+---
+--- @param deps neotest-java.IntellijJavaHomeDeps
+--- @return { get_java_home: fun(cwd: neotest-java.Path): neotest-java.Path }
+local function IntellijJavaHome(deps)
+ deps = deps or {}
+ assert(deps.client_provider, "IntellijJavaHome requires a client_provider")
+ local schedule = deps.schedule or vim.schedule
+
+ local cached_java_homes = {}
+
+ --- @param cwd neotest-java.Path
+ --- @return neotest-java.Path
+ local function get_java_home(cwd)
+ local cwd_str = cwd:to_string()
+ if cached_java_homes[cwd_str] then
+ return Path(cached_java_homes[cwd_str])
+ end
+
+ local client = deps.client_provider(cwd)
+
+ logger.debug("Resolving Java home via IntelliJ LSP for cwd: " .. cwd_str)
+
+ local cmd = {
+ command = "intellij.java.resolveLaunch",
+ arguments = { { uri = vim.uri_from_fname(cwd_str) } },
+ }
+ local result_future = nio.control.future()
+ schedule(function()
+ ---@diagnostic disable-next-line: undefined-field
+ client:request("workspace/executeCommand", cmd, function(err, res)
+ if err then
+ result_future.set_error(err)
+ else
+ result_future.set(res or {})
+ end
+ end)
+ end)
+
+ local ok, res = pcall(function()
+ return result_future.wait()
+ end)
+
+ local java_home_path = nil
+ if ok and res and res.javaExec and res.javaExec ~= "" then
+ -- res.javaExec points to /bin/java (or java.exe)
+ local java_bin = Path(res.javaExec)
+ java_home_path = java_bin:parent():parent()
+ else
+ -- Fallback to JAVA_HOME environment variable or path from java executable
+ if vim.env.JAVA_HOME and vim.env.JAVA_HOME ~= "" then
+ java_home_path = Path(vim.env.JAVA_HOME)
+ else
+ local exepath = vim.fn.exepath("java")
+ if exepath and exepath ~= "" then
+ java_home_path = Path(exepath):parent():parent()
+ end
+ end
+ end
+
+ assert(java_home_path, "Could not determine Java home from IntelliJ LSP or environment for " .. cwd_str)
+
+ cached_java_homes[cwd_str] = java_home_path:to_string()
+ return java_home_path
+ end
+
+ return {
+ get_java_home = get_java_home,
+ }
+end
+
+return IntellijJavaHome
diff --git a/lua/neotest-java/core/spec_builder/compiler/client_provider.lua b/lua/neotest-java/core/spec_builder/compiler/client_provider.lua
index 1ac5ad4a..fe4bbbc1 100644
--- a/lua/neotest-java/core/spec_builder/compiler/client_provider.lua
+++ b/lua/neotest-java/core/spec_builder/compiler/client_provider.lua
@@ -8,12 +8,17 @@ local Path = require("neotest-java.model.path")
--- @field bufload fun(path: string)
--- @field sleep fun(ms: number)
--- @field hrtime fun(): number
+--- @field client_name? string
+--- @field client_names? string[]
--- @param deps neotest-java.ClientProviderDeps
---@diagnostic disable-next-line: undefined-doc-name
--- @return fun(cwd: neotest-java.Path): vim.lsp.Client
local function ClientProvider(deps)
local client
+ local client_names = deps.client_names
+ or (deps.client_name and { deps.client_name })
+ or { "intellij", "jdtls" }
--- @param dir neotest-java.Path
--- @return neotest-java.Path
@@ -58,21 +63,29 @@ local function ClientProvider(deps)
return client
end
- client = deps.get_clients({ name = "jdtls" })[1]
+ for _, name in ipairs(client_names) do
+ client = deps.get_clients({ name = name })[1]
+ if client and client.initialized then
+ return client
+ end
+ end
- if not client then
- local any_java_file = find_any_java_file(cwd)
- local bufnr = preload_file_for_lsp(any_java_file)
+ local any_java_file = find_any_java_file(cwd)
+ local bufnr = preload_file_for_lsp(any_java_file)
- assert(
- wait(10000, function()
- client = deps.get_clients({ name = "jdtls", bufnr = bufnr })[1]
+ assert(
+ wait(10000, function()
+ for _, name in ipairs(client_names) do
+ client = deps.get_clients({ name = name, bufnr = bufnr })[1]
---@diagnostic disable-next-line: undefined-field
- return not not client and not not client.initialized
- end, 1000),
- "jdtls client not started in time"
- )
- end
+ if client and client.initialized then
+ return true
+ end
+ end
+ return false
+ end, 1000),
+ table.concat(client_names, " or ") .. " client not started in time"
+ )
return client
end
diff --git a/tests/unit/test_client_provider_spec.lua b/tests/unit/test_client_provider_spec.lua
index 5dcedbd9..244beb14 100644
--- a/tests/unit/test_client_provider_spec.lua
+++ b/tests/unit/test_client_provider_spec.lua
@@ -88,14 +88,14 @@ describe("ClientProvider", function()
local java_file = Path("/some/project/src/Main.java")
local provider = ClientProvider({
- get_clients = function(_)
+ get_clients = function(opts)
get_clients_call_count = get_clients_call_count + 1
- -- first call (no bufnr): jdtls not running yet
- if get_clients_call_count == 1 then
+ -- fast-path checks (no bufnr): neither client running yet
+ if not opts.bufnr then
return {}
end
- -- polling calls (with bufnr): ready on 3rd overall call
- if get_clients_call_count >= 3 then
+ -- polling calls (with bufnr): ready on 2nd polling round
+ if get_clients_call_count >= 5 then
return { mock_client }
end
return {}
@@ -128,4 +128,41 @@ describe("ClientProvider", function()
assert(sleep_count >= 1, "expected at least one sleep call during polling")
end)
)
+
+ it(
+ "detects intellij client when running",
+ async(function()
+ local queried_names = {}
+ local intellij_client = { name = "intellij", initialized = true }
+
+ local provider = ClientProvider({
+ get_clients = function(opts)
+ table.insert(queried_names, opts.name)
+ if opts.name == "intellij" then
+ return { intellij_client }
+ end
+ return {}
+ end,
+ globpath = function()
+ error("should not be called on fast path")
+ end,
+ bufadd = function()
+ error("should not be called on fast path")
+ end,
+ bufload = function()
+ error("should not be called on fast path")
+ end,
+ sleep = function()
+ error("should not be called on fast path")
+ end,
+ hrtime = function()
+ return 0
+ end,
+ })
+
+ local result = provider(Path("/some/project"))
+ eq(intellij_client, result)
+ assert(vim.tbl_contains(queried_names, "intellij"), "expected intellij to be queried")
+ end)
+ )
end)
diff --git a/tests/unit/test_intellij_language_server_spec.lua b/tests/unit/test_intellij_language_server_spec.lua
new file mode 100644
index 00000000..7252223d
--- /dev/null
+++ b/tests/unit/test_intellij_language_server_spec.lua
@@ -0,0 +1,194 @@
+local Path = require("neotest-java.model.path")
+local assertions = require("tests.assertions")
+local eq = assertions.eq
+local async = require("tests.async_helpers").async
+
+local LanguageServer = require("neotest-java.core.language_server")
+
+describe("JavaLanguageServer (intellij)", function()
+ local sync_schedule = function(fn)
+ fn()
+ end
+
+ --- Fake intellij client answering protocol commands.
+ local function stub_client_provider(calls, responses)
+ responses = responses or {}
+ return function(_)
+ return {
+ name = "intellij",
+ initialized = true,
+ request = function(_, _method, params, callback)
+ table.insert(calls, params.command or params)
+ if params.command == "intellij.java.resolveLaunch" then
+ local default_res = {
+ classpath = { "classes_dir", "dep.jar" },
+ modulePath = { "mod.jar" },
+ javaExec = Path("/usr/lib/jvm/default/bin/java"):to_string(),
+ }
+ callback(nil, responses.resolveLaunch or default_res)
+ elseif params.command == "intellij.java.resolveBuildCommand" then
+ local default_build = {
+ command = { "./mvnw", "test-compile" },
+ cwd = "/path/to/project",
+ supported = true,
+ }
+ callback(nil, responses.resolveBuildCommand or default_build)
+ end
+ end,
+ }
+ end
+ end
+
+ it(
+ "resolves java home from javaExec via resolveLaunch",
+ async(function()
+ local calls = {}
+ local server = LanguageServer.new({
+ client_provider = stub_client_provider(calls, {
+ resolveLaunch = {
+ javaExec = Path("/custom/jdk-21/bin/java"):to_string(),
+ },
+ }),
+ schedule = sync_schedule,
+ })
+ eq(Path("/custom/jdk-21"), server.get_java_home(Path("some")))
+ end)
+ )
+
+ it(
+ "caches java home per directory",
+ async(function()
+ local calls = {}
+ local server = LanguageServer.new({
+ client_provider = stub_client_provider(calls),
+ schedule = sync_schedule,
+ })
+ for _ = 1, 10 do
+ server.get_java_home(Path("some"))
+ server.get_java_home(Path("another_path"))
+ end
+ local launch_calls = vim.tbl_filter(function(c)
+ return c == "intellij.java.resolveLaunch"
+ end, calls)
+ eq(2, #launch_calls)
+ end)
+ )
+
+ it(
+ "merges classpath, modulePath, and extra entries with ':' separator",
+ async(function()
+ local server = LanguageServer.new({
+ client_provider = stub_client_provider({}),
+ schedule = sync_schedule,
+ path_separator = ":",
+ })
+ eq(
+ "classes_dir:dep.jar:mod.jar:additional",
+ tostring(server.get_classpath(Path("some"), { Path("additional") }))
+ )
+ end)
+ )
+
+ it(
+ "merges classpath, modulePath, and extra entries with ';' separator",
+ async(function()
+ local server = LanguageServer.new({
+ client_provider = stub_client_provider({}),
+ schedule = sync_schedule,
+ path_separator = ";",
+ })
+ eq(
+ "classes_dir;dep.jar;mod.jar;additional",
+ tostring(server.get_classpath(Path("some"), { Path("additional") }))
+ )
+ end)
+ )
+
+ it("executes build command returned by resolveBuildCommand", function()
+ local system_invocations = {}
+ local fake_system = function(cmd, opts)
+ table.insert(system_invocations, { cmd = cmd, opts = opts })
+ return {
+ wait = function()
+ return { code = 0 }
+ end,
+ }
+ end
+
+ local server = LanguageServer.new({
+ client_provider = stub_client_provider({}, {
+ resolveBuildCommand = {
+ command = { "./mvnw", "-pl", ":app", "test-compile" },
+ cwd = "/workspace/app",
+ supported = true,
+ },
+ }),
+ schedule = sync_schedule,
+ system = fake_system,
+ })
+
+ server.compile({ base_dir = Path("/workspace/app"), compile_mode = "full" })
+
+ eq(1, #system_invocations)
+ eq({ "./mvnw", "-pl", ":app", "test-compile" }, system_invocations[1].cmd)
+ eq("/workspace/app", system_invocations[1].opts.cwd)
+ end)
+
+ it("skips execution when resolveBuildCommand reports supported=false", function()
+ local system_invocations = {}
+ local fake_system = function(cmd, opts)
+ table.insert(system_invocations, { cmd = cmd, opts = opts })
+ return {
+ wait = function()
+ return { code = 0 }
+ end,
+ }
+ end
+
+ local server = LanguageServer.new({
+ client_provider = stub_client_provider({}, {
+ resolveBuildCommand = {
+ supported = false,
+ reason = "plain JPS project",
+ },
+ }),
+ schedule = sync_schedule,
+ system = fake_system,
+ })
+
+ server.compile({ base_dir = Path("/workspace/app"), compile_mode = "incremental" })
+
+ eq(0, #system_invocations)
+ end)
+
+ it(
+ "resolves test file URI from globpath when base_dir is a directory",
+ async(function()
+ local passed_uri = nil
+ local fake_client = function(_)
+ return {
+ name = "intellij",
+ initialized = true,
+ request = function(_, _, params, callback)
+ passed_uri = params.arguments and params.arguments[1] and params.arguments[1].uri
+ callback(nil, { classpath = { "target/test-classes" } })
+ end,
+ }
+ end
+
+ local server = LanguageServer.new({
+ client_provider = fake_client,
+ schedule = sync_schedule,
+ globpath = function(_, pattern, _, _)
+ if pattern:find("src/test") then
+ return { Path("/workspace/app/src/test/java/ExampleTest.java"):to_string() }
+ end
+ return {}
+ end,
+ })
+
+ server.get_classpath(Path("/workspace/app"))
+ eq(vim.uri_from_fname(Path("/workspace/app/src/test/java/ExampleTest.java"):to_string()), passed_uri)
+ end)
+ )
+end)