From df3733d29af44d9dbadb409f2de1d5834ef63da8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Cas=C3=ADa?= <31012661+rcasia@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:53:31 +0200 Subject: [PATCH 1/4] feat(lsp-gateway): add JavaLanguageServer facade with jdtls resolvers --- lua/neotest-java/command/binaries.lua | 59 +++------ .../core/language_server/init.lua | 47 +++++++ .../core/language_server/jdtls_classpath.lua | 89 ++++++++++++++ .../core/language_server/jdtls_compile.lua | 54 ++++++++ .../core/language_server/jdtls_java_home.lua | 54 ++++++++ .../compiler/classpath_provider.lua | 98 ++++----------- .../spec_builder/compiler/lsp_compiler.lua | 57 +++------ tests/unit/test_binaries_spec.lua | 82 +++++-------- tests/unit/test_classpath_provider_spec.lua | 51 +++----- tests/unit/test_language_server_spec.lua | 115 ++++++++++++++++++ tests/unit/test_lsp_compiler_spec.lua | 25 ++-- 11 files changed, 474 insertions(+), 257 deletions(-) create mode 100644 lua/neotest-java/core/language_server/init.lua create mode 100644 lua/neotest-java/core/language_server/jdtls_classpath.lua create mode 100644 lua/neotest-java/core/language_server/jdtls_compile.lua create mode 100644 lua/neotest-java/core/language_server/jdtls_java_home.lua create mode 100644 tests/unit/test_language_server_spec.lua diff --git a/lua/neotest-java/command/binaries.lua b/lua/neotest-java/command/binaries.lua index cccf6501..c68e8e73 100644 --- a/lua/neotest-java/command/binaries.lua +++ b/lua/neotest-java/command/binaries.lua @@ -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 diff --git a/lua/neotest-java/core/language_server/init.lua b/lua/neotest-java/core/language_server/init.lua new file mode 100644 index 00000000..24c9bc09 --- /dev/null +++ b/lua/neotest-java/core/language_server/init.lua @@ -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[]): string classpaths joined by the platform separator +--- @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, +} diff --git a/lua/neotest-java/core/language_server/jdtls_classpath.lua b/lua/neotest-java/core/language_server/jdtls_classpath.lua new file mode 100644 index 00000000..dd5e8944 --- /dev/null +++ b/lua/neotest-java/core/language_server/jdtls_classpath.lua @@ -0,0 +1,89 @@ +local nio = require("nio") + +-- 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[]): string } +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 string classpaths joined by the platform separator + 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() + + return vim.iter({ + runtime.wait(), + test.wait(), + additional_classpath_entries_strings, + }) + :flatten() + :join(path_separator) + end + + return { + get_classpath = get_classpath, + } +end + +return JdtlsClasspath diff --git a/lua/neotest-java/core/language_server/jdtls_compile.lua b/lua/neotest-java/core/language_server/jdtls_compile.lua new file mode 100644 index 00000000..ee191288 --- /dev/null +++ b/lua/neotest-java/core/language_server/jdtls_compile.lua @@ -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 diff --git a/lua/neotest-java/core/language_server/jdtls_java_home.lua b/lua/neotest-java/core/language_server/jdtls_java_home.lua new file mode 100644 index 00000000..530085f4 --- /dev/null +++ b/lua/neotest-java/core/language_server/jdtls_java_home.lua @@ -0,0 +1,54 @@ +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.JdtlsJavaHomeDeps +--- @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.JdtlsJavaHomeDeps +--- @return { get_java_home: fun(cwd: neotest-java.Path): neotest-java.Path } +local function JdtlsJavaHome(deps) + deps = deps or {} + assert(deps.client_provider, "JdtlsJavaHome 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) + if cached_java_homes[cwd:to_string()] then + return Path(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 Path(cached_java_homes[cwd:to_string()]) + end + + return { + get_java_home = get_java_home, + } +end + +return JdtlsJavaHome diff --git a/lua/neotest-java/core/spec_builder/compiler/classpath_provider.lua b/lua/neotest-java/core/spec_builder/compiler/classpath_provider.lua index 885d3ec1..02a599d5 100644 --- a/lua/neotest-java/core/spec_builder/compiler/classpath_provider.lua +++ b/lua/neotest-java/core/spec_builder/compiler/classpath_provider.lua @@ -1,88 +1,34 @@ -local nio = require("nio") - --- 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.ClasspathProvider --- @field get_classpath async fun(base_dir: neotest-java.Path, additional_classpath_entries?: neotest-java.Path[]): string classpaths joined by ":" ---- @class GetClasspathResponse ---- @field classpaths string[] ---- @field modulepaths string [] ---- @field projectRoot string - --- @class neotest-java.ClasspathProviderDeps ---- @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 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 schedule? fun(fn: fun()) Only used with the deprecated client_provider fallback. +--- @field path_separator? string Only used with the deprecated client_provider fallback. --- @param deps neotest-java.ClasspathProviderDeps --- @return neotest-java.ClasspathProvider local function ClasspathProvider(deps) - 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 + deps = deps or {} + --- @type neotest-java.JavaLanguageServer + local language_server = deps.language_server + if not language_server then + local JdtlsClasspath = require("neotest-java.core.language_server.jdtls_classpath") + local classpath = JdtlsClasspath({ + client_provider = assert( + deps.client_provider, + "ClasspathProvider requires language_server or client_provider" + ), + schedule = deps.schedule, + path_separator = deps.path_separator, + }) + ---@diagnostic disable-next-line: missing-fields + language_server = { get_classpath = classpath.get_classpath } + end + ---@cast language_server neotest-java.JavaLanguageServer return { - get_classpath = function(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() - - return vim.iter({ - runtime.wait(), - test.wait(), - additional_classpath_entries_strings, - }) - :flatten() - :join(path_separator) - end, + get_classpath = language_server.get_classpath, } end diff --git a/lua/neotest-java/core/spec_builder/compiler/lsp_compiler.lua b/lua/neotest-java/core/spec_builder/compiler/lsp_compiler.lua index adc00b6a..cf6becdc 100644 --- a/lua/neotest-java/core/spec_builder/compiler/lsp_compiler.lua +++ b/lua/neotest-java/core/spec_builder/compiler/lsp_compiler.lua @@ -1,46 +1,25 @@ -local logger = require("neotest-java.logger") +---@diagnostic disable-next-line: undefined-doc-name +--- @class neotest-java.LspCompilerDeps +--- @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. ---- @diagnostic disable-next-line: undefined-doc-name ---- @param deps { client_provider: fun(cwd: neotest-java.Path): vim.lsp.Client } +--- @param deps neotest-java.LspCompilerDeps --- @return NeotestJavaCompiler local function LspCompiler(deps) + deps = deps or {} + --- @type neotest-java.JavaLanguageServer + local language_server = deps.language_server + if not language_server then + local JdtlsCompile = require("neotest-java.core.language_server.jdtls_compile") + local compiler = JdtlsCompile({ + client_provider = assert(deps.client_provider, "LspCompiler requires language_server or client_provider"), + }) + ---@diagnostic disable-next-line: missing-fields + language_server = { compile = compiler.compile } + end + ---@cast language_server neotest-java.JavaLanguageServer return { - compile = function(args) - local client = deps.client_provider(args.base_dir) - if not client or not client.initialized then - return - end - - logger.debug(("compilation in %s mode"):format(args.compile_mode)) - - vim.schedule(function() - --- @diagnostic disable-next-line: undefined-field - client:request( - "java/buildWorkspace", - { forceRebuild = args.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, + compile = language_server.compile, } end diff --git a/tests/unit/test_binaries_spec.lua b/tests/unit/test_binaries_spec.lua index 0bcea249..938578a9 100644 --- a/tests/unit/test_binaries_spec.lua +++ b/tests/unit/test_binaries_spec.lua @@ -7,82 +7,64 @@ local Path = require("neotest-java.model.path") describe("Binaries", function() local expected_cwd = Path("some") - -- Inject a synchronous schedule so mock callbacks fire immediately - -- without needing the Neovim event loop. - local sync_schedule = function(fn) - fn() - end - - local test_client_provider = function(cwd) - eq(expected_cwd, cwd) + local language_server_stub = function(java_home) return { - request = function(_, method, params, callback) - eq(method, "workspace/executeCommand") - eq(params.command, "java.project.getSettings") - eq(params.arguments[1], "file://some") - eq(params.arguments[2], { "org.eclipse.jdt.ls.core.vm.location" }) - - if callback then - callback(nil, { ["org.eclipse.jdt.ls.core.vm.location"] = "my_java_home" }) - end + get_java_home = function(cwd) + eq(expected_cwd, cwd) + return Path(java_home) end, } end - it("resolves jdtls java binary", function() + it("resolves java binary", function() local bin = Binaries({ - client_provider = test_client_provider, + language_server = language_server_stub("my_java_home"), is_windows = false, - schedule = sync_schedule, }) local result = bin.java(expected_cwd) eq(Path("my_java_home/bin/java"), result) end) - it("resolves jdtls javap binary", function() + it("resolves javap binary", function() local bin = Binaries({ - client_provider = test_client_provider, + language_server = language_server_stub("my_java_home"), is_windows = false, - schedule = sync_schedule, }) local result = bin.javap(expected_cwd) eq(Path("my_java_home/bin/javap"), result) end) - it("uses the cached binary after the first first time", function() - local invocation_count = 0 - local bin = Binaries({ - client_provider = function() - return { - request = function(_, _, _, callback) - invocation_count = invocation_count + 1 - callback(nil, { ["org.eclipse.jdt.ls.core.vm.location"] = "my_java_home" }) - end, - } - end, - is_windows = false, - schedule = sync_schedule, - }) - - for _ = 1, 10 do - bin.java(Path("some")) - bin.javap(Path("some")) - bin.java(Path("another_path")) - bin.javap(Path("another_path")) - end - - assert(invocation_count == 2, "Expected two invocations of the LSP request, got " .. invocation_count) - end) - it("adds .exe extension on Windows", function() local bin = Binaries({ - client_provider = test_client_provider, + language_server = language_server_stub("my_java_home"), is_windows = true, - schedule = sync_schedule, }) local java_result = bin.java(expected_cwd) local javap_result = bin.javap(expected_cwd) eq(Path("my_java_home/bin/java.exe"), java_result) eq(Path("my_java_home/bin/javap.exe"), javap_result) end) + + it("falls back to client_provider with default jdtls behavior", function() + local sync_schedule = function(fn) + fn() + end + local bin = Binaries({ + client_provider = function(cwd) + eq(expected_cwd, cwd) + return { + request = function(_, method, params, callback) + eq(method, "workspace/executeCommand") + eq(params.command, "java.project.getSettings") + if callback then + callback(nil, { ["org.eclipse.jdt.ls.core.vm.location"] = "my_java_home" }) + end + end, + } + end, + is_windows = false, + schedule = sync_schedule, + }) + eq(Path("my_java_home/bin/java"), bin.java(expected_cwd)) + end) end) diff --git a/tests/unit/test_classpath_provider_spec.lua b/tests/unit/test_classpath_provider_spec.lua index 1daa5ffc..9cbadf81 100644 --- a/tests/unit/test_classpath_provider_spec.lua +++ b/tests/unit/test_classpath_provider_spec.lua @@ -7,47 +7,24 @@ local async = require("tests.async_helpers").async local ClasspathProvider = require("neotest-java.core.spec_builder.compiler.classpath_provider") describe("Classpath Provider", function() - local sync_schedule = function(fn) - fn() - end - - --- @param path_separator string The separator to inject (":" for Unix, ";" for Windows) - --- @return neotest-java.ClasspathProvider - local function create_provider(path_separator) - return ClasspathProvider({ - schedule = sync_schedule, - path_separator = path_separator, - client_provider = function() - return { - attached_buffers = { [1234] = true }, - request = function(_, _, params, callback) - local options = vim.json.decode(params.arguments[2]) - if options.scope == "runtime" then - callback(nil, { classpaths = { "source_classpath" } }) - elseif options.scope == "test" then - callback(nil, { classpaths = { "test_classpath" } }) - end - end, - } - end, - }) - end - it( - "uses ':' separator on Unix", + "forwards to the language server gateway", async(function() - local provider = create_provider(":") + local seen_base, seen_extra + local provider = ClasspathProvider({ + ---@diagnostic disable-next-line: missing-fields + language_server = { + get_classpath = function(base_dir, additional) + seen_base = base_dir + seen_extra = additional + return "source_classpath:test_classpath:additional" + end, + }, + }) local result = provider.get_classpath(Path("some"), { Path("additional") }) eq("source_classpath:test_classpath:additional", result) - end) - ) - - it( - "uses ';' separator on Windows", - async(function() - local provider = create_provider(";") - local result = provider.get_classpath(Path("some"), { Path("additional") }) - eq("source_classpath;test_classpath;additional", result) + eq(Path("some"), seen_base) + eq({ Path("additional") }, seen_extra) end) ) end) diff --git a/tests/unit/test_language_server_spec.lua b/tests/unit/test_language_server_spec.lua new file mode 100644 index 00000000..351477f2 --- /dev/null +++ b/tests/unit/test_language_server_spec.lua @@ -0,0 +1,115 @@ +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 (jdtls)", function() + local sync_schedule = function(fn) + fn() + end + + --- Fake jdtls client answering all three protocol commands. + local function stub_client_provider(calls) + return function(_) + return { + initialized = true, + attached_buffers = { [1234] = true }, + request = function(_, _method, params, callback) + table.insert(calls, params.command or params) + if params.command == "java.project.getSettings" then + callback(nil, { ["org.eclipse.jdt.ls.core.vm.location"] = "my_java_home" }) + elseif params.command == "java.project.getClasspaths" then + local options = vim.json.decode(params.arguments[2]) + if options.scope == "runtime" then + callback(nil, { classpaths = { "source_classpath" } }) + elseif options.scope == "test" then + callback(nil, { classpaths = { "test_classpath" } }) + end + elseif params == "java/buildWorkspace" then + callback(nil, { modulepaths = {} }) + end + end, + } + end + end + + it( + "resolves java home via getSettings", + async(function() + local calls = {} + local server = LanguageServer.new({ + client_provider = stub_client_provider(calls), + schedule = sync_schedule, + }) + eq(Path("my_java_home"), 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 settings_calls = vim.tbl_filter(function(c) + return c == "java.project.getSettings" + end, calls) + eq(2, #settings_calls) + end) + ) + + it( + "merges runtime and test classpaths with ':' separator", + async(function() + local server = LanguageServer.new({ + client_provider = stub_client_provider({}), + schedule = sync_schedule, + path_separator = ":", + }) + eq("source_classpath:test_classpath:additional", server.get_classpath(Path("some"), { Path("additional") })) + end) + ) + + it( + "merges runtime and test classpaths with ';' separator", + async(function() + local server = LanguageServer.new({ + client_provider = stub_client_provider({}), + schedule = sync_schedule, + path_separator = ";", + }) + eq("source_classpath;test_classpath;additional", server.get_classpath(Path("some"), { Path("additional") })) + end) + ) + + it("maps compile modes to forceRebuild", function() + local seen = {} + local server = LanguageServer.new({ + client_provider = function(_) + return { + initialized = true, + request = function(_, params, opts) + eq("java/buildWorkspace", params) + table.insert(seen, opts) + return true + end, + } + end, + schedule = sync_schedule, + }) + + server.compile({ base_dir = Path("/path/to/project"), compile_mode = "incremental" }) + server.compile({ base_dir = Path("/path/to/project"), compile_mode = "full" }) + + eq({ { forceRebuild = false }, { forceRebuild = true } }, seen) + end) +end) diff --git a/tests/unit/test_lsp_compiler_spec.lua b/tests/unit/test_lsp_compiler_spec.lua index 8d7cf124..527cd110 100644 --- a/tests/unit/test_lsp_compiler_spec.lua +++ b/tests/unit/test_lsp_compiler_spec.lua @@ -1,28 +1,27 @@ -local LspCompiler = require("neotest-java.core.spec_builder.compiler.lsp_compiler") local Path = require("neotest-java.model.path") local assertions = require("tests.assertions") local eq = assertions.eq +local LspCompiler = require("neotest-java.core.spec_builder.compiler.lsp_compiler") + describe("LSP compiler", function() - it("works", function() + it("forwards to the language server gateway", function() + local seen_opts local compiler = LspCompiler({ - client_provider = function(cwd) - eq(Path("/path/to/project"), cwd) - return { - request = function(_, params, opts) - eq("java/buildWorkspace", params) - eq({ forceRebuild = false }, opts) - - return true - end, - } - end, + ---@diagnostic disable-next-line: missing-fields + language_server = { + compile = function(opts) + seen_opts = opts + end, + }, }) compiler.compile({ base_dir = Path("/path/to/project"), compile_mode = "incremental", }) + + eq({ base_dir = Path("/path/to/project"), compile_mode = "incremental" }, seen_opts) end) end) From 5108f8cf5805d69434136d53876ee4ada604f98a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Cas=C3=ADa?= <31012661+rcasia@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:53:45 +0200 Subject: [PATCH 2/4] refactor(init): wire language_server gateway into compiler and public DI --- .../core/spec_builder/compiler/init.lua | 9 ++++++++- lua/neotest-java/init.lua | 20 ++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/lua/neotest-java/core/spec_builder/compiler/init.lua b/lua/neotest-java/core/spec_builder/compiler/init.lua index 0969dbbe..e2113a3b 100644 --- a/lua/neotest-java/core/spec_builder/compiler/init.lua +++ b/lua/neotest-java/core/spec_builder/compiler/init.lua @@ -1,5 +1,6 @@ local nio = require("nio") local ClientProvider = require("neotest-java.core.spec_builder.compiler.client_provider") +local LanguageServer = require("neotest-java.core.language_server") local LspCompiler = require("neotest-java.core.spec_builder.compiler.lsp_compiler") ---@class NeotestJavaCompiler.Opts @@ -27,14 +28,20 @@ local client_provider = ClientProvider({ end, }) +--- Single jdtls gateway shared by all delegates: swapping servers in the +--- future means building a different gateway here, not touching callers. +local language_server = LanguageServer.new({ client_provider = client_provider }) + ---@class neotest-java.Compilers +---@field language_server neotest-java.JavaLanguageServer ---@field lsp NeotestJavaCompiler ---@diagnostic disable-next-line: undefined-doc-name ---@field client_provider fun(cwd: neotest-java.Path): vim.lsp.Client ---@type neotest-java.Compilers local compilers = { - lsp = LspCompiler({ client_provider = client_provider }), + language_server = language_server, + lsp = LspCompiler({ language_server = language_server }), client_provider = client_provider, } diff --git a/lua/neotest-java/init.lua b/lua/neotest-java/init.lua index 4d7c5d41..c258f04c 100644 --- a/lua/neotest-java/init.lua +++ b/lua/neotest-java/init.lua @@ -18,6 +18,7 @@ local checksum = require("neotest-java.util.checksum") local JunitVersionDetector = require("neotest-java.util.junit_version_detector") local MethodIdResolver = require("neotest-java.method_id_resolver") local ClasspathProvider = require("neotest-java.core.spec_builder.compiler.classpath_provider") +local LspCompiler = require("neotest-java.core.spec_builder.compiler.lsp_compiler") local CommandExecutor = require("neotest-java.command.command_executor") local scan = require("neotest-java.util.dir_scan") local build_tools = require("neotest-java.build_tool") @@ -74,6 +75,7 @@ end --- @field check_junit_jar_deps? neotest-java.CheckJunitJarDeps ---@diagnostic disable-next-line: undefined-doc-name --- @field client_provider? fun(cwd: neotest-java.Path): vim.lsp.Client +--- @field language_server? neotest-java.JavaLanguageServer Single seam replacing client_provider + classpath + binaries + compile. Explicit component fields below win over it per-concern. --- @field classpath_provider? neotest-java.ClasspathProvider --- @field binaries? neotest-java.LspBinaries --- @field lsp_compiler? NeotestJavaCompiler @@ -85,6 +87,7 @@ end --- @field check_junit_jar_deps neotest-java.CheckJunitJarDeps ---@diagnostic disable-next-line: undefined-doc-name --- @field client_provider fun(cwd: neotest-java.Path): vim.lsp.Client +--- @field language_server neotest-java.JavaLanguageServer --- @field classpath_provider neotest-java.ClasspathProvider --- @field binaries neotest-java.LspBinaries --- @field lsp_compiler NeotestJavaCompiler @@ -95,9 +98,19 @@ end --- @return neotest-java.ResolvedDeps local function resolve_deps(deps) deps = deps or {} + local LanguageServer = require("neotest-java.core.language_server") local _client_provider = deps.client_provider or compilers.client_provider - local _classpath_provider = deps.classpath_provider or ClasspathProvider({ client_provider = _client_provider }) - local _binaries = deps.binaries or Binaries({ client_provider = _client_provider }) + -- Precedence: explicit language_server wins; otherwise a custom + -- client_provider composes into the default jdtls gateway; otherwise + -- share the default gateway. Explicit classpath_provider / binaries / + -- lsp_compiler below still win per-concern. + local _language_server = deps.language_server + or ( + deps.client_provider and LanguageServer.new({ client_provider = deps.client_provider }) + or compilers.language_server + ) + local _classpath_provider = deps.classpath_provider or ClasspathProvider({ language_server = _language_server }) + local _binaries = deps.binaries or Binaries({ language_server = _language_server }) local _method_id_resolver = deps.method_id_resolver or MethodIdResolver({ classpath_provider = _classpath_provider, @@ -109,9 +122,10 @@ local function resolve_deps(deps) root_finder = deps.root_finder or root_finder, check_junit_jar_deps = deps.check_junit_jar_deps or {}, client_provider = _client_provider, + language_server = _language_server, classpath_provider = _classpath_provider, binaries = _binaries, - lsp_compiler = deps.lsp_compiler or compilers.lsp, + lsp_compiler = deps.lsp_compiler or LspCompiler({ language_server = _language_server }), build_tool_getter = deps.build_tool_getter or build_tools.get, method_id_resolver = _method_id_resolver, } From 30c03d8436544f5c1fca10288cfc5856fcb360ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Cas=C3=ADa?= <31012661+rcasia@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:53:50 +0200 Subject: [PATCH 3/4] docs: document language_server gateway in ARCHITECTURE and README --- ARCHITECTURE.md | 14 +++++++++++--- README.md | 8 ++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3329078a..5216ee0b 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 — 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). | @@ -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 @@ -181,8 +182,15 @@ A quick map for "which file do I touch?": `` 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 diff --git a/README.md b/README.md index 41cbae3e..d6e9f47c 100644 --- a/README.md +++ b/README.md @@ -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, @@ -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 From d661d8202da21b55d8d5e4cf2aa235665acc30d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Cas=C3=ADa?= <31012661+rcasia@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:01:29 +0200 Subject: [PATCH 4/4] refactor(classpath): add Classpath struct owning entry and separator rules --- ARCHITECTURE.md | 3 +- README.md | 5 + .../core/language_server/init.lua | 2 +- .../core/language_server/jdtls_classpath.lua | 12 ++- .../compiler/classpath_provider.lua | 2 +- lua/neotest-java/core/spec_builder/init.lua | 4 +- lua/neotest-java/method_id_resolver.lua | 4 +- lua/neotest-java/model/classpath.lua | 91 +++++++++++++++++++ tests/unit/test_classpath_provider_spec.lua | 5 +- tests/unit/test_classpath_spec.lua | 49 ++++++++++ tests/unit/test_language_server_spec.lua | 10 +- 11 files changed, 174 insertions(+), 13 deletions(-) create mode 100644 lua/neotest-java/model/classpath.lua create mode 100644 tests/unit/test_classpath_spec.lua diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5216ee0b..c162d34f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -178,7 +178,8 @@ 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 `` XML node). - **`core/`** — the adapter's actual behavior: discovery, root-finding, spec building, result parsing. diff --git a/README.md b/README.md index d6e9f47c..a59ad4ee 100644 --- a/README.md +++ b/README.md @@ -256,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 diff --git a/lua/neotest-java/core/language_server/init.lua b/lua/neotest-java/core/language_server/init.lua index 24c9bc09..88584c7d 100644 --- a/lua/neotest-java/core/language_server/init.lua +++ b/lua/neotest-java/core/language_server/init.lua @@ -7,7 +7,7 @@ local JdtlsCompile = require("neotest-java.core.language_server.jdtls_compile") --- 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[]): string classpaths joined by the platform separator +--- @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 diff --git a/lua/neotest-java/core/language_server/jdtls_classpath.lua b/lua/neotest-java/core/language_server/jdtls_classpath.lua index dd5e8944..8bf7d1fd 100644 --- a/lua/neotest-java/core/language_server/jdtls_classpath.lua +++ b/lua/neotest-java/core/language_server/jdtls_classpath.lua @@ -1,5 +1,7 @@ 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 ":" @@ -10,7 +12,7 @@ local DEFAULT_PATH_SEPARATOR = vim.fn.has("win32") == 1 and ";" or ":" --- @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[]): string } +--- @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") @@ -21,7 +23,7 @@ local function JdtlsClasspath(deps) --- @param base_dir neotest-java.Path --- @param additional_classpath_entries? neotest-java.Path[] - --- @return string classpaths joined by the platform separator + --- @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 {} @@ -72,13 +74,15 @@ local function JdtlsClasspath(deps) end) :totable() - return vim.iter({ + local entries = vim.iter({ runtime.wait(), test.wait(), additional_classpath_entries_strings, }) :flatten() - :join(path_separator) + :totable() + + return Classpath(entries, { separator = path_separator }) end return { diff --git a/lua/neotest-java/core/spec_builder/compiler/classpath_provider.lua b/lua/neotest-java/core/spec_builder/compiler/classpath_provider.lua index 02a599d5..f6edd7a8 100644 --- a/lua/neotest-java/core/spec_builder/compiler/classpath_provider.lua +++ b/lua/neotest-java/core/spec_builder/compiler/classpath_provider.lua @@ -1,5 +1,5 @@ --- @class neotest-java.ClasspathProvider ---- @field get_classpath async fun(base_dir: neotest-java.Path, additional_classpath_entries?: neotest-java.Path[]): string classpaths joined by ":" +--- @field get_classpath async 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(). --- @class neotest-java.ClasspathProviderDeps --- @field language_server? neotest-java.JavaLanguageServer Single seam for language-server interaction. One of language_server / client_provider is required. diff --git a/lua/neotest-java/core/spec_builder/init.lua b/lua/neotest-java/core/spec_builder/init.lua index b08f0467..02d9a76d 100644 --- a/lua/neotest-java/core/spec_builder/init.lua +++ b/lua/neotest-java/core/spec_builder/init.lua @@ -102,7 +102,9 @@ local SpecBuilder = function(deps) module.base_dir, deps.scan(module.base_dir, { search_patterns = { Path("test/resources$"):to_string() } }) ) - command:classpath_file_arg(classpath_file_arg) + -- tostring coerces both Classpath structs and plain strings + -- returned by custom providers. + command:classpath_file_arg(tostring(classpath_file_arg)) -- DAP STRATEGY if args.strategy == "dap" then diff --git a/lua/neotest-java/method_id_resolver.lua b/lua/neotest-java/method_id_resolver.lua index 58aa0318..f91879e6 100644 --- a/lua/neotest-java/method_id_resolver.lua +++ b/lua/neotest-java/method_id_resolver.lua @@ -21,7 +21,9 @@ local MethodIdResolver = function(deps) end local javap_path = javap_paths[module_dir:to_string()] if not classpaths[module_dir:to_string()] then - classpaths[module_dir:to_string()] = deps.classpath_provider.get_classpath(module_dir) + -- tostring coerces both Classpath structs and plain strings + -- returned by custom providers. + classpaths[module_dir:to_string()] = tostring(deps.classpath_provider.get_classpath(module_dir)) end local classpath = classpaths[module_dir:to_string()] diff --git a/lua/neotest-java/model/classpath.lua b/lua/neotest-java/model/classpath.lua new file mode 100644 index 00000000..43c6c071 --- /dev/null +++ b/lua/neotest-java/model/classpath.lua @@ -0,0 +1,91 @@ +--- @class neotest-java.ClasspathOpts +--- @field separator? string Entry separator. Defaults to ";" on Windows, ":" on Unix. + +--- @class neotest-java.Classpath +--- @field entries string[] The raw classpath entries, in order. +--- @field separator string The separator used to join entries. +--- @overload fun(entries?: (string | neotest-java.Path)[], opts?: neotest-java.ClasspathOpts): neotest-java.Classpath +local Classpath = {} + +local CLASSPATH_METATABLE = { + + __index = Classpath, + __tostring = function(classpath) + return classpath:to_string() + end, + + --- @param left neotest-java.Classpath + --- @param right neotest-java.Classpath + __eq = function(left, right) + -- check if both are actually tables to avoid errors + if type(left) ~= "table" or type(right) ~= "table" then + return false + end + return left:to_string() == right:to_string() + end, +} + +local DEFAULT_SEPARATOR = vim.fn.has("win32") == 1 and ";" or ":" + +--- @param entry string | neotest-java.Path +--- @return string +local entry_to_string = function(entry) + return tostring(entry) +end + +--- Create a new Classpath instance. +--- Preserves entry order exactly (no deduplication, no filtering), +--- mirroring how the entries were collected from the language server. +--- @param entries? (string | neotest-java.Path)[] Classpath entries, in order. +--- @param opts? neotest-java.ClasspathOpts +--- @return neotest-java.Classpath +function Classpath.new(entries, opts) + local strings = vim.iter(entries or {}):map(entry_to_string):totable() + return setmetatable( + --- @type neotest-java.Classpath + { + entries = strings, + separator = (opts and opts.separator) or DEFAULT_SEPARATOR, + }, + CLASSPATH_METATABLE + ) +end + +--- Join the entries with the separator. +--- @return string +function Classpath:to_string() + return table.concat(self.entries, self.separator) +end + +--- Whether there are no entries. +--- @return boolean +function Classpath:is_empty() + return #self.entries == 0 +end + +--- Return a new Classpath with one entry appended. +--- @param entry string | neotest-java.Path +--- @return neotest-java.Classpath +function Classpath:append(entry) + return Classpath.new( + vim.iter({ self.entries, { entry_to_string(entry) } }):flatten():totable(), + { separator = self.separator } + ) +end + +--- Return a new Classpath concatenating two classpaths. +--- Uses this instance's separator. +--- @param other neotest-java.Classpath +--- @return neotest-java.Classpath +function Classpath:merge(other) + return Classpath.new(vim.iter({ self.entries, other.entries }):flatten():totable(), { separator = self.separator }) +end + +--- @type neotest-java.Classpath +local ClasspathStruct = setmetatable(Classpath --[[@as table]], { + __call = function(_, ...) + return Classpath.new(...) + end, +}) + +return ClasspathStruct diff --git a/tests/unit/test_classpath_provider_spec.lua b/tests/unit/test_classpath_provider_spec.lua index 9cbadf81..44b6d810 100644 --- a/tests/unit/test_classpath_provider_spec.lua +++ b/tests/unit/test_classpath_provider_spec.lua @@ -1,4 +1,5 @@ local Path = require("neotest-java.model.path") +local Classpath = require("neotest-java.model.classpath") local assertions = require("tests.assertions") local eq = assertions.eq @@ -17,12 +18,12 @@ describe("Classpath Provider", function() get_classpath = function(base_dir, additional) seen_base = base_dir seen_extra = additional - return "source_classpath:test_classpath:additional" + return Classpath({ "source_classpath", "test_classpath", "additional" }, { separator = ":" }) end, }, }) local result = provider.get_classpath(Path("some"), { Path("additional") }) - eq("source_classpath:test_classpath:additional", result) + eq("source_classpath:test_classpath:additional", tostring(result)) eq(Path("some"), seen_base) eq({ Path("additional") }, seen_extra) end) diff --git a/tests/unit/test_classpath_spec.lua b/tests/unit/test_classpath_spec.lua new file mode 100644 index 00000000..3746a4f7 --- /dev/null +++ b/tests/unit/test_classpath_spec.lua @@ -0,0 +1,49 @@ +local assertions = require("tests.assertions") +local eq = assertions.eq + +local Classpath = require("neotest-java.model.classpath") +local Path = require("neotest-java.model.path") + +describe("Classpath", function() + it("joins entries with the given separator", function() + eq("a:b:c", Classpath({ "a", "b", "c" }, { separator = ":" }):to_string()) + eq("a;b;c", Classpath({ "a", "b", "c" }, { separator = ";" }):to_string()) + end) + + it("accepts Path entries", function() + eq("a:b", Classpath({ Path("a"), Path("b") }, { separator = ":" }):to_string()) + end) + + it("stringifies implicitly via tostring", function() + eq("a:b", tostring(Classpath({ "a", "b" }, { separator = ":" }))) + end) + + it("compares by joined value", function() + eq(Classpath({ "a", "b" }, { separator = ":" }), Classpath({ "a", "b" }, { separator = ":" })) + assert( + Classpath({ "a", "b" }, { separator = ":" }) ~= Classpath({ "a", "b" }, { separator = ";" }), + "expected different separators to compare unequal" + ) + end) + + it("reports emptiness", function() + assert(Classpath(nil, { separator = ":" }):is_empty(), "expected nil entries to be empty") + assert(Classpath({}, { separator = ":" }):is_empty(), "expected no entries to be empty") + assert(not Classpath({ "a" }, { separator = ":" }):is_empty(), "expected an entry to be non-empty") + end) + + it("appends one entry without mutating", function() + local base = Classpath({ "a" }, { separator = ":" }) + eq("a:b", base:append("b"):to_string()) + eq("a", base:to_string()) + end) + + it("merges two classpaths preserving order", function() + local merged = Classpath({ "a" }, { separator = ":" }):merge(Classpath({ "b", "c" })) + eq("a:b:c", merged:to_string()) + end) + + it("preserves duplicates and order exactly", function() + eq("b:a:b", Classpath({ "b", "a", "b" }, { separator = ":" }):to_string()) + end) +end) diff --git a/tests/unit/test_language_server_spec.lua b/tests/unit/test_language_server_spec.lua index 351477f2..10d8237d 100644 --- a/tests/unit/test_language_server_spec.lua +++ b/tests/unit/test_language_server_spec.lua @@ -75,7 +75,10 @@ describe("JavaLanguageServer (jdtls)", function() schedule = sync_schedule, path_separator = ":", }) - eq("source_classpath:test_classpath:additional", server.get_classpath(Path("some"), { Path("additional") })) + eq( + "source_classpath:test_classpath:additional", + tostring(server.get_classpath(Path("some"), { Path("additional") })) + ) end) ) @@ -87,7 +90,10 @@ describe("JavaLanguageServer (jdtls)", function() schedule = sync_schedule, path_separator = ";", }) - eq("source_classpath;test_classpath;additional", server.get_classpath(Path("some"), { Path("additional") })) + eq( + "source_classpath;test_classpath;additional", + tostring(server.get_classpath(Path("some"), { Path("additional") })) + ) end) )