Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 — it just looks for an already-attached client named `jdtls` (`core/spec_builder/compiler/client_provider.lua`) to resolve the `java` binary, the classpath, and to trigger workspace compiles (see [flow 2](#2-spec-building)). No `jdtls` client attached means spec building can't proceed. |
| 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. |
| [`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). |

Expand Down Expand Up @@ -104,7 +104,8 @@ sequenceDiagram
**In plain terms:** `core/spec_builder/init.lua` is the orchestrator. It
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 Java LSP client (JDT.LS)
finding](#4-finding-the-project-root)), asks the `JavaLanguageServer`
gateway (jdtls-backed by default)
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
Expand Down Expand Up @@ -177,12 +178,20 @@ A quick map for "which file do I touch?":
files, checksums, directory scanning, XML reading, detecting Maven vs
Gradle.
- **`model/`** — value objects: `Path` (cross-platform paths),
`Project`/`Module` (multi-module layout), `JunitResult` (a parsed
`Classpath` (ordered entries + platform separator, same idea as
`Path`), `Project`/`Module` (multi-module layout), `JunitResult` (a parsed
`<testcase>` XML node).
- **`core/`** — the adapter's actual behavior: discovery, root-finding,
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.
- **`core/spec_builder/`** — orchestrates building the command to run
tests: build-tool detection, classpath via LSP, compile-on-run.
tests: build-tool detection, classpath via the gateway, compile-on-run.
- **`core/position_ids/`** — computes stable IDs for classes/methods so
neotest can match tree nodes to JUnit results.
- **`command/`** — building the actual JUnit CLI invocation, locating
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ require("neotest").setup({
-- configuration options
}, {
-- dependency overrides (all optional)
language_server = my_custom_language_server,
client_provider = my_custom_client_provider,
classpath_provider = my_custom_classpath_provider,
binaries = my_custom_binaries,
Expand All @@ -201,6 +202,13 @@ require("neotest").setup({
})
```

`language_server` is the single seam for all Java language-server
interaction (`get_java_home`, `get_classpath`, `compile`). Pass it to
replace java-home, classpath, and compilation behavior at once — e.g.
to back neotest-java with a non-jdtls server. Explicit
`client_provider` / `classpath_provider` / `binaries` / `lsp_compiler`
overrides still work and win per-concern over `language_server`.

### Example: Using coc.nvim as LSP client

```lua
Expand Down Expand Up @@ -248,6 +256,11 @@ require("neotest").setup({
})
```

`get_classpath` normally returns a `neotest-java.Classpath` value
object (`lua/neotest-java/model/classpath.lua`, same idea as `Path`:
entry rules and the `:`/`;` separator live in one place). Plain strings
from custom providers keep working — callers coerce via `tostring()`.

### Type Reference

See the `neotest-java.Dependencies` type annotation in
Expand Down
59 changes: 17 additions & 42 deletions lua/neotest-java/command/binaries.lua
Original file line number Diff line number Diff line change
@@ -1,74 +1,49 @@
local Path = require("neotest-java.model.path")

local logger = require("neotest-java.logger")
local nio = require("nio")

--- @class neotest-java.LspBinaries
--- @field java fun(cwd: neotest-java.Path): neotest-java.Path
--- @field javap fun(cwd: neotest-java.Path): neotest-java.Path

---@diagnostic disable: undefined-doc-name
--- @class neotest-java.BinariesDeps
--- @field client_provider fun(cwd: neotest-java.Path): vim.lsp.Client
--- @field language_server? neotest-java.JavaLanguageServer Single seam for language-server interaction. One of language_server / client_provider is required.
--- @field client_provider? fun(cwd: neotest-java.Path): vim.lsp.Client Deprecated: prefer language_server. When given, a default jdtls gateway is built around it.
--- @field is_windows? boolean
--- @field schedule? fun(fn: fun()) Defaults to vim.schedule. Inject a synchronous pass-through in tests.
--- @field schedule? fun(fn: fun()) Defaults to vim.schedule. Only used with the deprecated client_provider fallback. Inject a synchronous pass-through in tests.

--- @param deps neotest-java.BinariesDeps
--- @return neotest-java.LspBinaries
local Binaries = function(deps)
deps = deps or {}
-- Default platform detection if not provided
local is_windows = deps.is_windows
if is_windows == nil then
is_windows = vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1
end

-- vim.schedule moves the LSP request out of any fast-event context so that
-- nvim_get_current_buf (called internally by client:request when no bufnr is
-- given) is safe to call. Tests inject a synchronous pass-through instead so
-- the mock callback fires immediately without needing the event loop.
local schedule = deps.schedule or vim.schedule

local cached_java_homes = {}
--- @param cwd neotest-java.Path
local get_java_home = function(cwd)
if cached_java_homes[cwd:to_string()] then
return cached_java_homes[cwd:to_string()]
end
local client = deps.client_provider(cwd)

logger.debug("Resolving Java home via JDTLS for cwd: " .. cwd:to_string())

local cmd = {
command = "java.project.getSettings",
arguments = { vim.uri_from_fname(cwd:to_string()), { "org.eclipse.jdt.ls.core.vm.location" } },
}
local result_future = nio.control.future()
schedule(function()
---@diagnostic disable-next-line: undefined-field
client:request("workspace/executeCommand", cmd, function(err, res)
assert(not err, "Error while getting Java home from lsp server: " .. vim.inspect(err))
assert(not res.err, "Error while getting Java home from lsp server: " .. vim.inspect(res.err))
result_future.set(res)
end)
end)
local res = result_future.wait()

cached_java_homes[cwd:to_string()] = res["org.eclipse.jdt.ls.core.vm.location"]
return cached_java_homes[cwd:to_string()]
--- @type neotest-java.JavaLanguageServer
local language_server = deps.language_server
if not language_server then
local JdtlsJavaHome = require("neotest-java.core.language_server.jdtls_java_home")
local java_home = JdtlsJavaHome({
client_provider = assert(deps.client_provider, "Binaries requires language_server or client_provider"),
schedule = deps.schedule,
})
---@diagnostic disable-next-line: missing-fields
language_server = { get_java_home = java_home.get_java_home }
end
---@cast language_server neotest-java.JavaLanguageServer

return {

--- @param cwd neotest-java.Path
java = function(cwd)
local exe_ext = is_windows and ".exe" or ""
return Path(get_java_home(cwd)):append("bin/java" .. exe_ext)
return language_server.get_java_home(cwd):append("bin/java" .. exe_ext)
end,

--- @param cwd neotest-java.Path
javap = function(cwd)
local exe_ext = is_windows and ".exe" or ""
return Path(get_java_home(cwd)):append("bin/javap" .. exe_ext)
return language_server.get_java_home(cwd):append("bin/javap" .. exe_ext)
end,
}
end
Expand Down
47 changes: 47 additions & 0 deletions lua/neotest-java/core/language_server/init.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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")

--- 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.
--- @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 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.

--- @param deps neotest-java.JavaLanguageServerDeps
--- @return neotest-java.JavaLanguageServer
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,
})

return {
get_java_home = java_home.get_java_home,
get_classpath = classpath.get_classpath,
compile = compiler.compile,
}
end

return {
new = JavaLanguageServer,
}
93 changes: 93 additions & 0 deletions lua/neotest-java/core/language_server/jdtls_classpath.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
local nio = require("nio")

local Classpath = require("neotest-java.model.classpath")

-- Classpath separator: ";" on Windows, ":" on Unix.
-- Computed once at module load time since the platform doesn't change at runtime.
local DEFAULT_PATH_SEPARATOR = vim.fn.has("win32") == 1 and ";" or ":"

--- @class neotest-java.JdtlsClasspathDeps
--- @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.

--- @param deps neotest-java.JdtlsClasspathDeps
--- @return { get_classpath: fun(base_dir: neotest-java.Path, additional_classpath_entries?: neotest-java.Path[]): neotest-java.Classpath }
local function JdtlsClasspath(deps)
deps = deps or {}
assert(deps.client_provider, "JdtlsClasspath requires a client_provider")
local schedule = deps.schedule or vim.schedule
-- Inject path_separator to make tests deterministic across platforms.
-- Without injection, tests would need to know the platform they're running on.
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 runtime + test + extra entries, in order
local function get_classpath(base_dir, additional_classpath_entries)
additional_classpath_entries = additional_classpath_entries or {}

local base_dir_uri = vim.uri_from_fname(base_dir:to_string())
local client = deps.client_provider(base_dir)

---@diagnostic disable-next-line: undefined-field
local bufnr = vim.tbl_keys(client.attached_buffers)[1]
local runtime = nio.control.future()
local test = nio.control.future()

-- vim.schedule moves the LSP request out of any fast-event context
-- (nio coroutines run as libuv callbacks) so nvim_buf_is_valid,
-- called internally by client:request, is safe to invoke. Mirrors
-- the fix applied to command/binaries.lua in 7cdd189.
schedule(function()
---@diagnostic disable-next-line: undefined-field
client:request("workspace/executeCommand", {
command = "java.project.getClasspaths",
arguments = { base_dir_uri, vim.json.encode({ scope = "runtime" }) },
}, function(err, result)
if err then
runtime.set_error(err)
else
runtime.set(result.classpaths)
end
end, bufnr)

---@diagnostic disable-next-line: undefined-field
client:request("workspace/executeCommand", {
command = "java.project.getClasspaths",
arguments = { base_dir_uri, vim.json.encode({ scope = "test" }) },
}, function(err, result)
if err then
test.set_error(err)
else
test.set(result.classpaths)
end
end, bufnr)
end)

local additional_classpath_entries_strings = vim
--
.iter(additional_classpath_entries)
--- @param path neotest-java.Path
:map(function(path)
return path:to_string()
end)
:totable()

local entries = vim.iter({
runtime.wait(),
test.wait(),
additional_classpath_entries_strings,
})
:flatten()
:totable()

return Classpath(entries, { separator = path_separator })
end

return {
get_classpath = get_classpath,
}
end

return JdtlsClasspath
54 changes: 54 additions & 0 deletions lua/neotest-java/core/language_server/jdtls_compile.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
local logger = require("neotest-java.logger")

---@diagnostic disable-next-line: undefined-doc-name
--- @class neotest-java.JdtlsCompileDeps
--- @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.

--- @param deps neotest-java.JdtlsCompileDeps
--- @return { compile: fun(opts: { base_dir: neotest-java.Path, compile_mode: "full" | "incremental" }) }
local function JdtlsCompile(deps)
deps = deps or {}
assert(deps.client_provider, "JdtlsCompile requires a client_provider")
local schedule = deps.schedule or vim.schedule

--- @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(("compilation in %s mode"):format(opts.compile_mode))

schedule(function()
--- @diagnostic disable-next-line: undefined-field
client:request("java/buildWorkspace", { forceRebuild = opts.compile_mode == "full" }, function(err, result)
if err then
logger.error("compilation failed: " .. vim.inspect(err))
end

-- NOTE:
-- 1) module paths are not duplicated on the classpath,
-- 2) the classpath and modulepath generated by the eclipse jdtls, do
-- not include the target/test-classes directory, for maven projects
-- using the Java Platform Module System.
if next(result.modulepaths) ~= nil then
logger.error(
"Java Platform Module System not supported. Temporarily remove "
.. "module-info.java for the test duration. modulepath="
.. vim.inspect(result.modulepaths)
)
end
end)
end)

logger.debug("compilation complete!")
end

return {
compile = compile,
}
end

return JdtlsCompile
Loading
Loading