diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/JavaFormatExtension.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/JavaFormatExtension.java index 174944335..5554e5b0e 100644 --- a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/JavaFormatExtension.java +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/JavaFormatExtension.java @@ -17,17 +17,35 @@ package com.palantir.javaformat.gradle; import com.google.common.base.Suppliers; +import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; import com.palantir.javaformat.java.FormatterService; import java.io.UncheckedIOException; import java.net.MalformedURLException; import java.net.URL; import java.net.URLClassLoader; +import java.util.List; import java.util.ServiceLoader; import java.util.function.Supplier; +import java.util.stream.Collectors; +import org.gradle.api.GradleException; import org.gradle.api.artifacts.Configuration; public class JavaFormatExtension { + /** + * The javac packages the formatter reads. A plain Gradle JVM exports none of them, and the formatter then fails + * on its first file with an IllegalAccessError that names a class and a module and nothing the user can change. + * The check below fails before the first file instead, with the two settings that make it work. + */ + private static final ImmutableList JAVAC_PACKAGES = ImmutableList.of( + "com.sun.tools.javac.api", + "com.sun.tools.javac.file", + "com.sun.tools.javac.parser", + "com.sun.tools.javac.tree", + "com.sun.tools.javac.util"); + + private static final String DOCS = "https://openjavaformat.dev/get-started/gradle/#choose-how-the-formatter-runs"; + private final Configuration configuration; private final Supplier memoizedService; @@ -53,6 +71,42 @@ private FormatterService serviceLoadInternal() { .toArray(URL[]::new); ClassLoader classLoader = new URLClassLoader(jarUris, FormatterService.class.getClassLoader()); + checkJavacIsExportedTo(classLoader); return Iterables.getOnlyElement(ServiceLoader.load(FormatterService.class, classLoader)); } + + /** + * Fails with the fix spelt out when this JVM does not export javac's internals to the formatter's class loader: + * either the native binary, which runs outside the JVM, or the {@code --add-exports} flags on the Gradle JVM. + */ + private static void checkJavacIsExportedTo(ClassLoader formatterClassLoader) { + Module jdkCompiler = ModuleLayer.boot() + .findModule("jdk.compiler") + .orElseThrow(() -> new GradleException("open-java-format needs the module jdk.compiler, which this" + + " Gradle JVM does not have: run Gradle on a JDK, not a JRE. See " + DOCS)); + Module formatter = formatterClassLoader.getUnnamedModule(); + List notExported = JAVAC_PACKAGES.stream() + .filter(javacPackage -> !jdkCompiler.isExported(javacPackage, formatter)) + .collect(Collectors.toList()); + if (notExported.isEmpty()) { + return; + } + String addExports = JAVAC_PACKAGES.stream() + .map(javacPackage -> "--add-exports jdk.compiler/" + javacPackage + "=ALL-UNNAMED") + .collect(Collectors.joining(" ")); + throw new GradleException(""" + open-java-format cannot run inside this Gradle JVM: module jdk.compiler does not export %s to it. \ + Set one of these in gradle.properties: + + openjavaformat.native.formatter=true + runs the formatter as a native binary, outside the Gradle JVM (Linux with glibc, macOS, \ + Windows on x86-64) + + org.gradle.jvmargs=%s + opens javac's packages to the Gradle JVM; if the file already sets org.gradle.jvmargs, add \ + the flags to that line + + See %s\ + """.formatted(String.join(", ", notExported), addExports, DOCS)); + } } diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/FormatDiffWithoutJavacExportsTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/FormatDiffWithoutJavacExportsTest.java new file mode 100644 index 000000000..5d398040b --- /dev/null +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/FormatDiffWithoutJavacExportsTest.java @@ -0,0 +1,97 @@ +/* + * (c) Copyright 2026 Palantir Technologies Inc. All rights reserved. + * + * 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. + */ +package com.palantir.javaformat.gradle; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.palantir.javaformat.gradle.testing.GradleTestProject; +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import org.gradle.testkit.runner.BuildResult; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * A build without the JVM flags used to fail on the first file with a raw IllegalAccessError from the formatter's + * internals (#19). It fails before the first file now, naming the two settings that make the formatter run. This + * class runs in the default test task; PalantirJavaFormatPluginTest does not, because it needs the native binary. + */ +class FormatDiffWithoutJavacExportsTest { + + /** ./gradlew writeImplClasspath generates this file. Forward slashes: the path goes into a Groovy string. */ + private static final String CLASSPATH_FILE = + new File("build/impl.classpath").getAbsolutePath().replace('\\', '/'); + + private static final String MAIN_JAVA = "src/main/java/Main.java"; + + @TempDir + private Path projectDir; + + @Test + void formatDiff_names_both_settings_when_the_gradle_jvm_does_not_export_javac() + throws IOException, InterruptedException { + GradleTestProject project = new GradleTestProject(projectDir) + .plugins("java", "dev.openjavaformat.java-format") + .buildGradle( + """ + dependencies { + palantirJavaFormat files(file("%s").text.split(File.pathSeparator)) + } + """, + CLASSPATH_FILE); + + git(project, "init"); + git(project, "config", "user.name", "Foo"); + git(project, "config", "user.email", "foo@bar.com"); + // The repository this runs in may sign commits; a throwaway one has no key to sign with. + git(project, "config", "commit.gpgsign", "false"); + project.writeFile(MAIN_JAVA, "class Main {}\n"); + git(project, "add", "."); + git(project, "commit", "-m", "Commit"); + project.writeFile(MAIN_JAVA, "class Main { int x; }\n"); + + BuildResult result = project.fails("formatDiff"); + + // Which of the five packages the message lists depends on what the Gradle daemon exports on its own; + // com.sun.tools.javac.parser, the one the old error named, is never among those. + assertThat(result.getOutput()) + .contains("open-java-format cannot run inside this Gradle JVM: module jdk.compiler does not export ") + .contains("com.sun.tools.javac.parser") + .contains("openjavaformat.native.formatter=true") + .contains("org.gradle.jvmargs=--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED" + + " --add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED" + + " --add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED" + + " --add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED" + + " --add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED") + .contains("https://openjavaformat.dev/get-started/gradle/#choose-how-the-formatter-runs") + .doesNotContain("IllegalAccessError") + .doesNotContain("Formatting "); + // The check runs before the first file, so nothing was written. + assertThat(project.readFile(MAIN_JAVA)).isEqualTo("class Main { int x; }\n"); + } + + private static void git(GradleTestProject project, String... args) throws IOException, InterruptedException { + String[] command = new String[args.length + 1]; + command[0] = "git"; + System.arraycopy(args, 0, command, 1, args.length); + Process process = new ProcessBuilder(command).directory(project.path().toFile()).start(); + int exitCode = process.waitFor(); + if (exitCode != 0) { + throw new RuntimeException("git " + String.join(" ", args) + " failed with exit code " + exitCode); + } + } +}