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
This commit is contained in:
l0ng-ai
2026-10-31 00:59:22 +08:00
committed by GitHub
parent 2754cf327f
commit 0660f3c020
17 changed files with 265 additions and 30 deletions
+5
View File
@@ -78,6 +78,7 @@ struct FileConfig {
#[serde(default)]
args: Vec<String>,
cwd: Option<PathBuf>,
module: Option<String>,
#[serde(default)]
env: BTreeMap<String, String>,
#[serde(default, alias = "isTest", alias = "is_test")]
@@ -161,6 +162,7 @@ fn convert(configs: Vec<FileConfig>, local: bool) -> Vec<RunConfig> {
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::<Array>()));
}
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('\\', "/")));
}
+5 -5
View File
@@ -548,11 +548,11 @@ fn spring_boot_apps(root: &Path, build: &Path, out: &mut Vec<RunConfig>) {
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);
}
}
}
+5
View File
@@ -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<String>,
/// 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,
+28
View File
@@ -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")));
}
}
+1
View File
@@ -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");
+4
View File
@@ -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. */
@@ -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}`);
}
});
@@ -26,6 +26,7 @@ const config: RunConfig = {
command: "java",
args: ["-cp", "${classpath}", "Main"],
cwd: null,
module: "",
env: {},
detected: true,
isTest: false,
+5 -3
View File
@@ -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<LaunchConfig> {
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,
+5 -1
View File
@@ -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 &quot;"
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 });
}}
/>
</Field>
+3 -16
View File
@@ -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,
+114
View File
@@ -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<typeof import("@/platform/ipc")>();
return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } };
});
vi.mock("@tauri-apps/api/core", async (original) => ({
...(await original<typeof import("@tauri-apps/api/core")>()),
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();
});
@@ -27,6 +27,7 @@ const config: RunConfig = {
command: "java",
args: ["-cp", "${classpath}", "Main"],
cwd: null,
module: "",
env: {},
detected: false,
isTest: false,
+16 -4
View File
@@ -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<void> {
/** Persists user configurations and reloads the merged list. */
export async function saveUserConfigs(user: RunConfig[], removedDetectedIds: string[] = []): Promise<void> {
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");
+4
View File
@@ -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<string, string>, 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 } : {}),
};
+47 -1
View File
@@ -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<string | null> {
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<string>();
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<string> {
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;
}
+1
View File
@@ -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} 语言服务器运行时",