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()

Reply via email to