From 0ea3fcd11ff7a3ec87df87ec8bbce803edf61039 Mon Sep 17 00:00:00 2001 From: Chris Hassell Date: Wed, 18 Feb 2026 13:48:32 -0600 Subject: [PATCH] fix(lsp): resolve JDTLS root to Gradle workspace root in monorepos JDTLS spawned a separate process per subproject in Gradle monorepos because the root function resolved to the nearest build.gradle instead of the workspace root (settings.gradle). Walk up from the subproject to find the Gradle workspace root, matching how RustAnalyzer already handles Cargo workspaces. Also clean up JDTLS temp data directories on shutdown via Handle.cleanup. --- packages/opencode/src/lsp/client.ts | 1 + packages/opencode/src/lsp/server.ts | 21 +++- packages/opencode/test/lsp/server.test.ts | 115 ++++++++++++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/lsp/server.test.ts diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 8704b65acb5b..d22557144c3e 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -241,6 +241,7 @@ export namespace LSPClient { connection.end() connection.dispose() input.server.process.kill() + await input.server.cleanup?.() l.info("shutdown") }, } diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 0200be2260c7..4d8cd025aac4 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -22,6 +22,7 @@ export namespace LSPServer { export interface Handle { process: ChildProcessWithoutNullStreams initialization?: Record + cleanup?: () => Promise } type RootFunction = (file: string) => Promise @@ -1129,7 +1130,22 @@ export namespace LSPServer { export const JDTLS: Info = { id: "jdtls", - root: NearestRoot(["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"]), + root: async (file) => { + const subproject = + (await NearestRoot(["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"])(file)) ?? + Instance.directory + + // Walk up from subproject looking for Gradle workspace root + let dir = subproject + while (dir !== path.dirname(dir) && dir.startsWith(Instance.worktree)) { + for (const marker of ["settings.gradle", "settings.gradle.kts"]) { + if (await pathExists(path.join(dir, marker))) return dir + } + dir = path.dirname(dir) + } + + return subproject + }, extensions: [".java"], async spawn(root) { const java = Bun.which("java") @@ -1224,6 +1240,9 @@ export namespace LSPServer { cwd: root, }, ), + async cleanup() { + await fs.rm(dataDir, { recursive: true, force: true }).catch(() => {}) + }, } }, } diff --git a/packages/opencode/test/lsp/server.test.ts b/packages/opencode/test/lsp/server.test.ts new file mode 100644 index 000000000000..740dd1c886fc --- /dev/null +++ b/packages/opencode/test/lsp/server.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test, beforeEach } from "bun:test" +import path from "path" +import fs from "fs/promises" +import { LSPServer } from "../../src/lsp/server" +import { LSPClient } from "../../src/lsp/client" +import { Instance } from "../../src/project/instance" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +function spawnFakeServer(): LSPServer.Handle { + const { spawn } = require("child_process") + const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js") + return { + process: spawn(process.execPath, [serverPath], { + stdio: "pipe", + }), + } +} + +describe("JDTLS root resolution", () => { + beforeEach(async () => { + await Log.init({ print: true }) + }) + + test("resolves to workspace root when settings.gradle exists", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "settings.gradle"), "") + await fs.mkdir(path.join(dir, "app/src"), { recursive: true }) + await Bun.write(path.join(dir, "app/build.gradle"), "") + await Bun.write(path.join(dir, "app/src/Main.java"), "") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const root = await LSPServer.JDTLS.root(path.join(tmp.path, "app/src/Main.java")) + expect(root).toBe(tmp.path) + }, + }) + }) + + test("resolves to subproject root when no settings.gradle exists", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await fs.mkdir(path.join(dir, "app/src"), { recursive: true }) + await Bun.write(path.join(dir, "app/pom.xml"), "") + await Bun.write(path.join(dir, "app/src/Main.java"), "") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const root = await LSPServer.JDTLS.root(path.join(tmp.path, "app/src/Main.java")) + expect(root).toBe(path.join(tmp.path, "app")) + }, + }) + }) + + test("resolves to workspace root across multiple subprojects", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "settings.gradle.kts"), "") + await fs.mkdir(path.join(dir, "modules/api/src"), { recursive: true }) + await fs.mkdir(path.join(dir, "modules/core/src"), { recursive: true }) + await Bun.write(path.join(dir, "modules/api/build.gradle.kts"), "") + await Bun.write(path.join(dir, "modules/core/build.gradle.kts"), "") + await Bun.write(path.join(dir, "modules/api/src/Api.java"), "") + await Bun.write(path.join(dir, "modules/core/src/Core.java"), "") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const api = await LSPServer.JDTLS.root(path.join(tmp.path, "modules/api/src/Api.java")) + const core = await LSPServer.JDTLS.root(path.join(tmp.path, "modules/core/src/Core.java")) + expect(api).toBe(tmp.path) + expect(core).toBe(tmp.path) + }, + }) + }) +}) + +describe("Handle cleanup", () => { + beforeEach(async () => { + await Log.init({ print: true }) + }) + + test("shutdown calls handle cleanup", async () => { + let cleaned = false + const handle = spawnFakeServer() + handle.cleanup = async () => { + cleaned = true + } + + const client = await Instance.provide({ + directory: process.cwd(), + fn: () => + LSPClient.create({ + serverID: "fake", + server: handle, + root: process.cwd(), + }), + }) + + await client.shutdown() + expect(cleaned).toBe(true) + }) +})