diff --git a/.github/workflows/feature-branches.yml b/.github/workflows/feature-branches.yml new file mode 100644 index 0000000..2b067b4 --- /dev/null +++ b/.github/workflows/feature-branches.yml @@ -0,0 +1,41 @@ +name: Feature Branch Tests + +on: + push: + branches: + - 'feature/**' + pull_request: + branches: + - master + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + +jobs: + test: + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + runs-on: ubuntu-latest + strategy: + matrix: + java: ['17', '21', '25', '26'] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{ matrix.java }} + cache: maven + + - name: Run tests and generate coverage report + run: mvn -B verify --file pom.xml + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: jacoco-coverage-report-java-${{ matrix.java }} + path: target/site/jacoco/ + if-no-files-found: error diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index cbb3902..f047000 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -6,14 +6,32 @@ on: pull_request: branches: [master] +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest + strategy: + matrix: + java: ['17', '21', '25', '26'] steps: - - uses: actions/checkout@v2 - - name: Set up JDK 1.8 - uses: actions/setup-java@v1 - with: - java-version: 1.8 - - name: Build with Maven - run: mvn -B package --file pom.xml + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{ matrix.java }} + cache: maven + + - name: Run tests and generate coverage report + run: mvn -B verify --file pom.xml + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: jacoco-coverage-report-java-${{ matrix.java }} + path: target/site/jacoco/ + if-no-files-found: error diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ae73a0c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,67 @@ +# AGENTS.md + +## Project overview + +This repository contains a small Java library for compiling and loading Java +source code at runtime. Production code is under +`src/main/java/com/raulgomis/djc`, and tests are under the matching package in +`src/test/java`. + +The project targets Java 17 bytecode and uses Maven, JUnit 5, and JaCoCo. + +## Build and verification + +Run commands from the repository root. + +- `mvn test` runs the unit tests. +- `mvn verify` runs the tests and generates the JaCoCo report. +- `mvn clean verify` performs a clean, full verification when stale build + output may matter. +- The HTML coverage report is written to + `target/site/jacoco/index.html`. + +Before handing off a code change, run `mvn verify`. Also run +`git diff --check` to catch whitespace errors. + +## Implementation guidelines + +- Keep the public API small and compatible unless the task explicitly calls + for an API change. +- Compile with Java 17-compatible language and library features. +- Follow the existing four-space indentation and brace style. +- Prefer standard-library solutions; avoid adding dependencies for behavior + that can be implemented clearly without them. +- Keep compiler diagnostics associated with the individual compilation that + produced them. +- Preserve the in-memory design: generated source and bytecode should not + require temporary source or class files. +- Treat package names, binary class names, source file names, and resource + paths carefully. A source package and the qualified name used for loading + must agree. +- Preserve exception causes when wrapping unexpected compiler, file-manager, + or class-loading failures. + +## Testing guidelines + +- Use JUnit Jupiter. +- Name tests after observable behavior, such as + `compilesAClassInANamedPackage`. +- Add regression tests for bug fixes and focused tests for new branches. +- Test both successful compilation and relevant failure behavior, including + diagnostics and wrapped causes. +- Tests for package-private implementation classes should remain in + `com.raulgomis.djc` rather than widening production visibility. +- Prefer small inline source strings for a single scenario. Use + `src/test/resources` when a source fixture is substantial or reused. +- Dynamically compiled test code must be deterministic and must not perform + network access, modify user files, or depend on machine-specific state. +- Do not weaken assertions merely to increase the coverage percentage. + +## Change hygiene + +- The worktree may already contain user changes. Inspect `git status` and + preserve unrelated modifications. +- Do not edit generated files under `target`. +- Keep documentation and CI configuration consistent with the Java version + and Maven commands in `pom.xml`. +- Avoid broad formatting or refactoring unrelated to the requested change. diff --git a/README.md b/README.md index c603dfc..a6fb30d 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,156 @@ -# What is dynamic-java-compiler? -![Build](https://github.com/raulgomis/dynamic-java-compiler/workflows/Build/badge.svg) +# Dynamic Java Compiler -_Dynamic-java-compiler_ is a library that allows users to dynamically compile and execute any java source code. Writing dynamically executed Java applications require some boilerplate code: working with classloaders, compilation error handling, etc. The idea behind this library is to free you from this development and let you focus on your business logic. +[![Build](https://github.com/raulgomis/dynamic-java-compiler/actions/workflows/master.yml/badge.svg)](https://github.com/raulgomis/dynamic-java-compiler/actions/workflows/master.yml) -# How does it work? +Compile Java source held in a string and load the resulting class directly into +the running JVM. -The dynamic ompilation task is very simple with this library. Imagine we want to compile this source code introduced dynamically as a text string by the user: +Dynamic Java Compiler wraps the standard Java compiler, an in-memory file +manager, and a dedicated class loader behind a small API. Source and generated +bytecode stay in memory, so callers do not need to manage temporary `.java` or +`.class` files. + +## Features + +- Compiles Java source at runtime using the JDK compiler +- Loads generated classes without writing bytecode to disk +- Supports packages, imports, and nested classes +- Returns structured compiler diagnostics on failure +- Has no runtime dependencies outside the JDK + +## Requirements + +- Java 17 or later +- A full JDK that includes the system Java compiler + +The project is compiled with `maven.compiler.release=17`, so its bytecode +remains compatible with Java 17 while builds can run on newer JDKs. A minimal +runtime image without the `jdk.compiler` module cannot perform dynamic +compilation. + +## Quick start + +Create the source code to compile: ```java -public class Test01 implements Runnable { - public void run() { - System.out.println("Hello World!"); - } -} +String source = """ + public class GreetingTask implements Runnable { + @Override + public void run() { + System.out.println("Hello from dynamically compiled code!"); + } + } + """; ``` -So simple, we just need to instantiate the compiler and compile the code: +Compile, instantiate, and execute it: ```java +import com.raulgomis.djc.DynamicCompiler; + +DynamicCompiler compiler = new DynamicCompiler<>(); +Class taskClass = compiler.compile( + null, + "GreetingTask", + source +); + +Runnable task = taskClass.getDeclaredConstructor().newInstance(); +task.run(); +``` + +The first argument to `compile` is the package name. Pass `null` or an empty +string for the default package. The second argument is the simple class name +and must agree with the class declared by the source. + +## Compiling a packaged class + +The package declared in the source must match the package passed to +`compile`: + +```java +String source = """ + package example.tasks; + + public class GreetingTask implements Runnable { + @Override + public void run() { + System.out.println("Hello!"); + } + } + """; + DynamicCompiler compiler = new DynamicCompiler<>(); -// Read source code as String -Class clazz = compiler.compile(null, "Test01", source); -final Runnable r; +Class taskClass = compiler.compile( + "example.tasks", + "GreetingTask", + source +); +``` + +The returned class has the binary name `example.tasks.GreetingTask`. + +## Handling compilation errors + +Compilation failures throw `DynamicCompilerException`. Use +`getDiagnostics()` for structured JDK diagnostics or +`getDiagnosticsError()` for a readable summary: + +```java +import com.raulgomis.djc.DynamicCompilerException; + try { - r = clazz.newInstance(); - r.run(); -} catch (InstantiationException | IllegalAccessException e) { - e.printStackTrace(); + DynamicCompiler compiler = new DynamicCompiler<>(); + compiler.compile(null, "BrokenTask", brokenSource); +} catch (DynamicCompilerException exception) { + System.err.print(exception.getDiagnosticsError()); + + exception.getDiagnostics().forEach(diagnostic -> { + System.err.printf( + "line %d, column %d: %s%n", + diagnostic.getLineNumber(), + diagnostic.getColumnNumber(), + diagnostic.getMessage(null) + ); + }); } ``` -The final result will be: +Diagnostic wording is produced by the active JDK and may differ between JDK +versions. + +## Security + +This library compiles code; it does not sandbox it. Once loaded and invoked, +dynamically compiled code runs with the same permissions as the host +application. Do not compile or execute untrusted source without a separate, +appropriately isolated execution environment. + +## Building and testing + +Run the full verification build: + +```shell +mvn verify ``` -Hello World! + +This runs the JUnit 5 test suite and generates a JaCoCo coverage report at +`target/site/jacoco/index.html`. + +For a clean build: + +```shell +mvn clean verify ``` - -## Contribution +## Contributing + +Pull requests are welcome. Please include focused tests for behavioral changes +and run `mvn verify` before submitting. + +Use the [issue tracker](https://github.com/raulgomis/dynamic-java-compiler/issues) +to report bugs or propose features. -You are welcome to contribute to the project using pull requests on GitHub. +## License -If you find a bug or want to request a feature, please use the [issue tracker](https://github.com/raulgomis/dynamic-java-compiler/issues) of Github. +This project is available under the [MIT License](LICENSE). diff --git a/pom.xml b/pom.xml index ed9a419..e14c44e 100644 --- a/pom.xml +++ b/pom.xml @@ -17,7 +17,7 @@ The MIT License https://github.com/raulgomis/dynamic-java-compiler/blob/master/LICENSE - + @@ -29,27 +29,16 @@ UTF-8 - 1.8 - 1.8 + 17 + 0.8.15 + 5.12.2 org.junit.jupiter - junit-jupiter-engine - 5.6.1 - test - - - org.junit.jupiter - junit-jupiter-params - 5.6.1 - test - - - org.junit.jupiter - junit-jupiter-api - 5.6.1 + junit-jupiter + ${junit.jupiter.version} test @@ -59,17 +48,34 @@ org.apache.maven.plugins maven-compiler-plugin - 3.8.1 + 3.14.1 - ${maven.compiler.source} - ${maven.compiler.target} + ${maven.compiler.release} - org.apache.maven.plugins maven-surefire-plugin - 2.22.2 + 3.5.4 + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + + prepare-agent + + + + report + verify + + report + + + diff --git a/src/main/java/com/raulgomis/djc/DynamicByteObject.java b/src/main/java/com/raulgomis/djc/DynamicByteObject.java index 248c055..8fd5146 100644 --- a/src/main/java/com/raulgomis/djc/DynamicByteObject.java +++ b/src/main/java/com/raulgomis/djc/DynamicByteObject.java @@ -1,11 +1,12 @@ package com.raulgomis.djc; -import javax.tools.SimpleJavaFileObject; import java.io.ByteArrayOutputStream; import java.io.OutputStream; +import javax.tools.SimpleJavaFileObject; public class DynamicByteObject extends SimpleJavaFileObject { - private ByteArrayOutputStream outputStream; + + private final ByteArrayOutputStream outputStream; DynamicByteObject(String name, Kind kind) { super(DynamicCompilerUtils.createURI(name), kind); diff --git a/src/main/java/com/raulgomis/djc/DynamicClassLoader.java b/src/main/java/com/raulgomis/djc/DynamicClassLoader.java index f075ada..24c0260 100644 --- a/src/main/java/com/raulgomis/djc/DynamicClassLoader.java +++ b/src/main/java/com/raulgomis/djc/DynamicClassLoader.java @@ -1,29 +1,28 @@ package com.raulgomis.djc; -import javax.tools.JavaFileObject.Kind; import java.io.ByteArrayInputStream; import java.io.InputStream; -import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import javax.tools.JavaFileObject.Kind; public final class DynamicClassLoader extends ClassLoader { - private Map classes = new HashMap<>(); + private final Map classes = new ConcurrentHashMap<>(); DynamicClassLoader(ClassLoader parentClassLoader) { super(parentClassLoader); } void addClass(DynamicByteObject compiledObj) { - System.out.println("Compiled " + compiledObj.getName()); classes.put(compiledObj.getName(), compiledObj); } @Override public Class findClass(String className) throws ClassNotFoundException { - DynamicByteObject byteObject = classes.get(className); + final DynamicByteObject byteObject = classes.get(className); if (byteObject != null) { - byte[] bytes = byteObject.getBytes(); + final byte[] bytes = byteObject.getBytes(); return defineClass(className, bytes, 0, bytes.length); } @@ -31,15 +30,10 @@ public Class findClass(String className) throws ClassNotFoundException { } @Override - protected synchronized Class loadClass(final String name, final boolean resolve) throws ClassNotFoundException { - return super.loadClass(name, resolve); - } - - @Override - public InputStream getResourceAsStream(final String name) { + public InputStream getResourceAsStream(String name) { if (name.endsWith(Kind.CLASS.extension)) { - String qualifiedClassName = name.substring(0, name.length() - Kind.CLASS.extension.length()).replace('/', '.'); - DynamicByteObject file = classes.get(qualifiedClassName); + final String qualifiedClassName = name.substring(0, name.length() - Kind.CLASS.extension.length()).replace('/', '.'); + final DynamicByteObject file = classes.get(qualifiedClassName); if (file != null) { return new ByteArrayInputStream(file.getBytes()); } diff --git a/src/main/java/com/raulgomis/djc/DynamicCompiler.java b/src/main/java/com/raulgomis/djc/DynamicCompiler.java index 614f2db..d426c30 100644 --- a/src/main/java/com/raulgomis/djc/DynamicCompiler.java +++ b/src/main/java/com/raulgomis/djc/DynamicCompiler.java @@ -1,5 +1,6 @@ package com.raulgomis.djc; +import java.util.List; import javax.tools.DiagnosticCollector; import javax.tools.JavaCompiler; import javax.tools.JavaCompiler.CompilationTask; @@ -7,15 +8,12 @@ import javax.tools.StandardJavaFileManager; import javax.tools.StandardLocation; import javax.tools.ToolProvider; -import java.util.Arrays; public final class DynamicCompiler { - private JavaCompiler compiler; - private DynamicExtendedFileManager dynamicExtendedFileManager; - private DynamicClassLoader classLoader; - - private DiagnosticCollector diagnostics; + private final JavaCompiler compiler; + private final DynamicExtendedFileManager dynamicExtendedFileManager; + private final DynamicClassLoader classLoader; public DynamicCompiler() throws DynamicCompilerException { compiler = ToolProvider.getSystemJavaCompiler(); @@ -24,33 +22,42 @@ public DynamicCompiler() throws DynamicCompilerException { } classLoader = new DynamicClassLoader(Thread.currentThread().getContextClassLoader()); - diagnostics = new DiagnosticCollector<>(); - StandardJavaFileManager standardFileManager = compiler.getStandardFileManager(diagnostics, null, null); + final StandardJavaFileManager standardFileManager = compiler.getStandardFileManager(null, null, null); dynamicExtendedFileManager = new DynamicExtendedFileManager(standardFileManager, classLoader); } @SuppressWarnings("unchecked") public synchronized Class compile(String packageName, String className, String javaSource) throws DynamicCompilerException { + final DiagnosticCollector diagnostics = new DiagnosticCollector<>(); try { + final String qualifiedClassName = DynamicCompilerUtils.getQualifiedClassName(packageName, className); + final DynamicStringObject sourceObj = new DynamicStringObject(className, javaSource); - String qualifiedClassName = DynamicCompilerUtils.getQualifiedClassName(packageName, className); - DynamicStringObject sourceObj = new DynamicStringObject(className, javaSource); - - dynamicExtendedFileManager.putFileForInput(StandardLocation.SOURCE_PATH, packageName, - className + JavaFileObject.Kind.SOURCE.extension, sourceObj); + dynamicExtendedFileManager.putFileForInput( + StandardLocation.SOURCE_PATH, + packageName, + className + JavaFileObject.Kind.SOURCE.extension, + sourceObj + ); - CompilationTask task = compiler.getTask(null, dynamicExtendedFileManager, diagnostics, null, null, Arrays.asList(sourceObj)); - boolean result = task.call(); + final CompilationTask task = compiler.getTask( + null, + dynamicExtendedFileManager, + diagnostics, + null, + null, + List.of(sourceObj) + ); - if (!result) { + if (!task.call()) { throw new DynamicCompilerException("Compilation failure", diagnostics.getDiagnostics()); } dynamicExtendedFileManager.close(); - return (Class) classLoader.loadClass(qualifiedClassName); - + } catch (DynamicCompilerException exception) { + throw exception; } catch (Exception exception) { throw new DynamicCompilerException(exception, diagnostics.getDiagnostics()); } diff --git a/src/main/java/com/raulgomis/djc/DynamicCompilerException.java b/src/main/java/com/raulgomis/djc/DynamicCompilerException.java index b6eb814..b8078af 100644 --- a/src/main/java/com/raulgomis/djc/DynamicCompilerException.java +++ b/src/main/java/com/raulgomis/djc/DynamicCompilerException.java @@ -1,34 +1,33 @@ package com.raulgomis.djc; +import java.util.List; import javax.tools.Diagnostic; import javax.tools.JavaFileObject; -import java.util.List; public final class DynamicCompilerException extends Exception { - private List> diagnostics; + private final List> diagnostics; public DynamicCompilerException(String message) { super(message); + this.diagnostics = List.of(); } public DynamicCompilerException(String message, List> diagnostics) { super(message); - this.diagnostics = diagnostics; + this.diagnostics = diagnostics == null ? List.of() : List.copyOf(diagnostics); } public DynamicCompilerException(Throwable e, List> diagnostics) { super(e); - this.diagnostics = diagnostics; + this.diagnostics = diagnostics == null ? List.of() : List.copyOf(diagnostics); } public String getDiagnosticsError() { - StringBuilder sb = new StringBuilder(); - if(diagnostics != null) { - diagnostics.forEach(diagnostic -> sb.append(String.format("Error on line %d: %s\n", - diagnostic.getLineNumber(), - diagnostic.getMessage(null)))); - } + final StringBuilder sb = new StringBuilder(); + diagnostics.forEach(diagnostic -> sb.append( + String.format("Error on line %d: %s\n", diagnostic.getLineNumber(), diagnostic.getMessage(null)) + )); return sb.toString(); } diff --git a/src/main/java/com/raulgomis/djc/DynamicCompilerUtils.java b/src/main/java/com/raulgomis/djc/DynamicCompilerUtils.java index 0356b31..e53c28b 100644 --- a/src/main/java/com/raulgomis/djc/DynamicCompilerUtils.java +++ b/src/main/java/com/raulgomis/djc/DynamicCompilerUtils.java @@ -1,31 +1,29 @@ package com.raulgomis.djc; -import javax.tools.JavaFileObject.Kind; import java.net.URI; import java.net.URISyntaxException; +import javax.tools.JavaFileObject.Kind; public final class DynamicCompilerUtils { - private DynamicCompilerUtils() { + public static final String EMPTY = ""; + private DynamicCompilerUtils() { } - public static final String EMPTY = ""; - static URI createURI(String str) { try { return new URI(str); } catch (URISyntaxException e) { - throw new RuntimeException(e); + throw new IllegalArgumentException("Invalid URI: " + str, e); } } static String getQualifiedClassName(String packageName, String className) { if (isEmpty(packageName)) { return className; - } else { - return packageName + "." + className; } + return packageName + "." + className; } static String getClassNameWithExt(String className) { @@ -33,7 +31,6 @@ static String getClassNameWithExt(String className) { } static boolean isEmpty(String str) { - return str == null || str.length() == 0; + return str == null || str.isEmpty(); } - -} \ No newline at end of file +} diff --git a/src/main/java/com/raulgomis/djc/DynamicExtendedFileManager.java b/src/main/java/com/raulgomis/djc/DynamicExtendedFileManager.java index e87340b..7782c53 100644 --- a/src/main/java/com/raulgomis/djc/DynamicExtendedFileManager.java +++ b/src/main/java/com/raulgomis/djc/DynamicExtendedFileManager.java @@ -1,19 +1,19 @@ package com.raulgomis.djc; +import java.io.IOException; +import java.net.URI; +import java.util.HashMap; +import java.util.Map; import javax.tools.FileObject; import javax.tools.ForwardingJavaFileManager; import javax.tools.JavaFileManager; import javax.tools.JavaFileObject; import javax.tools.JavaFileObject.Kind; import javax.tools.StandardLocation; -import java.io.IOException; -import java.net.URI; -import java.util.HashMap; -import java.util.Map; public final class DynamicExtendedFileManager extends ForwardingJavaFileManager { - private DynamicClassLoader classLoader; + private final DynamicClassLoader classLoader; private final Map fileObjects = new HashMap<>(); DynamicExtendedFileManager(JavaFileManager fileManager, DynamicClassLoader classLoader) { @@ -23,9 +23,9 @@ public final class DynamicExtendedFileManager extends ForwardingJavaFileManager< @Override public FileObject getFileForInput(Location location, String packageName, String relativeName) throws IOException { - FileObject o = fileObjects.get(uri(location, packageName, relativeName)); - if (o != null) { - return o; + final FileObject fileObject = fileObjects.get(uri(location, packageName, relativeName)); + if (fileObject != null) { + return fileObject; } return super.getFileForInput(location, packageName, relativeName); } @@ -38,11 +38,9 @@ private URI uri(Location location, String packageName, String relativeName) { return DynamicCompilerUtils.createURI(location.getName() + '/' + packageName + '/' + relativeName); } - @Override - public JavaFileObject getJavaFileForOutput(Location location, - String qualifiedName, Kind kind, FileObject outputFile) { - DynamicByteObject dynamicByteObject = new DynamicByteObject(qualifiedName, kind); + public JavaFileObject getJavaFileForOutput(Location location, String qualifiedName, Kind kind, FileObject outputFile) { + final DynamicByteObject dynamicByteObject = new DynamicByteObject(qualifiedName, kind); classLoader.addClass(dynamicByteObject); return dynamicByteObject; } @@ -53,10 +51,10 @@ public ClassLoader getClassLoader(JavaFileManager.Location location) { } @Override - public String inferBinaryName(Location loc, JavaFileObject file) { + public String inferBinaryName(Location location, JavaFileObject file) { if (file instanceof DynamicByteObject) { return file.getName(); } - return super.inferBinaryName(loc, file); + return super.inferBinaryName(location, file); } } diff --git a/src/main/java/com/raulgomis/djc/DynamicStringObject.java b/src/main/java/com/raulgomis/djc/DynamicStringObject.java index c7c5af7..d2b1059 100644 --- a/src/main/java/com/raulgomis/djc/DynamicStringObject.java +++ b/src/main/java/com/raulgomis/djc/DynamicStringObject.java @@ -3,6 +3,7 @@ import javax.tools.SimpleJavaFileObject; public class DynamicStringObject extends SimpleJavaFileObject { + private final String source; DynamicStringObject(String name, String source) { diff --git a/src/test/java/com/raulgomis/djc/DynamicClassLoaderTest.java b/src/test/java/com/raulgomis/djc/DynamicClassLoaderTest.java new file mode 100644 index 0000000..7549d03 --- /dev/null +++ b/src/test/java/com/raulgomis/djc/DynamicClassLoaderTest.java @@ -0,0 +1,44 @@ +package com.raulgomis.djc; + +import org.junit.jupiter.api.Test; + +import java.io.InputStream; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DynamicClassLoaderTest { + + @Test + void exposesCompiledClassesAsClassResources() throws Exception { + DynamicClassLoader classLoader = new DynamicClassLoader(getClass().getClassLoader()); + DynamicByteObject byteObject = new DynamicByteObject("example.Generated", javax.tools.JavaFileObject.Kind.CLASS); + byte[] bytecode = {1, 2, 3, 4}; + byteObject.openOutputStream().write(bytecode); + classLoader.addClass(byteObject); + + try (InputStream resource = classLoader.getResourceAsStream("example/Generated.class")) { + assertNotNull(resource); + assertArrayEquals(bytecode, resource.readAllBytes()); + } + } + + @Test + void delegatesUnknownResourcesToItsParent() throws Exception { + DynamicClassLoader classLoader = new DynamicClassLoader(getClass().getClassLoader()); + + try (InputStream resource = classLoader.getResourceAsStream("Test01.java")) { + assertNotNull(resource); + } + assertNull(classLoader.getResourceAsStream("missing/Type.class")); + } + + @Test + void reportsUnknownClasses() { + DynamicClassLoader classLoader = new DynamicClassLoader(null); + + assertThrows(ClassNotFoundException.class, () -> classLoader.findClass("missing.Type")); + } +} diff --git a/src/test/java/com/raulgomis/djc/DynamicCompilerExceptionTest.java b/src/test/java/com/raulgomis/djc/DynamicCompilerExceptionTest.java new file mode 100644 index 0000000..223068f --- /dev/null +++ b/src/test/java/com/raulgomis/djc/DynamicCompilerExceptionTest.java @@ -0,0 +1,64 @@ +package com.raulgomis.djc; + +import org.junit.jupiter.api.Test; + +import javax.tools.Diagnostic; +import javax.tools.JavaFileObject; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DynamicCompilerExceptionTest { + + @Test + void messageOnlyExceptionHasNoDiagnostics() { + DynamicCompilerException exception = new DynamicCompilerException("Compiler not found"); + + assertEquals("Compiler not found", exception.getMessage()); + assertTrue(exception.getDiagnostics().isEmpty()); + assertEquals("", exception.getDiagnosticsError()); + assertEquals("", exception.toString()); + assertTrue(new DynamicCompilerException("Compilation failure", null).getDiagnostics().isEmpty()); + } + + @Test + void snapshotsDiagnosticsAndFormatsThem() { + List> diagnostics = new ArrayList<>(); + diagnostics.add(diagnostic(7, "bad source")); + + DynamicCompilerException exception = new DynamicCompilerException("Compilation failure", diagnostics); + diagnostics.clear(); + + assertEquals("Error on line 7: bad source\n", exception.getDiagnosticsError()); + assertEquals(exception.getDiagnosticsError(), exception.toString()); + assertThrows(UnsupportedOperationException.class, () -> exception.getDiagnostics().clear()); + } + + @Test + void throwableConstructorPreservesCauseAndHandlesNullDiagnostics() { + IllegalStateException cause = new IllegalStateException("closed"); + + DynamicCompilerException exception = new DynamicCompilerException(cause, null); + + assertSame(cause, exception.getCause()); + assertTrue(exception.getDiagnostics().isEmpty()); + } + + private static Diagnostic diagnostic(long line, String message) { + return new Diagnostic<>() { + @Override public Kind getKind() { return Kind.ERROR; } + @Override public JavaFileObject getSource() { return null; } + @Override public long getPosition() { return NOPOS; } + @Override public long getStartPosition() { return NOPOS; } + @Override public long getEndPosition() { return NOPOS; } + @Override public long getLineNumber() { return line; } + @Override public long getColumnNumber() { return NOPOS; } + @Override public String getCode() { return "test"; } + @Override public String getMessage(java.util.Locale locale) { return message; } + }; + } +} diff --git a/src/test/java/com/raulgomis/djc/DynamicCompilerTest.java b/src/test/java/com/raulgomis/djc/DynamicCompilerTest.java index 791cbb7..cad4165 100644 --- a/src/test/java/com/raulgomis/djc/DynamicCompilerTest.java +++ b/src/test/java/com/raulgomis/djc/DynamicCompilerTest.java @@ -4,58 +4,106 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class DynamicCompilerTest { @Test - void test01() { + void compilesAndInstantiatesSimpleRunnable() throws Exception { CompilerExecutor compilerExecutor = new CompilerExecutor(); - try { - Class clazz = compilerExecutor.compile("Test01.java"); - final Runnable r; - try { - r = clazz.newInstance(); - r.run(); - } catch (InstantiationException | IllegalAccessException e) { - e.printStackTrace(); - } - assertNotNull(clazz); - } catch (DynamicCompilerException e) { - e.printStackTrace(); - fail(); - } + + Class clazz = compilerExecutor.compile("Test01.java"); + Runnable runnable = clazz.getDeclaredConstructor().newInstance(); + + runnable.run(); + assertNotNull(clazz); + } + + @Test + void returnsReadableDiagnosticsForCompilationFailure() { + CompilerExecutor compilerExecutor = new CompilerExecutor(); + + DynamicCompilerException exception = assertThrows( + DynamicCompilerException.class, + () -> compilerExecutor.compile("Test02.java") + ); + + assertEquals("Error on line 3: ';' expected\n", exception.getDiagnosticsError()); } @Test - void test02() { + void compilesRunnableWithImports() throws Exception { CompilerExecutor compilerExecutor = new CompilerExecutor(); - try { - compilerExecutor.compile("Test02.java"); - fail(); - } catch (DynamicCompilerException e) { - assertEquals("Error on line 3: ';' expected\n", - e.getDiagnosticsError()); - } + + Class clazz = compilerExecutor.compile("Test03.java"); + Runnable runnable = clazz.getDeclaredConstructor().newInstance(); + + runnable.run(); + assertNotNull(clazz); } @Test - void test03() { - CompilerExecutor compilerExecutor = new CompilerExecutor(); - try { - Class clazz = compilerExecutor.compile("Test03.java"); - final Runnable r; - try { - r = clazz.newInstance(); - r.run(); - } catch (InstantiationException | IllegalAccessException e) { - e.printStackTrace(); - } - assertNotNull(clazz); - } catch (DynamicCompilerException e) { - e.printStackTrace(); - fail(); - } + void keepsDiagnosticsIndependentAcrossCompilationFailures() throws Exception { + DynamicCompiler compiler = new DynamicCompiler<>(); + + DynamicCompilerException firstFailure = assertThrows( + DynamicCompilerException.class, + () -> compiler.compile( + null, + "BrokenOne", + "public class BrokenOne implements Runnable { public void run() { nope } }" + ) + ); + int firstFailureDiagnosticCount = firstFailure.getDiagnostics().size(); + + DynamicCompilerException secondFailure = assertThrows( + DynamicCompilerException.class, + () -> compiler.compile( + null, + "BrokenTwo", + "public class BrokenTwo implements Runnable { public void run() { nope } }" + ) + ); + + assertTrue(firstFailureDiagnosticCount > 0); + assertEquals(firstFailureDiagnosticCount, firstFailure.getDiagnostics().size()); + assertEquals(firstFailureDiagnosticCount, secondFailure.getDiagnostics().size()); + assertTrue(secondFailure.getDiagnostics().stream().allMatch( + diagnostic -> diagnostic.getSource().getName().contains("BrokenTwo") + )); + } + + @Test + void compilesAClassInANamedPackage() throws Exception { + DynamicCompiler compiler = new DynamicCompiler<>(); + + Class clazz = compiler.compile( + "example.generated", + "PackagedTask", + "package example.generated; public class PackagedTask implements Runnable { public void run() {} }" + ); + + assertEquals("example.generated.PackagedTask", clazz.getName()); + assertNotNull(clazz.getDeclaredConstructor().newInstance()); + } + + @Test + void wrapsClassLoadingFailuresWithTheirCause() throws Exception { + DynamicCompiler compiler = new DynamicCompiler<>(); + + DynamicCompilerException exception = assertThrows( + DynamicCompilerException.class, + () -> compiler.compile( + "expected.package", + "UnexpectedPackage", + "public class UnexpectedPackage implements Runnable { public void run() {} }" + ) + ); + + assertInstanceOf(ClassNotFoundException.class, exception.getCause()); + assertTrue(exception.getDiagnostics().isEmpty()); } } diff --git a/src/test/java/com/raulgomis/djc/DynamicExtendedFileManagerTest.java b/src/test/java/com/raulgomis/djc/DynamicExtendedFileManagerTest.java new file mode 100644 index 0000000..65695b3 --- /dev/null +++ b/src/test/java/com/raulgomis/djc/DynamicExtendedFileManagerTest.java @@ -0,0 +1,60 @@ +package com.raulgomis.djc; + +import org.junit.jupiter.api.Test; + +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.StandardLocation; +import javax.tools.ToolProvider; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +class DynamicExtendedFileManagerTest { + + @Test + void returnsRegisteredInputsAndDelegatesPlatformLookups() throws Exception { + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + try (StandardJavaFileManager standardManager = compiler.getStandardFileManager(null, null, null); + DynamicExtendedFileManager manager = new DynamicExtendedFileManager( + standardManager, + new DynamicClassLoader(getClass().getClassLoader()) + )) { + DynamicStringObject source = new DynamicStringObject("Example", "class Example {}"); + manager.putFileForInput(StandardLocation.SOURCE_PATH, "example", "Example.java", source); + + assertSame(source, manager.getFileForInput(StandardLocation.SOURCE_PATH, "example", "Example.java")); + assertNotNull(manager.getFileForInput( + StandardLocation.PLATFORM_CLASS_PATH, + "java.lang", + "String.class" + )); + } + } + + @Test + void createsOutputsAndInfersBinaryNamesForBothFileKinds() throws Exception { + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + DynamicClassLoader classLoader = new DynamicClassLoader(getClass().getClassLoader()); + try (StandardJavaFileManager standardManager = compiler.getStandardFileManager(null, null, null); + DynamicExtendedFileManager manager = new DynamicExtendedFileManager(standardManager, classLoader)) { + JavaFileObject output = manager.getJavaFileForOutput( + StandardLocation.CLASS_OUTPUT, + "example.Generated", + JavaFileObject.Kind.CLASS, + null + ); + JavaFileObject stringClass = standardManager.getJavaFileForInput( + StandardLocation.PLATFORM_CLASS_PATH, + "java.lang.String", + JavaFileObject.Kind.CLASS + ); + + assertEquals("example.Generated", manager.inferBinaryName(StandardLocation.CLASS_OUTPUT, output)); + assertEquals("java.lang.String", manager.inferBinaryName(StandardLocation.PLATFORM_CLASS_PATH, stringClass)); + assertSame(classLoader, manager.getClassLoader(StandardLocation.CLASS_PATH)); + } + } +} diff --git a/src/test/java/com/raulgomis/djc/utils/CompilerExecutor.java b/src/test/java/com/raulgomis/djc/utils/CompilerExecutor.java index b3e1ce0..b618e92 100644 --- a/src/test/java/com/raulgomis/djc/utils/CompilerExecutor.java +++ b/src/test/java/com/raulgomis/djc/utils/CompilerExecutor.java @@ -3,15 +3,11 @@ import com.raulgomis.djc.DynamicCompiler; import com.raulgomis.djc.DynamicCompilerException; -/** - * @author rgomis - */ public class CompilerExecutor { - public Class compile(final String className) throws DynamicCompilerException { + public Class compile(String className) throws DynamicCompilerException { DynamicCompiler compiler = new DynamicCompiler<>(); CompilerTestInput compilerTestInput = CompilerTestUtils.getSource(className); - Class clazz = compiler.compile(null, compilerTestInput.getClassName(), compilerTestInput.getSource()); - return clazz; + return compiler.compile(null, compilerTestInput.getClassName(), compilerTestInput.getSource()); } } diff --git a/src/test/java/com/raulgomis/djc/utils/CompilerTestInput.java b/src/test/java/com/raulgomis/djc/utils/CompilerTestInput.java index 701ac0f..684c78a 100644 --- a/src/test/java/com/raulgomis/djc/utils/CompilerTestInput.java +++ b/src/test/java/com/raulgomis/djc/utils/CompilerTestInput.java @@ -1,7 +1,6 @@ package com.raulgomis.djc.utils; import com.raulgomis.djc.DynamicCompilerUtils; - import java.io.Serializable; import java.util.Objects; @@ -11,9 +10,13 @@ class CompilerTestInput implements Serializable { static final CompilerTestInput EMPTY = new CompilerTestInput(DynamicCompilerUtils.EMPTY, DynamicCompilerUtils.EMPTY); - private String className; + private final String className; + private final String source; - private String source; + CompilerTestInput(String className, String source) { + this.className = className; + this.source = source; + } String getClassName() { return className; @@ -23,19 +26,16 @@ String getSource() { return source; } - CompilerTestInput(String className, String source) { - this.className = className; - this.source = source; - } - - @Override public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } CompilerTestInput that = (CompilerTestInput) o; - return Objects.equals(className, that.className) && - Objects.equals(source, that.source); + return Objects.equals(className, that.className) && Objects.equals(source, that.source); } @Override @@ -46,8 +46,8 @@ public int hashCode() { @Override public String toString() { return "CompilerTestInput{" + - "className='" + className + '\'' + - ", source='" + source + '\'' + - '}'; + "className='" + className + '\'' + + ", source='" + source + '\'' + + '}'; } } diff --git a/src/test/java/com/raulgomis/djc/utils/CompilerTestUtils.java b/src/test/java/com/raulgomis/djc/utils/CompilerTestUtils.java index 96d5dba..0089ad7 100644 --- a/src/test/java/com/raulgomis/djc/utils/CompilerTestUtils.java +++ b/src/test/java/com/raulgomis/djc/utils/CompilerTestUtils.java @@ -1,7 +1,6 @@ package com.raulgomis.djc.utils; import com.raulgomis.djc.DynamicCompilerUtils; - import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; @@ -10,29 +9,23 @@ import java.nio.file.Path; import java.nio.file.Paths; -/** - * @author rgomis - */ class CompilerTestUtils { private static final String SPLIT_EXPRESSION = "\\."; private CompilerTestUtils() { - } static CompilerTestInput getSource(String fileName) { try { - URI uri = DynamicCompilerUtils.class.getResource("/" + fileName).toURI(); + URI uri = java.util.Objects.requireNonNull(DynamicCompilerUtils.class.getResource("/" + fileName)).toURI(); Path path = Paths.get(uri); - byte[] result = Files.readAllBytes(path); - String source = new String(result, StandardCharsets.UTF_8); + String source = Files.readString(path, StandardCharsets.UTF_8); String className = path.getFileName().toString().split(SPLIT_EXPRESSION)[0]; return new CompilerTestInput(className, source); } catch (URISyntaxException | IOException e) { - e.printStackTrace(); return CompilerTestInput.EMPTY; } }