From 227ff99ae506024ba02cf4b9680302b85feb320a Mon Sep 17 00:00:00 2001 From: Reed von Redwitz Date: Wed, 10 Jun 2026 11:00:23 +0200 Subject: [PATCH] feat(base-compile-testing): javac testing harness --- base-compile-testing/pom.xml | 34 +++ .../base/compile/testing/Compilation.java | 109 ++++++++ .../build/base/compile/testing/Compiler.java | 196 +++++++++++++ .../ForwardingStandardJavaFileManager.java | 123 ++++++++ .../testing/InMemoryJavaFileManager.java | 112 ++++++++ .../base/compile/testing/JavaFileObjects.java | 72 +++++ .../base/compile/testing/package-info.java | 24 ++ .../src/main/java/module-info.java | 30 ++ .../compile/testing/CompilationTests.java | 264 ++++++++++++++++++ pom.xml | 6 + 10 files changed, 970 insertions(+) create mode 100644 base-compile-testing/pom.xml create mode 100644 base-compile-testing/src/main/java/build/base/compile/testing/Compilation.java create mode 100644 base-compile-testing/src/main/java/build/base/compile/testing/Compiler.java create mode 100644 base-compile-testing/src/main/java/build/base/compile/testing/ForwardingStandardJavaFileManager.java create mode 100644 base-compile-testing/src/main/java/build/base/compile/testing/InMemoryJavaFileManager.java create mode 100644 base-compile-testing/src/main/java/build/base/compile/testing/JavaFileObjects.java create mode 100644 base-compile-testing/src/main/java/build/base/compile/testing/package-info.java create mode 100644 base-compile-testing/src/main/java/module-info.java create mode 100644 base-compile-testing/src/test/java/build/base/compile/testing/CompilationTests.java diff --git a/base-compile-testing/pom.xml b/base-compile-testing/pom.xml new file mode 100644 index 0000000..1be2ea5 --- /dev/null +++ b/base-compile-testing/pom.xml @@ -0,0 +1,34 @@ + + + + 4.0.0 + + + build.base + base-project + ${revision} + + + base-compile-testing + + base.build Compile Testing + In-memory javac fixture for testing annotation processors and compiler-driven tooling. + + + + + org.junit.jupiter + junit-jupiter-api + test + + + + org.assertj + assertj-core + test + + + + diff --git a/base-compile-testing/src/main/java/build/base/compile/testing/Compilation.java b/base-compile-testing/src/main/java/build/base/compile/testing/Compilation.java new file mode 100644 index 0000000..3d5e569 --- /dev/null +++ b/base-compile-testing/src/main/java/build/base/compile/testing/Compilation.java @@ -0,0 +1,109 @@ +package build.base.compile.testing; + +/*- + * #%L + * base.build Compile Testing + * %% + * Copyright (C) 2026 Workday, Inc. + * %% + * Licensed 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. + * #L% + */ + +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; +import javax.annotation.processing.Processor; +import javax.tools.Diagnostic; +import javax.tools.JavaFileManager; +import javax.tools.JavaFileObject; + +/** + * The result of a {@link Compiler#compile} invocation. + */ +public record Compilation(Status status, + List sources, + List> diagnostics, + List generatedFiles, + List processors, + List options +) { + + /** + * Whether the compilation succeeded or failed. + */ + public enum Status { + SUCCESS, + FAILURE + } + + /** + * Returns {@code true} if the compilation succeeded. + */ + public boolean succeeded() { + return status == Status.SUCCESS; + } + + /** + * Returns a stream of all error diagnostics. + */ + public Stream> errors() { + return diagnostics.stream().filter(d -> d.getKind() == Diagnostic.Kind.ERROR); + } + + /** + * Returns a stream of all warning diagnostics. + */ + public Stream> warnings() { + return diagnostics.stream().filter(d -> d.getKind() == Diagnostic.Kind.WARNING + || d.getKind() == Diagnostic.Kind.MANDATORY_WARNING); + } + + /** + * Returns a stream of all note diagnostics. + */ + public Stream> notes() { + return diagnostics.stream().filter(d -> d.getKind() == Diagnostic.Kind.NOTE); + } + + /** + * Returns the generated source file for the given qualified class name, if any. + * + * @param qualifiedName fully-qualified class name, e.g. {@code com.example.Foo} + */ + public Optional generatedSourceFile(final String qualifiedName) { + final String expectedPath = qualifiedName.replace('.', '/') + JavaFileObject.Kind.SOURCE.extension; + return generatedFiles.stream() + .filter(f -> { + final String path = f.toUri().getPath(); + return path != null && path.endsWith(expectedPath); + }) + .findFirst(); + } + + /** + * Returns the generated file at the given location, package, and relative name, if any. + */ + public Optional generatedFile(final JavaFileManager.Location location, + final String packageName, + final String relativeName) { + final String packagePath = packageName.isEmpty() ? "" : packageName.replace('.', '/') + "/"; + final String expectedSuffix = "/" + location.getName() + "/" + packagePath + relativeName; + return generatedFiles.stream() + .filter(f -> { + final String path = f.toUri().getPath(); + return path != null && path.endsWith(expectedSuffix); + }) + .findFirst(); + } +} diff --git a/base-compile-testing/src/main/java/build/base/compile/testing/Compiler.java b/base-compile-testing/src/main/java/build/base/compile/testing/Compiler.java new file mode 100644 index 0000000..96258de --- /dev/null +++ b/base-compile-testing/src/main/java/build/base/compile/testing/Compiler.java @@ -0,0 +1,196 @@ +package build.base.compile.testing; + +/*- + * #%L + * base.build Compile Testing + * %% + * Copyright (C) 2026 Workday, Inc. + * %% + * Licensed 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. + * #L% + */ + +import java.io.File; +import java.io.IOException; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import javax.annotation.processing.Processor; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.ToolProvider; + +/** + * Fluent builder for running {@code javac} in-process against in-memory source files. + * + *

Instances are immutable; every {@code with*} method returns a new instance.

+ */ +public final class Compiler { + + private final List processors; + private final List options; + private final List classpath; + private final List modulePath; + + private Compiler(final List processors, + final List options, + final List classpath, + final List modulePath) { + this.processors = processors; + this.options = options; + this.classpath = classpath; + this.modulePath = modulePath; + } + + /** + * Returns a default compiler backed by the system {@code javac}. + */ + public static Compiler javac() { + return new Compiler(List.of(), List.of(), List.of(), List.of()); + } + + /** + * Returns a new compiler with the given annotation processors appended. + */ + public Compiler withProcessors(final Processor... processors) { + final List merged = new ArrayList<>(this.processors); + merged.addAll(Arrays.asList(processors)); + return new Compiler(List.copyOf(merged), options, classpath, modulePath); + } + + /** + * Returns a new compiler with the given extra {@code javac} options appended. + */ + public Compiler withOptions(final String... options) { + final List merged = new ArrayList<>(this.options); + merged.addAll(Arrays.asList(options)); + return new Compiler(processors, List.copyOf(merged), classpath, modulePath); + } + + /** + * Returns a new compiler with the given paths added to the class path. + */ + public Compiler withClasspath(final Path... paths) { + final List merged = new ArrayList<>(this.classpath); + merged.addAll(Arrays.asList(paths)); + return new Compiler(processors, options, List.copyOf(merged), modulePath); + } + + /** + * Returns a new compiler with the given paths added to the class path. + */ + public Compiler withClasspath(final List paths) { + final List merged = new ArrayList<>(this.classpath); + merged.addAll(paths); + return new Compiler(processors, options, List.copyOf(merged), modulePath); + } + + /** + * Returns a new compiler with the given paths added to the module path. + */ + public Compiler withModulePath(final Path... paths) { + final List merged = new ArrayList<>(this.modulePath); + merged.addAll(Arrays.asList(paths)); + return new Compiler(processors, options, classpath, List.copyOf(merged)); + } + + /** + * Returns a new compiler with the given paths added to the module path. + */ + public Compiler withModulePath(final List paths) { + final List merged = new ArrayList<>(this.modulePath); + merged.addAll(paths); + return new Compiler(processors, options, classpath, List.copyOf(merged)); + } + + /** + * Compiles the given source files and returns the result. + * + * @param sources in-memory source files to compile; see {@link JavaFileObjects} + * @throws IllegalStateException if no system Java compiler is available + */ + public Compilation compile(final JavaFileObject... sources) { + final JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + if (compiler == null) { + throw new IllegalStateException( + "no system Java compiler available; ensure tools.jar / JDK (not JRE) is on the module path"); + } + + final DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + final StandardJavaFileManager standardFileManager = + compiler.getStandardFileManager(diagnostics, Locale.ROOT, StandardCharsets.UTF_8); + + final List allOptions = buildOptions(); + final List sourceList = List.of(sources); + + try (InMemoryJavaFileManager fileManager = new InMemoryJavaFileManager(standardFileManager)) { + final JavaCompiler.CompilationTask task = compiler.getTask( + Writer.nullWriter(), + fileManager, + diagnostics, + allOptions.isEmpty() ? null : allOptions, + null, + sourceList); + + if (!processors.isEmpty()) { + task.setProcessors(processors); + } + + final boolean success = task.call(); + + return new Compilation( + success ? Compilation.Status.SUCCESS : Compilation.Status.FAILURE, + sourceList, + List.copyOf(diagnostics.getDiagnostics()), + fileManager.generatedFiles(), + List.copyOf(processors), + List.copyOf(allOptions)); + + } catch (final IOException e) { + throw new IllegalStateException("error closing file manager", e); + } + } + + private List buildOptions() { + final List all = new ArrayList<>(options); + + if (!modulePath.isEmpty()) { + all.add("--module-path"); + all.add(joinPaths(modulePath)); + } + + if (!classpath.isEmpty()) { + all.add("--class-path"); + all.add(joinPaths(classpath)); + } + + return all; + } + + private static String joinPaths(final List paths) { + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < paths.size(); i++) { + if (i > 0) { + sb.append(File.pathSeparator); + } + sb.append(paths.get(i).toString()); + } + return sb.toString(); + } +} diff --git a/base-compile-testing/src/main/java/build/base/compile/testing/ForwardingStandardJavaFileManager.java b/base-compile-testing/src/main/java/build/base/compile/testing/ForwardingStandardJavaFileManager.java new file mode 100644 index 0000000..cb31c37 --- /dev/null +++ b/base-compile-testing/src/main/java/build/base/compile/testing/ForwardingStandardJavaFileManager.java @@ -0,0 +1,123 @@ +package build.base.compile.testing; + +/*- + * #%L + * base.build Compile Testing + * %% + * Copyright (C) 2026 Workday, Inc. + * %% + * Licensed 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. + * #L% + */ + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Set; +import javax.tools.FileObject; +import javax.tools.ForwardingJavaFileManager; +import javax.tools.JavaFileObject; +import javax.tools.StandardJavaFileManager; + +/** + * A {@link StandardJavaFileManager} that forwards all calls to a delegate. + */ +abstract class ForwardingStandardJavaFileManager + extends ForwardingJavaFileManager + implements StandardJavaFileManager { + + protected ForwardingStandardJavaFileManager(final StandardJavaFileManager delegate) { + super(delegate); + } + + @Override + public Iterable getJavaFileObjectsFromFiles( + final Iterable files) { + return fileManager.getJavaFileObjectsFromFiles(files); + } + + @Override + public Iterable getJavaFileObjectsFromPaths( + final Collection paths) { + return fileManager.getJavaFileObjectsFromPaths(paths); + } + + @Override + public Iterable getJavaFileObjects(final File... files) { + return fileManager.getJavaFileObjects(files); + } + + @Override + public Iterable getJavaFileObjects(final Path... paths) { + return fileManager.getJavaFileObjects(paths); + } + + @Override + public Iterable getJavaFileObjects(final String... names) { + return fileManager.getJavaFileObjects(names); + } + + @Override + public Iterable getJavaFileObjectsFromStrings(final Iterable names) { + return fileManager.getJavaFileObjectsFromStrings(names); + } + + @Override + public void setLocation(final Location location, + final Iterable path) + throws IOException { + fileManager.setLocation(location, path); + } + + @Override + public void setLocationFromPaths(final Location location, + final Collection paths) + throws IOException { + fileManager.setLocationFromPaths(location, paths); + } + + @Override + public void setLocationForModule(final Location location, + final String moduleName, + final Collection paths) throws IOException { + fileManager.setLocationForModule(location, moduleName, paths); + } + + @Override + public Iterable getLocation(final Location location) { + return fileManager.getLocation(location); + } + + @Override + public Iterable getLocationAsPaths(final Location location) { + return fileManager.getLocationAsPaths(location); + } + + @Override + public String inferModuleName(final Location location) throws IOException { + return fileManager.inferModuleName(location); + } + + @Override + public Iterable> listLocationsForModules(final Location location) + throws IOException { + return fileManager.listLocationsForModules(location); + } + + @Override + public boolean contains(final Location location, + final FileObject fo) throws IOException { + return fileManager.contains(location, fo); + } +} diff --git a/base-compile-testing/src/main/java/build/base/compile/testing/InMemoryJavaFileManager.java b/base-compile-testing/src/main/java/build/base/compile/testing/InMemoryJavaFileManager.java new file mode 100644 index 0000000..9fb35b4 --- /dev/null +++ b/base-compile-testing/src/main/java/build/base/compile/testing/InMemoryJavaFileManager.java @@ -0,0 +1,112 @@ +package build.base.compile.testing; + +/*- + * #%L + * base.build Compile Testing + * %% + * Copyright (C) 2026 Workday, Inc. + * %% + * Licensed 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. + * #L% + */ + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.tools.FileObject; +import javax.tools.JavaFileObject; +import javax.tools.SimpleJavaFileObject; +import javax.tools.StandardJavaFileManager; + +/** + * A {@link StandardJavaFileManager} that captures all compiler output in memory. + */ +final class InMemoryJavaFileManager extends ForwardingStandardJavaFileManager { + + private final Map inMemoryOutputs = new LinkedHashMap<>(); + + InMemoryJavaFileManager(final StandardJavaFileManager delegate) { + super(delegate); + } + + @Override + public JavaFileObject getJavaFileForOutput(final Location location, + final String className, + final JavaFileObject.Kind kind, + final FileObject sibling) { + final URI uri = uriForJavaFile(location, className, kind); + final JavaFileObject file = new InMemoryJavaFileObject(uri, kind); + inMemoryOutputs.put(uri, file); + return file; + } + + @Override + public FileObject getFileForOutput(final Location location, + final String packageName, + final String relativeName, + final FileObject sibling) { + final URI uri = uriForFile(location, packageName, relativeName); + final JavaFileObject file = new InMemoryJavaFileObject(uri, JavaFileObject.Kind.OTHER); + inMemoryOutputs.put(uri, file); + return file; + } + + List generatedFiles() { + return List.copyOf(inMemoryOutputs.values()); + } + + private static URI uriForJavaFile(final Location location, + final String className, + final JavaFileObject.Kind kind) { + return URI.create("mem:///" + location.getName() + "/" + className.replace('.', '/') + kind.extension); + } + + private static URI uriForFile(final Location location, + final String packageName, + final String relativeName) { + final String packagePath = packageName.isEmpty() ? "" : packageName.replace('.', '/') + "/"; + return URI.create("mem:///" + location.getName() + "/" + packagePath + relativeName); + } + + private static final class InMemoryJavaFileObject extends SimpleJavaFileObject { + + private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + + InMemoryJavaFileObject(final URI uri, + final Kind kind) { + super(uri, kind); + } + + @Override + public OutputStream openOutputStream() { + buffer.reset(); + return buffer; + } + + @Override + public InputStream openInputStream() { + return new ByteArrayInputStream(buffer.toByteArray()); + } + + @Override + public CharSequence getCharContent(final boolean ignoreEncodingErrors) { + return buffer.toString(StandardCharsets.UTF_8); + } + } +} diff --git a/base-compile-testing/src/main/java/build/base/compile/testing/JavaFileObjects.java b/base-compile-testing/src/main/java/build/base/compile/testing/JavaFileObjects.java new file mode 100644 index 0000000..0d6a5f5 --- /dev/null +++ b/base-compile-testing/src/main/java/build/base/compile/testing/JavaFileObjects.java @@ -0,0 +1,72 @@ +package build.base.compile.testing; + +/*- + * #%L + * base.build Compile Testing + * %% + * Copyright (C) 2026 Workday, Inc. + * %% + * Licensed 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. + * #L% + */ + +import java.net.URI; +import javax.tools.JavaFileObject; +import javax.tools.SimpleJavaFileObject; + +/** + * Factory methods for in-memory {@link JavaFileObject} instances. + */ +public final class JavaFileObjects { + + private JavaFileObjects() { + } + + /** + * Returns an in-memory source file with the given qualified class name and source text. + * + * @param qualifiedName fully-qualified class name, e.g. {@code com.example.Foo} + * @param source the Java source text + */ + public static JavaFileObject forSourceString(final String qualifiedName, + final String source) { + final URI uri = URI.create("mem:///" + qualifiedName.replace('.', '/') + JavaFileObject.Kind.SOURCE.extension); + return new SourceStringFileObject(uri, source); + } + + /** + * Returns an in-memory source file built by joining the given lines with newlines. + * + * @param qualifiedName fully-qualified class name, e.g. {@code com.example.Foo} + * @param lines lines of source text + */ + public static JavaFileObject forSourceLines(final String qualifiedName, + final String... lines) { + return forSourceString(qualifiedName, String.join("\n", lines)); + } + + private static final class SourceStringFileObject extends SimpleJavaFileObject { + + private final String source; + + SourceStringFileObject(final URI uri, final String source) { + super(uri, Kind.SOURCE); + this.source = source; + } + + @Override + public CharSequence getCharContent(final boolean ignoreEncodingErrors) { + return source; + } + } +} diff --git a/base-compile-testing/src/main/java/build/base/compile/testing/package-info.java b/base-compile-testing/src/main/java/build/base/compile/testing/package-info.java new file mode 100644 index 0000000..c0d5371 --- /dev/null +++ b/base-compile-testing/src/main/java/build/base/compile/testing/package-info.java @@ -0,0 +1,24 @@ +/*- + * #%L + * base.build Compile Testing + * %% + * Copyright (C) 2026 Workday, Inc. + * %% + * Licensed 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. + * #L% + */ + +/** + * In-memory javac fixture for testing annotation processors and compiler-driven tooling. + */ +package build.base.compile.testing; diff --git a/base-compile-testing/src/main/java/module-info.java b/base-compile-testing/src/main/java/module-info.java new file mode 100644 index 0000000..7dcb6cf --- /dev/null +++ b/base-compile-testing/src/main/java/module-info.java @@ -0,0 +1,30 @@ +/*- + * #%L + * base.build Compile Testing + * %% + * Copyright (C) 2026 Workday, Inc. + * %% + * Licensed 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. + * #L% + */ +/** + * In-memory javac fixture for testing annotation processors and compiler-driven tooling. + * + * @author reed.von.redwitz + * @since May-2026 + */ +module build.base.compile.testing { + requires java.compiler; + + exports build.base.compile.testing; +} diff --git a/base-compile-testing/src/test/java/build/base/compile/testing/CompilationTests.java b/base-compile-testing/src/test/java/build/base/compile/testing/CompilationTests.java new file mode 100644 index 0000000..490a2ea --- /dev/null +++ b/base-compile-testing/src/test/java/build/base/compile/testing/CompilationTests.java @@ -0,0 +1,264 @@ +package build.base.compile.testing; + +/*- + * #%L + * base.build Compile Testing + * %% + * Copyright (C) 2026 Workday, Inc. + * %% + * Licensed 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. + * #L% + */ + +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.PrintStream; +import java.io.PrintWriter; +import java.nio.file.Path; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.Filer; +import javax.annotation.processing.RoundEnvironment; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.TypeElement; +import javax.tools.JavaFileObject; +import javax.tools.StandardLocation; + +import static org.assertj.core.api.Assertions.assertThat; + +class CompilationTests { + + @Test + void trivialSourceCompilesSuccessfully() { + final Compilation result = Compiler.javac().compile( + JavaFileObjects.forSourceString("com.example.Hello", + "package com.example; public class Hello {}")); + + assertThat(result.status()).isEqualTo(Compilation.Status.SUCCESS); + assertThat(result.succeeded()).isTrue(); + assertThat(result.errors().toList()).isEmpty(); + } + + @Test + void syntaxErrorProducesFailureAndErrorDiagnostic() { + final Compilation result = Compiler.javac().compile( + JavaFileObjects.forSourceString("com.example.Broken", + "package com.example; public class Broken { SYNTAX ERROR }")); + + assertThat(result.status()).isEqualTo(Compilation.Status.FAILURE); + assertThat(result.errors().toList()).isNotEmpty(); + } + + @Test + void unresolvedTypeProducesError() { + final Compilation result = Compiler.javac().compile( + JavaFileObjects.forSourceString("com.example.Missing", + "package com.example; public class Missing { DoesNotExist x; }")); + + assertThat(result.status()).isEqualTo(Compilation.Status.FAILURE); + assertThat(result.errors().toList()).isNotEmpty(); + } + + @Test + void processorObservesRoundEnvironment() { + final AtomicBoolean processorRan = new AtomicBoolean(false); + + final AbstractProcessor processor = new AbstractProcessor() { + @Override + public Set getSupportedAnnotationTypes() { + return Set.of("*"); + } + + @Override + public SourceVersion getSupportedSourceVersion() { + return SourceVersion.latestSupported(); + } + + @Override + public boolean process(final Set annotations, final RoundEnvironment roundEnv) { + if (!roundEnv.processingOver()) { + processorRan.set(true); + assertThat(roundEnv.getRootElements()).isNotEmpty(); + } + return false; + } + }; + + final Compilation result = Compiler.javac() + .withProcessors(processor) + .compile(JavaFileObjects.forSourceString("com.example.Subject", + "package com.example; public class Subject {}")); + + assertThat(result.succeeded()).isTrue(); + assertThat(processorRan).isTrue(); + } + + @Test + void processorGeneratesSourceFileAccessibleFromResult() { + final AtomicBoolean didGenerate = new AtomicBoolean(false); + + final AbstractProcessor processor = new AbstractProcessor() { + @Override + public Set getSupportedAnnotationTypes() { + return Set.of("*"); + } + + @Override + public SourceVersion getSupportedSourceVersion() { + return SourceVersion.latestSupported(); + } + + @Override + public boolean process(final Set annotations, final RoundEnvironment roundEnv) { + if (!roundEnv.processingOver() && didGenerate.compareAndSet(false, true)) { + final Filer filer = processingEnv.getFiler(); + try { + final JavaFileObject file = filer.createSourceFile("com.example.Generated"); + try (final PrintWriter writer = new PrintWriter(file.openWriter())) { + writer.println("package com.example; public class Generated {}"); + } + } catch (final IOException e) { + throw new RuntimeException(e); + } + } + return false; + } + }; + + final Compilation result = Compiler.javac() + .withProcessors(processor) + .compile(JavaFileObjects.forSourceString("com.example.Trigger", + "package com.example; public class Trigger {}")); + + assertThat(result.succeeded()).isTrue(); + assertThat(result.generatedFiles()).isNotEmpty(); + + final Optional generated = result.generatedSourceFile("com.example.Generated"); + assertThat(generated).isPresent(); + } + + @Test + void multipleSourceFilesInDistinctPackagesCompileTogether() { + final Compilation result = Compiler.javac().compile( + JavaFileObjects.forSourceString("com.example.a.Alpha", + "package com.example.a; public class Alpha {}"), + JavaFileObjects.forSourceString("com.example.b.Beta", + "package com.example.b; import com.example.a.Alpha; public class Beta { Alpha a; }")); + + assertThat(result.succeeded()).isTrue(); + assertThat(result.errors().toList()).isEmpty(); + } + + @Test + void classpathEntriesArePickedUp() { + final String assertjJar = findJarOnClasspath("assertj"); + Assumptions.assumeTrue(assertjJar != null, "assertj not found on java.class.path; skipping classpath test"); + + final Compilation result = Compiler.javac() + .withClasspath(Path.of(assertjJar)) + .compile(JavaFileObjects.forSourceString("com.example.UsesAssertJ", + "package com.example;" + + " import org.assertj.core.api.Assertions;" + + " public class UsesAssertJ {" + + " public void check(final boolean b) { Assertions.assertThat(b).isTrue(); }" + + " }")); + + assertThat(result.errors().toList()).isEmpty(); + } + + @Test + void diagnosticsDoNotLeakToStderr() { + final PrintStream originalErr = System.err; + final ByteArrayOutputStream captured = new ByteArrayOutputStream(); + System.setErr(new PrintStream(captured)); + try { + Compiler.javac().compile( + JavaFileObjects.forSourceString("com.example.Bad", + "package com.example; public class Bad { SYNTAX ERROR }")); + } finally { + System.setErr(originalErr); + } + assertThat(captured.toString()).isEmpty(); + } + + @Test + void forSourceLinesJoinsWithNewlines() { + final Compilation result = Compiler.javac().compile( + JavaFileObjects.forSourceLines("com.example.Multiline", + "package com.example;", + "public class Multiline {", + " public int value() { return 42; }", + "}")); + + assertThat(result.succeeded()).isTrue(); + } + + @Test + void generatedFileIsAccessibleByLocationPackageAndName() { + final AtomicBoolean didGenerate = new AtomicBoolean(false); + + final AbstractProcessor processor = new AbstractProcessor() { + @Override + public Set getSupportedAnnotationTypes() { + return Set.of("*"); + } + + @Override + public SourceVersion getSupportedSourceVersion() { + return SourceVersion.latestSupported(); + } + + @Override + public boolean process(final Set annotations, final RoundEnvironment roundEnv) { + if (!roundEnv.processingOver() && didGenerate.compareAndSet(false, true)) { + try { + final var resource = processingEnv.getFiler() + .createResource(StandardLocation.CLASS_OUTPUT, "com.example", "generated.txt"); + try (final PrintWriter w = new PrintWriter(resource.openWriter())) { + w.print("hello"); + } + } catch (final IOException e) { + throw new RuntimeException(e); + } + } + return false; + } + }; + + final Compilation result = Compiler.javac() + .withProcessors(processor) + .compile(JavaFileObjects.forSourceString("com.example.Trigger", + "package com.example; public class Trigger {}")); + + assertThat(result.succeeded()).isTrue(); + final Optional resource = result.generatedFile( + StandardLocation.CLASS_OUTPUT, "com.example", "generated.txt"); + assertThat(resource).isPresent(); + } + + private static String findJarOnClasspath(final String fragment) { + final String cp = System.getProperty("java.class.path", ""); + for (final String entry : cp.split(File.pathSeparator)) { + if (entry.contains(fragment)) { + return entry; + } + } + return null; + } +} diff --git a/pom.xml b/pom.xml index 37c519b..eeeddb6 100644 --- a/pom.xml +++ b/pom.xml @@ -92,6 +92,7 @@ base-archiving base-assertion base-commandline + base-compile-testing base-configuration base-expression base-flow @@ -139,6 +140,11 @@ base-commandline ${revision} + + build.base + base-compile-testing + ${revision} + build.base base-configuration