From 72c37f08a35130e01b36280eacd291196e2ea52e Mon Sep 17 00:00:00 2001 From: Paul King Date: Fri, 22 May 2026 10:13:20 +1000 Subject: [PATCH] GROOVY-12026: Graduate JavaShell from incubating to stable --- .../org/apache/groovy/util/JavaShell.java | 7 +- src/spec/doc/core-testing-guide.adoc | 41 ++++++++++ src/spec/doc/guide-integrating.adoc | 55 ++++++++++++++ src/spec/test/IntegrationTest.groovy | 55 ++++++++++++++ .../testingguide/JavaShellExampleTests.groovy | 75 +++++++++++++++++++ .../groovy/groovy/console/ui/Console.groovy | 2 +- .../src/spec/doc/groovy-console.adoc | 17 +++++ 7 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 src/spec/test/testingguide/JavaShellExampleTests.groovy diff --git a/src/main/java/org/apache/groovy/util/JavaShell.java b/src/main/java/org/apache/groovy/util/JavaShell.java index 00892147f8c..5155cac6dda 100644 --- a/src/main/java/org/apache/groovy/util/JavaShell.java +++ b/src/main/java/org/apache/groovy/util/JavaShell.java @@ -81,7 +81,6 @@ * additionally written to disk by {@code compileAllTo}). See the {@code javac} documentation * for the complete list of supported flags. */ -@Incubating public class JavaShell { private static final String MAIN_METHOD_NAME = "main"; private static final URL[] EMPTY_URL_ARRAY = new URL[0]; @@ -194,6 +193,10 @@ public Map> compileAll(final String className, String src) thro private void doCompile(String className, String src, Iterable options) throws IOException { JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + if (compiler == null) { + throw new IllegalStateException("JavaShell requires a JDK at runtime; " + + "ToolProvider.getSystemJavaCompiler() returned null (running on a JRE?)."); + } try (BytesJavaFileManager bjfm = new BytesJavaFileManager(compiler.getStandardFileManager(null, locale, charset))) { StringBuilderWriter out = new StringBuilderWriter(); JavaCompiler.CompilationTask task = @@ -240,6 +243,7 @@ private void doCompile(String className, String src, Iterable options) t * @throws JavaShellCompilationException if the source fails to compile * @since 6.0.0 */ + @Incubating public Map compileAllTo(String className, Iterable options, String src, Path outputDir) throws IOException { @@ -269,6 +273,7 @@ public Map compileAllTo(String className, Iterable options * @throws JavaShellCompilationException if the source fails to compile * @since 6.0.0 */ + @Incubating public Map compileAllTo(String className, String src, Path outputDir) throws IOException { return compileAllTo(className, Collections.emptyList(), src, outputDir); diff --git a/src/spec/doc/core-testing-guide.adoc b/src/spec/doc/core-testing-guide.adoc index 1d3f1287552..685a57b72bb 100644 --- a/src/spec/doc/core-testing-guide.adoc +++ b/src/spec/doc/core-testing-guide.adoc @@ -21,6 +21,10 @@ = Testing Guide +ifndef::guide-integrating[] +:guide-integrating: guide-integrating.adoc +endif::[] + == Introduction The Groovy programming language comes with great support for writing tests. In addition to the language @@ -355,6 +359,43 @@ cobertura { Several output formats can be chosen for Cobertura coverage reports and test code coverage reports can be added to continuous integration build tasks. +==== JavaShell + +The `org.apache.groovy.util.JavaShell` class (covered in detail in the +<<{guide-integrating}#integ-javashell,integration guide>>) is also useful in tests when you need +real Java classes alongside your Groovy code. It avoids two recurring frictions: + +* you can declare Java fixture classes _inside_ the test that uses them, instead of adding + `src/test/java/...` files that exist only to support one Groovy test; +* you can capture genuine `javac`-emitted bytecode for use cases such as comparing what the + Groovy compiler produces against what Java produces for an equivalent source — useful when + asserting joint-compilation behaviour, annotation-processing output, or `@CompileStatic` + codegen equivalence. + +A short fixture example: + +[source,groovy] +---- +include::../test/testingguide/JavaShellExampleTests.groovy[tags=javashell_test_fixture,indent=0] +---- +<1> compile a Java POJO in-memory; no `.java` file is added to the test source tree +<2> attach `JavaShell`'s class loader to a `GroovyShell` so the test's Groovy code can resolve + the freshly-compiled class +<3> exercise the fixture from Groovy + +For a bytecode-equivalence assertion, `compileAllTo` writes the generated `.class` files to a +temporary directory from which the bytes can be read and fed into a bytecode-diff tool of choice +(ASM, `javap` output, etc.). The corresponding Groovy-side bytecode can be obtained through the +`GroovyClassLoader` / `CompilationUnit` paths used elsewhere in Groovy's own test suite. + +[source,groovy] +---- +include::../test/testingguide/JavaShellExampleTests.groovy[tags=javashell_bytecode_capture,indent=0] +---- +<1> compile and write the Java class to a temp directory +<2> read the class bytes back from disk +<3> compare against equivalent Groovy bytes obtained however best suits your test + == Testing with JUnit Groovy simplifies JUnit testing in the following ways: diff --git a/src/spec/doc/guide-integrating.adoc b/src/spec/doc/guide-integrating.adoc index 4266c26c68e..eedc3074f40 100644 --- a/src/spec/doc/guide-integrating.adoc +++ b/src/spec/doc/guide-integrating.adoc @@ -314,6 +314,61 @@ Hello, dependency 2! Hello, dependency 2! ---- +[[integ-javashell]] +=== JavaShell + +While `GroovyShell` and `GroovyClassLoader` compile Groovy source, `org.apache.groovy.util.JavaShell` +compiles _Java_ source in-memory using the platform `javax.tools.JavaCompiler`. It is useful when a +Groovy or polyglot application needs to compile and load Java classes at runtime: code generation, +plug-in loaders, scripting-style Java endpoints, or mixed-language testing setups. + +NOTE: `JavaShell` requires a JDK at runtime, not just a JRE — `ToolProvider.getSystemJavaCompiler()` +returns `null` on a JRE. + +The simplest usage compiles a single source string and returns the loaded `Class`: + +[source,groovy] +---- +include::../test/IntegrationTest.groovy[tags=javashell_compile,indent=0] +---- +<1> create a new `JavaShell` (an optional `ClassLoader` argument sets the parent loader) +<2> compile the source; `className` is the fully-qualified binary name and must match the + `package` declaration — see the `JavaShell` javadoc for the full naming rules +<3> invoke the compiled method reflectively + +`compileAll` returns _every_ class produced from the source unit — useful when the source declares +auxiliary or nested classes: + +[source,groovy] +---- +include::../test/IntegrationTest.groovy[tags=javashell_compile_all,indent=0] +---- +<1> compile a primary public class together with its package-private helper +<2> the returned map is keyed by binary class name + +To additionally persist the generated class files to disk (for tools that consume `.class` files +or to seed a classpath outside the JVM), use `compileAllTo`. It accepts a `Path` output directory +and writes each class to its conventional package subdirectory: + +[source,groovy] +---- +include::../test/IntegrationTest.groovy[tags=javashell_compile_all_to,indent=0] +---- +<1> compile and write to disk in one call; the second argument passes `javac` options (here + `-parameters` to retain formal parameter names) +<2> packaged classes land under `//.class`; default-package + classes land directly under `outputDir` + +The compiled classes remain available through `js.getClassLoader()` after `compileAllTo` returns, +so the same instance covers both "compile to memory" and "compile to memory and disk". + +When the source fails to compile, `JavaShell` throws `JavaShellCompilationException` carrying the +`javac` diagnostics. See the `JavaShell` javadoc for the full set of overloads (with/without an +`Iterable` options argument) and the expected `className`/`options` formats. + +NOTE: `compileAllTo` and its no-options overload are marked `@Incubating` — their signatures may +evolve in a subsequent 6.x release based on usage feedback. + === CompilationUnit Ultimately, it is possible to perform more operations during compilation by relying directly on the diff --git a/src/spec/test/IntegrationTest.groovy b/src/spec/test/IntegrationTest.groovy index ca2e71c9c92..0d08cf5944d 100644 --- a/src/spec/test/IntegrationTest.groovy +++ b/src/spec/test/IntegrationTest.groovy @@ -254,6 +254,61 @@ try { } finally { tmpDir.deleteDir() } +''' + } + + @Test + void testJavaShell() { + assertScript '''// tag::javashell_compile[] +import org.apache.groovy.util.JavaShell + +def js = new JavaShell() // <1> +def src = """ + package demo; + public class Foo { + public static String greet(String who) { return "hello, " + who; } + } +""" +Class foo = js.compile('demo.Foo', src) // <2> +assert foo.getDeclaredMethod('greet', String).invoke(null, 'world') == 'hello, world' // <3> +// end::javashell_compile[] +''' + + assertScript '''// tag::javashell_compile_all[] +import org.apache.groovy.util.JavaShell + +def js = new JavaShell() +def src = """ + package demo; + public class Box { public static int hidden() { return Helper.value(); } } + class Helper { static int value() { return 42; } } +""" +Map> classes = js.compileAll('demo.Box', src) // <1> +assert classes.keySet() == ['demo.Box', 'demo.Helper'] as Set // <2> +assert classes['demo.Box'].getDeclaredMethod('hidden').invoke(null) == 42 +// end::javashell_compile_all[] +''' + + assertScript '''// tag::javashell_compile_all_to[] +import org.apache.groovy.util.JavaShell +import java.nio.file.Files + +def out = Files.createTempDirectory('javashell-') +try { + def js = new JavaShell() + def src = """ + package demo; + public class Deep { public static String tag(String label) { return label; } } + """ + def written = js.compileAllTo('demo.Deep', ['-parameters'], src, out) // <1> + def classFile = out.resolve('demo/Deep.class') // <2> + assert written['demo.Deep'] == classFile && Files.exists(classFile) + // classes also remain available through js.classLoader, same as compileAll + assert js.classLoader.loadClass('demo.Deep').getDeclaredMethod('tag', String).invoke(null, 'x') == 'x' +} finally { + out.toFile().deleteDir() +} +// end::javashell_compile_all_to[] ''' } } diff --git a/src/spec/test/testingguide/JavaShellExampleTests.groovy b/src/spec/test/testingguide/JavaShellExampleTests.groovy new file mode 100644 index 00000000000..87333a526c5 --- /dev/null +++ b/src/spec/test/testingguide/JavaShellExampleTests.groovy @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package testingguide + +import org.junit.jupiter.api.Test + +import static groovy.test.GroovyAssert.assertScript + +final class JavaShellExampleTests { + + @Test + void testJavaFixture() { + assertScript '''// tag::javashell_test_fixture[] +import org.apache.groovy.util.JavaShell + +def js = new JavaShell() +js.compile('fixtures.PojoFixture', """ + package fixtures; + public class PojoFixture { + private final String name; + public PojoFixture(String name) { this.name = name; } + public String getName() { return name; } + } +""") // <1> + +// share JavaShell\'s class loader so Groovy code under test sees the fixture +def shell = new GroovyShell(js.classLoader) // <2> +shell.evaluate """ + def bean = fixtures.PojoFixture.newInstance('Groovy') + assert bean.name == 'Groovy' +""" // <3> +// end::javashell_test_fixture[] +''' + } + + @Test + void testBytecodeCapture() { + assertScript '''// tag::javashell_bytecode_capture[] +import org.apache.groovy.util.JavaShell +import java.nio.file.Files + +def out = Files.createTempDirectory('bc-') +try { + new JavaShell().compileAllTo('bc.Calc', """ + package bc; + public class Calc { public static int add(int a, int b) { return a + b; } } + """, out) // <1> + byte[] javaBytes = Files.readAllBytes(out.resolve('bc/Calc.class')) // <2> + // Feed javaBytes (and bytes for the equivalent Groovy source) into ASM, + // javap, or your bytecode-diff tool of choice for an equivalence assertion. // <3> + assert javaBytes.length > 0 + assert javaBytes[0..3] == [(byte)0xCA, (byte)0xFE, (byte)0xBA, (byte)0xBE] +} finally { + out.toFile().deleteDir() +} +// end::javashell_bytecode_capture[] +''' + } +} diff --git a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/Console.groovy b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/Console.groovy index cce1b481ed1..49583547817 100644 --- a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/Console.groovy +++ b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/Console.groovy @@ -1628,7 +1628,7 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo Optional optionalPrimaryClassName = findPrimaryClassName(src) if (optionalPrimaryClassName.isPresent()) { def js = new JavaShell(Thread.currentThread().contextClassLoader) - js.run(optionalPrimaryClassName.get(), src) + js.run(optionalPrimaryClassName.get(), src, Console.this.scriptArgsArray) } else { System.err.println('Initial parsing successful but no public class found. Compile/run will not proceed.') } diff --git a/subprojects/groovy-console/src/spec/doc/groovy-console.adoc b/subprojects/groovy-console/src/spec/doc/groovy-console.adoc index 1ec3267a983..e8b19811b83 100644 --- a/subprojects/groovy-console/src/spec/doc/groovy-console.adoc +++ b/subprojects/groovy-console/src/spec/doc/groovy-console.adoc @@ -83,6 +83,23 @@ executed. * You can turn the System.out capture on and off by selecting `Capture System.out` from the `Actions` menu +[[GroovyConsole-RunningAsJava]] +=== Running and compiling as Java + +The console can also treat the editor contents as Java source rather than Groovy: + +* `Script > Run as Java` (shortcut `Alt+R`) compiles the input as Java and invokes its `main` + method, with any program arguments taken from `Script > Set Script Arguments`. +* `Script > Run Selection as Java` does the same for just the highlighted text. +* `Script > Compile as Java` compiles the input without running it — useful as a quick syntax + check. + +The source must declare a single top-level public class whose name matches a fully-qualified +binary name in any `package` declaration; the console parses the source to locate that class +and reports if none is found. Compilation and execution go through the +`org.apache.groovy.util.JavaShell` API, so the same `javac` diagnostics surface in the output +area. + [[GroovyConsole-EditingFiles]] === Editing Files