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)