diff --git a/src/features/run/java.test.ts b/src/features/run/java.test.ts index c679929b2..f74d3e7ec 100644 --- a/src/features/run/java.test.ts +++ b/src/features/run/java.test.ts @@ -11,7 +11,7 @@ import { useProject } from "@/platform/project"; import { applicationConfig, jvmModuleDir, jvmTestConfig } from "./application"; import { buildRows } from "./ConfigChooser"; import { jdwpLaunch } from "./javaDebug"; -import { jvmMainCandidates, scanJvmTargets } from "./jvmTargets"; +import { firstTopLevelType, jvmMainCandidates, scanJvmTargets } from "./jvmTargets"; import { adoptTemporary, MAX_TEMPORARY, saveTemporary, testReportEvents, useRun } from "./store"; import { detectRunTargets, targetConfig } from "./targets"; @@ -121,6 +121,22 @@ describe("scanJvmTargets (Java)", () => { }); }); +describe("firstTopLevelType", () => { + it("is the first top-level class, interface, enum, record or annotation, with the package", () => { + const src = [ + "package demo;", + "import java.util.List; // class Commented {}", + '/* enum Old {} */ @Tag(type = List.class, name = "class Str")', + "interface Shape { class Nested {} }", + "public class Test { public static void main(String[] args) {} }", + ].join("\n"); + expect(firstTopLevelType(src)).toBe("demo.Shape"); + expect(firstTopLevelType("record Point(int x) {}\nclass Test {}")).toBe("Point"); + expect(firstTopLevelType("@interface Marker {}\nclass Test {}")).toBe("Marker"); + expect(firstTopLevelType("void main() {\n IO.println(1);\n}\n")).toBeNull(); + }); +}); + describe("scanJvmTargets (Kotlin)", () => { it("top-level main runs the file facade, objects and companions with @JvmStatic", () => { expect(pick("package app\n\nfun main(args: Array) {}", "src/main/kotlin/app/server.kt", true)).toEqual([ @@ -363,6 +379,18 @@ describe("debugging build tool configurations", () => { expect(merged.env.JAVA_TOOL_OPTIONS).toMatch(/^-Dfile\.encoding=UTF-8 -agentlib:jdwp=/); }); + it("debugs a single Java file: the agent before the file, or for java only on a javac line", () => { + const source = jdwpLaunch(cfg({ id: "f", kind: "java-file", command: "/bin/java", args: ["Hi.java"] }))!; + expect(source.args).toEqual([expect.stringMatching(AGENT), "Hi.java"]); + const line = "mkdir -p /tmp/c && /bin/javac -g -d /tmp/c Test.java && /bin/java -cp /tmp/c Test"; + const compiled = jdwpLaunch( + cfg({ id: "f", kind: "java-file", command: line, env: { JDK_JAVA_OPTIONS: "-Xmx1g" } }), + )!; + expect(compiled).toMatchObject({ command: line, args: [] }); + expect(compiled.env.JDK_JAVA_OPTIONS).toMatch(/^-Xmx1g -agentlib:jdwp=/); + expect(compiled.env.JAVA_TOOL_OPTIONS).toBeUndefined(); + }); + it("leaves Application configurations and other debuggers alone", () => { for (const kind of ["application", "spring-boot"]) expect(jdwpLaunch(cfg({ id: "a", kind, command: "java", debugAdapter: "java-debug" }))).toBeNull(); diff --git a/src/features/run/javaDebug.ts b/src/features/run/javaDebug.ts index 1932608cb..225cf18cf 100644 --- a/src/features/run/javaDebug.ts +++ b/src/features/run/javaDebug.ts @@ -40,8 +40,12 @@ function withProperty(args: readonly string[], key: string, value: string): stri export function jdwpLaunch(config: RunConfig): RunConfig | null { if (config.kind === "tomcat") return { ...config, params: { ...config.params, tomcatDebug: "true" } }; const args = config.args; - // A single source file (`java Hi.java`): the agent goes before the file. - if (config.kind === "java-file") return { ...config, args: [JDWP_AGENT, ...args] }; + // A single source file (`java Hi.java`): the agent goes before the file. Compiled first + // (`javac … && java -cp …`): through `JDK_JAVA_OPTIONS`, which only the `java` launcher reads. + if (config.kind === "java-file") + return args.length + ? { ...config, args: [JDWP_AGENT, ...args] } + : { ...config, env: withOption(config.env, "JDK_JAVA_OPTIONS", JDWP_AGENT) }; if (config.kind === "maven" || /(^|\/)mvnw?(\.cmd)?$/.test(config.command)) { if (args.some((a) => a === "spring-boot:run" || /:spring-boot-maven-plugin:[^:]*:run$/.test(a))) return { ...config, args: withProperty(args, "spring-boot.run.jvmArguments", JDWP_AGENT) }; diff --git a/src/features/run/jvmTargets.ts b/src/features/run/jvmTargets.ts index e188b22e4..1384ce202 100644 --- a/src/features/run/jvmTargets.ts +++ b/src/features/run/jvmTargets.ts @@ -349,6 +349,24 @@ function extendsClause(tokens: Token[], at: number): string | null { return null; } +/** Binary name of a Java file's first top-level type: the class the source launcher (`java Hi.java`) runs. */ +export function firstTopLevelType(src: string): string | null { + const code = stripCommentsAndStrings(src); + const pkg = /^\s*package\s+([\w.]+)/m.exec(code)?.[1] ?? ""; + const tokens = tokenize(code); + let depth = 0; + for (let i = 0; i < tokens.length; i++) { + const t = tokens[i]!.text; + if (t === "{") depth++; + else if (t === "}") depth = Math.max(0, depth - 1); + else if (depth === 0 && TYPE_KEYWORDS.has(t) && tokens[i - 1]?.text !== ".") { + const name = tokens[i + 1]?.text; + if (isIdent(name)) return pkg ? `${pkg}.${name}` : name!; + } + } + return null; +} + /** Simple name of a binary class name (`a.b.Outer$Inner` → `Inner`). */ export function simpleClassName(className: string): string { return className.split(/[.$]/).pop() ?? className; diff --git a/src/features/run/producers.test.ts b/src/features/run/producers.test.ts index 6a800050c..21574434e 100644 --- a/src/features/run/producers.test.ts +++ b/src/features/run/producers.test.ts @@ -344,6 +344,69 @@ describe("single-file producers (scratches)", () => { expect(p.config!.args.at(-1)).toBe("demo.Hi"); }); + it("Java: a main class after another top-level class compiles with javac, then runs with java -cp", () => { + const src = + "class Parent {\n void show() {}\n}\n\npublic class Test {\n" + + " public static void main(String[] args) {\n new Parent().show();\n }\n}\n"; + const input = { path: `${ROOT}/src/Test.java`, root: ROOT, configs: [], content: src }; + const out = "/tmp/openidea-run/Test-[a-z0-9]+-classes"; + for (const p of [produceFile(input, ALL), produceContext(input, ALL), produceContext({ ...input, line: 6 }, ALL)]) { + expect(p!.config).toMatchObject({ + id: "context:file:src/Test.java", + name: "Test.java", + kind: "java-file", + args: [], + cwd: null, + debugAdapter: "java-debug", + }); + expect(p!.config!.command).toMatch( + new RegExp(`^mkdir -p (${out}) && /bin/javac -g -d \\1 src/Test\\.java && /bin/java -cp \\1 Test$`), + ); + } + // The source launcher runs the first top-level class: a main there keeps `java Test.java`. + expect( + run(scratch("Test.java", "public class Test {\n public static void main(String[] a) {}\n}\nclass B {}")), + ).toEqual({ name: "Test.java", command: "/bin/java", args: ["Test.java"], cwd: SCRATCH }); + // Java 25 compact source file: no class, `java Hi.java` as before. + expect(run(scratch("Hi.java", "void main() {\n IO.println(1);\n}\nclass Helper {}\n"))).toMatchObject({ + command: "/bin/java", + args: ["Hi.java"], + }); + }); + + it("Java: the gutter runs the main class it marks, nested ones and those of a JDK on Windows too", () => { + const src = [ + "package demo;", + "class First {", + " public static void main(String[] args) {}", + " static class Inner {", + " public static void main(String[] args) {}", + " }", + "}", + "public class Second {", + " public static void main(String[] args) {}", + "}", + ].join("\n"); + const at = (line?: number) => produceContext(scratch("Second.java", src, line), ALL)!; + // The file and the first class: the source launcher. + for (const p of [at(), at(2)]) expect(p.config).toMatchObject({ command: "/bin/java", args: ["Second.java"] }); + const second = at(8); + expect(second).toMatchObject({ name: "Second", scope: "element" }); + expect(second.config).toMatchObject({ id: `context:file:${SCRATCH}/Second.java#demo.Second`, name: "Second" }); + expect(second.config!.command).toMatch(/ && \/bin\/java -cp \S+ demo\.Second$/); + expect(at(4).config!.command).toMatch(/ && \/bin\/java -cp \S+ 'demo\.First\$Inner'$/); + const jdk = "C:\\Program Files\\Java\\jdk-21\\bin\\java.exe"; + const win: ToolEnv = { + tool: (n) => (n === "java" ? { path: jdk, version: null } : null), + compileDir: "C:/Users/me/AppData/Local/openidea/run", + windows: true, + }; + const p = produceContext(scratch("Second.java", src, 8), win)!; + expect(p.config!.command).toMatch( + /^\(if not exist (C:\\Users\\me\\AppData\\Local\\openidea\\run\\Second-[a-z0-9]+-classes) mkdir \1\) && "C:\\Program Files\\Java\\jdk-21\\bin\\javac\.exe" -g -d \1 Second\.java && "C:\\Program Files\\Java\\jdk-21\\bin\\java\.exe" -cp \1 demo\.Second$/, + ); + }); + it("Java: a main in a Gradle module outside the project runs on the module's classpath", () => { // `includeFlat 'billing'`: the module folder is a sibling of the project folder. const svc = "/work/billing/modules/core"; diff --git a/src/features/run/producers.ts b/src/features/run/producers.ts index 461a39d9b..85f6d0b5c 100644 --- a/src/features/run/producers.ts +++ b/src/features/run/producers.ts @@ -18,6 +18,7 @@ import { goGenerateConfig } from "./goGenerate"; import { goTestParts } from "./goArguments"; import { contextConfig, fileRole, projectPython, testBasesFor, testConfigFor } from "./context"; import { djangoLabel, djangoTestsOf, hasDjangoTests } from "./djangoTypes"; +import { firstTopLevelType, scanJvmTargets, simpleClassName } from "./jvmTargets"; import { isWindows } from "./jvmTypes"; import { inheritedRunTargets } from "./inheritedMain"; import { detectRunTargets, targetConfig, targetLanguage, type RunTarget } from "./targets"; @@ -543,6 +544,44 @@ function compiled( return { name, scope, config, debug: { build: buildConfig, config: binary } }; } +/** + * A single Java file. The source launcher (`java Hi.java`, JEP 330) runs the file's first + * top-level type, so another main class (declared after it, nested, or picked in the gutter) + * is compiled with the JDK's `javac` into a class directory and run with `java -cp`. + */ +function javaFile(input: ContextInput, env: ToolEnv, s: FileSite, mainClass?: string): Produced { + const tools = toolsFor("java"); + const java = pick(env, tools); + if (!java) return { name: s.file, scope: "file", missing: missing("java", s.file, tools) }; + const source: Produced = { + name: s.file, + scope: "file", + config: fileConfig(s, "java-file", java.command, [s.arg], "java-debug"), + }; + if (input.content === undefined) return source; + const first = firstTopLevelType(input.content); + const mains = scanJvmTargets(input.content, s.idPath, false) + .filter((t) => t.kind === "main") + .map((t) => t.className); + const stem = stemOf(s.file); + const fileMain = + mains.find((c) => c === first) ?? mains.find((c) => c.slice(c.lastIndexOf(".") + 1) === stem) ?? mains[0]; + const launch = mainClass ?? fileMain; + if (!launch || launch === first) return source; + const win = !!env.windows; + const dir = env.compileDir.replace(/\\/g, "/").replace(/(.)\/+$/, "$1"); + const out = nativePath(`${dir}/${stem}-${hash(input.path)}-classes`, win); + // The `javac` of the JDK that runs the class, so their class file versions match. + const javac = java.command.replace(/(^|[\\/])java(\.exe)?$/i, "$1javac$2"); + const build = `${mkdirLine(out, win)} && ${shellLine([javac, "-g", "-d", out, s.arg], win)}`; + const run = shellLine([java.command, "-cp", out, launch], win); + const config: RunConfig = { + ...fileConfig(s, "java-file", `${build} && ${run}`, [], "java-debug"), + ...(launch === fileMain ? {} : { id: `context:file:${s.idPath}#${launch}`, name: simpleClassName(launch) }), + }; + return { name: config.name, scope: "file", config }; +} + function simple( lang: ScriptLanguage, s: FileSite, @@ -781,8 +820,7 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null return simple(lang, s, env, "go", () => ["run", s.arg], "delve"); case "java": if (input.content !== undefined && !/\bvoid\s+main\s*\(/.test(input.content)) return null; - // JEP 330: `java Hi.java` compiles and runs a single source file. - return simple(lang, s, env, "java-file", () => [s.arg], "java-debug"); + return javaFile(input, env, s); case "rust": if (!hasRustMain(input.content)) { // A standalone file with tests only: run them all. @@ -903,6 +941,8 @@ function elementProduction(input: ContextInput, env: ToolEnv, target: RunTarget) const config = applicationConfig({ className: target.className, spring: target.spring }, rel, input.configs); return { name: config.name, scope: "element", config }; } + if (target.className && /\.java$/.test(input.path)) + return { ...javaFile(input, env, s, target.className), scope: "element" }; const file = produceFile(input, env); return file ? { ...file, scope: "element" } : null; }