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
16 changes: 8 additions & 8 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 — 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). |

Expand Down Expand Up @@ -83,7 +83,7 @@ sequenceDiagram
participant neotest
participant sb as spec_builder
participant proj as root_finder /<br/>build_tool / Project
participant lsp as JDT.LS (via LSP)
participant lsp as Java LSP<br/>(JDT.LS / IntelliJ)
participant cb as junit_command_builder

neotest->>sb: build_spec(args)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions lua/neotest-java/core/language_server/document_uri.lua
Original file line number Diff line number Diff line change
@@ -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
99 changes: 79 additions & 20 deletions lua/neotest-java/core/language_server/init.lua
Original file line number Diff line number Diff line change
@@ -1,47 +1,106 @@
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
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,
}
83 changes: 83 additions & 0 deletions lua/neotest-java/core/language_server/intellij_classpath.lua
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading