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 extends JavaFileObject> getJavaFileObjectsFromFiles(
+ final Iterable extends File> files) {
+ return fileManager.getJavaFileObjectsFromFiles(files);
+ }
+
+ @Override
+ public Iterable extends JavaFileObject> getJavaFileObjectsFromPaths(
+ final Collection extends Path> paths) {
+ return fileManager.getJavaFileObjectsFromPaths(paths);
+ }
+
+ @Override
+ public Iterable extends JavaFileObject> getJavaFileObjects(final File... files) {
+ return fileManager.getJavaFileObjects(files);
+ }
+
+ @Override
+ public Iterable extends JavaFileObject> getJavaFileObjects(final Path... paths) {
+ return fileManager.getJavaFileObjects(paths);
+ }
+
+ @Override
+ public Iterable extends JavaFileObject> getJavaFileObjects(final String... names) {
+ return fileManager.getJavaFileObjects(names);
+ }
+
+ @Override
+ public Iterable extends JavaFileObject> getJavaFileObjectsFromStrings(final Iterable names) {
+ return fileManager.getJavaFileObjectsFromStrings(names);
+ }
+
+ @Override
+ public void setLocation(final Location location,
+ final Iterable extends File> path)
+ throws IOException {
+ fileManager.setLocation(location, path);
+ }
+
+ @Override
+ public void setLocationFromPaths(final Location location,
+ final Collection extends Path> paths)
+ throws IOException {
+ fileManager.setLocationFromPaths(location, paths);
+ }
+
+ @Override
+ public void setLocationForModule(final Location location,
+ final String moduleName,
+ final Collection extends Path> paths) throws IOException {
+ fileManager.setLocationForModule(location, moduleName, paths);
+ }
+
+ @Override
+ public Iterable extends File> getLocation(final Location location) {
+ return fileManager.getLocation(location);
+ }
+
+ @Override
+ public Iterable extends Path> 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 extends TypeElement> 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 extends TypeElement> 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 extends TypeElement> 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