From 0660f3c020644f9d0737ce07bdea2f05d85c38e9 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:59:22 +0800 Subject: [PATCH] fix(java): resolve application classpaths independently of working directory (#493) Run and Debug passed the configuration working directory to java_resolve_launch as the module, so changing the working directory also changed which jdtls project supplied the classpath and failed with "references non-existing project". Java application configurations now store their module at detection and creation, keep it when the working directory changes, and clear it when the main class changes. Older saved configurations resolve it from the main class source file. The Kotlin debugger check also uses the module directory. Fixes #435 --- crates/ide-run/src/config.rs | 5 + crates/ide-run/src/detect.rs | 10 +- crates/ide-run/src/lib.rs | 5 + crates/ide-run/tests/config.rs | 28 +++++ crates/ide-run/tests/detect.rs | 1 + src/bindings.ts | 4 + src/features/debugger/__tests__/java.test.ts | 20 +++ .../debugger/__tests__/javaCancel.test.ts | 1 + src/features/debugger/java.ts | 8 +- src/features/run/ConfigForm.tsx | 6 +- src/features/run/application.ts | 19 +-- src/features/run/javaModule.test.ts | 114 ++++++++++++++++++ src/features/run/resolveCancellation.test.ts | 1 + src/features/run/store.ts | 20 ++- src/features/run/types.ts | 4 + src/platform/javaLaunch.ts | 48 +++++++- src/platform/locales/text/zh.json | 1 + 17 files changed, 265 insertions(+), 30 deletions(-) create mode 100644 src/features/run/javaModule.test.ts diff --git a/crates/ide-run/src/config.rs b/crates/ide-run/src/config.rs index ada02e1c..7855f074 100644 --- a/crates/ide-run/src/config.rs +++ b/crates/ide-run/src/config.rs @@ -78,6 +78,7 @@ struct FileConfig { #[serde(default)] args: Vec, cwd: Option, + module: Option, #[serde(default)] env: BTreeMap, #[serde(default, alias = "isTest", alias = "is_test")] @@ -161,6 +162,7 @@ fn convert(configs: Vec, local: bool) -> Vec { debug_adapter: c.debug_adapter.filter(|d| !d.is_empty()), allow_parallel: c.allow_parallel, extras: RunExtras { + module: c.module, params: c.params, folder: non_empty(c.folder), local, @@ -210,6 +212,9 @@ fn to_table(c: &RunConfig) -> Table { if !c.args.is_empty() { t.insert("args", value(c.args.iter().collect::())); } + if let Some(module) = &c.extras.module { + t.insert("module", value(module)); + } if let Some(cwd) = &c.cwd { t.insert("cwd", value(cwd.to_string_lossy().replace('\\', "/"))); } diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index b6b0c89e..a56fec5d 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -548,11 +548,11 @@ fn spring_boot_apps(root: &Path, build: &Path, out: &mut Vec) { Some(m) => format!("application:{m}:{class}"), None => format!("application:{class}"), }; - out.push( - config(id, simple, "spring-boot", "java", &["-cp", "${classpath}", &class]) - .in_dir(module_rel.as_ref().map(PathBuf::from)) - .debug("java-debug"), - ); + let mut app = config(id, simple, "spring-boot", "java", &["-cp", "${classpath}", &class]) + .in_dir(module_rel.as_ref().map(PathBuf::from)) + .debug("java-debug"); + app.extras.module = Some(module_rel.clone().unwrap_or_default()); + out.push(app); } } } diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index 97eb4afd..b32ec8b5 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -70,6 +70,10 @@ fn is_true(b: &bool) -> bool { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct RunExtras { + /// Module supplying a JVM application's classpath; empty means the project root. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[specta(optional)] + pub module: Option, /// Values of the configuration type's own form fields (the command line derives from them). #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] #[specta(optional)] @@ -137,6 +141,7 @@ pub struct RunExtras { impl Default for RunExtras { fn default() -> Self { Self { + module: None, params: BTreeMap::new(), folder: None, local: false, diff --git a/crates/ide-run/tests/config.rs b/crates/ide-run/tests/config.rs index 17761870..7ba18d86 100644 --- a/crates/ide-run/tests/config.rs +++ b/crates/ide-run/tests/config.rs @@ -234,3 +234,31 @@ fn suppression_round_trip_and_resaving() { assert_eq!(load_suppressed_config_ids(dir.path()).unwrap(), vec![ids[1].clone()]); assert!(fs::read_to_string(dir.path().join(".openidea/.gitignore")).unwrap().contains("/run.local.toml")); } + +#[test] +fn java_module_is_optional_and_independent_of_working_directory() { + let dir = tempfile::tempdir().unwrap(); + let path = user_configs_path(dir.path()); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + r#"[[configurations]] +id = "user:main" +name = "Main" +kind = "application" +command = "java" +args = ["-cp", "${classpath}", "a.Main"] +cwd = "sibling" +"#, + ) + .unwrap(); + let mut configs = load_user_configs(dir.path()).unwrap(); + assert_eq!(configs[0].extras.module, None); + for module in ["app", ""] { + configs[0].extras.module = Some(module.into()); + save_user_configs(dir.path(), &configs).unwrap(); + let loaded = load_user_configs(dir.path()).unwrap(); + assert_eq!(loaded, configs); + assert_eq!(loaded[0].cwd.as_deref(), Some(std::path::Path::new("sibling"))); + } +} diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index 5567466c..31a672a3 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -216,6 +216,7 @@ fn builds_nested_in_a_folder_of_repositories() { assert_eq!(app.kind, "spring-boot"); assert_eq!(cmdline(app), "java -cp ${classpath} org.dromara.DromaraApplication"); assert_eq!(app.cwd.as_deref(), Some(Path::new("RuoYi-Vue-Plus/ruoyi-admin"))); + assert_eq!(app.extras.module.as_deref(), Some("RuoYi-Vue-Plus/ruoyi-admin")); assert_eq!(app.debug_adapter.as_deref(), Some("java-debug")); let package = find(&c, "maven:package:RuoYi-Vue-Plus"); assert_eq!(package.name, "RuoYi-Vue-Plus: mvn package"); diff --git a/src/bindings.ts b/src/bindings.ts index 4f0982ad..b62486f8 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -5076,6 +5076,8 @@ export type RunConfig_Serialize = { export type RunExtras = RunExtras_Serialize | RunExtras_Deserialize; export type RunExtras_Deserialize = { + /** Module supplying a JVM application's classpath; empty means the project root. */ + module?: string | null, /** Values of the configuration type's own form fields (the command line derives from them). */ params?: { [key in string]: string }, /** Folder in the Run/Debug Configurations tree. */ @@ -5113,6 +5115,8 @@ export type RunExtras_Deserialize = { }; export type RunExtras_Serialize = { + /** Module supplying a JVM application's classpath; empty means the project root. */ + module?: string | null, /** Values of the configuration type's own form fields (the command line derives from them). */ params?: { [key in string]: string }, /** Folder in the Run/Debug Configurations tree. */ diff --git a/src/features/debugger/__tests__/java.test.ts b/src/features/debugger/__tests__/java.test.ts index 25365626..3c5743a2 100644 --- a/src/features/debugger/__tests__/java.test.ts +++ b/src/features/debugger/__tests__/java.test.ts @@ -11,6 +11,14 @@ vi.mock("@/platform/ipc", async (orig) => { ...real, commands: { ...real.commands, + findFiles: (name: string) => + ok( + name === "Main.java" + ? [{ path: "/p/api/src/main/java/a/Main.java", relPath: "api/src/main/java/a/Main.java" }] + : [], + ), + fsReadText: () => ok("package a;\npublic class Main {}"), + runConfigs: () => ok([]), fsReadDir: (path: string) => ok( kotlinFiles && path.endsWith("/src") @@ -49,6 +57,7 @@ const app: RunConfig = { command: "java", args: ["-Xmx1g", "-cp", "${classpath}", "a.Main", "--port", "8081 x"], cwd: "api", + module: "api", env: { A: "1" }, detected: true, isTest: false, @@ -144,3 +153,14 @@ describe("Java debugging (java-debug through jdtls)", () => { expect(JSON.parse(l.extraJson!)).toMatchObject({ debugServer: 41234, hostName: "127.0.0.1", port: 5005 }); }); }); + +it.each(["sibling", "/outside/modules"])("debugs from %s with the main class module", async (cwd) => { + kotlinFiles = false; + for (const module of ["api", undefined]) { + invoked.length = 0; + const launch = await javaLaunchConfig({ ...app, module, cwd }, "/p"); + expect(invoked).toContainEqual(["javaResolveLaunch", ["api", false, true, expect.any(Number)]]); + expect(invoked).toContainEqual(["javaDebugServer", ["api"]]); + expect(launch.cwd).toBe(cwd.startsWith("/") ? cwd : `/p/${cwd}`); + } +}); diff --git a/src/features/debugger/__tests__/javaCancel.test.ts b/src/features/debugger/__tests__/javaCancel.test.ts index edbb3dfc..4b8addb5 100644 --- a/src/features/debugger/__tests__/javaCancel.test.ts +++ b/src/features/debugger/__tests__/javaCancel.test.ts @@ -26,6 +26,7 @@ const config: RunConfig = { command: "java", args: ["-cp", "${classpath}", "Main"], cwd: null, + module: "", env: {}, detected: true, isTest: false, diff --git a/src/features/debugger/java.ts b/src/features/debugger/java.ts index 3ad22fbe..459daca8 100644 --- a/src/features/debugger/java.ts +++ b/src/features/debugger/java.ts @@ -20,6 +20,7 @@ import { joinJavaArgs, parseJavaArgs, resolveJavaCommand, + resolveJavaModule, } from "@/platform/javaLaunch"; import { notifications } from "@/workbench/contributions"; @@ -85,11 +86,12 @@ export async function javaLaunchConfig( root: string, preparation?: { request: RequestId; signal: AbortSignal }, ): Promise { - const module = rc.cwd ?? ""; const cwd = rc.cwd ? join(root, rc.cwd) : root; const parsed = parseJavaArgs(rc.args); if (!parsed.mainClass) throw new Error(`No main class in '${rc.name}'`); preparation?.signal.throwIfAborted(); + const module = await resolveJavaModule(rc); + preparation?.signal.throwIfAborted(); const info = await call( commands.javaResolveLaunch(module, parsed.classpath === "test", true, preparation?.request ?? nextRequestId()), ); @@ -103,7 +105,7 @@ export async function javaLaunchConfig( body: formatCompileErrors(info).trimEnd(), }); const resolved = resolveJavaCommand(rc, info); - const kotlin = rc.debugAdapter === KOTLIN_ADAPTER || (await moduleHasKotlinSources(cwd)); + const kotlin = rc.debugAdapter === KOTLIN_ADAPTER || (await moduleHasKotlinSources(join(root, module))); preparation?.signal.throwIfAborted(); const classPaths = parsed.explicitClasspath ? parsed.explicitClasspath.split(/[:;]/) : info.classPaths; if (kotlin) { @@ -116,7 +118,7 @@ export async function javaLaunchConfig( cwd, env: { ...rc.env }, extraJson: JSON.stringify({ - projectRoot: cwd, + projectRoot: join(root, module), mainClass: parsed.mainClass, classPaths, modulePaths: info.modulePaths, diff --git a/src/features/run/ConfigForm.tsx b/src/features/run/ConfigForm.tsx index 34c9f94b..1d11ed2d 100644 --- a/src/features/run/ConfigForm.tsx +++ b/src/features/run/ConfigForm.tsx @@ -3,6 +3,7 @@ * common ones, the options added through "Modify options" (⌥M) and the * Before launch list. */ +import { parseJavaArgs } from "@/platform/javaLaunch"; import { useMemo, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react"; import clsx from "clsx"; import type { BeforeLaunchTask, RunConfig } from "@/platform/ipc"; @@ -428,7 +429,10 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi placeholder="Space separated; quote with ' or "" onChange={(e) => { setArgsText(e.target.value); - update({ args: parseArgs(e.target.value), params: {} }); + const args = parseArgs(e.target.value); + const module = + parseJavaArgs(args).mainClass === parseJavaArgs(config.args).mainClass ? config.module : undefined; + update({ args, params: {}, module }); }} /> diff --git a/src/features/run/application.ts b/src/features/run/application.ts index 681ac8e5..9be6396a 100644 --- a/src/features/run/application.ts +++ b/src/features/run/application.ts @@ -4,26 +4,12 @@ * belongs to, and test configurations scoped to one class or method. */ import type { RunConfig } from "@/platform/ipc"; -import { CLASSPATH, TEST_CLASSPATH } from "@/platform/javaLaunch"; +import { CLASSPATH, TEST_CLASSPATH, jvmModuleDir } from "@/platform/javaLaunch"; import { isTestPath } from "@/platform/languageSettings"; import { currentRoots } from "@/platform/projectRoots"; import { simpleClassName } from "./jvmTargets"; -/** - * Module directory (relative to the project root, `""` for the root) of a JVM - * source file: the part before `src/{main,test}/…`, else the working directory - * of the closest Maven / Gradle configuration. - */ -export function jvmModuleDir(relPath: string, configs: readonly RunConfig[] = []): string { - const m = /^(?:(.*)\/)?src\/(?:main|test|[\w-]+)\/(?:java|kotlin|scala|groovy)\//.exec(relPath); - if (m) return m[1] ?? ""; - const cwds = configs - .filter((c) => c.kind === "maven" || c.kind === "gradle") - .map((c) => (c.cwd ?? "").replace(/^\.\/?/, "").replace(/\/+$/, "")) - .filter((cwd) => !cwd || relPath.startsWith(cwd + "/")) - .sort((a, b) => b.length - a.length); - return cwds[0] ?? ""; -} +export { jvmModuleDir } from "@/platform/javaLaunch"; /** In a test source root (detected or marked in Project Structure), or Maven's `src/test`. */ const isTestSource = (relPath: string) => /(^|\/)src\/test\//.test(relPath) || isTestPath(currentRoots(), relPath); @@ -42,6 +28,7 @@ export function applicationConfig( command: "java", args: ["-cp", isTestSource(relPath) ? TEST_CLASSPATH : CLASSPATH, target.className], cwd: module || null, + module, env: {}, detected: true, isTest: false, diff --git a/src/features/run/javaModule.test.ts b/src/features/run/javaModule.test.ts new file mode 100644 index 00000000..4856b831 --- /dev/null +++ b/src/features/run/javaModule.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { RunConfig } from "@/platform/ipc"; +import { findJavaModule } from "@/platform/javaLaunch"; +import { provideNotificationService } from "@/workbench/contributions"; +import { applicationConfig } from "./application"; +import { resetRuns, saveUserConfigs, startRun } from "./store"; +import { applicationType } from "./jvmTypes"; +import { withParams } from "./types"; + +const ipc = vi.hoisted(() => { + const ok = (data: unknown) => Promise.resolve({ status: "ok", data }); + return { + findFiles: vi.fn((name: string) => + ok( + name === "Main.java" + ? [{ path: "/p/app/src/main/java/a/Main.java", relPath: "app/src/main/java/a/Main.java" }] + : [], + ), + ), + fsReadText: vi.fn(() => ok("package a;\npublic class Main {}")), + runConfigs: vi.fn(() => ok([])), + runSaveConfigs: vi.fn(() => ok(null)), + runStart: vi.fn(() => ok({ id: 1, pty: false })), + javaResolveLaunch: vi.fn(() => + ok({ + build: "succeeded", + projectRoot: "/p", + classPaths: ["/p/app/target/classes"], + modulePaths: [], + javaHome: null, + javaExec: "java", + shortenCommandLine: null, + errors: [], + errorCount: 0, + hints: [], + }), + ), + }; +}); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } }; +}); +vi.mock("@tauri-apps/api/core", async (original) => ({ + ...(await original()), + Channel: class { + onmessage = null; + }, +})); +const config: RunConfig = { + id: "user:main", + name: "Main", + kind: "application", + command: "java", + args: ["-cp", "${classpath}", "a.Main"], + cwd: "app", + env: {}, + detected: false, + isTest: false, + debugAdapter: "java-debug", +}; +beforeEach(() => { + vi.clearAllMocks(); + provideNotificationService({ notify: vi.fn() }); +}); +afterEach(() => resetRuns()); + +it.each(["sibling", "/outside/modules"])("runs from %s using the stored module's classpath", async (cwd) => { + const session = await startRun({ ...config, module: "app", cwd }); + expect(session?.status).toBe("running"); + expect(ipc.javaResolveLaunch).toHaveBeenCalledWith("app", false, true, expect.any(Number)); + expect(ipc.runStart).toHaveBeenCalledWith( + expect.objectContaining({ cwd, args: ["-cp", "/p/app/target/classes", "a.Main"] }), + expect.anything(), + ); + expect(ipc.findFiles).not.toHaveBeenCalled(); +}); + +it("finds the source module for an old saved configuration without a module", async () => { + await startRun({ ...config, cwd: "/outside/modules" }); + expect(ipc.findFiles).toHaveBeenCalledWith("Main.java", 1000); + expect(ipc.javaResolveLaunch).toHaveBeenCalledWith("app", false, true, expect.any(Number)); + expect(ipc.runStart).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/outside/modules" }), expect.anything()); +}); + +it("keeps an explicitly stored root module", async () => { + expect(await findJavaModule({ ...config, module: "", cwd: "sibling" })).toBe(""); + expect(ipc.findFiles).not.toHaveBeenCalled(); +}); + +it("stores the source module when a manually created application is saved", async () => { + await saveUserConfigs([{ ...config, cwd: "sibling" }]); + expect(ipc.runSaveConfigs).toHaveBeenCalledWith([expect.objectContaining({ module: "app", cwd: "sibling" })], []); +}); + +it("stores the module at context creation and forgets it when the main class changes", () => { + const detected = applicationConfig({ className: "a.Main" }, "app/src/main/java/a/Main.java"); + expect(detected.module).toBe("app"); + expect(withParams(detected, { mainClass: "b.Other" }, applicationType).module).toBeUndefined(); + expect(withParams(detected, { mainClass: "a.Main", programArgs: "x" }, applicationType).module).toBe("app"); +}); + +it("does not substitute the working directory when sources are missing or ambiguous", async () => { + ipc.findFiles.mockResolvedValueOnce({ status: "ok", data: [] }); + expect(await findJavaModule({ ...config, cwd: "sibling" })).toBeNull(); + ipc.findFiles.mockResolvedValueOnce({ + status: "ok", + data: [ + { path: "/p/app/src/main/java/a/Main.java", relPath: "app/src/main/java/a/Main.java" }, + { path: "/p/other/src/main/java/a/Main.java", relPath: "other/src/main/java/a/Main.java" }, + ], + }); + expect(await findJavaModule(config)).toBeNull(); +}); diff --git a/src/features/run/resolveCancellation.test.ts b/src/features/run/resolveCancellation.test.ts index ab16fa39..2cb382ac 100644 --- a/src/features/run/resolveCancellation.test.ts +++ b/src/features/run/resolveCancellation.test.ts @@ -27,6 +27,7 @@ const config: RunConfig = { command: "java", args: ["-cp", "${classpath}", "Main"], cwd: null, + module: "", env: {}, detected: false, isTest: false, diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 04ff9ad1..e4eb58f2 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -17,7 +17,14 @@ import { type RunId, type TestCaseReport, } from "@/platform/ipc"; -import { formatCompileErrors, isJvmApplication, parseJavaArgs, resolveJavaCommand } from "@/platform/javaLaunch"; +import { + findJavaModule, + formatCompileErrors, + isJvmApplication, + parseJavaArgs, + resolveJavaCommand, + resolveJavaModule, +} from "@/platform/javaLaunch"; import { downloadJdkActions } from "@/platform/jdk"; import { useProject } from "@/platform/project"; import { executeAction } from "@/platform/actions"; @@ -319,6 +326,11 @@ export async function deleteConfig(id: string): Promise { /** Persists user configurations and reloads the merged list. */ export async function saveUserConfigs(user: RunConfig[], removedDetectedIds: string[] = []): Promise { + user = await Promise.all( + user.map(async (config) => + isJvmApplication(config) && config.module == null ? { ...config, module: await findJavaModule(config) } : config, + ), + ); await call( commands.runSaveConfigs( user.map((c) => ({ ...c, detected: false })), @@ -701,9 +713,9 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom const request = { id: nextRequestId(), cancelled: false }; rt.resolveRequest = request; try { - const info = await call( - commands.javaResolveLaunch(config.cwd ?? "", parsed.classpath === "test", true, request.id), - ); + const module = await resolveJavaModule(config); + if (request.cancelled) return null; + const info = await call(commands.javaResolveLaunch(module, parsed.classpath === "test", true, request.id)); if (request.cancelled) return null; if (info.build === "withErrors") say("Build completed with errors; launching with the last compiled classes.\n", "stderr"); diff --git a/src/features/run/types.ts b/src/features/run/types.ts index 11333694..c531a11e 100644 --- a/src/features/run/types.ts +++ b/src/features/run/types.ts @@ -3,6 +3,7 @@ * Application, Spring Boot, Compound) and the helpers that turn a type's params into the launched * command line. Other features contribute types with `registerRunConfigType`. */ +import { isJvmApplication, parseJavaArgs } from "@/platform/javaLaunch"; import type { RunConfig } from "@/platform/ipc"; import { registerRunConfigType, useRunConfigTypes, type RunConfigTypeContribution } from "@/workbench/contributions"; import { formatArgs, parseArgs, uid, uniqueName } from "./configs"; @@ -441,6 +442,9 @@ export function withParams(config: RunConfig, params: Record, ty params, command: line.command, args: line.args, + ...(isJvmApplication(config) && parseJavaArgs(config.args).mainClass !== parseJavaArgs(line.args).mainClass + ? { module: undefined } + : {}), ...(type?.ownsWorkingDirectory ? { cwd: line.cwd ?? null } : {}), ...(line.stop ? { stopCommand: line.stop } : {}), }; diff --git a/src/platform/javaLaunch.ts b/src/platform/javaLaunch.ts index e2136366..35a8db44 100644 --- a/src/platform/javaLaunch.ts +++ b/src/platform/javaLaunch.ts @@ -4,7 +4,8 @@ * `${classpath}` (or `${testClasspath}`) and the `java` launcher are resolved * through jdtls when the configuration starts. */ -import type { JavaLaunchInfo, RunConfig } from "@/platform/ipc"; +import { call, commands, type JavaLaunchInfo, type RunConfig } from "@/platform/ipc"; +import { tx } from "@/platform/i18n"; export const CLASSPATH = "${classpath}"; export const TEST_CLASSPATH = "${testClasspath}"; @@ -103,3 +104,48 @@ export function jdwpListeningPort(line: string): number | null { const m = /Listening for transport dt_socket at address:\s*(?:[\w.[\]:-]*:)?(\d+)\s*$/.exec(line.trim()); return m ? Number(m[1]) : null; } + +/** + * Module directory (relative to the project root, `""` for the root) of a JVM + * source file: the part before `src/{main,test}/…`, else the working directory + * of the closest Maven / Gradle configuration. + */ +export function jvmModuleDir(relPath: string, configs: readonly RunConfig[] = []): string { + const m = /^(?:(.*)\/)?src\/(?:main|test|[\w-]+)\/(?:java|kotlin|scala|groovy)\//.exec(relPath); + if (m) return m[1] ?? ""; + const cwds = configs + .filter((c) => c.kind === "maven" || c.kind === "gradle") + .map((c) => (c.cwd ?? "").replace(/^\.\/?/, "").replace(/\/+$/, "")) + .filter((cwd) => !cwd || relPath.startsWith(cwd + "/")) + .sort((a, b) => b.length - a.length); + return cwds[0] ?? ""; +} + +/** Recover the classpath module of configurations saved before it was stored separately. */ +export async function findJavaModule(config: RunConfig): Promise { + if (config.module != null) return config.module; + const main = parseJavaArgs(config.args).mainClass; + if (!main) return null; + const outer = main.split("$")[0]!; + const simple = outer.slice(outer.lastIndexOf(".") + 1); + const pkg = outer.slice(0, Math.max(0, outer.lastIndexOf("."))); + const names = [`${simple}.java`, `${simple}.kt`, ...(simple.endsWith("Kt") ? [`${simple.slice(0, -2)}.kt`] : [])]; + const matches = (await Promise.all(names.map((name) => call(commands.findFiles(name, 1000))))) + .flat() + .filter((file) => names.includes(file.relPath.split("/").pop()!)); + const configs = await call(commands.runConfigs()); + const modules = new Set(); + for (const file of matches) { + const text = await call(commands.fsReadText(file.path)); + const sourcePackage = /^\s*package\s+([\w.]+)\s*[;\r\n]/m.exec(text)?.[1] ?? ""; + if (sourcePackage === pkg) modules.add(jvmModuleDir(file.relPath, configs)); + } + return modules.size === 1 ? [...modules][0]! : null; +} + +export async function resolveJavaModule(config: RunConfig): Promise { + const module = await findJavaModule(config); + if (module === null) + throw new Error(tx`Cannot determine the module for main class '${parseJavaArgs(config.args).mainClass}'`); + return module; +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 227a177f..c09e63d1 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,5 @@ { + "Cannot determine the module for main class '{value}'": "无法确定主类 '{value}' 所属的模块", "Attach": "附加", "Remove from Workspace": "从工作区移除", "Downloading Java {0} language server runtime": "正在下载 Java {0} 语言服务器运行时",