This is an automated email from the ASF dual-hosted git repository.
paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
The following commit(s) were added to refs/heads/master by this push:
new 57406e5384 GROOVY-12025: Provide a JavaShell compileAllTo method
57406e5384 is described below
commit 57406e53848bb6d47af02963686d99061732bbb8
Author: Paul King <[email protected]>
AuthorDate: Thu May 21 11:22:24 2026 +1000
GROOVY-12025: Provide a JavaShell compileAllTo method
---
.../java/org/apache/groovy/util/JavaShell.java | 92 ++++++++++++++-
.../org/apache/groovy/util/JavaShellTest.groovy | 131 +++++++++++++++++++++
2 files changed, 220 insertions(+), 3 deletions(-)
diff --git a/src/main/java/org/apache/groovy/util/JavaShell.java
b/src/main/java/org/apache/groovy/util/JavaShell.java
index 65253d8771..00892147f8 100644
--- a/src/main/java/org/apache/groovy/util/JavaShell.java
+++ b/src/main/java/org/apache/groovy/util/JavaShell.java
@@ -39,15 +39,47 @@ import java.net.URI;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.charset.Charset;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.Collections;
-import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
- * A shell for compiling or running pure Java code
+ * A shell for compiling or running pure Java code in-memory using the
platform's
+ * {@link javax.tools.JavaCompiler}. Compiled bytes are kept in a backing
class loader
+ * (see {@link #getClassLoader()}) and can optionally be written to disk via
+ * {@link #compileAllTo(String, Iterable, String, Path)}.
+ *
+ * <p>The {@code className} argument supplied to {@code run}, {@code compile},
+ * {@code compileAll} and {@code compileAllTo} must be the fully-qualified
binary name
+ * and match any {@code package} declaration in {@code src}: if the source
begins
+ * with {@code package com.example;} and declares class {@code Foo}, pass
+ * {@code "com.example.Foo"}, not {@code "Foo"}. A mismatch produces a standard
+ * {@code javac} diagnostic ("class X is public, should be declared in a file
named X.java").
+ * Source with no {@code package} declaration takes a simple name (e.g. {@code
"Foo"});
+ * {@code compileAllTo} writes such a class directly under {@code outputDir} as
+ * {@code Foo.class}, while packaged classes land under the matching
subdirectory
+ * (e.g. {@code <outputDir>/com/example/Foo.class}).
+ *
+ * <p>The {@code Iterable<String>} compiler-options parameter accepted by
{@code run},
+ * {@code compile}, {@code compileAll} and {@code compileAllTo} uses the same
flags as
+ * the {@code javac} command line, with one token per element (so {@code
"-source"} and
+ * {@code "17"} are two entries, not a single {@code "-source 17"} string).
Examples:
+ * <ul>
+ * <li>{@code List.of("--release", "17")} — target a specific Java
release</li>
+ * <li>{@code List.of("-source", "17", "-target", "17")} — separate
source/target levels</li>
+ * <li>{@code List.of("-parameters")} — retain formal parameter names</li>
+ * <li>{@code List.of("-g")} — emit debug information</li>
+ * <li>{@code List.of("-Xlint:all", "-Werror")} — enable all warnings and
treat them as errors</li>
+ * <li>{@code List.of("-proc:none")} — disable annotation processing</li>
+ * <li>{@code List.of("-classpath", extraJars)} — extend the compile-time
class path</li>
+ * </ul>
+ * The {@code -d} flag has no effect: in-memory output is always captured (and
is
+ * additionally written to disk by {@code compileAllTo}). See the {@code
javac} documentation
+ * for the complete list of supported flags.
*/
@Incubating
public class JavaShell {
@@ -188,6 +220,60 @@ public class JavaShell {
}
}
+ /**
+ * Compile {@code src} and write each resulting class file beneath {@code
outputDir}
+ * as a standard package directory tree (e.g. {@code com.example.Foo$Bar}
becomes
+ * {@code <outputDir>/com/example/Foo$Bar.class}). Intermediate
directories are
+ * created as needed; existing class files at those locations are
overwritten.
+ * The compiled classes also remain available through {@link
#getClassLoader()}.
+ *
+ * @param className the main class name (binary name, e.g. {@code
com.example.Foo})
+ * @param options compiler options; see the
+ * {@linkplain JavaShell class-level documentation} for
the expected format
+ * @param src the source code
+ * @param outputDir root directory under which class files are written;
created if absent
+ * @return a map from binary class name to the {@link Path} of the written
+ * {@code .class} file, iterating in the order the compiler
emitted them
+ * (stable across invocations for the same source, but
JDK-dependent —
+ * inner and auxiliary classes commonly appear before the
enclosing class)
+ * @throws IOException if writing a class file fails
+ * @throws JavaShellCompilationException if the source fails to compile
+ * @since 6.0.0
+ */
+ public Map<String, Path> compileAllTo(String className, Iterable<String>
options,
+ String src, Path outputDir)
+ throws IOException {
+ doCompile(className, src, options); // populates
jscl's classMap
+
+ Map<String, byte[]> classes = jscl.getClassMap(); // already public
internally
+ Map<String, Path> written = new LinkedHashMap<>();
+ for (Map.Entry<String, byte[]> e : classes.entrySet()) {
+ // binary name -> relative path: com.example.Foo$Bar ->
com/example/Foo$Bar.class
+ Path target = outputDir.resolve(e.getKey().replace('.', '/') +
".class");
+ Files.createDirectories(target.getParent() == null ? outputDir :
target.getParent());
+ Files.write(target, e.getValue()); // CREATE +
TRUNCATE_EXISTING by default
+ written.put(e.getKey(), target);
+ }
+ return written;
+ }
+
+ /**
+ * Convenience overload of {@link #compileAllTo(String, Iterable, String,
Path)} that
+ * compiles with no extra compiler options.
+ *
+ * @param className the main class name (binary name)
+ * @param src the source code
+ * @param outputDir root directory under which class files are written;
created if absent
+ * @return a map from binary class name to the {@link Path} of the written
{@code .class} file
+ * @throws IOException if writing a class file fails
+ * @throws JavaShellCompilationException if the source fails to compile
+ * @since 6.0.0
+ */
+ public Map<String, Path> compileAllTo(String className, String src, Path
outputDir)
+ throws IOException {
+ return compileAllTo(className, Collections.emptyList(), src,
outputDir);
+ }
+
/**
* When and only when {@link #compile(String, String)} or {@link
#compileAll(String, String)} is invoked,
* returned class loader will reference the compiled classes.
@@ -242,7 +328,7 @@ public class JavaShell {
}
private static final class BytesJavaFileManager extends
ForwardingJavaFileManager<StandardJavaFileManager> {
- private final Map<String, BytesJavaFileObject> fileObjectMap = new
HashMap<>();
+ private final Map<String, BytesJavaFileObject> fileObjectMap = new
LinkedHashMap<>();
private Map<String, byte[]> classMap;
/**
diff --git a/src/test/groovy/org/apache/groovy/util/JavaShellTest.groovy
b/src/test/groovy/org/apache/groovy/util/JavaShellTest.groovy
index 03d5543cf9..6be26b8887 100644
--- a/src/test/groovy/org/apache/groovy/util/JavaShellTest.groovy
+++ b/src/test/groovy/org/apache/groovy/util/JavaShellTest.groovy
@@ -19,6 +19,10 @@
package org.apache.groovy.util
import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+
+import java.nio.file.Files
+import java.nio.file.Path
class JavaShellTest {
@Test
@@ -73,6 +77,133 @@ class JavaShellTest {
}
}
+ @Test
+ void compileAllTo_writesPackagedClassUnderPackageDir(@TempDir Path out) {
+ JavaShell js = new JavaShell()
+ final mcn = 'tests.Test1'
+ Map<String, Path> written = js.compileAllTo(mcn, '''
+ package tests;
+ public class Test1 {
+ public static String hello() { return "hi"; }
+ }
+ ''', out)
+
+ assert written.size() == 1
+ Path expected = out.resolve('tests/Test1.class')
+ assert written[mcn] == expected
+ assert Files.exists(expected)
+ assert Files.size(expected) > 0
+ }
+
+ @Test
+ void compileAllTo_writesDefaultPackageClassDirectlyUnderOutputDir(@TempDir
Path out) {
+ JavaShell js = new JavaShell()
+ Map<String, Path> written = js.compileAllTo('Plain', '''
+ public class Plain {}
+ ''', out)
+
+ assert written.size() == 1
+ Path expected = out.resolve('Plain.class')
+ assert written['Plain'] == expected
+ assert Files.exists(expected)
+ }
+
+ @Test
+ void compileAllTo_writesAuxiliaryAndNestedClasses(@TempDir Path out) {
+ final mcn = 'tests.Outer'
+ final src = '''
+ package tests;
+ public class Outer {
+ public static class Inner {}
+ }
+ class Helper {}
+ '''
+ Map<String, Path> written = new JavaShell().compileAllTo(mcn, src, out)
+
+ assert written.keySet() == ['tests.Outer', 'tests.Outer$Inner',
'tests.Helper'] as Set
+ assert Files.exists(out.resolve('tests/Outer.class'))
+ assert Files.exists(out.resolve('tests/Outer$Inner.class'))
+ assert Files.exists(out.resolve('tests/Helper.class'))
+
+ // contract: iteration order is stable across invocations with the
same source
+ // (the specific order is whatever javac emitted, which is
JDK-dependent)
+ Map<String, Path> written2 = new JavaShell().compileAllTo(mcn, src,
out)
+ assert new ArrayList<>(written2.keySet()) == new
ArrayList<>(written.keySet())
+ }
+
+ @Test
+ void compileAllTo_createsMissingIntermediateDirectories(@TempDir Path
root) {
+ Path out = root.resolve('does/not/exist/yet')
+ assert !Files.exists(out)
+
+ JavaShell js = new JavaShell()
+ js.compileAllTo('a.b.c.Deep', '''
+ package a.b.c;
+ public class Deep {}
+ ''', out)
+
+ assert Files.exists(out.resolve('a/b/c/Deep.class'))
+ }
+
+ @Test
+ void compileAllTo_overwritesExistingClassFile(@TempDir Path out) {
+ Path target = out.resolve('pkg/Same.class')
+ Files.createDirectories(target.getParent())
+ Files.write(target, 'stale'.getBytes())
+ long staleSize = Files.size(target)
+
+ JavaShell js = new JavaShell()
+ js.compileAllTo('pkg.Same', '''
+ package pkg;
+ public class Same { public static int v() { return 42; } }
+ ''', out)
+
+ assert Files.exists(target)
+ assert Files.size(target) != staleSize // replaced, not
appended-to
+ assert Files.readAllBytes(target)[0..3] == [(byte)0xCA, (byte)0xFE,
(byte)0xBA, (byte)0xBE]
+ }
+
+ @Test
+ void compileAllTo_classesAlsoAvailableViaClassLoader(@TempDir Path out) {
+ JavaShell js = new JavaShell()
+ final mcn = 'tests.Probe'
+ js.compileAllTo(mcn, '''
+ package tests;
+ public class Probe { public static String tag() { return "loaded";
} }
+ ''', out)
+
+ Class<?> c = js.getClassLoader().loadClass(mcn)
+ assert 'loaded' == c.getDeclaredMethod('tag').invoke(null)
+ }
+
+ @Test
+ void compileAllTo_appliesProvidedCompilerOptions(@TempDir Path out) {
+ JavaShell js = new JavaShell()
+ // -parameters retains formal parameter names in the class file;
without it they become arg0/arg1.
+ js.compileAllTo('p.WithParams', ['-parameters'], '''
+ package p;
+ public class WithParams {
+ public static String greet(String who) { return "hi " + who; }
+ }
+ ''', out)
+
+ Class<?> c = js.getClassLoader().loadClass('p.WithParams')
+ def param = c.getDeclaredMethod('greet', String).parameters[0]
+ assert param.isNamePresent()
+ assert param.name == 'who'
+ }
+
+ @Test
+ void compileAllTo_noOptionsOverloadCompilesIdentically(@TempDir Path out) {
+ JavaShell js = new JavaShell()
+ Map<String, Path> written = js.compileAllTo('q.Q', '''
+ package q;
+ public class Q {}
+ ''', out)
+ assert written.keySet() == ['q.Q'] as Set
+ assert Files.exists(out.resolve('q/Q.class'))
+ }
+
@Test
void getClassLoader() {
JavaShell js = new JavaShell()