From 6b1cd06854db97c8a134df5b249574342b02d96e Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Sun, 8 Nov 2026 10:58:30 +0300 Subject: [PATCH 01/22] Test spotlessJava with the build cache No test ran a build with the build cache on, so nothing showed that spotlessJava is taken from it only when it should be. BuildCacheTest does, with the configuration cache and without: spotlessJava is up to date when the source is written again unchanged, runs when the source or the formatter's jars change, and comes from the cache when the source changes back, after the build directory is deleted and in a copy of the project in another directory. Each build is spotlessApply, and each test checks the file it leaves. That shows the output spotlessJava ran for, kept or took from the cache is the source formatted; a step that changes nothing passes a spotlessCheck of a formatted file as well. GradleTestProject can now run its builds with the build cache, in a directory of the test's own: TestKit builds share one Gradle user home, and with it the default local cache. It can also delete a file or a directory of the project. --- .../javaformat/gradle/BuildCacheTest.java | 159 ++++++++++++++++++ .../gradle/testing/GradleTestProject.java | 39 ++++- 2 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/BuildCacheTest.java diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/BuildCacheTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/BuildCacheTest.java new file mode 100644 index 000000000..b17dc6d60 --- /dev/null +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/BuildCacheTest.java @@ -0,0 +1,159 @@ +/* + * 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.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.gradle.testkit.runner.TaskOutcome; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.Parameter; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * spotlessJava is cacheable, and the open-java-format step is part of its cache key: the key changes with the + * formatter's jars, and not with the directory the project is in. + * + *

Every build here is spotlessApply, after the source is written again. The formatted file is what shows that + * spotlessJava's output, whether it ran, was up to date or came from the cache, holds the source formatted: a step that + * leaves the source as it is passes a spotlessCheck of a formatted file just as well. Writing the same source again is + * the change that must not count, as only its time changes. + */ +@ParameterizedClass(name = "configuration cache {0}") +@ValueSource(booleans = {true, false}) +final class BuildCacheTest { + + /** ./gradlew writeImplClasspath generates this file. */ + private static final Path CLASSPATH_FILE = Path.of("build/impl.classpath"); + + private static final String MAIN_JAVA = "src/main/java/Main.java"; + private static final String UNFORMATTED = "class Main {\n int x;\n}\n"; + private static final String FORMATTED = "class Main {\n int x;\n}\n"; + private static final String OTHER_UNFORMATTED = "class Main {\n int y;\n}\n"; + private static final String OTHER_FORMATTED = "class Main {\n int y;\n}\n"; + + @Parameter + private boolean configurationCache; + + @TempDir + private Path projectDir; + + @TempDir + private Path otherProjectDir; + + @TempDir + private Path buildCacheDir; + + @Test + void spotlessJava_is_up_to_date_when_the_source_is_written_again_unchanged() throws IOException { + var project = project(projectDir); + assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); + assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); + + assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.UP_TO_DATE); + assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); + } + + @Test + void spotlessJava_runs_again_when_the_source_changes() throws IOException { + var project = project(projectDir); + assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); + + assertThat(spotlessApply(project, OTHER_UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); + assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(OTHER_FORMATTED); + } + + @Test + void spotlessJava_comes_from_the_cache_when_the_source_changes_back() throws IOException { + var project = project(projectDir); + assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); + assertThat(spotlessApply(project, OTHER_UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); + + assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.FROM_CACHE); + assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); + } + + @Test + void spotlessJava_comes_from_the_cache_after_the_build_directory_is_deleted() throws IOException { + var project = project(projectDir); + assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); + + project.delete("build"); + + assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.FROM_CACHE); + assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); + } + + @Test + void spotlessJava_comes_from_the_cache_in_another_directory() throws IOException { + assertThat(spotlessApply(project(projectDir), UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); + + // The same project somewhere else, as another checkout or another CI machine has it. + var moved = project(otherProjectDir); + + assertThat(spotlessApply(moved, UNFORMATTED)).isEqualTo(TaskOutcome.FROM_CACHE); + assertThat(moved.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); + } + + @Test + void spotlessJava_runs_again_when_the_formatter_jars_change() throws IOException, URISyntaxException { + var project = project(projectDir); + assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); + + // Spotless signs the jars by name, size and content, as another formatter version changes them. The extra jar + // is JUnit's, not one written into projectDir: the daemon keeps the formatter's jars open after the build, and + // Windows does not delete an open file. Without the build directory, only the cache could skip the task. + var extraJar = Path.of( + Test.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + project.writeFile("formatter.classpath", formatterClasspath() + File.pathSeparator + extraJar) + .delete("build"); + + assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); + assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); + } + + private GradleTestProject project(Path dir) throws IOException { + return new GradleTestProject(dir) + .configurationCache(configurationCache) + .withBuildCache(buildCacheDir) + .plugins("java", "dev.openjavaformat.java-format", "com.diffplug.spotless") + .withJavacInternalExports() + .writeFile("formatter.classpath", formatterClasspath()) + .buildGradle(""" + dependencies { + palantirJavaFormat files(file("formatter.classpath").text.split(File.pathSeparator)) + } + """); + } + + private static String formatterClasspath() throws IOException { + return Files.readString(CLASSPATH_FILE).trim(); + } + + /** Writes the source and runs spotlessApply; returns what spotlessJava did for it. */ + private static TaskOutcome spotlessApply(GradleTestProject project, String source) { + var task = + project.writeFile(MAIN_JAVA, source).succeeds("spotlessApply").task(":spotlessJava"); + assertThat(task).isNotNull(); + return task.getOutcome(); + } +} diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/testing/GradleTestProject.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/testing/GradleTestProject.java index f7c8fe50a..43d954e24 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/testing/GradleTestProject.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/testing/GradleTestProject.java @@ -25,11 +25,14 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; +import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.Properties; import java.util.Set; +import java.util.stream.Stream; import org.gradle.testkit.runner.BuildResult; import org.gradle.testkit.runner.GradleRunner; @@ -61,6 +64,7 @@ public final class GradleTestProject { private final StringBuilder gradleProperties = new StringBuilder(); private boolean configurationCache = true; private boolean spotlessOnClasspath = true; + private Optional buildCacheDir = Optional.empty(); public GradleTestProject(Path projectDir) { this.projectDir = projectDir; @@ -109,6 +113,15 @@ public GradleTestProject configurationCache(boolean enabled) { return this; } + /** + * Runs the builds with the build cache, kept in {@code directory}. Not the default local cache: TestKit builds share + * one Gradle user home, and with it that cache, so a test would read what another test stored. + */ + public GradleTestProject withBuildCache(Path directory) { + this.buildCacheDir = Optional.of(directory); + return this; + } + /** * Leaves the Spotless jars out of the classpath injected next to the plugin under test. build.gradle puts them there * so that generated projects can apply Spotless, which gives every other test Spotless classes whether it applies @@ -134,6 +147,22 @@ public GradleTestProject writeFile(String relativePath, String content) { return this; } + /** Deletes a file, or a directory with everything in it, if it is there. */ + public GradleTestProject delete(String relativePath) { + Path target = file(relativePath); + if (!Files.exists(target)) { + return this; + } + try (Stream paths = Files.walk(target)) { + for (var path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.delete(path); + } + } catch (IOException e) { + throw new UncheckedIOException("Could not delete " + relativePath, e); + } + return this; + } + public String readFile(String relativePath) { try { return Files.readString(file(relativePath), StandardCharsets.UTF_8); @@ -158,6 +187,9 @@ private GradleRunner runner(String... arguments) { // Every build looks at the files afresh. A test changes files between two builds faster than file system // watching reports it, and the second build then took a deleted output for one still there. allArguments.add("--no-watch-fs"); + if (buildCacheDir.isPresent()) { + allArguments.add("--build-cache"); + } GradleRunner runner = GradleRunner.create() .withProjectDir(projectDir.toFile()) .withGradleVersion(GRADLE_VERSION) @@ -186,7 +218,12 @@ private void write() { plugins.forEach(id -> script.append(" id '").append(id).append("'\n")); script.append("}\n\nrepositories {\n mavenCentral()\n}\n\n").append(buildGradle); - writeFile("settings.gradle", "rootProject.name = 'test-project'\n"); + StringBuilder settings = new StringBuilder("rootProject.name = 'test-project'\n"); + // Forward slashes: the path goes into a Groovy string, where a Windows backslash would start an escape. + buildCacheDir.ifPresent(dir -> settings.append("\nbuildCache {\n local {\n directory = file('") + .append(dir.toAbsolutePath().toString().replace('\\', '/')) + .append("')\n }\n}\n")); + writeFile("settings.gradle", settings.toString()); writeFile("build.gradle", script.toString()); writeFile("gradle.properties", gradleProperties.toString()); } From 7160468173c747c770271094b94b4a8dfd10f825 Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Sun, 8 Nov 2026 11:01:35 +0300 Subject: [PATCH 02/22] Check the formatted files in the Spotless tests A Spotless test passed as long as its task did: three of them would have passed with a step that formats nothing, as they did while the configuration cache lost the formatter. spotlessJava_is_up_to_date_until_the_formatter_jars_change ran spotlessCheck on a formatted file, which a step that changes nothing passes. It now runs spotlessApply on the unformatted source each time and checks the file it leaves; line endings no longer matter, so the build no longer sets lineEndings to PRESERVE. format_ignores_excluded_directories passed when nothing was formatted at all. It now runs spotlessApply with a file outside the excluded directory too, and checks that the one is formatted and the other is left as it was. format_checks_non_generated_files passed on any failure of the check, a lint error from a failing formatter as well; it now looks for the format violation. With the step's formatter returning its input, every test that runs a Spotless task now fails. --- .../gradle/ConfigurationCacheTest.java | 24 +++++++++++-------- .../gradle/SpotlessExcludesTest.java | 23 ++++++++++++++++-- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/ConfigurationCacheTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/ConfigurationCacheTest.java index 11987197f..17e7eacb0 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/ConfigurationCacheTest.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/ConfigurationCacheTest.java @@ -104,22 +104,20 @@ void spotlessJava_is_up_to_date_until_the_formatter_jars_change() throws IOExcep .plugins("java", "dev.openjavaformat.java-format", "com.diffplug.spotless") .withJavacInternalExports() .writeFile("formatter.classpath", jars) - // By default Spotless checks for the platform's line endings, CRLF on Windows; PRESERVE keeps the - // file's own. .buildGradle(""" dependencies { palantirJavaFormat files(file("formatter.classpath").text.split(File.pathSeparator)) } - spotless { - lineEndings = 'PRESERVE' - } - """) - .writeFile(MAIN_JAVA, FORMATTED); + """); - assertThat(spotlessJava(project.succeeds("spotlessCheck"))).isEqualTo(TaskOutcome.SUCCESS); - var unchanged = project.succeeds("spotlessCheck"); + // spotlessApply of the same source each time, and the file it leaves: a step that changes nothing is up to + // date just as well, and passes a spotlessCheck of a formatted file. + assertThat(spotlessJava(spotlessApply(project))).isEqualTo(TaskOutcome.SUCCESS); + assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); + var unchanged = spotlessApply(project); assertThat(unchanged.getOutput()).contains(REUSED); assertThat(spotlessJava(unchanged)).isEqualTo(TaskOutcome.UP_TO_DATE); + assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); // Spotless signs the jars by name, size and content, as another formatter version changes them. The extra jar // is JUnit's, not one written into projectDir: the daemon keeps the formatter's jars open after the build, and @@ -128,7 +126,13 @@ palantirJavaFormat files(file("formatter.classpath").text.split(File.pathSeparat Test.class.getProtectionDomain().getCodeSource().getLocation().toURI()); project.writeFile("formatter.classpath", jars + File.pathSeparator + extraJar); - assertThat(spotlessJava(project.succeeds("spotlessCheck"))).isEqualTo(TaskOutcome.SUCCESS); + assertThat(spotlessJava(spotlessApply(project))).isEqualTo(TaskOutcome.SUCCESS); + assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); + } + + /** spotlessApply of the unformatted source, written again: only its time changes. */ + private static BuildResult spotlessApply(GradleTestProject project) { + return project.writeFile(MAIN_JAVA, UNFORMATTED).succeeds("spotlessApply"); } @Test diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SpotlessExcludesTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SpotlessExcludesTest.java index 7487f5a26..b4035595b 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SpotlessExcludesTest.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SpotlessExcludesTest.java @@ -43,6 +43,15 @@ final class SpotlessExcludesTest { public class Test { Void test() { return null; } } """; + /** + * Formatted next to a file in an excluded directory. If it stays as it is, the formatter did not run, and the + * excluded file staying as it is proves nothing. + */ + private static final String MAIN_JAVA = "src/main/java/Main.java"; + + private static final String UNFORMATTED_MAIN = "class Main {\n int x;\n}\n"; + private static final String FORMATTED_MAIN = "class Main {\n int x;\n}\n"; + @Parameter private boolean configurationCache; @@ -81,12 +90,17 @@ void format_ignores_excluded_directories(String srcDir) { java { srcDir '%s' } } } - """, srcDir).writeFile(srcDir + "/test/Test.java", SOURCE_FILE); + """, srcDir) + .writeFile(srcDir + "/test/Test.java", SOURCE_FILE) + .writeFile(MAIN_JAVA, UNFORMATTED_MAIN); - BuildResult result = project.succeeds("spotlessJavaCheck"); + BuildResult result = project.succeeds("spotlessApply"); assertThat(result.task(":spotlessJava")).isNotNull().extracting(task -> task.getOutcome()) .isEqualTo(TaskOutcome.SUCCESS); + assertThat(project.readFile(srcDir + "/test/Test.java")).isEqualTo(SOURCE_FILE); + // Spotless writes the platform's line endings, CRLF on Windows. + assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED_MAIN); } @ParameterizedTest @@ -108,5 +122,10 @@ void format_checks_non_generated_files(String srcDir) { assertThat(result.task(":spotlessJava")).isNotNull().extracting(task -> task.getOutcome()) .isEqualTo(TaskOutcome.SUCCESS); + // A formatter that fails reports a lint error instead, which fails the check just as well. + assertThat(result.getOutput()) + .contains("format violations") + .contains("Test.java") + .doesNotContain("lint error"); } } From 0ce3cbfc53339bc982e857429f1707b6b2074c23 Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Sun, 8 Nov 2026 11:02:47 +0300 Subject: [PATCH 03/22] Remove the Spotless step for the native image SpotlessInterop picked NativePalantirJavaFormatStep when the native formatter was switched on and Gradle ran on a JVM older than 21. The plugin is compiled for 21 and does not load on an older JVM, so the branch never ran: Spotless formats on the Gradle JVM, as the docs say, whatever openjavaformat.native.formatter is. The step, the version check and the native configuration lookup go. So does PalantirJavaFormatStep.create(Configuration, Supplier), deprecated when the step stopped using the supplier; nothing calls it. --- .../javaformat/gradle/SpotlessInterop.java | 18 ++--- .../NativePalantirJavaFormatStep.java | 75 ------------------- .../spotless/PalantirJavaFormatStep.java | 15 ---- .../PalantirJavaFormatSpotlessPluginTest.java | 6 +- 4 files changed, 6 insertions(+), 108 deletions(-) delete mode 100644 gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/spotless/NativePalantirJavaFormatStep.java diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/SpotlessInterop.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/SpotlessInterop.java index 7c89a6e7e..f0ea5cc58 100644 --- a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/SpotlessInterop.java +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/SpotlessInterop.java @@ -17,7 +17,6 @@ import com.diffplug.gradle.spotless.JavaExtension; import com.diffplug.spotless.FormatterStep; -import com.palantir.javaformat.gradle.spotless.NativePalantirJavaFormatStep; import com.palantir.javaformat.gradle.spotless.PalantirJavaFormatStep; import java.io.File; import java.util.List; @@ -25,14 +24,12 @@ import java.util.regex.Pattern; import javax.inject.Inject; import org.gradle.api.Action; -import org.gradle.api.JavaVersion; import org.gradle.api.artifacts.ConfigurationContainer; import org.gradle.api.file.FileCollection; import org.gradle.api.logging.Logger; import org.gradle.api.logging.Logging; import org.gradle.api.model.ObjectFactory; import org.gradle.api.specs.Spec; -import org.gradle.api.tasks.Nested; import org.gradle.api.tasks.SourceSetContainer; /** @@ -56,9 +53,6 @@ public abstract class SpotlessInterop implements Action { private final File projectDir; private final SourceSetContainer sourceSets; - @Nested - protected abstract NativeImageSupport getNativeImageSupport(); - @Inject protected abstract ConfigurationContainer getConfigurations(); @@ -90,14 +84,12 @@ private FileCollection generatedSources() { .toList()); } + /** + * The Java-based formatter, on the Gradle JVM, whether or not {@code openjavaformat.native.formatter} is set: the + * property is about formatDiff, and Spotless has no native step. + */ private FormatterStep spotlessJavaFormatStep() { - if (getNativeImageSupport().isNativeImageConfigured() - && JavaVersion.current().compareTo(JavaVersion.VERSION_21) < 0) { - logger.info("Using the native-image formatter"); - return NativePalantirJavaFormatStep.create( - getConfigurations().getByName(NativeImageFormatProviderPlugin.NATIVE_CONFIGURATION_NAME)); - } - logger.info("Using the Java-based formatter {}", JavaVersion.current()); + logger.info("Using the Java-based formatter"); return PalantirJavaFormatStep.create( getConfigurations().getByName(PalantirJavaFormatProviderPlugin.CONFIGURATION_NAME)); } diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/spotless/NativePalantirJavaFormatStep.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/spotless/NativePalantirJavaFormatStep.java deleted file mode 100644 index f4414386a..000000000 --- a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/spotless/NativePalantirJavaFormatStep.java +++ /dev/null @@ -1,75 +0,0 @@ -/* - * (c) Copyright 2025 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.spotless; - -import com.diffplug.spotless.FileSignature; -import com.diffplug.spotless.FormatterFunc; -import com.diffplug.spotless.FormatterStep; -import com.diffplug.spotless.ProcessRunner; -import java.io.File; -import java.io.IOException; -import java.io.Serializable; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.function.Supplier; -import org.gradle.api.artifacts.Configuration; -import org.gradle.api.logging.Logger; -import org.gradle.api.logging.Logging; - -public final class NativePalantirJavaFormatStep { - private static final Logger logger = Logging.getLogger(NativePalantirJavaFormatStep.class); - - private NativePalantirJavaFormatStep() {} - - private static final String NAME = "open-java-format"; - - /** Creates a step which formats everything - code, import order, and unused imports. */ - public static FormatterStep create(Configuration configuration) { - return FormatterStep.createLazy(NAME, () -> new State(configuration::getSingleFile), State::createFormat); - } - - static class State implements Serializable { - private static final long serialVersionUID = 1L; - - // Kept for state serialization purposes. - // For more details, see PalantirJavaFormatStep.State#jarSignature - @SuppressWarnings({"unused", "FieldCanBeLocal"}) - private FileSignature execSignature; - - private final transient Supplier execSupplier; - - @SuppressWarnings("for-rollout:NullAway") - State(Supplier supplier) { - this.execSupplier = supplier; - } - - String format(ProcessRunner runner, String input) throws IOException, InterruptedException { - File execFile = execSupplier.get(); - logger.info("Using native-image at {}", execFile); - execSignature = FileSignature.signAsSet(execFile); - List argumentsWithPathToExe = - List.of(execSignature.getOnlyFile().getAbsolutePath(), "-"); - return runner.exec(input.getBytes(StandardCharsets.UTF_8), argumentsWithPathToExe) - .assertExitZero(StandardCharsets.UTF_8); - } - - FormatterFunc.Closeable createFormat() { - ProcessRunner runner = new ProcessRunner(); - return FormatterFunc.Closeable.of(runner, this::format); - } - } -} diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/spotless/PalantirJavaFormatStep.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/spotless/PalantirJavaFormatStep.java index 27f0a95a3..9195c1741 100644 --- a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/spotless/PalantirJavaFormatStep.java +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/spotless/PalantirJavaFormatStep.java @@ -18,10 +18,8 @@ import com.diffplug.spotless.FileSignature; import com.diffplug.spotless.FormatterFunc; import com.diffplug.spotless.FormatterStep; -import com.google.errorprone.annotations.InlineMe; import com.palantir.javaformat.gradle.FormatterServiceLoader; import com.palantir.javaformat.java.FormatterService; -import java.util.function.Supplier; import org.gradle.api.artifacts.Configuration; public final class PalantirJavaFormatStep { @@ -47,19 +45,6 @@ public static FormatterStep create(Configuration palantirJavaFormat) { PalantirJavaFormatStep::createFormat); } - /** - * Creates the same step as {@link #create(Configuration)}. - * - * @deprecated the step loads the formatter from the configuration's jars, and ignores {@code memoizedService}. - */ - @Deprecated - @InlineMe( - replacement = "PalantirJavaFormatStep.create(palantirJavaFormat)", - imports = "com.palantir.javaformat.gradle.spotless.PalantirJavaFormatStep") - public static FormatterStep create(Configuration palantirJavaFormat, Supplier memoizedService) { - return create(palantirJavaFormat); - } - private static FormatterFunc createFormat(FileSignature jars) { FormatterService formatter = FormatterServiceLoader.load(jars.files()); return formatter::formatSourceReflowStringsAndFixImports; diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/PalantirJavaFormatSpotlessPluginTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/PalantirJavaFormatSpotlessPluginTest.java index ee43932c1..d33597939 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/PalantirJavaFormatSpotlessPluginTest.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/PalantirJavaFormatSpotlessPluginTest.java @@ -51,11 +51,7 @@ final class PalantirJavaFormatSpotlessPluginTest { @TempDir private Path projectDir; - // There used to be a third case — native.formatter=true on Java 17, expecting the native-image - // formatter. SpotlessInterop only picks the native image when JavaVersion.current() < 21, and the - // generated build's daemon used to be forced onto 17 by palantir/gradle-jdks. Without that plugin - // TestKit runs the generated build on the same JVM as the test, so the case could only ever assert - // the Java-based path. Restoring it means pointing org.gradle.java.home at a JDK below 21. + // native.formatter=true too formats on the Gradle JVM: Spotless has no native step. @ParameterizedTest @CsvSource( delimiter = '|', From 7ac1ac65143db8fd6bd603d0c3f88b29888ef927 Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Sun, 8 Nov 2026 11:09:13 +0300 Subject: [PATCH 04/22] Test the Spotless step in a build of several projects MultiProjectTest builds the layout the docs recommend: the plugins declared in the root project, applied to two subprojects, the root project without java. It fails on two counts. With --parallel, a subproject's spotlessJava resolves the root project's palantirJavaFormat without the root project's lock, and Gradle refuses: "Resolution of the configuration ':palantirJavaFormat' was attempted without an exclusive lock". A root project with java and Spotless of its own fails the same way. Without java on the root project, palantirJavaFormat asks for no attributes, and Gradle cannot pick a variant of a library that has one for the JRE and one for Android, as Guava does. The formatter depends on Guava, so the default formatter fails to resolve. The test uses a library of its own with two such variants, in a repository it writes under build/, so that it runs offline. --- .../javaformat/gradle/MultiProjectTest.java | 221 ++++++++++++++++++ .../gradle/testing/GradleTestProject.java | 19 ++ 2 files changed, 240 insertions(+) create mode 100644 gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/MultiProjectTest.java diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/MultiProjectTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/MultiProjectTest.java new file mode 100644 index 000000000..421cd120b --- /dev/null +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/MultiProjectTest.java @@ -0,0 +1,221 @@ +/* + * 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.io.UncheckedIOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.jar.Attributes; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.Parameter; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * The formatter in a build of several projects, laid out as the docs recommend: the plugins declared in the root + * project, applied to the subprojects, and the formatter resolved through the root project's palantirJavaFormat. + * + *

Every test checks the files the build leaves: a subproject whose formatter went missing is not formatted, though + * its task may well succeed. + */ +@ParameterizedClass(name = "configuration cache {0}") +@ValueSource(booleans = {true, false}) +final class MultiProjectTest { + + // Forward slashes: the path goes into a Groovy string, where a Windows backslash would start an escape. + private static final String CLASSPATH_FILE = + new File("build/impl.classpath").getAbsolutePath().replace('\\', '/'); + + /** + * A Maven repository with test:variants:1.0. Not in a temporary directory: the daemon keeps the formatter's jars + * open after the build, and Windows does not delete an open file. + */ + private static final Path VARIANTS_REPO = Path.of("build/variants-repo"); + + private static final List SUBPROJECTS = List.of("a", "b"); + private static final String MAIN_JAVA = "src/main/java/Main.java"; + private static final String UNFORMATTED = "class Main {\n int x;\n}\n"; + private static final String FORMATTED = "class Main {\n int x;\n}\n"; + + /** The subprojects apply the plugins, and the formatter is the jars this build made, not a release. */ + private static final String SUBPROJECTS_AND_FORMATTER = """ + subprojects { + apply plugin: 'java' + apply plugin: 'dev.openjavaformat.java-format' + apply plugin: 'com.diffplug.spotless' + } + + // Below subprojects: applying the plugin there is what gives the root project palantirJavaFormat. + dependencies { + palantirJavaFormat files(file("%s").text.split(File.pathSeparator)) + } + """; + + @Parameter + private boolean configurationCache; + + @TempDir + private Path projectDir; + + @Test + void spotlessApply_formats_every_subproject_in_a_parallel_build() { + var project = project().writeFile("a/" + MAIN_JAVA, UNFORMATTED).writeFile("b/" + MAIN_JAVA, UNFORMATTED); + + // In parallel, a subproject's task runs without the root project's lock, which resolving the root project's + // configuration needs. + project.succeeds("spotlessApply", "--parallel"); + + assertFormatted(project, "a/" + MAIN_JAVA, "b/" + MAIN_JAVA); + } + + @Test + void spotlessApply_formats_the_root_project_and_every_subproject_when_the_root_has_java() { + // The root project's own java variants must not stand in for the formatter's. + var project = new GradleTestProject(projectDir) + .configurationCache(configurationCache) + .plugins("java", "dev.openjavaformat.java-format", "com.diffplug.spotless") + .subprojects(SUBPROJECTS.toArray(String[]::new)) + .withJavacInternalExports() + .buildGradle(SUBPROJECTS_AND_FORMATTER, CLASSPATH_FILE) + .writeFile(MAIN_JAVA, UNFORMATTED) + .writeFile("a/" + MAIN_JAVA, UNFORMATTED) + .writeFile("b/" + MAIN_JAVA, UNFORMATTED); + + project.succeeds("spotlessApply", "--parallel"); + + assertFormatted(project, MAIN_JAVA, "a/" + MAIN_JAVA, "b/" + MAIN_JAVA); + } + + @Test + void formats_with_a_formatter_dependency_that_has_variants_when_the_root_has_no_java() { + writeLibraryWithVariants(); + // idea: updateOpenJavaFormatXml runs with every build and resolves palantirJavaFormat in the root project. + var project = project() + .plugins("idea") + .buildGradle(""" + allprojects { + repositories { + maven { url = file("%s") } + } + } + dependencies { + palantirJavaFormat 'test:variants:1.0' + } + """, VARIANTS_REPO.toAbsolutePath().toString().replace('\\', '/')) + .writeFile("a/" + MAIN_JAVA, UNFORMATTED) + .writeFile("b/" + MAIN_JAVA, UNFORMATTED); + + project.succeeds("spotlessApply"); + + assertFormatted(project, "a/" + MAIN_JAVA, "b/" + MAIN_JAVA); + assertThat(project.readFile(".idea/open-java-format.xml")) + .contains("variants-1.0.jar") + .doesNotContain("variants-1.0-android.jar"); + } + + /** The layout of the docs, with no java on the root project. */ + private GradleTestProject project() { + return new GradleTestProject(projectDir) + .configurationCache(configurationCache) + .pluginsNotApplied("dev.openjavaformat.java-format", "com.diffplug.spotless") + .subprojects(SUBPROJECTS.toArray(String[]::new)) + .withJavacInternalExports() + .buildGradle(SUBPROJECTS_AND_FORMATTER, CLASSPATH_FILE); + } + + private static void assertFormatted(GradleTestProject project, String... files) { + for (var file : files) { + // Spotless writes the platform's line endings, CRLF on Windows. + assertThat(project.readFile(file)).as(file).isEqualToNormalizingNewlines(FORMATTED); + } + } + + /** + * A library with two runtime variants, for the JRE and for Android, as Guava publishes them: Gradle picks one only + * for a configuration that asks for a library of a standard JVM. The jars are empty. + */ + private static void writeLibraryWithVariants() { + var dir = VARIANTS_REPO.resolve("test/variants/1.0"); + try { + Files.createDirectories(dir); + writeEmptyJar(dir.resolve("variants-1.0.jar")); + writeEmptyJar(dir.resolve("variants-1.0-android.jar")); + write(dir.resolve("variants-1.0.pom"), """ + + + + 4.0.0 + test + variants + 1.0 + + """); + write(dir.resolve("variants-1.0.module"), """ + { + "formatVersion": "1.1", + "component": { "group": "test", "module": "variants", "version": "1.0" }, + "variants": [%s, %s] + } + """.formatted( + variant("jreRuntimeElements", "standard-jvm", "variants-1.0.jar"), + variant("androidRuntimeElements", "android", "variants-1.0-android.jar"))); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static String variant(String name, String environment, String jar) { + return """ + { + "name": "%s", + "attributes": { + "org.gradle.category": "library", + "org.gradle.dependency.bundling": "external", + "org.gradle.jvm.environment": "%s", + "org.gradle.libraryelements": "jar", + "org.gradle.usage": "java-runtime" + }, + "files": [{ "name": "%s", "url": "%s" }] + } + """.formatted(name, environment, jar, jar); + } + + /** Written once: on Windows a daemon of an earlier test may hold the jars open, and they never change. */ + private static void writeEmptyJar(Path jar) throws IOException { + var manifest = new Manifest(); + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + try (var out = new JarOutputStream(Files.newOutputStream(Files.createFile(jar)), manifest)) { + out.flush(); + } catch (FileAlreadyExistsException e) { + // An earlier test wrote it. + } + } + + private static void write(Path file, String content) throws IOException { + if (!Files.exists(file)) { + Files.writeString(file, content); + } + } +} diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/testing/GradleTestProject.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/testing/GradleTestProject.java index 43d954e24..2e68c122d 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/testing/GradleTestProject.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/testing/GradleTestProject.java @@ -60,6 +60,8 @@ public final class GradleTestProject { private final Path projectDir; private final Set plugins = new LinkedHashSet<>(); + private final Set pluginsNotApplied = new LinkedHashSet<>(); + private final Set subprojects = new LinkedHashSet<>(); private final StringBuilder buildGradle = new StringBuilder(); private final StringBuilder gradleProperties = new StringBuilder(); private boolean configurationCache = true; @@ -79,6 +81,21 @@ public GradleTestProject plugins(String... ids) { return this; } + /** + * Declares these plugins in the plugins block with {@code apply false}, as a multi-project build does: on the build's + * classpath, for the subprojects to apply. + */ + public GradleTestProject pluginsNotApplied(String... ids) { + pluginsNotApplied.addAll(Arrays.asList(ids)); + return this; + } + + /** Includes these subprojects, in directories of their names. A test writes their files with {@link #writeFile}. */ + public GradleTestProject subprojects(String... names) { + subprojects.addAll(Arrays.asList(names)); + return this; + } + /** Appends to build.gradle, below the plugins block. {@code %s} placeholders are substituted as in printf. */ public GradleTestProject buildGradle(String format, Object... args) { buildGradle.append(args.length == 0 ? format : String.format(format, args)).append('\n'); @@ -216,9 +233,11 @@ private static List pluginClasspathWithoutSpotless() { private void write() { StringBuilder script = new StringBuilder("plugins {\n"); plugins.forEach(id -> script.append(" id '").append(id).append("'\n")); + pluginsNotApplied.forEach(id -> script.append(" id '").append(id).append("' apply false\n")); script.append("}\n\nrepositories {\n mavenCentral()\n}\n\n").append(buildGradle); StringBuilder settings = new StringBuilder("rootProject.name = 'test-project'\n"); + subprojects.forEach(name -> settings.append("include '").append(name).append("'\n")); // Forward slashes: the path goes into a Groovy string, where a Windows backslash would start an escape. buildCacheDir.ifPresent(dir -> settings.append("\nbuildCache {\n local {\n directory = file('") .append(dir.toAbsolutePath().toString().replace('\\', '/')) From c97ceeaad39ce54439516be33fe03aba9b84712e Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Sun, 8 Nov 2026 11:13:47 +0300 Subject: [PATCH 05/22] Resolve the formatter in the project that formats A subproject's Spotless step resolved the root project's palantirJavaFormat itself, which a parallel build does not allow: the subproject's task runs without the root project's lock. And palantirJavaFormat asked for no attributes, so in a root project without java Gradle could not pick between Guava's variants for the JRE and for Android, and the default formatter did not resolve. The layout the docs recommend has no java on the root project. The root project now offers palantirJavaFormat as a variant of its own, openJavaFormatFormatterElements, with a capability of its own so that a root project with java of its own still hands out this variant and not runtimeElements. Every project that applies the Spotless step resolves it through openJavaFormatFormatterClasspath, a dependency on the root project that Gradle resolves in the project that asks. palantirJavaFormat and both new configurations ask for the runtime jars of a JVM library. A formatter declared in palantirJavaFormat, files as well as modules, reaches every project as before. MultiProjectTest passes, with the configuration cache and without, and a new test there shows that a build that does not format still resolves nothing. --- .../javaformat/gradle/FormatterClasspath.java | 92 +++++++++++++++++++ .../PalantirJavaFormatProviderPlugin.java | 3 + .../PalantirJavaFormatSpotlessPlugin.java | 3 +- .../javaformat/gradle/SpotlessInterop.java | 16 ++-- .../spotless/PalantirJavaFormatStep.java | 10 +- .../javaformat/gradle/MultiProjectTest.java | 12 +++ .../gradle/testing/GradleTestProject.java | 10 +- 7 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/FormatterClasspath.java diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/FormatterClasspath.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/FormatterClasspath.java new file mode 100644 index 000000000..fd304a6e5 --- /dev/null +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/FormatterClasspath.java @@ -0,0 +1,92 @@ +/* + * 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 java.util.Map; +import org.gradle.api.NamedDomainObjectProvider; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.ModuleDependency; +import org.gradle.api.attributes.AttributeContainer; +import org.gradle.api.attributes.Bundling; +import org.gradle.api.attributes.Category; +import org.gradle.api.attributes.LibraryElements; +import org.gradle.api.attributes.Usage; +import org.gradle.api.attributes.java.TargetJvmEnvironment; +import org.gradle.api.model.ObjectFactory; + +/** + * The formatter's jars for the tasks of one project, resolved in that project. + * + *

The formatter is declared once, in the root project's palantirJavaFormat. A task of a subproject cannot resolve + * that configuration itself: in a parallel build it runs without the root project's lock, and Gradle refuses. So the + * root project offers palantirJavaFormat as a variant of its own, and every project that formats resolves it through a + * dependency on the root project, which Gradle resolves in the project that asks. + */ +final class FormatterClasspath { + + /** Picks the root project's variant out of its others, such as runtimeElements when it has java too. */ + private static final String CAPABILITY = "dev.openjavaformat.internal:formatter-classpath"; + + private static final String ELEMENTS = "openJavaFormatFormatterElements"; + private static final String DEPENDENCIES = "openJavaFormatFormatter"; + private static final String CLASSPATH = "openJavaFormatFormatterClasspath"; + + private FormatterClasspath() {} + + /** Offers the root project's palantirJavaFormat to the projects that format. */ + static void offer(Project rootProject, Configuration palantirJavaFormat) { + rootProject.getConfigurations().consumable(ELEMENTS, conf -> { + conf.setDescription("Internal: the open-java-format implementation, for the projects that format"); + conf.extendsFrom(palantirJavaFormat); + javaRuntime(conf.getAttributes(), rootProject.getObjects()); + conf.getOutgoing().capability(CAPABILITY + ":1"); + }); + } + + /** The formatter's jars for the tasks of this project. */ + static NamedDomainObjectProvider of(Project project) { + var configurations = project.getConfigurations(); + if (!configurations.getNames().contains(CLASSPATH)) { + var rootProject = (ModuleDependency) project.getDependencies().project(Map.of("path", ":")); + rootProject.capabilities(capabilities -> capabilities.requireCapability(CAPABILITY)); + var dependencies = configurations.dependencyScope(DEPENDENCIES, conf -> { + conf.setDescription("Internal: the root project's open-java-format implementation"); + conf.getDependencies().add(rootProject); + }); + configurations.resolvable(CLASSPATH, conf -> { + conf.setDescription("Internal: resolves the open-java-format implementation for this project"); + conf.extendsFrom(dependencies.get()); + javaRuntime(conf.getAttributes(), project.getObjects()); + }); + } + return configurations.named(CLASSPATH); + } + + /** + * What a Java library's runtime jars are asked for with. Without them Gradle cannot pick between the variants of a + * library that has one for the JRE and one for Android, as Guava does, which the formatter depends on. + */ + static void javaRuntime(AttributeContainer attributes, ObjectFactory objects) { + attributes.attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.class, Usage.JAVA_RUNTIME)); + attributes.attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.class, Category.LIBRARY)); + attributes.attribute( + LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements.class, LibraryElements.JAR)); + attributes.attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling.class, Bundling.EXTERNAL)); + attributes.attribute( + TargetJvmEnvironment.TARGET_JVM_ENVIRONMENT_ATTRIBUTE, + objects.named(TargetJvmEnvironment.class, TargetJvmEnvironment.STANDARD_JVM)); + } +} diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/PalantirJavaFormatProviderPlugin.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/PalantirJavaFormatProviderPlugin.java index ae5ebdb29..9d251e3fa 100644 --- a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/PalantirJavaFormatProviderPlugin.java +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/PalantirJavaFormatProviderPlugin.java @@ -45,7 +45,10 @@ public void apply(Project rootProject) { "dev.openjavaformat:open-java-format:%s", JavaFormatExtension.class.getPackage().getImplementationVersion()))); }); + // Resolved here too, by the IntelliJ task, in a root project that may well not have java. + FormatterClasspath.javaRuntime(conf.getAttributes(), rootProject.getObjects()); }); + FormatterClasspath.offer(rootProject, configuration); rootProject.getExtensions().create("palantirJavaFormat", JavaFormatExtension.class, configuration); } diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/PalantirJavaFormatSpotlessPlugin.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/PalantirJavaFormatSpotlessPlugin.java index 3b183eb24..d948df907 100644 --- a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/PalantirJavaFormatSpotlessPlugin.java +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/PalantirJavaFormatSpotlessPlugin.java @@ -43,7 +43,8 @@ public void apply(Project project) { project.getProjectDir(), project.getExtensions() .getByType(JavaPluginExtension.class) - .getSourceSets()); + .getSourceSets(), + FormatterClasspath.of(project)); SpotlessExtension spotlessExtension = project.getExtensions().getByType(SpotlessExtension.class); spotlessExtension.java(spotlessInterop); diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/SpotlessInterop.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/SpotlessInterop.java index f0ea5cc58..3827f540b 100644 --- a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/SpotlessInterop.java +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/SpotlessInterop.java @@ -24,7 +24,8 @@ import java.util.regex.Pattern; import javax.inject.Inject; import org.gradle.api.Action; -import org.gradle.api.artifacts.ConfigurationContainer; +import org.gradle.api.NamedDomainObjectProvider; +import org.gradle.api.artifacts.Configuration; import org.gradle.api.file.FileCollection; import org.gradle.api.logging.Logger; import org.gradle.api.logging.Logging; @@ -52,17 +53,19 @@ public abstract class SpotlessInterop implements Action { private final File projectDir; private final SourceSetContainer sourceSets; - - @Inject - protected abstract ConfigurationContainer getConfigurations(); + private final NamedDomainObjectProvider formatterClasspath; @Inject protected abstract ObjectFactory getObjects(); @Inject - public SpotlessInterop(File projectDir, SourceSetContainer sourceSets) { + public SpotlessInterop( + File projectDir, + SourceSetContainer sourceSets, + NamedDomainObjectProvider formatterClasspath) { this.projectDir = projectDir; this.sourceSets = sourceSets; + this.formatterClasspath = formatterClasspath; } @Override @@ -90,8 +93,7 @@ private FileCollection generatedSources() { */ private FormatterStep spotlessJavaFormatStep() { logger.info("Using the Java-based formatter"); - return PalantirJavaFormatStep.create( - getConfigurations().getByName(PalantirJavaFormatProviderPlugin.CONFIGURATION_NAME)); + return PalantirJavaFormatStep.create(formatterClasspath.get()); } /** diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/spotless/PalantirJavaFormatStep.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/spotless/PalantirJavaFormatStep.java index 9195c1741..2dc12e7d5 100644 --- a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/spotless/PalantirJavaFormatStep.java +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/spotless/PalantirJavaFormatStep.java @@ -30,8 +30,12 @@ private PalantirJavaFormatStep() {} private static final String NAME = "open-java-format"; - /** Creates a step which formats everything - code, import order, and unused imports. */ - public static FormatterStep create(Configuration palantirJavaFormat) { + /** + * Creates a step which formats everything - code, import order, and unused imports - with the formatter in these + * jars. A configuration of the project the Spotless task belongs to: resolving another project's needs that + * project's lock, which a parallel build does not hold. + */ + public static FormatterStep create(Configuration formatterClasspath) { ensureImplementationNotDirectlyLoadable(); // Spotless keeps a step in two forms, and neither can hold the configuration or a loaded formatter, which do // not serialize. The configuration cache stores the jars, resolved when the entry is written at the earliest. @@ -40,7 +44,7 @@ public static FormatterStep create(Configuration palantirJavaFormat) { // formatter is loaded from the jars on first use. return FormatterStep.createLazy( NAME, - () -> FileSignature.promise(palantirJavaFormat.getFiles()), + () -> FileSignature.promise(formatterClasspath.getFiles()), FileSignature.Promised::get, PalantirJavaFormatStep::createFormat); } diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/MultiProjectTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/MultiProjectTest.java index 421cd120b..7449a7912 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/MultiProjectTest.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/MultiProjectTest.java @@ -135,6 +135,18 @@ void formats_with_a_formatter_dependency_that_has_variants_when_the_root_has_no_ .doesNotContain("variants-1.0-android.jar"); } + @Test + void nothing_is_resolved_for_a_build_that_does_not_format() { + // A formatter that cannot be resolved fails a build only once a task needs its jars. + var project = project().buildGradle(""" + dependencies { + palantirJavaFormat 'does.not:exist:1.0' + } + """); + + project.succeeds("help"); + } + /** The layout of the docs, with no java on the root project. */ private GradleTestProject project() { return new GradleTestProject(projectDir) diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/testing/GradleTestProject.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/testing/GradleTestProject.java index 2e68c122d..74987ed9f 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/testing/GradleTestProject.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/testing/GradleTestProject.java @@ -237,7 +237,15 @@ private void write() { script.append("}\n\nrepositories {\n mavenCentral()\n}\n\n").append(buildGradle); StringBuilder settings = new StringBuilder("rootProject.name = 'test-project'\n"); - subprojects.forEach(name -> settings.append("include '").append(name).append("'\n")); + for (var name : subprojects) { + settings.append("include '").append(name).append("'\n"); + // Gradle refuses a project whose directory is missing. + try { + Files.createDirectories(file(name)); + } catch (IOException e) { + throw new UncheckedIOException("Could not create " + name, e); + } + } // Forward slashes: the path goes into a Groovy string, where a Windows backslash would start an escape. buildCacheDir.ifPresent(dir -> settings.append("\nbuildCache {\n local {\n directory = file('") .append(dir.toAbsolutePath().toString().replace('\\', '/')) From 87a1f36673527c9717f5bcfa354796e1f9389187 Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Sun, 8 Nov 2026 11:14:43 +0300 Subject: [PATCH 06/22] Test formatDiff in a parallel build of several projects formatDiff fails there as the Spotless step did: with the Java-based formatter, a subproject's formatDiff resolves the root project's palantirJavaFormat as its classpath, without the root project's lock, and Gradle refuses. With the configuration cache the build fails as soon as the entry is stored. --- .../javaformat/gradle/MultiProjectTest.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/MultiProjectTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/MultiProjectTest.java index 7449a7912..1dd82160f 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/MultiProjectTest.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/MultiProjectTest.java @@ -147,6 +147,24 @@ void nothing_is_resolved_for_a_build_that_does_not_format() { project.succeeds("help"); } + @Test + void formatDiff_formats_every_subproject_in_a_parallel_build() throws IOException, InterruptedException { + var project = + project().writeFile("a/" + MAIN_JAVA, "class Main {}\n").writeFile("b/" + MAIN_JAVA, "class Main {}\n"); + 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"); + git(project, "add", "."); + git(project, "commit", "-m", "Commit"); + project.writeFile("a/" + MAIN_JAVA, UNFORMATTED).writeFile("b/" + MAIN_JAVA, UNFORMATTED); + + project.succeeds("formatDiff", "--parallel"); + + assertFormatted(project, "a/" + MAIN_JAVA, "b/" + MAIN_JAVA); + } + /** The layout of the docs, with no java on the root project. */ private GradleTestProject project() { return new GradleTestProject(projectDir) @@ -230,4 +248,16 @@ private static void write(Path file, String content) throws IOException { Files.writeString(file, content); } } + + private static void git(GradleTestProject project, String... args) throws IOException, InterruptedException { + var command = new String[args.length + 1]; + command[0] = "git"; + System.arraycopy(args, 0, command, 1, args.length); + var process = + new ProcessBuilder(command).directory(project.path().toFile()).start(); + var exitCode = process.waitFor(); + if (exitCode != 0) { + throw new RuntimeException("git " + String.join(" ", args) + " failed with exit code " + exitCode); + } + } } From 83720dbc69717a51093048fcea1c6f7bc398e13c Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Sun, 8 Nov 2026 11:16:30 +0300 Subject: [PATCH 07/22] Give formatDiff the formatter of its own project With the Java-based formatter, formatDiff's classpath was the root project's palantirJavaFormat, which a subproject's task cannot resolve in a parallel build. It now takes openJavaFormatFormatterClasspath, as the Spotless step does, resolved in the project the task belongs to. The native image is left as it was: formatDiff already runs it in parallel builds, this repository's among them. --- .../javaformat/gradle/PalantirJavaFormatPlugin.java | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/gradle-open-java-format/src/main/groovy/com/palantir/javaformat/gradle/PalantirJavaFormatPlugin.java b/gradle-open-java-format/src/main/groovy/com/palantir/javaformat/gradle/PalantirJavaFormatPlugin.java index 22dffc61c..35e955d6f 100644 --- a/gradle-open-java-format/src/main/groovy/com/palantir/javaformat/gradle/PalantirJavaFormatPlugin.java +++ b/gradle-open-java-format/src/main/groovy/com/palantir/javaformat/gradle/PalantirJavaFormatPlugin.java @@ -22,7 +22,6 @@ import org.gradle.api.DefaultTask; import org.gradle.api.Plugin; import org.gradle.api.Project; -import org.gradle.api.artifacts.Configuration; import org.gradle.api.file.ConfigurableFileCollection; import org.gradle.api.file.DirectoryProperty; import org.gradle.api.file.FileCollection; @@ -53,6 +52,7 @@ public void apply(Project project) { project.getPlugins().apply(PalantirJavaFormatSpotlessPlugin.class); project.getPlugins().withId("java", p -> { + var formatterClasspath = FormatterClasspath.of(project); // TODO(dfox): in the future we may want to offer a simple 'format' task so people don't need to use // spotless to try out our formatter @@ -61,16 +61,12 @@ public void apply(Project project) { if (getNativeImageSupport().isNativeImageConfigured()) { task.getNativeImage().fileProvider(getNativeImplConfiguration(project)); } else { - task.getFormatterClasspath().from(getImplConfiguration(project)); + task.getFormatterClasspath().from(formatterClasspath); } }); }); } - private static Provider getImplConfiguration(Project project) { - return project.getRootProject().getConfigurations().named(PalantirJavaFormatProviderPlugin.CONFIGURATION_NAME); - } - private static Provider getNativeImplConfiguration(Project project) { return (project.getRootProject() .getConfigurations() From ec66207dc1ac6717a2153af31153435175e576c5 Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Sun, 8 Nov 2026 11:49:52 +0300 Subject: [PATCH 08/22] Put the formatter's jars inside the Gradle plugin The plugin resolved the Java-based formatter from Maven Central, as dev.openjavaformat:open-java-format in the plugin's version, through the root project's palantirJavaFormat: a build needed a repository for it, and the formatter could be another version than the plugin (#20). bundleFormatter now puts the formatter's runtime classpath, the jars of open-java-format and its dependencies from the same commit, into the plugin's resources under open-java-format/formatter/, unchanged, next to jars.txt: a line per jar with its sha256 and its name, in the order of the classpath. The plugin jar grows from 64 KB to 6.3 MB; its POM and module metadata do not change, and the sources jar leaves the jars out. TestKit sees them through build/resources/main. Nothing reads them yet. BundledFormatterTest checks that the list is the classpath, and that every jar in it is in the plugin as listed. --- gradle-open-java-format/build.gradle | 25 +++++ .../gradle/BundledFormatterTest.java | 93 +++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/BundledFormatterTest.java diff --git a/gradle-open-java-format/build.gradle b/gradle-open-java-format/build.gradle index 3713d8d50..9869c8573 100644 --- a/gradle-open-java-format/build.gradle +++ b/gradle-open-java-format/build.gradle @@ -136,6 +136,31 @@ def writeImplClasspath = tasks.register("writeImplClasspath") { } } +// The Java-based formatter travels inside the plugin, so that a build needs no repository and no dependency for it, +// and runs the formatter of the plugin's own commit (#20). The jars go in unchanged, each with its own LICENSE and +// NOTICE, next to jars.txt: a line per jar, " ", in the order of the classpath. BundledFormatter +// extracts them on first use. +def bundledFormatterDir = layout.buildDirectory.dir("generated/bundledFormatter") +def bundledFormatterPath = "open-java-format/formatter" +def bundleFormatter = tasks.register("bundleFormatter", Sync) { + into(bundledFormatterDir) + into(bundledFormatterPath) { + from(implFiles) + } + doLast { + def lines = implFiles.files.collect { jar -> + "${java.security.MessageDigest.getInstance('SHA-256').digest(jar.bytes).encodeHex()} ${jar.name}" + } + bundledFormatterDir.get().file("${bundledFormatterPath}/jars.txt").asFile.text = lines.join('\n') + '\n' + } +} +sourceSets.main.resources.srcDir(bundleFormatter) + +// The sources jar takes the resources too; the formatter's jars are not the plugin's sources. +tasks.named('sourcesJar', Jar) { + exclude("${bundledFormatterPath}/**") +} + def writeNativeImagePath = tasks.register("writeNativeImagePath") { inputs.files(tasks.named("copyNativeImage")) outputs.file(nativeImagePathFile) diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/BundledFormatterTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/BundledFormatterTest.java new file mode 100644 index 000000000..3747c6a7f --- /dev/null +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/BundledFormatterTest.java @@ -0,0 +1,93 @@ +/* + * 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 java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import org.junit.jupiter.api.Test; + +final class BundledFormatterTest { + + /** ./gradlew writeImplClasspath generates this file: the formatter's runtime classpath, as bundleFormatter takes it. */ + private static final Path CLASSPATH_FILE = Path.of("build/impl.classpath"); + + private static final String BUNDLE = "open-java-format/formatter/"; + + @Test + void the_plugin_carries_the_jars_of_the_formatter_runtime_classpath() throws IOException { + var classpath = Arrays.stream(Files.readString(CLASSPATH_FILE).trim().split(File.pathSeparator)) + .map(Path::of) + .toList(); + + var expected = classpath.stream() + .map(jar -> sha256(read(jar)) + " " + jar.getFileName()) + .toList(); + + assertThat(bundledList()).containsExactlyElementsOf(expected); + } + + @Test + void every_jar_in_the_list_is_in_the_plugin_as_listed() { + for (var line : bundledList()) { + var hash = line.substring(0, line.indexOf(' ')); + var name = line.substring(line.indexOf(' ') + 1); + + assertThat(sha256(resource(BUNDLE + name))).as(name).isEqualTo(hash); + } + } + + private static List bundledList() { + return new String(resource(BUNDLE + "jars.txt"), StandardCharsets.UTF_8) + .lines() + .toList(); + } + + private static byte[] resource(String name) { + try (InputStream in = BundledFormatterTest.class.getClassLoader().getResourceAsStream(name)) { + return Objects.requireNonNull(in, name + " is not on the classpath").readAllBytes(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static byte[] read(Path file) { + try { + return Files.readAllBytes(file); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static String sha256(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} From 7fd872714a37a6379ddc3a184fed9aecadcbf296 Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Sun, 8 Nov 2026 11:52:34 +0300 Subject: [PATCH 09/22] Extract the bundled formatter on first use A class loader cannot load a jar inside a jar, and the IDE plugin needs files it can still find after a clean. BundledFormatter.jars extracts the plugin's formatter jars into /open-java-format-caches//, where the hash is of jars.txt, which holds every jar's sha256: another formatter gets another directory, and a directory, once there, never changes. A daemon that holds the jars open, as Windows does not let a file be replaced, is in nobody's way. Builds and daemons may extract at the same time. Each copies the jars into a directory of its own next to the target, checking every jar against its sha256, and renames it into place, which fails when the target is already there; the build that loses deletes its copy and uses the one that won. There is no file lock: FileChannel.lock fails in a daemon whose other build holds the same file through another class loader of the plugin. A directory that was deleted, or lost a jar, is extracted again. A jar that does not match the list fails with its name and leaves no directory behind. Nothing calls this yet. --- .../javaformat/gradle/BundledFormatter.java | 192 ++++++++++++++++++ .../gradle/BundledFormatterTest.java | 138 ++++++++++++- 2 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/BundledFormatter.java diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/BundledFormatter.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/BundledFormatter.java new file mode 100644 index 000000000..e9e0c6d95 --- /dev/null +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/BundledFormatter.java @@ -0,0 +1,192 @@ +/* + * 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 java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.stream.Stream; + +/** + * The Java-based formatter that comes inside the plugin: the jars of its runtime classpath, which the build puts under + * {@code open-java-format/formatter/} with a list of them, {@code jars.txt}. + * + *

A class loader cannot load a jar inside a jar, and the IDE plugin needs files it can find after a clean, so the + * jars are extracted on first use into {@code /open-java-format-caches//}. The hash is of the + * list, which holds every jar's sha256: another formatter is another directory, and a directory, once there, never + * changes. That is also why a daemon holding the jars open, as Windows does not let a file be replaced, gets in nobody's + * way. + * + *

Builds and daemons may extract at the same time. Each extracts into a directory of its own next to the target and + * renames it into place, which fails when the target is already there; the build that loses deletes its copy and uses + * the one that won. + */ +public final class BundledFormatter { + + private static final String BUNDLE = "open-java-format/formatter/"; + private static final String LIST = BUNDLE + "jars.txt"; + static final String CACHES = "open-java-format-caches"; + + private BundledFormatter() {} + + /** Names the directory the jars go in, and changes with every jar: the first 16 hex digits of the list's sha256. */ + public static String hash() { + return hash(BundledFormatter.class.getClassLoader()); + } + + /** The formatter's jars, in the order of its classpath, extracted under this Gradle user home if not there yet. */ + public static List jars(File gradleUserHome) { + return jars(gradleUserHome.toPath(), BundledFormatter.class.getClassLoader()); + } + + static String hash(ClassLoader plugin) { + return sha256(resource(plugin, LIST)).substring(0, 16); + } + + static List jars(Path gradleUserHome, ClassLoader plugin) { + var list = new String(resource(plugin, LIST), StandardCharsets.UTF_8) + .lines() + .map(Jar::parse) + .toList(); + var caches = gradleUserHome.resolve(CACHES); + var target = caches.resolve(hash(plugin)); + if (!isComplete(target, list)) { + extract(plugin, list, caches, target); + } + return list.stream().map(jar -> target.resolve(jar.name()).toFile()).toList(); + } + + private static boolean isComplete(Path dir, List list) { + return list.stream().allMatch(jar -> Files.isRegularFile(dir.resolve(jar.name()))); + } + + private static void extract(ClassLoader plugin, List list, Path caches, Path target) { + try { + Files.createDirectories(caches); + var copy = Files.createTempDirectory(caches, target.getFileName() + "-"); + try { + for (var jar : list) { + copy(plugin, jar, copy.resolve(jar.name())); + } + if (Files.exists(target) && !isComplete(target, list)) { + // Someone deleted a jar out of it. Whoever still runs from it keeps the files it has open. + delete(target); + } + moveIntoPlace(copy, target); + } finally { + delete(copy); + } + } catch (IOException e) { + throw new UncheckedIOException("Could not extract the formatter's jars into " + target, e); + } + } + + private static void copy(ClassLoader plugin, Jar jar, Path file) throws IOException { + var digest = sha256Digest(); + try (InputStream in = new DigestInputStream(open(plugin, BUNDLE + jar.name()), digest)) { + Files.copy(in, file); + } + var actual = HexFormat.of().formatHex(digest.digest()); + if (!actual.equals(jar.sha256())) { + throw new IllegalStateException(String.format( + "%s in the open-java-format Gradle plugin has sha256 %s, but %s lists %s: the plugin's jar is" + + " damaged", + jar.name(), actual, LIST, jar.sha256())); + } + } + + /** Renames the copy into place, unless another build got there first: then the copy is left to be deleted. */ + private static void moveIntoPlace(Path copy, Path target) throws IOException { + try { + Files.move(copy, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + // Both are in the same directory, so the move is a rename all the same. + moveUnlessThere(copy, target); + } catch (IOException e) { + if (!Files.isDirectory(target)) { + throw e; + } + } + } + + private static void moveUnlessThere(Path copy, Path target) throws IOException { + try { + Files.move(copy, target); + } catch (IOException e) { + if (!Files.isDirectory(target)) { + throw e; + } + } + } + + private static void delete(Path dir) throws IOException { + if (!Files.exists(dir)) { + return; + } + try (Stream paths = Files.walk(dir)) { + for (var path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(path); + } + } + } + + private static byte[] resource(ClassLoader plugin, String name) { + try (InputStream in = open(plugin, name)) { + return in.readAllBytes(); + } catch (IOException e) { + throw new UncheckedIOException("Could not read " + name + " from the open-java-format Gradle plugin", e); + } + } + + private static InputStream open(ClassLoader plugin, String name) throws IOException { + var in = plugin.getResourceAsStream(name); + if (in == null) { + throw new IOException(name + " is not in the open-java-format Gradle plugin"); + } + return in; + } + + private static String sha256(byte[] bytes) { + return HexFormat.of().formatHex(sha256Digest().digest(bytes)); + } + + private static MessageDigest sha256Digest() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Every JVM has SHA-256", e); + } + } + + /** A line of jars.txt: the jar's sha256 and its name. */ + private record Jar(String sha256, String name) { + static Jar parse(String line) { + var space = line.indexOf(' '); + return new Jar(line.substring(0, space), line.substring(space + 1)); + } + } +} diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/BundledFormatterTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/BundledFormatterTest.java index 3747c6a7f..baa6165c2 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/BundledFormatterTest.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/BundledFormatterTest.java @@ -15,29 +15,46 @@ package com.palantir.javaformat.gradle; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.UncheckedIOException; +import java.net.URL; +import java.net.URLClassLoader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.FileTime; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Comparator; import java.util.HexFormat; import java.util.List; import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.stream.Stream; +import org.junit.jupiter.api.RepeatedTest; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; final class BundledFormatterTest { - /** ./gradlew writeImplClasspath generates this file: the formatter's runtime classpath, as bundleFormatter takes it. */ + /** + * ./gradlew writeImplClasspath generates this file: the formatter's runtime classpath, as bundleFormatter takes it. + */ private static final Path CLASSPATH_FILE = Path.of("build/impl.classpath"); private static final String BUNDLE = "open-java-format/formatter/"; + @TempDir + private Path gradleUserHome; + @Test void the_plugin_carries_the_jars_of_the_formatter_runtime_classpath() throws IOException { var classpath = Arrays.stream(Files.readString(CLASSPATH_FILE).trim().split(File.pathSeparator)) @@ -61,12 +78,131 @@ void every_jar_in_the_list_is_in_the_plugin_as_listed() { } } + @Test + void the_jars_are_extracted_into_a_directory_named_by_the_hash_once() throws IOException { + var jars = BundledFormatter.jars(gradleUserHome.toFile()); + + var dir = gradleUserHome.resolve("open-java-format-caches").resolve(BundledFormatter.hash()); + assertThat(BundledFormatter.hash()).matches("[0-9a-f]{16}"); + assertThat(jars).extracting(File::getName).containsExactlyElementsOf(bundledNames()); + assertThat(jars).allSatisfy(jar -> assertThat(jar.toPath().getParent()).isEqualTo(dir)); + for (var line : bundledList()) { + var name = line.substring(line.indexOf(' ') + 1); + assertThat(sha256(read(dir.resolve(name)))).as(name).isEqualTo(line.substring(0, line.indexOf(' '))); + } + + // A second build finds them there and leaves them as they are: a daemon may have them open. + var written = lastModified(jars); + assertThat(BundledFormatter.jars(gradleUserHome.toFile())).isEqualTo(jars); + assertThat(lastModified(jars)).isEqualTo(written); + } + + // Repeated, so that the builds do race to rename their copies into place. + @RepeatedTest(5) + void builds_that_extract_at_the_same_time_get_the_same_jars() throws Exception { + var executor = Executors.newFixedThreadPool(4); + try { + var start = new CountDownLatch(1); + var builds = Stream.generate(() -> CompletableFuture.supplyAsync( + () -> { + await(start); + return BundledFormatter.jars(gradleUserHome.toFile()); + }, + executor)) + .limit(4) + .toList(); + start.countDown(); + + var results = builds.stream().map(CompletableFuture::join).toList(); + + assertThat(results).allSatisfy(jars -> assertThat(jars).isEqualTo(results.get(0))); + assertThat(results.get(0)).allSatisfy(jar -> assertThat(jar).isFile()); + // The copies of the builds that lost are gone. + assertThat(list(gradleUserHome.resolve("open-java-format-caches"))) + .containsExactly(BundledFormatter.hash()); + } finally { + executor.shutdownNow(); + } + } + + @Test + void a_deleted_directory_is_extracted_again() throws IOException { + var jars = BundledFormatter.jars(gradleUserHome.toFile()); + delete(gradleUserHome.resolve("open-java-format-caches")); + + assertThat(BundledFormatter.jars(gradleUserHome.toFile())).isEqualTo(jars); + assertThat(jars).allSatisfy(jar -> assertThat(jar).isFile()); + } + + @Test + void a_directory_that_lost_a_jar_is_extracted_again() throws IOException { + var jars = BundledFormatter.jars(gradleUserHome.toFile()); + Files.delete(jars.get(0).toPath()); + + assertThat(BundledFormatter.jars(gradleUserHome.toFile())).isEqualTo(jars); + assertThat(jars).allSatisfy(jar -> assertThat(jar).isFile()); + } + + @Test + void a_jar_that_does_not_match_the_list_fails_and_leaves_nothing_behind(@TempDir Path plugin) throws IOException { + var bundle = Files.createDirectories(plugin.resolve(BUNDLE)); + Files.writeString(bundle.resolve("formatter.jar"), "not what the list says"); + Files.writeString( + bundle.resolve("jars.txt"), + sha256("what the list says".getBytes(StandardCharsets.UTF_8)) + " formatter.jar\n"); + + try (var loader = new URLClassLoader(new URL[] {plugin.toUri().toURL()}, null)) { + assertThatThrownBy(() -> BundledFormatter.jars(gradleUserHome, loader)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("formatter.jar") + .hasMessageContaining("damaged"); + } + assertThat(list(gradleUserHome.resolve("open-java-format-caches"))).isEmpty(); + } + private static List bundledList() { return new String(resource(BUNDLE + "jars.txt"), StandardCharsets.UTF_8) .lines() .toList(); } + private static List bundledNames() { + return bundledList().stream() + .map(line -> line.substring(line.indexOf(' ') + 1)) + .toList(); + } + + private static List lastModified(List jars) throws IOException { + var times = new ArrayList(); + for (var jar : jars) { + times.add(Files.getLastModifiedTime(jar.toPath())); + } + return times; + } + + private static List list(Path dir) throws IOException { + try (Stream paths = Files.list(dir)) { + return paths.map(path -> path.getFileName().toString()).toList(); + } + } + + private static void delete(Path dir) throws IOException { + try (Stream paths = Files.walk(dir)) { + for (var path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.delete(path); + } + } + } + + private static void await(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + private static byte[] resource(String name) { try (InputStream in = BundledFormatterTest.class.getClassLoader().getResourceAsStream(name)) { return Objects.requireNonNull(in, name + " is not on the classpath").readAllBytes(); From 70429fee0ea393319fe7269ec25d3f6140d02fab Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Sun, 8 Nov 2026 11:54:07 +0300 Subject: [PATCH 10/22] Test the plugin in a build without repositories WithoutRepositoriesTest applies the plugin to a build that declares no repository and no formatter, alone and as the docs lay out several projects, and runs spotlessApply and formatDiff, in parallel where there are subprojects. Every test fails: palantirJavaFormat depends on dev.openjavaformat:open-java-format by default, and "no repositories are defined" to resolve it. Under TestKit its version is even null, as the plugin's classes are not in a jar with a manifest; the other tests put the formatter in palantirJavaFormat themselves. GradleTestProject can leave out the repository it otherwise declares. --- .../gradle/WithoutRepositoriesTest.java | 147 ++++++++++++++++++ .../gradle/testing/GradleTestProject.java | 13 +- 2 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/WithoutRepositoriesTest.java diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/WithoutRepositoriesTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/WithoutRepositoriesTest.java new file mode 100644 index 000000000..f96828839 --- /dev/null +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/WithoutRepositoriesTest.java @@ -0,0 +1,147 @@ +/* + * 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.IOException; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.Parameter; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Where the plugin is applied, it formats: with no repository in the build and nothing declared, the formatter is the + * one inside the plugin (#20). + * + *

Every test checks the files the build leaves, as a task that finds no formatter may still succeed. + */ +@ParameterizedClass(name = "configuration cache {0}") +@ValueSource(booleans = {true, false}) +final class WithoutRepositoriesTest { + + private static final String MAIN_JAVA = "src/main/java/Main.java"; + private static final String UNFORMATTED = "class Main {\n int x;\n}\n"; + private static final String FORMATTED = "class Main {\n int x;\n}\n"; + + /** The layout of the docs, minus the repositories: the plugins declared in the root project, applied below. */ + private static final String SUBPROJECTS = """ + subprojects { + apply plugin: 'java' + apply plugin: 'dev.openjavaformat.java-format' + apply plugin: 'com.diffplug.spotless' + } + """; + + @Parameter + private boolean configurationCache; + + @TempDir + private Path projectDir; + + @Test + void spotlessApply_formats_with_the_formatter_inside_the_plugin() { + var project = project() + .plugins("java", "dev.openjavaformat.java-format", "com.diffplug.spotless") + .writeFile(MAIN_JAVA, UNFORMATTED); + + project.succeeds("spotlessApply"); + + assertFormatted(project, MAIN_JAVA); + } + + @Test + void formatDiff_formats_with_the_formatter_inside_the_plugin() throws IOException, InterruptedException { + var project = project().plugins("java", "dev.openjavaformat.java-format"); + commitThenChange(project, MAIN_JAVA); + + project.succeeds("formatDiff"); + + assertFormatted(project, MAIN_JAVA); + } + + @Test + void spotlessApply_formats_every_subproject_in_a_parallel_build() { + var project = subprojects().writeFile("a/" + MAIN_JAVA, UNFORMATTED).writeFile("b/" + MAIN_JAVA, UNFORMATTED); + + project.succeeds("spotlessApply", "--parallel"); + + assertFormatted(project, "a/" + MAIN_JAVA, "b/" + MAIN_JAVA); + } + + @Test + void formatDiff_formats_every_subproject_in_a_parallel_build() throws IOException, InterruptedException { + var project = subprojects(); + commitThenChange(project, "a/" + MAIN_JAVA, "b/" + MAIN_JAVA); + + project.succeeds("formatDiff", "--parallel"); + + assertFormatted(project, "a/" + MAIN_JAVA, "b/" + MAIN_JAVA); + } + + private GradleTestProject project() { + return new GradleTestProject(projectDir) + .configurationCache(configurationCache) + .withoutRepositories() + .withJavacInternalExports(); + } + + private GradleTestProject subprojects() { + return project() + .pluginsNotApplied("dev.openjavaformat.java-format", "com.diffplug.spotless") + .subprojects("a", "b") + .buildGradle(SUBPROJECTS); + } + + /** Commits the files in a formatted form, then writes them unformatted: formatDiff formats what git shows changed. */ + private static void commitThenChange(GradleTestProject project, String... files) + throws IOException, InterruptedException { + for (var file : files) { + project.writeFile(file, "class Main {}\n"); + } + 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"); + git(project, "add", "."); + git(project, "commit", "-m", "Commit"); + for (var file : files) { + project.writeFile(file, UNFORMATTED); + } + } + + private static void assertFormatted(GradleTestProject project, String... files) { + for (var file : files) { + // Spotless writes the platform's line endings, CRLF on Windows. + assertThat(project.readFile(file)).as(file).isEqualToNormalizingNewlines(FORMATTED); + } + } + + private static void git(GradleTestProject project, String... args) throws IOException, InterruptedException { + var command = new String[args.length + 1]; + command[0] = "git"; + System.arraycopy(args, 0, command, 1, args.length); + var process = + new ProcessBuilder(command).directory(project.path().toFile()).start(); + var exitCode = process.waitFor(); + if (exitCode != 0) { + throw new RuntimeException("git " + String.join(" ", args) + " failed with exit code " + exitCode); + } + } +} diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/testing/GradleTestProject.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/testing/GradleTestProject.java index 74987ed9f..a9eabab06 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/testing/GradleTestProject.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/testing/GradleTestProject.java @@ -66,6 +66,7 @@ public final class GradleTestProject { private final StringBuilder gradleProperties = new StringBuilder(); private boolean configurationCache = true; private boolean spotlessOnClasspath = true; + private boolean repositories = true; private Optional buildCacheDir = Optional.empty(); public GradleTestProject(Path projectDir) { @@ -139,6 +140,12 @@ public GradleTestProject withBuildCache(Path directory) { return this; } + /** Leaves out the root project's {@code repositories { mavenCentral() } }: the build declares no repository at all. */ + public GradleTestProject withoutRepositories() { + this.repositories = false; + return this; + } + /** * Leaves the Spotless jars out of the classpath injected next to the plugin under test. build.gradle puts them there * so that generated projects can apply Spotless, which gives every other test Spotless classes whether it applies @@ -234,7 +241,11 @@ private void write() { StringBuilder script = new StringBuilder("plugins {\n"); plugins.forEach(id -> script.append(" id '").append(id).append("'\n")); pluginsNotApplied.forEach(id -> script.append(" id '").append(id).append("' apply false\n")); - script.append("}\n\nrepositories {\n mavenCentral()\n}\n\n").append(buildGradle); + script.append("}\n\n"); + if (repositories) { + script.append("repositories {\n mavenCentral()\n}\n\n"); + } + script.append(buildGradle); StringBuilder settings = new StringBuilder("rootProject.name = 'test-project'\n"); for (var name : subprojects) { From d70aab2c537df6c1133726cb1a435abab7ef7097 Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Sun, 8 Nov 2026 11:56:53 +0300 Subject: [PATCH 11/22] Format with the formatter inside the plugin by default palantirJavaFormat no longer depends on dev.openjavaformat:open-java- format by default: it is empty unless a build declares a formatter there, and BundledFormatter.jarsUnlessOverridden then gives the jars inside the plugin, extracted on first use. The Spotless step, formatDiff and the palantirJavaFormat extension pick their jars that way, so a build formats where it applies the plugin, with no repository and nothing declared. An empty override resolves to nothing, which needs no repository either. The Spotless step keeps what the build declared and the Gradle user home, and signs the jars it formats with when Gradle fingerprints the task: by name, size and content, as before, so the build cache key does not depend on the directory they were extracted into. A build that reuses its configuration cache entry after that directory was deleted extracts the jars again. WithoutRepositoriesTest passes. The provider plugin's description now says what palantirJavaFormat is for. --- gradle-open-java-format/build.gradle | 2 +- .../gradle/PalantirJavaFormatPlugin.java | 14 +++++- .../javaformat/gradle/BundledFormatter.java | 9 ++++ .../javaformat/gradle/FormatterClasspath.java | 6 ++- .../gradle/JavaFormatExtension.java | 7 ++- .../PalantirJavaFormatProviderPlugin.java | 18 +++---- .../PalantirJavaFormatSpotlessPlugin.java | 3 +- .../javaformat/gradle/SpotlessInterop.java | 7 ++- .../spotless/PalantirJavaFormatStep.java | 48 +++++++++++++++---- 9 files changed, 85 insertions(+), 29 deletions(-) diff --git a/gradle-open-java-format/build.gradle b/gradle-open-java-format/build.gradle index 9869c8573..0b7f683d8 100644 --- a/gradle-open-java-format/build.gradle +++ b/gradle-open-java-format/build.gradle @@ -70,7 +70,7 @@ gradlePlugin { openJavaFormatProvider { id = 'dev.openjavaformat.java-format-provider' implementationClass = 'com.palantir.javaformat.gradle.PalantirJavaFormatProviderPlugin' - description = 'Exposes a configuration containing the open-java-format jars' + description = 'Declares palantirJavaFormat, which overrides the formatter that comes inside the open-java-format plugins' displayName = 'Open Java Format Provider' tags.set(['java', 'style']) } diff --git a/gradle-open-java-format/src/main/groovy/com/palantir/javaformat/gradle/PalantirJavaFormatPlugin.java b/gradle-open-java-format/src/main/groovy/com/palantir/javaformat/gradle/PalantirJavaFormatPlugin.java index 35e955d6f..e84a300fc 100644 --- a/gradle-open-java-format/src/main/groovy/com/palantir/javaformat/gradle/PalantirJavaFormatPlugin.java +++ b/gradle-open-java-format/src/main/groovy/com/palantir/javaformat/gradle/PalantirJavaFormatPlugin.java @@ -62,6 +62,7 @@ public void apply(Project project) { task.getNativeImage().fileProvider(getNativeImplConfiguration(project)); } else { task.getFormatterClasspath().from(formatterClasspath); + task.getGradleUserHome().set(project.getGradle().getGradleUserHomeDir()); } }); }); @@ -85,10 +86,17 @@ public abstract static class FormatDiffTask extends DefaultTask { @PathSensitive(PathSensitivity.NAME_ONLY) abstract RegularFileProperty getNativeImage(); - /** The jars of the Java-based formatter, which runs when the native image is not configured. */ + /** + * The jars a build declared for the Java-based formatter, which runs when the native image is not configured. + * Most builds declare none, and the task then runs the one inside the plugin. + */ @Classpath abstract ConfigurableFileCollection getFormatterClasspath(); + /** Where the formatter inside the plugin is extracted to. */ + @Internal + abstract DirectoryProperty getGradleUserHome(); + /** * The project whose changed lines are formatted. The task reads it here rather than from {@code getProject()}, * which a task must not call when it runs with the configuration cache. @@ -114,7 +122,9 @@ public final void formatDiff() throws IOException, InterruptedException { log.info("Using the Java-based formatter"); FormatDiff.formatDiff( projectDir, - FormatterServiceLoader.load(getFormatterClasspath().getFiles())); + FormatterServiceLoader.load(BundledFormatter.jarsUnlessOverridden( + getFormatterClasspath().getFiles(), + getGradleUserHome().get().getAsFile()))); } } } diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/BundledFormatter.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/BundledFormatter.java index e9e0c6d95..4a323efa4 100644 --- a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/BundledFormatter.java +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/BundledFormatter.java @@ -26,6 +26,7 @@ import java.security.DigestInputStream; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.Collection; import java.util.Comparator; import java.util.HexFormat; import java.util.List; @@ -63,6 +64,14 @@ public static List jars(File gradleUserHome) { return jars(gradleUserHome.toPath(), BundledFormatter.class.getClassLoader()); } + /** + * The jars a build declared in palantirJavaFormat, which override the formatter inside the plugin; or, when it + * declared none, as most builds do, the plugin's own. + */ + public static List jarsUnlessOverridden(Collection override, File gradleUserHome) { + return override.isEmpty() ? jars(gradleUserHome) : List.copyOf(override); + } + static String hash(ClassLoader plugin) { return sha256(resource(plugin, LIST)).substring(0, 16); } diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/FormatterClasspath.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/FormatterClasspath.java index fd304a6e5..a935e63c6 100644 --- a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/FormatterClasspath.java +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/FormatterClasspath.java @@ -28,9 +28,11 @@ import org.gradle.api.model.ObjectFactory; /** - * The formatter's jars for the tasks of one project, resolved in that project. + * The formatter a build declared in the root project's palantirJavaFormat, for the tasks of one project, resolved in + * that project. Declared, it overrides the formatter inside the plugin; most builds declare none, and this resolves to + * nothing, which needs no repository. * - *

The formatter is declared once, in the root project's palantirJavaFormat. A task of a subproject cannot resolve + *

The override is declared once, in the root project's palantirJavaFormat. A task of a subproject cannot resolve * that configuration itself: in a parallel build it runs without the root project's lock, and Gradle refuses. So the * root project offers palantirJavaFormat as a variant of its own, and every project that formats resolves it through a * dependency on the root project, which Gradle resolves in the project that asks. 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 f8b8a4795..084056b50 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 @@ -18,14 +18,17 @@ import com.google.common.base.Suppliers; import com.palantir.javaformat.java.FormatterService; +import java.io.File; import java.util.function.Supplier; import org.gradle.api.artifacts.Configuration; public class JavaFormatExtension { private final Supplier memoizedService; - public JavaFormatExtension(Configuration configuration) { - this.memoizedService = Suppliers.memoize(() -> FormatterServiceLoader.load(configuration.getFiles())); + /** The formatter in {@code configuration} if the build declared one there, else the one inside the plugin. */ + public JavaFormatExtension(Configuration configuration, File gradleUserHome) { + this.memoizedService = Suppliers.memoize(() -> FormatterServiceLoader.load( + BundledFormatter.jarsUnlessOverridden(configuration.getFiles(), gradleUserHome))); } public FormatterService serviceLoad() { diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/PalantirJavaFormatProviderPlugin.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/PalantirJavaFormatProviderPlugin.java index 9d251e3fa..39d219a21 100644 --- a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/PalantirJavaFormatProviderPlugin.java +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/PalantirJavaFormatProviderPlugin.java @@ -36,20 +36,20 @@ public void apply(Project rootProject) { @SuppressWarnings("for-rollout:ConfigurationAvoidanceRegistration") Configuration configuration = rootProject.getConfigurations().create(CONFIGURATION_NAME, conf -> { - conf.setDescription("Internal configuration for resolving the open-java-format implementation"); + // Empty unless the build declares a formatter: the plugin carries its own, and needs no repository. + conf.setDescription("Overrides the open-java-format implementation that comes inside the plugin"); conf.setCanBeConsumed(false); - conf.defaultDependencies(deps -> { - deps.add(rootProject - .getDependencies() - .create(String.format( - "dev.openjavaformat:open-java-format:%s", - JavaFormatExtension.class.getPackage().getImplementationVersion()))); - }); // Resolved here too, by the IntelliJ task, in a root project that may well not have java. FormatterClasspath.javaRuntime(conf.getAttributes(), rootProject.getObjects()); }); FormatterClasspath.offer(rootProject, configuration); - rootProject.getExtensions().create("palantirJavaFormat", JavaFormatExtension.class, configuration); + rootProject + .getExtensions() + .create( + "palantirJavaFormat", + JavaFormatExtension.class, + configuration, + rootProject.getGradle().getGradleUserHomeDir()); } } diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/PalantirJavaFormatSpotlessPlugin.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/PalantirJavaFormatSpotlessPlugin.java index d948df907..efae9c2fc 100644 --- a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/PalantirJavaFormatSpotlessPlugin.java +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/PalantirJavaFormatSpotlessPlugin.java @@ -44,7 +44,8 @@ public void apply(Project project) { project.getExtensions() .getByType(JavaPluginExtension.class) .getSourceSets(), - FormatterClasspath.of(project)); + FormatterClasspath.of(project), + project.getGradle().getGradleUserHomeDir()); SpotlessExtension spotlessExtension = project.getExtensions().getByType(SpotlessExtension.class); spotlessExtension.java(spotlessInterop); diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/SpotlessInterop.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/SpotlessInterop.java index 3827f540b..7aa4f3221 100644 --- a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/SpotlessInterop.java +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/SpotlessInterop.java @@ -54,6 +54,7 @@ public abstract class SpotlessInterop implements Action { private final File projectDir; private final SourceSetContainer sourceSets; private final NamedDomainObjectProvider formatterClasspath; + private final File gradleUserHome; @Inject protected abstract ObjectFactory getObjects(); @@ -62,10 +63,12 @@ public abstract class SpotlessInterop implements Action { public SpotlessInterop( File projectDir, SourceSetContainer sourceSets, - NamedDomainObjectProvider formatterClasspath) { + NamedDomainObjectProvider formatterClasspath, + File gradleUserHome) { this.projectDir = projectDir; this.sourceSets = sourceSets; this.formatterClasspath = formatterClasspath; + this.gradleUserHome = gradleUserHome; } @Override @@ -93,7 +96,7 @@ private FileCollection generatedSources() { */ private FormatterStep spotlessJavaFormatStep() { logger.info("Using the Java-based formatter"); - return PalantirJavaFormatStep.create(formatterClasspath.get()); + return PalantirJavaFormatStep.create(formatterClasspath.get(), gradleUserHome); } /** diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/spotless/PalantirJavaFormatStep.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/spotless/PalantirJavaFormatStep.java index 2dc12e7d5..5320070ae 100644 --- a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/spotless/PalantirJavaFormatStep.java +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/spotless/PalantirJavaFormatStep.java @@ -18,8 +18,13 @@ import com.diffplug.spotless.FileSignature; import com.diffplug.spotless.FormatterFunc; import com.diffplug.spotless.FormatterStep; +import com.palantir.javaformat.gradle.BundledFormatter; import com.palantir.javaformat.gradle.FormatterServiceLoader; import com.palantir.javaformat.java.FormatterService; +import java.io.File; +import java.io.IOException; +import java.io.Serializable; +import java.util.List; import org.gradle.api.artifacts.Configuration; public final class PalantirJavaFormatStep { @@ -31,24 +36,47 @@ private PalantirJavaFormatStep() {} private static final String NAME = "open-java-format"; /** - * Creates a step which formats everything - code, import order, and unused imports - with the formatter in these - * jars. A configuration of the project the Spotless task belongs to: resolving another project's needs that - * project's lock, which a parallel build does not hold. + * Creates a step which formats everything - code, import order, and unused imports - with the formatter inside the + * plugin, or with the jars in {@code formatterClasspath} if the build declared any. A configuration of the project + * the Spotless task belongs to: resolving another project's needs that project's lock, which a parallel build does + * not hold. The plugin's own formatter is extracted under {@code gradleUserHome}. */ - public static FormatterStep create(Configuration formatterClasspath) { + public static FormatterStep create(Configuration formatterClasspath, File gradleUserHome) { ensureImplementationNotDirectlyLoadable(); // Spotless keeps a step in two forms, and neither can hold the configuration or a loaded formatter, which do - // not serialize. The configuration cache stores the jars, resolved when the entry is written at the earliest. - // The up-to-date check compares their signature, so that another formatter version makes the task run again; - // Spotless takes it when the task runs, or with the configuration cache when the entry is written. The - // formatter is loaded from the jars on first use. + // not serialize. The configuration cache stores the declared jars, resolved when the entry is written at the + // earliest. The up-to-date check compares the signature of the jars the step formats with, so that another + // formatter makes the task run again; Spotless takes it when the task runs. The formatter is loaded from the + // jars on first use. return FormatterStep.createLazy( NAME, - () -> FileSignature.promise(formatterClasspath.getFiles()), - FileSignature.Promised::get, + () -> new Jars(List.copyOf(formatterClasspath.getFiles()), gradleUserHome), + Jars::sign, PalantirJavaFormatStep::createFormat); } + /** + * The jars as the configuration cache stores them: those the build declared, or none, for the formatter inside the + * plugin. That one's jars are found, or extracted again, when the step is signed: the directory they were extracted + * into may be gone by the time a build reuses the entry. + */ + static final class Jars implements Serializable { + private static final long serialVersionUID = 1L; + + private final List override; + private final File gradleUserHome; + + Jars(List override, File gradleUserHome) { + this.override = override; + this.gradleUserHome = gradleUserHome; + } + + /** By name, size and content, not path: the build cache key holds for another directory and machine. */ + FileSignature sign() throws IOException { + return FileSignature.signAsList(BundledFormatter.jarsUnlessOverridden(override, gradleUserHome)); + } + } + private static FormatterFunc createFormat(FileSignature jars) { FormatterService formatter = FormatterServiceLoader.load(jars.files()); return formatter::formatSourceReflowStringsAndFixImports; From 7a0a5e65c9611df9ae32d5c1b78d3b2e2f1d3d4d Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Sun, 8 Nov 2026 12:01:44 +0300 Subject: [PATCH 12/22] Test the configuration cache after the extracted formatter is gone With the formatter inside the plugin, the Spotless step signs the extracted jars, and Spotless stores that signature in the configuration cache entry: the jars are extracted, and checked for, while the entry is stored. Gradle takes those checks for inputs, so a build after the directory is deleted, or after the first extraction, cannot reuse the entry and stores another; on a new machine or a new plugin version that is one more configuration of every project. spotlessApply_reuses_the_entry_after_the_extracted_formatter_is_deleted fails on that, and is left out on Windows, which does not delete the jars a daemon holds open. --- .../gradle/ConfigurationCacheTest.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/ConfigurationCacheTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/ConfigurationCacheTest.java index 17e7eacb0..c48aa1865 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/ConfigurationCacheTest.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/ConfigurationCacheTest.java @@ -27,6 +27,8 @@ import org.gradle.testkit.runner.BuildTask; import org.gradle.testkit.runner.TaskOutcome; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; import org.junit.jupiter.api.io.TempDir; /** @@ -96,6 +98,37 @@ void spotlessCheck_reports_the_violation_from_a_reused_entry() throws IOExceptio } } + @Test + @DisabledOnOs( + value = OS.WINDOWS, + disabledReason = "The daemon keeps the extracted jars open, and Windows does not delete an open file") + void spotlessApply_reuses_the_entry_after_the_extracted_formatter_is_deleted() throws IOException { + // The formatter inside the plugin, not the jars this build made: nothing in palantirJavaFormat. + var project = new GradleTestProject(projectDir) + .configurationCache(true) + .plugins("java", "dev.openjavaformat.java-format", "com.diffplug.spotless") + .withJavacInternalExports() + .buildGradle("println \"gradleUserHome=${gradle.gradleUserHomeDir}\"") + .writeFile(MAIN_JAVA, UNFORMATTED); + var stored = project.succeeds("spotlessApply"); + assertThat(stored.getOutput()).contains(STORED); + + // Storing the entry must not depend on the extracted jars, which a later build may find gone. + var gradleUserHome = stored.getOutput() + .lines() + .filter(line -> line.startsWith("gradleUserHome=")) + .map(line -> Path.of(line.substring("gradleUserHome=".length()))) + .findFirst() + .orElseThrow(); + new GradleTestProject(gradleUserHome).delete("open-java-format-caches/" + BundledFormatter.hash()); + project.writeFile(MAIN_JAVA, UNFORMATTED); + + var reused = project.succeeds("spotlessApply"); + + assertThat(reused.getOutput()).contains(REUSED); + assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); + } + @Test void spotlessJava_is_up_to_date_until_the_formatter_jars_change() throws IOException, URISyntaxException { var jars = Files.readString(CLASSPATH_FILE).trim(); From fc9747b30b06d8da5008bd9911ea8aa9939475ed Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Sun, 8 Nov 2026 12:03:22 +0300 Subject: [PATCH 13/22] Sign the formatter inside the plugin by its list, not its files The Spotless step's signature, which the up-to-date check and the build cache key compare and which the configuration cache entry stores, now goes by the hash of jars.txt for the formatter inside the plugin. jars.txt holds every jar's name and sha256, so the signature changes with any of them as before, and still holds no path; but nothing on disk is read to make it. Storing a configuration cache entry no longer extracts the jars or checks for them, and a build after the extracted directory was deleted reuses the entry. The jars are extracted when the step makes its formatter, as the task runs. A formatter declared in palantirJavaFormat is still signed by its jars' names, sizes and contents. --- .../spotless/PalantirJavaFormatStep.java | 53 ++++++++++++++----- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/spotless/PalantirJavaFormatStep.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/spotless/PalantirJavaFormatStep.java index 5320070ae..d56ecbea1 100644 --- a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/spotless/PalantirJavaFormatStep.java +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/spotless/PalantirJavaFormatStep.java @@ -45,9 +45,9 @@ public static FormatterStep create(Configuration formatterClasspath, File gradle ensureImplementationNotDirectlyLoadable(); // Spotless keeps a step in two forms, and neither can hold the configuration or a loaded formatter, which do // not serialize. The configuration cache stores the declared jars, resolved when the entry is written at the - // earliest. The up-to-date check compares the signature of the jars the step formats with, so that another - // formatter makes the task run again; Spotless takes it when the task runs. The formatter is loaded from the - // jars on first use. + // earliest. The up-to-date check compares the step's signature, so that another formatter makes the task run + // again; Spotless takes it when the task runs, and stores it in the configuration cache entry too. The + // formatter is loaded from the jars on first use, after the plugin's own are extracted if need be. return FormatterStep.createLazy( NAME, () -> new Jars(List.copyOf(formatterClasspath.getFiles()), gradleUserHome), @@ -55,11 +55,7 @@ public static FormatterStep create(Configuration formatterClasspath, File gradle PalantirJavaFormatStep::createFormat); } - /** - * The jars as the configuration cache stores them: those the build declared, or none, for the formatter inside the - * plugin. That one's jars are found, or extracted again, when the step is signed: the directory they were extracted - * into may be gone by the time a build reuses the entry. - */ + /** The jars as the configuration cache stores them: those the build declared, or none, for the plugin's own. */ static final class Jars implements Serializable { private static final long serialVersionUID = 1L; @@ -71,14 +67,45 @@ static final class Jars implements Serializable { this.gradleUserHome = gradleUserHome; } - /** By name, size and content, not path: the build cache key holds for another directory and machine. */ - FileSignature sign() throws IOException { - return FileSignature.signAsList(BundledFormatter.jarsUnlessOverridden(override, gradleUserHome)); + Signature sign() throws IOException { + return new Signature( + override.isEmpty() ? BundledFormatter.hash() : "", + FileSignature.signAsList(override), + gradleUserHome); + } + } + + /** + * What the up-to-date check and the build cache key compare, never a path, so that the key holds for another + * directory and machine. The jars a build declared go by name, size and content. The plugin's own go by the hash of + * their list, which holds every jar's sha256: nothing on disk is read, so storing a configuration cache entry does + * not depend on whether the jars were extracted, and a build after they were deleted still reuses the entry. + */ + static final class Signature implements Serializable { + private static final long serialVersionUID = 1L; + + // Spotless compares the signature in its serialized form, and reads nothing out of it. + @SuppressWarnings("UnusedVariable") + private final String bundled; + + private final FileSignature override; + + /** Where to extract the plugin's own jars. Spotless formats with a signature it made, never one it read back. */ + private final transient File gradleUserHome; + + Signature(String bundled, FileSignature override, File gradleUserHome) { + this.bundled = bundled; + this.override = override; + this.gradleUserHome = gradleUserHome; + } + + List jars() { + return BundledFormatter.jarsUnlessOverridden(override.files(), gradleUserHome); } } - private static FormatterFunc createFormat(FileSignature jars) { - FormatterService formatter = FormatterServiceLoader.load(jars.files()); + private static FormatterFunc createFormat(Signature signature) { + FormatterService formatter = FormatterServiceLoader.load(signature.jars()); return formatter::formatSourceReflowStringsAndFixImports; } From bbe0cbc4fa455fca7427675aa79323b5d3e863de Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Sun, 8 Nov 2026 12:27:44 +0300 Subject: [PATCH 14/22] Test the IntelliJ settings with the formatter inside the plugin Since palantirJavaFormat is empty by default, updateOpenJavaFormatXml writes an empty implementationClassPath into .idea/open-java-format.xml, and the IDE no longer runs the build's formatter. And once it lists the extracted jars, the task must not stay up to date after their directory is deleted, or the IDE keeps reading jars that are gone. Both new tests in WithoutRepositoriesTest fail on that. --- .../gradle/WithoutRepositoriesTest.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/WithoutRepositoriesTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/WithoutRepositoriesTest.java index f96828839..e8cfb0fc1 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/WithoutRepositoriesTest.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/WithoutRepositoriesTest.java @@ -19,7 +19,12 @@ import com.palantir.javaformat.gradle.testing.GradleTestProject; import java.io.IOException; import java.nio.file.Path; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.BuildTask; +import org.gradle.testkit.runner.TaskOutcome; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.Parameter; import org.junit.jupiter.params.ParameterizedClass; @@ -94,6 +99,55 @@ void formatDiff_formats_every_subproject_in_a_parallel_build() throws IOExceptio assertFormatted(project, "a/" + MAIN_JAVA, "b/" + MAIN_JAVA); } + @Test + void the_idea_settings_list_the_formatter_inside_the_plugin() { + // updateOpenJavaFormatXml runs with every build of a root project that applies idea. + var project = ideaProject(); + + var result = project.succeeds("help"); + + var jars = BundledFormatter.jars(gradleUserHome(result).toFile()); + assertThat(project.readFile(".idea/open-java-format.xml")) + .contains(jars.stream().map(jar -> jar.toURI().toString()).toList()); + } + + @Test + @DisabledOnOs( + value = OS.WINDOWS, + disabledReason = + "A daemon of another test may hold the extracted jars open, and Windows does not delete it") + void the_idea_settings_are_written_again_once_the_extracted_formatter_is_deleted() { + var project = ideaProject(); + var gradleUserHome = gradleUserHome(project.succeeds("help")); + new GradleTestProject(gradleUserHome).delete("open-java-format-caches/" + BundledFormatter.hash()); + + var result = project.succeeds("help"); + + // The IDE reads the jars from there: the task must not be up to date while they are gone. + assertThat(result.task(":updateOpenJavaFormatXml")) + .isNotNull() + .extracting(BuildTask::getOutcome) + .isEqualTo(TaskOutcome.SUCCESS); + assertThat(BundledFormatter.jars(gradleUserHome.toFile())) + .allSatisfy(jar -> assertThat(jar).isFile()); + } + + /** Prints the Gradle user home, where the formatter inside the plugin is extracted. */ + private GradleTestProject ideaProject() { + return project() + .plugins("java", "dev.openjavaformat.java-format", "idea") + .buildGradle("println \"gradleUserHome=${gradle.gradleUserHomeDir}\""); + } + + private static Path gradleUserHome(BuildResult result) { + return result.getOutput() + .lines() + .filter(line -> line.startsWith("gradleUserHome=")) + .map(line -> Path.of(line.substring("gradleUserHome=".length()))) + .findFirst() + .orElseThrow(); + } + private GradleTestProject project() { return new GradleTestProject(projectDir) .configurationCache(configurationCache) From ea0283fc4a287bc1cb8fc492ee5990988bfc9764 Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Sun, 8 Nov 2026 12:29:33 +0300 Subject: [PATCH 15/22] List the formatter inside the plugin in the IntelliJ settings updateOpenJavaFormatXml now writes the jars BundledFormatter extracts into implementationClassPath when palantirJavaFormat is empty, and the declared ones otherwise, so the IDE runs the build's formatter as it did when the formatter came from Maven Central. The extracted jars are where the IDE loads them from after a clean, too. The task takes the bundle's hash as an input, and is not up to date while the extracted jars are missing: after their directory is deleted the next build extracts them again and writes the XML, instead of leaving the IDE with paths to jars that are gone. The check is a class, which the configuration cache stores with the task. --- .../gradle/PalantirJavaFormatIdeaPlugin.java | 2 + .../UpdatePalantirJavaFormatIdeaXmlFile.java | 42 ++++++++++++++++++- .../javaformat/gradle/BundledFormatter.java | 18 ++++++-- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/gradle-open-java-format/src/main/groovy/com/palantir/javaformat/gradle/PalantirJavaFormatIdeaPlugin.java b/gradle-open-java-format/src/main/groovy/com/palantir/javaformat/gradle/PalantirJavaFormatIdeaPlugin.java index 33ec4a09b..7269badb5 100644 --- a/gradle-open-java-format/src/main/groovy/com/palantir/javaformat/gradle/PalantirJavaFormatIdeaPlugin.java +++ b/gradle-open-java-format/src/main/groovy/com/palantir/javaformat/gradle/PalantirJavaFormatIdeaPlugin.java @@ -46,6 +46,8 @@ public void apply(Project rootProject) { .from(rootProject .getConfigurations() .getByName(PalantirJavaFormatProviderPlugin.CONFIGURATION_NAME)); + task.getBundledFormatter().set(BundledFormatter.hash()); + task.getGradleUserHome().set(rootProject.getGradle().getGradleUserHomeDir()); }); TaskProvider updateWorkspaceXml = rootProject diff --git a/gradle-open-java-format/src/main/groovy/com/palantir/javaformat/gradle/UpdatePalantirJavaFormatIdeaXmlFile.java b/gradle-open-java-format/src/main/groovy/com/palantir/javaformat/gradle/UpdatePalantirJavaFormatIdeaXmlFile.java index 408e23ad4..7cb70b14e 100644 --- a/gradle-open-java-format/src/main/groovy/com/palantir/javaformat/gradle/UpdatePalantirJavaFormatIdeaXmlFile.java +++ b/gradle-open-java-format/src/main/groovy/com/palantir/javaformat/gradle/UpdatePalantirJavaFormatIdeaXmlFile.java @@ -20,10 +20,16 @@ import java.net.URI; import java.util.List; import org.gradle.api.DefaultTask; +import org.gradle.api.Task; import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.DirectoryProperty; import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.specs.Spec; import org.gradle.api.tasks.Classpath; +import org.gradle.api.tasks.Input; import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Internal; import org.gradle.api.tasks.OutputFile; import org.gradle.api.tasks.TaskAction; import org.gradle.work.DisableCachingByDefault; @@ -31,20 +37,52 @@ @DisableCachingByDefault(because = "Writes the developer's local .idea configuration; nothing to cache") public abstract class UpdatePalantirJavaFormatIdeaXmlFile extends DefaultTask { + /** + * The jars a build declared for the formatter. Most builds declare none, and the IDE then gets the formatter inside + * the plugin. + */ @InputFiles @Classpath public abstract ConfigurableFileCollection getImplementationConfig(); + /** The hash of the formatter inside the plugin, which names the directory its jars are extracted into. */ + @Input + public abstract Property getBundledFormatter(); + + /** Where the formatter inside the plugin is extracted to. */ + @Internal + public abstract DirectoryProperty getGradleUserHome(); + + public UpdatePalantirJavaFormatIdeaXmlFile() { + // The IDE loads the jars from where the XML says: while they are gone, the task is not up to date. + getOutputs().upToDateWhen(new BundledFormatterIsExtracted()); + } + @org.gradle.api.tasks.Optional @OutputFile public abstract RegularFileProperty getXmlOutputFile(); @TaskAction public final void updateXml() { - List uris = - getImplementationConfig().getFiles().stream().map(File::toURI).toList(); + List uris = BundledFormatter.jarsUnlessOverridden( + getImplementationConfig().getFiles(), + getGradleUserHome().get().getAsFile()) + .stream() + .map(File::toURI) + .toList(); XmlUtils.updateIdeaXmlFile( getXmlOutputFile().getAsFile().get(), node -> ConfigureJavaFormatterXml.configureJavaFormat(node, uris)); } + + /** A class, not a lambda: the configuration cache stores it with the task. */ + private static final class BundledFormatterIsExtracted implements Spec { + @Override + public boolean isSatisfiedBy(Task task) { + var xml = (UpdatePalantirJavaFormatIdeaXmlFile) task; + return !xml.getImplementationConfig().isEmpty() + || BundledFormatter.isExtracted( + xml.getGradleUserHome().get().getAsFile()); + } + } } diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/BundledFormatter.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/BundledFormatter.java index 4a323efa4..15fdcece1 100644 --- a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/BundledFormatter.java +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/BundledFormatter.java @@ -64,6 +64,12 @@ public static List jars(File gradleUserHome) { return jars(gradleUserHome.toPath(), BundledFormatter.class.getClassLoader()); } + /** Whether the jars are all there under this Gradle user home, so that {@link #jars} would not extract them. */ + public static boolean isExtracted(File gradleUserHome) { + var plugin = BundledFormatter.class.getClassLoader(); + return isComplete(gradleUserHome.toPath().resolve(CACHES).resolve(hash(plugin)), list(plugin)); + } + /** * The jars a build declared in palantirJavaFormat, which override the formatter inside the plugin; or, when it * declared none, as most builds do, the plugin's own. @@ -77,10 +83,7 @@ static String hash(ClassLoader plugin) { } static List jars(Path gradleUserHome, ClassLoader plugin) { - var list = new String(resource(plugin, LIST), StandardCharsets.UTF_8) - .lines() - .map(Jar::parse) - .toList(); + var list = list(plugin); var caches = gradleUserHome.resolve(CACHES); var target = caches.resolve(hash(plugin)); if (!isComplete(target, list)) { @@ -89,6 +92,13 @@ static List jars(Path gradleUserHome, ClassLoader plugin) { return list.stream().map(jar -> target.resolve(jar.name()).toFile()).toList(); } + private static List list(ClassLoader plugin) { + return new String(resource(plugin, LIST), StandardCharsets.UTF_8) + .lines() + .map(Jar::parse) + .toList(); + } + private static boolean isComplete(Path dir, List list) { return list.stream().allMatch(jar -> Files.isRegularFile(dir.resolve(jar.name()))); } From 9dfbab8466f9183fccbd9059ca56bbc2a58b5b07 Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Sun, 8 Nov 2026 12:33:51 +0300 Subject: [PATCH 16/22] Run the Spotless tests with the formatter inside the plugin The Spotless tests declared the jars this build made in palantirJavaFormat, which was the only way they could format: under TestKit the default dependency had no version. They now run with nothing declared, as a build that applies the plugin does, through the jars the plugin carries. BuildCacheTest keeps two tests of a declared formatter: declaring one makes spotlessJava run again, and so does a change of the declared jars. ConfigurationCacheTest keeps its test of the declared jars too, and MultiProjectTest stays on a declared formatter, as resolving that in the subprojects is what it tests. With the step's formatter returning its input, every test that runs a Spotless task still fails. --- .../javaformat/gradle/BuildCacheTest.java | 40 +++++++++++++------ .../gradle/ConfigurationCacheTest.java | 11 ++--- .../PalantirJavaFormatSpotlessPluginTest.java | 7 +--- .../gradle/SpotlessExcludesTest.java | 12 +----- .../gradle/SupportsCurrentSpotlessTest.java | 11 +---- 5 files changed, 33 insertions(+), 48 deletions(-) diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/BuildCacheTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/BuildCacheTest.java index b17dc6d60..7da152970 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/BuildCacheTest.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/BuildCacheTest.java @@ -31,7 +31,7 @@ /** * spotlessJava is cacheable, and the open-java-format step is part of its cache key: the key changes with the - * formatter's jars, and not with the directory the project is in. + * formatter, the one inside the plugin or the one a build declares, and not with the directory the project is in. * *

Every build here is spotlessApply, after the source is written again. The formatted file is what shows that * spotlessJava's output, whether it ran, was up to date or came from the cache, holds the source formatted: a step that @@ -42,7 +42,7 @@ @ValueSource(booleans = {true, false}) final class BuildCacheTest { - /** ./gradlew writeImplClasspath generates this file. */ + /** ./gradlew writeImplClasspath generates this file: the jars this build made, to declare as an override. */ private static final Path CLASSPATH_FILE = Path.of("build/impl.classpath"); private static final String MAIN_JAVA = "src/main/java/Main.java"; @@ -64,7 +64,7 @@ final class BuildCacheTest { private Path buildCacheDir; @Test - void spotlessJava_is_up_to_date_when_the_source_is_written_again_unchanged() throws IOException { + void spotlessJava_is_up_to_date_when_the_source_is_written_again_unchanged() { var project = project(projectDir); assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); @@ -74,7 +74,7 @@ void spotlessJava_is_up_to_date_when_the_source_is_written_again_unchanged() thr } @Test - void spotlessJava_runs_again_when_the_source_changes() throws IOException { + void spotlessJava_runs_again_when_the_source_changes() { var project = project(projectDir); assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); @@ -83,7 +83,7 @@ void spotlessJava_runs_again_when_the_source_changes() throws IOException { } @Test - void spotlessJava_comes_from_the_cache_when_the_source_changes_back() throws IOException { + void spotlessJava_comes_from_the_cache_when_the_source_changes_back() { var project = project(projectDir); assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); assertThat(spotlessApply(project, OTHER_UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); @@ -93,7 +93,7 @@ void spotlessJava_comes_from_the_cache_when_the_source_changes_back() throws IOE } @Test - void spotlessJava_comes_from_the_cache_after_the_build_directory_is_deleted() throws IOException { + void spotlessJava_comes_from_the_cache_after_the_build_directory_is_deleted() { var project = project(projectDir); assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); @@ -104,7 +104,7 @@ void spotlessJava_comes_from_the_cache_after_the_build_directory_is_deleted() th } @Test - void spotlessJava_comes_from_the_cache_in_another_directory() throws IOException { + void spotlessJava_comes_from_the_cache_in_another_directory() { assertThat(spotlessApply(project(projectDir), UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); // The same project somewhere else, as another checkout or another CI machine has it. @@ -115,13 +115,25 @@ void spotlessJava_comes_from_the_cache_in_another_directory() throws IOException } @Test - void spotlessJava_runs_again_when_the_formatter_jars_change() throws IOException, URISyntaxException { + void spotlessJava_runs_again_when_the_build_declares_a_formatter() throws IOException { var project = project(projectDir); assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); + // Without the build directory, only the cache could skip the task. + project.writeFile("formatter.classpath", formatterClasspath()).delete("build"); + + assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); + assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); + } + + @Test + void spotlessJava_runs_again_when_the_declared_jars_change() throws IOException, URISyntaxException { + var project = project(projectDir).writeFile("formatter.classpath", formatterClasspath()); + assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); + // Spotless signs the jars by name, size and content, as another formatter version changes them. The extra jar // is JUnit's, not one written into projectDir: the daemon keeps the formatter's jars open after the build, and - // Windows does not delete an open file. Without the build directory, only the cache could skip the task. + // Windows does not delete an open file. var extraJar = Path.of( Test.class.getProtectionDomain().getCodeSource().getLocation().toURI()); project.writeFile("formatter.classpath", formatterClasspath() + File.pathSeparator + extraJar) @@ -131,16 +143,18 @@ void spotlessJava_runs_again_when_the_formatter_jars_change() throws IOException assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); } - private GradleTestProject project(Path dir) throws IOException { + /** The formatter inside the plugin, unless the test writes formatter.classpath, which the build then declares. */ + private GradleTestProject project(Path dir) { return new GradleTestProject(dir) .configurationCache(configurationCache) .withBuildCache(buildCacheDir) .plugins("java", "dev.openjavaformat.java-format", "com.diffplug.spotless") .withJavacInternalExports() - .writeFile("formatter.classpath", formatterClasspath()) .buildGradle(""" - dependencies { - palantirJavaFormat files(file("formatter.classpath").text.split(File.pathSeparator)) + if (file("formatter.classpath").exists()) { + dependencies { + palantirJavaFormat files(file("formatter.classpath").text.split(File.pathSeparator)) + } } """); } diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/ConfigurationCacheTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/ConfigurationCacheTest.java index c48aa1865..c7004c6ef 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/ConfigurationCacheTest.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/ConfigurationCacheTest.java @@ -38,7 +38,7 @@ */ final class ConfigurationCacheTest { - /** ./gradlew writeImplClasspath generates this file. */ + /** ./gradlew writeImplClasspath generates this file: the jars this build made, to declare as an override. */ private static final Path CLASSPATH_FILE = Path.of("build/impl.classpath"); private static final String STORED = "Configuration cache entry stored."; @@ -201,17 +201,12 @@ void required_idea_plugin_is_listed_from_a_reused_entry() { } } + /** With the formatter inside the plugin. */ private GradleTestProject project(String... plugins) { - // Forward slashes: the path goes into a Groovy string, where a Windows backslash would start an escape. return new GradleTestProject(projectDir) .configurationCache(true) .plugins(plugins) - .withJavacInternalExports() - .buildGradle(""" - dependencies { - palantirJavaFormat files(file("%s").text.split(File.pathSeparator)) - } - """, CLASSPATH_FILE.toAbsolutePath().toString().replace('\\', '/')); + .withJavacInternalExports(); } private static TaskOutcome spotlessJava(BuildResult result) { diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/PalantirJavaFormatSpotlessPluginTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/PalantirJavaFormatSpotlessPluginTest.java index d33597939..b5b828456 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/PalantirJavaFormatSpotlessPluginTest.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/PalantirJavaFormatSpotlessPluginTest.java @@ -32,11 +32,7 @@ @ValueSource(booleans = {true, false}) final class PalantirJavaFormatSpotlessPluginTest { - /** ./gradlew writeImplClasspath generates this file. */ // Forward slashes: the path goes into a Groovy string, where a Windows backslash would start an escape. - private static final String CLASSPATH_FILE = - new File("build/impl.classpath").getAbsolutePath().replace('\\', '/'); - private static final String NATIVE_IMAGE_FILE = new File("build/nativeImage.path").getAbsolutePath().replace('\\', '/'); @@ -81,10 +77,9 @@ void formats_with_spotless_when_spotless_is_applied( """, javaVersion) .buildGradle(""" dependencies { - palantirJavaFormat files(file("%s").text.split(File.pathSeparator)) %s } - """, CLASSPATH_FILE, extraDependencies) + """, extraDependencies) .writeFile(MAIN_JAVA, invalidJavaFile()); BuildResult result = project.succeeds("spotlessApply", "--info"); diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SpotlessExcludesTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SpotlessExcludesTest.java index b4035595b..933d34c88 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SpotlessExcludesTest.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SpotlessExcludesTest.java @@ -18,7 +18,6 @@ import static org.assertj.core.api.Assertions.assertThat; import com.palantir.javaformat.gradle.testing.GradleTestProject; -import java.io.File; import java.nio.file.Path; import org.gradle.testkit.runner.BuildResult; import org.gradle.testkit.runner.TaskOutcome; @@ -33,10 +32,6 @@ @ValueSource(booleans = {true, false}) final class SpotlessExcludesTest { - // Forward slashes: the path goes into a Groovy string, where a Windows backslash would start an escape. - private static final String CLASSPATH_FILE = - new File("build/impl.classpath").getAbsolutePath().replace('\\', '/'); - private static final String SOURCE_FILE = """ package test; import java.lang.Void; @@ -65,12 +60,7 @@ void setup() { project = new GradleTestProject(projectDir) .configurationCache(configurationCache) .plugins("java", "dev.openjavaformat.java-format", "com.diffplug.spotless") - .withJavacInternalExports() - .buildGradle(""" - dependencies { - palantirJavaFormat files(file("%s").text.split(File.pathSeparator)) - } - """, CLASSPATH_FILE); + .withJavacInternalExports(); } @ParameterizedTest diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SupportsCurrentSpotlessTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SupportsCurrentSpotlessTest.java index e70b03166..bd57e10fa 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SupportsCurrentSpotlessTest.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SupportsCurrentSpotlessTest.java @@ -16,7 +16,6 @@ package com.palantir.javaformat.gradle; import com.palantir.javaformat.gradle.testing.GradleTestProject; -import java.io.File; import java.nio.file.Path; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -33,10 +32,6 @@ */ final class SupportsCurrentSpotlessTest { - // Forward slashes: the path goes into a Groovy string, where a Windows backslash would start an escape. - private static final String CLASSPATH_FILE = - new File("build/impl.classpath").getAbsolutePath().replace('\\', '/'); - @TempDir private Path projectDir; @@ -47,14 +42,10 @@ void palantirjavaformatplugin_works_with_current_spotless() { .plugins("java", "dev.openjavaformat.java-format", "com.diffplug.spotless") .withJavacInternalExports() .buildGradle(""" - dependencies { - palantirJavaFormat files(file("%s").text.split(File.pathSeparator)) - } - // Forces realization of the spotlessJava task, creating the spotless steps. Any // configuration resolved eagerly in them fails the configuration cache here. project.getTasks().getByName("spotlessJava") - """, CLASSPATH_FILE) + """) .succeeds("classes", "--info"); } } From bcb07d8f150da877a2b1b59d0fc5c7814ead333a Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Sun, 8 Nov 2026 12:35:18 +0300 Subject: [PATCH 17/22] Run the formatDiff tests with the formatter inside the plugin FormatDiffWithoutJavacExportsTest and the Java-based case of PalantirJavaFormatPluginTest no longer declare the jars this build made: formatDiff runs the formatter the plugin carries, and still names both settings when the Gradle JVM does not export javac. The native case still declares the binary the native jobs build. The tests that read impl.classpath are now those of a declared formatter, BundledFormatterTest, and FormatDiffTest, which loads the formatter itself. --- gradle-open-java-format/build.gradle | 5 +++-- .../gradle/FormatDiffWithoutJavacExportsTest.java | 12 +----------- .../gradle/PalantirJavaFormatPluginTest.java | 7 +------ 3 files changed, 5 insertions(+), 19 deletions(-) diff --git a/gradle-open-java-format/build.gradle b/gradle-open-java-format/build.gradle index 0b7f683d8..90e87e731 100644 --- a/gradle-open-java-format/build.gradle +++ b/gradle-open-java-format/build.gradle @@ -117,8 +117,9 @@ tasks.register("copyNativeImage", Copy.class) { into("$buildDir/nativeImage") } -// Split in two so the jar build does not need a GraalVM image: every test reads -// impl.classpath, only the four that drive the binary read nativeImage.path. +// Split in two so the jar build does not need a GraalVM image: the tests that declare the +// formatter themselves read impl.classpath, which is also what bundleFormatter puts inside the +// plugin; only the ones that drive the binary read nativeImage.path. // // Everything the doLast bodies touch is captured here, at configuration time: reaching for // `configurations` or `tasks` from inside them would pull the whole script into the task and 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 index 06eabede1..5e9b40607 100644 --- 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 @@ -18,7 +18,6 @@ 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; @@ -37,10 +36,6 @@ @ValueSource(booleans = {true, false}) final 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"; @Parameter @@ -54,12 +49,7 @@ void formatDiff_names_both_settings_when_the_gradle_jvm_does_not_export_javac() throws IOException, InterruptedException { GradleTestProject project = new GradleTestProject(projectDir) .configurationCache(configurationCache) - .plugins("java", "dev.openjavaformat.java-format") - .buildGradle(""" - dependencies { - palantirJavaFormat files(file("%s").text.split(File.pathSeparator)) - } - """, CLASSPATH_FILE); + .plugins("java", "dev.openjavaformat.java-format"); git(project, "init"); git(project, "config", "user.name", "Foo"); diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/PalantirJavaFormatPluginTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/PalantirJavaFormatPluginTest.java index a8e4f8eda..447b3359f 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/PalantirJavaFormatPluginTest.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/PalantirJavaFormatPluginTest.java @@ -33,11 +33,7 @@ @ValueSource(booleans = {true, false}) final class PalantirJavaFormatPluginTest { - /** ./gradlew writeImplClasspath generates this file. */ // Forward slashes: the path goes into a Groovy string, where a Windows backslash would start an escape. - private static final String CLASSPATH_FILE = - new File("build/impl.classpath").getAbsolutePath().replace('\\', '/'); - private static final String NATIVE_IMAGE_FILE = new File("build/nativeImage.path").getAbsolutePath().replace('\\', '/'); @@ -70,10 +66,9 @@ void formatDiff_updates_only_lines_changed_in_git_diff(String extraGradlePropert .gradleProperties(nativeFormatter ? extraGradleProperties : "") .buildGradle(""" dependencies { - palantirJavaFormat files(file("%s").text.split(File.pathSeparator)) %s } - """, CLASSPATH_FILE, nativeFormatter ? NATIVE_CONFIG : ""); + """, nativeFormatter ? NATIVE_CONFIG : ""); executeGitCommand(project, "git", "init"); executeGitCommand(project, "git", "config", "user.name", "Foo"); From c04a32149b80a23d42c3e0d743441501f0bf0c60 Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Sun, 8 Nov 2026 12:37:27 +0300 Subject: [PATCH 18/22] Test the Spotless step when the Gradle JVM does not export javac Spotless turns whatever a step throws into a lint of the file, and spotlessJava succeeds with that lint as its output. Without the --add-exports flags the formatter's check fails inside the step, so spotlessJava succeeds, the lint goes into the build cache, and after the flags are added the same key hands the lint back: spotlessApply fails until the cache is cleared. SpotlessWithoutJavacExportsTest fails on both. Two more tests hold what is right today: a declared formatter that does not resolve fails the build, while storing the configuration cache entry or in spotlessJava, and a file the formatter cannot parse is still a lint of that file. --- .../SpotlessWithoutJavacExportsTest.java | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SpotlessWithoutJavacExportsTest.java diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SpotlessWithoutJavacExportsTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SpotlessWithoutJavacExportsTest.java new file mode 100644 index 000000000..59f058d55 --- /dev/null +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SpotlessWithoutJavacExportsTest.java @@ -0,0 +1,123 @@ +/* + * 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.nio.file.Path; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.BuildTask; +import org.gradle.testkit.runner.TaskOutcome; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.Parameter; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Spotless turns whatever a step throws into a lint of the file, and spotlessJava succeeds, with that lint as its + * output. That is right for a file the formatter cannot parse, and wrong for a build the formatter cannot run in: the + * lint goes into the build cache, the build cache hands it back after the build is fixed, and spotlessApply has + * nothing to apply. The formatter's failures for the build, rather than for a file, fail spotlessJava itself. + */ +@ParameterizedClass(name = "configuration cache {0}") +@ValueSource(booleans = {true, false}) +final class SpotlessWithoutJavacExportsTest { + + private static final String MAIN_JAVA = "src/main/java/Main.java"; + private static final String UNFORMATTED = "class Main {\n int x;\n}\n"; + private static final String FORMATTED = "class Main {\n int x;\n}\n"; + + @Parameter + private boolean configurationCache; + + @TempDir + private Path projectDir; + + @TempDir + private Path buildCacheDir; + + @Test + void spotlessApply_fails_and_names_both_settings_when_the_gradle_jvm_does_not_export_javac() { + var project = project().writeFile(MAIN_JAVA, UNFORMATTED); + + var result = project.fails("spotlessApply"); + + assertThat(spotlessJava(result)).isEqualTo(TaskOutcome.FAILED); + assertThat(result.getOutput()) + .contains("open-java-format cannot run inside this Gradle JVM: module jdk.compiler does not export ") + .contains("openjavaformat.native.formatter=true") + .contains("org.gradle.jvmargs=--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED") + .doesNotContain("lint error"); + assertThat(project.readFile(MAIN_JAVA)).isEqualTo(UNFORMATTED); + } + + @Test + void spotlessApply_formats_once_the_gradle_jvm_exports_javac() { + var project = project().writeFile(MAIN_JAVA, UNFORMATTED); + project.fails("spotlessApply"); + + // Another daemon, with the flags. The same cache: what the failed build left there must not stand in for it. + project.withJavacInternalExports().writeFile(MAIN_JAVA, UNFORMATTED); + var result = project.succeeds("spotlessApply"); + + assertThat(spotlessJava(result)).isEqualTo(TaskOutcome.SUCCESS); + // Spotless writes the platform's line endings, CRLF on Windows. + assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); + } + + @Test + void spotlessApply_fails_when_the_declared_formatter_does_not_resolve() { + var project = project().withJavacInternalExports().buildGradle(""" + dependencies { + palantirJavaFormat 'does.not:exist:1.0' + } + """).writeFile(MAIN_JAVA, UNFORMATTED); + + var result = project.fails("spotlessApply"); + + // With the configuration cache the build fails as it stores the entry, before any task runs. + if (result.task(":spotlessJava") != null) { + assertThat(spotlessJava(result)).isEqualTo(TaskOutcome.FAILED); + } + assertThat(result.getOutput()).contains("does.not:exist:1.0").doesNotContain("lint error"); + assertThat(project.readFile(MAIN_JAVA)).isEqualTo(UNFORMATTED); + } + + @Test + void a_file_the_formatter_cannot_parse_is_still_a_lint_of_that_file() { + var project = project().withJavacInternalExports().writeFile(MAIN_JAVA, "class Main {\n int x =\n"); + + var result = project.fails("spotlessCheck"); + + assertThat(spotlessJava(result)).isEqualTo(TaskOutcome.SUCCESS); + assertThat(result.getOutput()).contains("lint error").contains("Main.java"); + } + + /** No --add-exports, unless a test adds them: a Gradle JVM as it comes. */ + private GradleTestProject project() { + return new GradleTestProject(projectDir) + .configurationCache(configurationCache) + .withBuildCache(buildCacheDir) + .plugins("java", "dev.openjavaformat.java-format", "com.diffplug.spotless"); + } + + private static TaskOutcome spotlessJava(BuildResult result) { + BuildTask task = result.task(":spotlessJava"); + assertThat(task).isNotNull(); + return task.getOutcome(); + } +} From d4a14800864b3260cf500be5ccb299bad97f15e0 Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Sun, 8 Nov 2026 12:39:02 +0300 Subject: [PATCH 19/22] Fail spotlessJava when the Gradle JVM does not export javac The formatter checks that javac's internals are exported to it when it loads, and inside the Spotless step that failure became a lint of every file: spotlessJava succeeded, the build cache kept the lint, and spotlessApply failed on it until the cache was cleared, even after the flags were added. spotlessJava now runs the same check before the step does, as an action of its own, without loading the formatter: the flags export the packages to every unnamed module, so the plugin's module answers for the formatter's. spotlessJava fails with the message formatDiff gives, nothing is stored in the build cache, and once the flags are there the next build formats. A file the formatter cannot parse is still a lint of that file. --- .../javaformat/gradle/FormatterServiceLoader.java | 8 ++++++++ .../gradle/PalantirJavaFormatSpotlessPlugin.java | 15 +++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/FormatterServiceLoader.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/FormatterServiceLoader.java index 52229d198..233e6b0ef 100644 --- a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/FormatterServiceLoader.java +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/FormatterServiceLoader.java @@ -86,6 +86,14 @@ private static FormatterService loadUncached(Collection jars) { 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, without loading + * it. The flags export them to every unnamed module, so the plugin's own module answers for the formatter's. + */ + public static void checkJavacIsExported() { + checkJavacIsExportedTo(FormatterServiceLoader.class.getClassLoader()); + } + /** * 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. diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/PalantirJavaFormatSpotlessPlugin.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/PalantirJavaFormatSpotlessPlugin.java index efae9c2fc..1b981a18f 100644 --- a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/PalantirJavaFormatSpotlessPlugin.java +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/PalantirJavaFormatSpotlessPlugin.java @@ -17,8 +17,10 @@ import com.diffplug.gradle.spotless.SpotlessExtension; import com.google.common.collect.ImmutableList; +import org.gradle.api.Action; import org.gradle.api.Plugin; import org.gradle.api.Project; +import org.gradle.api.Task; import org.gradle.api.plugins.JavaPluginExtension; public abstract class PalantirJavaFormatSpotlessPlugin implements Plugin { @@ -49,7 +51,20 @@ public void apply(Project project) { SpotlessExtension spotlessExtension = project.getExtensions().getByType(SpotlessExtension.class); spotlessExtension.java(spotlessInterop); + // Before the step runs: Spotless turns what a step throws into a lint of the file, which + // the build cache would then hand back after the build is fixed. + project.getTasks() + .named("spotlessJava") + .configure(spotlessJava -> spotlessJava.doFirst(new CheckJavacIsExported())); })); }); } + + /** A class, not a lambda: the configuration cache stores it with the task. */ + private static final class CheckJavacIsExported implements Action { + @Override + public void execute(Task _task) { + FormatterServiceLoader.checkJavacIsExported(); + } + } } From f18e3d458fdce2f3ea96949323937269d6278e9f Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Sun, 8 Nov 2026 13:02:59 +0300 Subject: [PATCH 20/22] Test the native image with repositories in the subprojects only NativeImageResolutionTest builds what #20 reports: the root project declares no repository, the subprojects declare theirs, and openjavaformat.native.formatter is on. formatDiff fails, alone and in parallel, with "Cannot resolve external dependency dev.openjavaformat:open-java-format-native:1.0 because no repositories are defined": the root project resolves palantirJavaFormatNative. The image is a shell script that answers formatDiff with the replacements that format the file, in a Maven repository the test writes under build/, so that the test runs in every build on Linux and macOS, offline; the build has no --add-exports, so only the image could have formatted the file. The test also expects the image to run from a copy in open-java-format-caches, made executable. --- .../gradle/NativeImageResolutionTest.java | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/NativeImageResolutionTest.java diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/NativeImageResolutionTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/NativeImageResolutionTest.java new file mode 100644 index 000000000..5d6f43434 --- /dev/null +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/NativeImageResolutionTest.java @@ -0,0 +1,228 @@ +/* + * 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.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.guava.GuavaModule; +import com.palantir.javaformat.gradle.testing.GradleTestProject; +import com.palantir.javaformat.java.Replacement; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.List; +import java.util.stream.Stream; +import org.gradle.testkit.runner.BuildResult; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.Parameter; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * The native image resolves through the repositories of the project that formats, as #20 asks: a build that declares + * its repositories in the subprojects only, as it may for a mirror of Maven Central, formats with it. + * + *

The image is a shell script that stands in for open-java-format-native: it answers formatDiff with the + * replacements that format Main.java. The build has no --add-exports, so the formatter inside the plugin could not + * have formatted it; the native jobs of CI run the real image through PalantirJavaFormatPluginTest. + */ +@ParameterizedClass(name = "configuration cache {0}") +@ValueSource(booleans = {true, false}) +@DisabledOnOs(value = OS.WINDOWS, disabledReason = "The stand-in for the native image is a shell script") +final class NativeImageResolutionTest { + + /** + * A Maven repository with dev.openjavaformat:open-java-format-native:1.0 for this platform. Under build/, as the + * other repositories of the tests are: the daemon may still run the image after a test. + */ + private static final Path NATIVE_REPO = Path.of("build/native-repo"); + + private static final String VERSION = "1.0"; + private static final String MAIN_JAVA = "src/main/java/Main.java"; + private static final String UNFORMATTED = "class Main {\n int x;\n}\n"; + private static final String FORMATTED = "class Main {\n int x;\n}\n"; + + @Parameter + private boolean configurationCache; + + @TempDir + private Path projectDir; + + @Test + void formatDiff_runs_the_native_image_from_the_repositories_of_the_subprojects() throws Exception { + var project = project(); + commitThenChange(project, "a/" + MAIN_JAVA, "b/" + MAIN_JAVA); + + var result = project.succeeds("formatDiff", "--info"); + + assertThat(result.getOutput()).contains("Using the native-image formatter"); + assertFormatted(project, "a/" + MAIN_JAVA, "b/" + MAIN_JAVA); + assertRunFromTheCaches(result); + } + + @Test + void formatDiff_runs_the_native_image_in_a_parallel_build() throws Exception { + var project = project(); + commitThenChange(project, "a/" + MAIN_JAVA, "b/" + MAIN_JAVA); + + project.succeeds("formatDiff", "--parallel"); + + assertFormatted(project, "a/" + MAIN_JAVA, "b/" + MAIN_JAVA); + } + + /** + * The layout of the docs, with the repositories in the subprojects only, and the image declared in the root project + * as a release's would be by default. Prints the Gradle user home, where the image is copied to. + */ + private GradleTestProject project() throws IOException { + var platform = currentPlatform(); + writeNativeImage(platform); + return new GradleTestProject(projectDir) + .configurationCache(configurationCache) + .withoutRepositories() + .pluginsNotApplied("dev.openjavaformat.java-format") + .subprojects("a", "b") + .gradleProperties("openjavaformat.native.formatter=true") + .buildGradle( + """ + subprojects { + apply plugin: 'java' + apply plugin: 'dev.openjavaformat.java-format' + repositories { + maven { url = rootProject.file("%s") } + } + } + dependencies { + palantirJavaFormatNative 'dev.openjavaformat:open-java-format-native:%s:%s@%s' + } + println "gradleUserHome=${gradle.gradleUserHomeDir}" + """, + NATIVE_REPO.toAbsolutePath().toString().replace('\\', '/'), + VERSION, + platform.classifier(), + platform.extension()); + } + + private static void assertFormatted(GradleTestProject project, String... files) { + for (var file : files) { + assertThat(project.readFile(file)).as(file).isEqualTo(FORMATTED); + } + } + + /** The build runs the image from a copy of its own, made executable: the repository's file may not be. */ + private static void assertRunFromTheCaches(BuildResult result) throws IOException { + var gradleUserHome = result.getOutput() + .lines() + .filter(line -> line.startsWith("gradleUserHome=")) + .map(line -> Path.of(line.substring("gradleUserHome=".length()))) + .findFirst() + .orElseThrow(); + try (Stream files = Files.walk(gradleUserHome.resolve(BundledFormatter.CACHES))) { + assertThat(files.filter(file -> file.getFileName().toString().startsWith("open-java-format-native-")) + .toList()) + .isNotEmpty() + .allSatisfy(image -> assertThat(Files.isExecutable(image)).isTrue()); + } + } + + private static NativePlatform currentPlatform() { + return NativePlatform.of( + System.getProperty("os.name"), System.getProperty("os.arch"), NativeImageResolutionTest::lddVersion); + } + + private static String lddVersion() { + try { + var process = new ProcessBuilder("ldd", "--version") + .redirectErrorStream(true) + .start(); + var output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + process.waitFor(); + return output.lines().findFirst().orElse(""); + } catch (IOException e) { + throw new UncheckedIOException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + /** + * open-java-format-native as Maven Central has it, a POM and the image under the platform's classifier, with a + * script for the image: it reads the source formatDiff sends, and answers with the replacements that format it. + */ + private static void writeNativeImage(NativePlatform platform) throws IOException { + var dir = Files.createDirectories(NATIVE_REPO.resolve("dev/openjavaformat/open-java-format-native/" + VERSION)); + Files.writeString(dir.resolve("open-java-format-native-" + VERSION + ".pom"), """ + + + 4.0.0 + dev.openjavaformat + open-java-format-native + %s + + """.formatted(VERSION)); + var replacements = JsonMapper.builder() + .addModule(new GuavaModule()) + .build() + .writeValueAsString(List.of(Replacement.create(0, UNFORMATTED.length(), FORMATTED))); + var image = dir.resolve( + "open-java-format-native-" + VERSION + "-" + platform.classifier() + "." + platform.extension()); + Files.writeString(image, """ + #!/bin/sh + cat > /dev/null + cat <<'JSON' + %s + JSON + """.formatted(replacements)); + Files.setPosixFilePermissions(image, PosixFilePermissions.fromString("rw-r--r--")); + } + + /** Commits the files in a formatted form, then writes them unformatted: formatDiff formats what git shows changed. */ + private static void commitThenChange(GradleTestProject project, String... files) + throws IOException, InterruptedException { + for (var file : files) { + project.writeFile(file, "class Main {}\n"); + } + 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"); + git(project, "add", "."); + git(project, "commit", "-m", "Commit"); + for (var file : files) { + project.writeFile(file, UNFORMATTED); + } + } + + private static void git(GradleTestProject project, String... args) throws IOException, InterruptedException { + var command = new String[args.length + 1]; + command[0] = "git"; + System.arraycopy(args, 0, command, 1, args.length); + var process = + new ProcessBuilder(command).directory(project.path().toFile()).start(); + var exitCode = process.waitFor(); + if (exitCode != 0) { + throw new RuntimeException("git " + String.join(" ", args) + " failed with exit code " + exitCode); + } + } +} From c1fb2fbd754e8e365dbc707fac61f4c17ae87078 Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Sun, 8 Nov 2026 13:07:24 +0300 Subject: [PATCH 21/22] Resolve the native image in the project that formats The root project resolved palantirJavaFormatNative through its own repositories, so a build that declares them in its subprojects only failed (#20), and the image ran from the output of ExecutableTransform. The root project now offers palantirJavaFormatNative as a variant, openJavaFormatNativeElements, as it offers palantirJavaFormat, and every project with formatDiff resolves it through openJavaFormatNativeImage, with its own repositories: a build behind a mirror of Maven Central declares the mirror where it declares its other repositories. The default dependency on the image of the plugin's version and platform stays, and so does declaring another. formatDiff copies the image it resolved into /open-java-format-caches//, next to the jars of the formatter inside the plugin, makes the copy executable and runs it from there. NativeImageAtomicCopy is back for that, without its file lock: it writes through AtomicCacheDirectory, which BundledFormatter now uses too. The hash is of the image's contents, kept per daemon by path, size and time, as the image is some 60 MB. ExecutableTransform goes; the copy sets the executable bit. NativeImageResolutionTest passes. --- .../gradle/PalantirJavaFormatPlugin.java | 21 +-- .../gradle/AtomicCacheDirectory.java | 96 +++++++++++++ .../javaformat/gradle/BundledFormatter.java | 72 ++-------- .../gradle/ExecutableTransform.java | 91 ------------ .../javaformat/gradle/FormatterClasspath.java | 106 ++++++++++---- .../gradle/NativeImageAtomicCopy.java | 115 +++++++++++++++ .../NativeImageFormatProviderPlugin.java | 14 +- .../gradle/NativeImageAtomicCopyTest.java | 134 ++++++++++++++++++ 8 files changed, 450 insertions(+), 199 deletions(-) create mode 100644 gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/AtomicCacheDirectory.java delete mode 100644 gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/ExecutableTransform.java create mode 100644 gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/NativeImageAtomicCopy.java create mode 100644 gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/NativeImageAtomicCopyTest.java diff --git a/gradle-open-java-format/src/main/groovy/com/palantir/javaformat/gradle/PalantirJavaFormatPlugin.java b/gradle-open-java-format/src/main/groovy/com/palantir/javaformat/gradle/PalantirJavaFormatPlugin.java index e84a300fc..46572b5b8 100644 --- a/gradle-open-java-format/src/main/groovy/com/palantir/javaformat/gradle/PalantirJavaFormatPlugin.java +++ b/gradle-open-java-format/src/main/groovy/com/palantir/javaformat/gradle/PalantirJavaFormatPlugin.java @@ -17,7 +17,6 @@ package com.palantir.javaformat.gradle; import com.palantir.javaformat.bootstrap.NativeImageFormatterService; -import java.io.File; import java.io.IOException; import org.gradle.api.DefaultTask; import org.gradle.api.Plugin; @@ -28,7 +27,6 @@ import org.gradle.api.file.RegularFileProperty; import org.gradle.api.logging.Logger; import org.gradle.api.logging.Logging; -import org.gradle.api.provider.Provider; import org.gradle.api.tasks.Classpath; import org.gradle.api.tasks.InputFile; import org.gradle.api.tasks.Internal; @@ -53,28 +51,22 @@ public void apply(Project project) { project.getPlugins().withId("java", p -> { var formatterClasspath = FormatterClasspath.of(project); + var nativeImage = FormatterClasspath.nativeImageOf(project); // TODO(dfox): in the future we may want to offer a simple 'format' task so people don't need to use // spotless to try out our formatter project.getTasks().register("formatDiff", FormatDiffTask.class, task -> { task.getProjectDirectory().set(project.getLayout().getProjectDirectory()); + task.getGradleUserHome().set(project.getGradle().getGradleUserHomeDir()); if (getNativeImageSupport().isNativeImageConfigured()) { - task.getNativeImage().fileProvider(getNativeImplConfiguration(project)); + task.getNativeImage().fileProvider(nativeImage.map(FileCollection::getSingleFile)); } else { task.getFormatterClasspath().from(formatterClasspath); - task.getGradleUserHome().set(project.getGradle().getGradleUserHomeDir()); } }); }); } - private static Provider getNativeImplConfiguration(Project project) { - return (project.getRootProject() - .getConfigurations() - .named(NativeImageFormatProviderPlugin.NATIVE_CONFIGURATION_NAME) - .map(FileCollection::getSingleFile)); - } - @DisableCachingByDefault( because = "Reformats whatever the working tree currently differs by; there is no reusable output") public abstract static class FormatDiffTask extends DefaultTask { @@ -93,7 +85,7 @@ public abstract static class FormatDiffTask extends DefaultTask { @Classpath abstract ConfigurableFileCollection getFormatterClasspath(); - /** Where the formatter inside the plugin is extracted to. */ + /** Where the formatter inside the plugin is extracted to, and the native image copied to. */ @Internal abstract DirectoryProperty getGradleUserHome(); @@ -116,8 +108,9 @@ public final void formatDiff() throws IOException, InterruptedException { log.info("Using the native-image formatter"); FormatDiff.formatDiff( projectDir, - new NativeImageFormatterService( - getNativeImage().get().getAsFile().toPath())); + new NativeImageFormatterService(NativeImageAtomicCopy.copyToCache( + getNativeImage().get().getAsFile().toPath(), + getGradleUserHome().get().getAsFile()))); } else { log.info("Using the Java-based formatter"); FormatDiff.formatDiff( diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/AtomicCacheDirectory.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/AtomicCacheDirectory.java new file mode 100644 index 000000000..4dc5e49af --- /dev/null +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/AtomicCacheDirectory.java @@ -0,0 +1,96 @@ +/* + * 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 java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Comparator; +import java.util.function.Predicate; +import java.util.stream.Stream; + +/** + * A directory of open-java-format-caches, written once and never changed after: its name says what is in it. + * + *

Builds and daemons may write it at the same time. Each writes into a directory of its own next to the target and + * renames it into place, which fails when the target is already there; the build that loses deletes its copy and uses + * the one that won. There is no file lock: FileChannel.lock fails in a daemon whose other build holds the same file + * through another class loader of the plugin. + */ +final class AtomicCacheDirectory { + + /** Writes what the directory holds. */ + interface Contents { + void writeInto(Path dir) throws IOException; + } + + private AtomicCacheDirectory() {} + + /** + * Creates {@code target} with these contents, unless another build has. A target that is not complete, as when + * someone deleted a file out of it, is replaced; whoever still runs from it keeps the files it has open. + */ + static void create(Path target, Contents contents, Predicate isComplete) throws IOException { + var caches = target.getParent(); + Files.createDirectories(caches); + var copy = Files.createTempDirectory(caches, target.getFileName() + "-"); + try { + contents.writeInto(copy); + if (Files.exists(target) && !isComplete.test(target)) { + delete(target); + } + moveIntoPlace(copy, target); + } finally { + delete(copy); + } + } + + /** Renames the copy into place, unless another build got there first: then the copy is left to be deleted. */ + private static void moveIntoPlace(Path copy, Path target) throws IOException { + try { + Files.move(copy, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + // Both are in the same directory, so the move is a rename all the same. + moveUnlessThere(copy, target); + } catch (IOException e) { + if (!Files.isDirectory(target)) { + throw e; + } + } + } + + private static void moveUnlessThere(Path copy, Path target) throws IOException { + try { + Files.move(copy, target); + } catch (IOException e) { + if (!Files.isDirectory(target)) { + throw e; + } + } + } + + private static void delete(Path dir) throws IOException { + if (!Files.exists(dir)) { + return; + } + try (Stream paths = Files.walk(dir)) { + for (var path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(path); + } + } + } +} diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/BundledFormatter.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/BundledFormatter.java index 15fdcece1..6caba53d8 100644 --- a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/BundledFormatter.java +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/BundledFormatter.java @@ -19,18 +19,14 @@ import java.io.InputStream; import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; -import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.StandardCopyOption; import java.security.DigestInputStream; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Collection; -import java.util.Comparator; import java.util.HexFormat; import java.util.List; -import java.util.stream.Stream; /** * The Java-based formatter that comes inside the plugin: the jars of its runtime classpath, which the build puts under @@ -42,9 +38,7 @@ * changes. That is also why a daemon holding the jars open, as Windows does not let a file be replaced, gets in nobody's * way. * - *

Builds and daemons may extract at the same time. Each extracts into a directory of its own next to the target and - * renames it into place, which fails when the target is already there; the build that loses deletes its copy and uses - * the one that won. + *

Builds and daemons may extract at the same time: see {@link AtomicCacheDirectory}. */ public final class BundledFormatter { @@ -84,10 +78,9 @@ static String hash(ClassLoader plugin) { static List jars(Path gradleUserHome, ClassLoader plugin) { var list = list(plugin); - var caches = gradleUserHome.resolve(CACHES); - var target = caches.resolve(hash(plugin)); + var target = gradleUserHome.resolve(CACHES).resolve(hash(plugin)); if (!isComplete(target, list)) { - extract(plugin, list, caches, target); + extract(plugin, list, target); } return list.stream().map(jar -> target.resolve(jar.name()).toFile()).toList(); } @@ -103,22 +96,16 @@ private static boolean isComplete(Path dir, List list) { return list.stream().allMatch(jar -> Files.isRegularFile(dir.resolve(jar.name()))); } - private static void extract(ClassLoader plugin, List list, Path caches, Path target) { + private static void extract(ClassLoader plugin, List list, Path target) { try { - Files.createDirectories(caches); - var copy = Files.createTempDirectory(caches, target.getFileName() + "-"); - try { - for (var jar : list) { - copy(plugin, jar, copy.resolve(jar.name())); - } - if (Files.exists(target) && !isComplete(target, list)) { - // Someone deleted a jar out of it. Whoever still runs from it keeps the files it has open. - delete(target); - } - moveIntoPlace(copy, target); - } finally { - delete(copy); - } + AtomicCacheDirectory.create( + target, + dir -> { + for (var jar : list) { + copy(plugin, jar, dir.resolve(jar.name())); + } + }, + existing -> isComplete(existing, list)); } catch (IOException e) { throw new UncheckedIOException("Could not extract the formatter's jars into " + target, e); } @@ -138,41 +125,6 @@ private static void copy(ClassLoader plugin, Jar jar, Path file) throws IOExcept } } - /** Renames the copy into place, unless another build got there first: then the copy is left to be deleted. */ - private static void moveIntoPlace(Path copy, Path target) throws IOException { - try { - Files.move(copy, target, StandardCopyOption.ATOMIC_MOVE); - } catch (AtomicMoveNotSupportedException e) { - // Both are in the same directory, so the move is a rename all the same. - moveUnlessThere(copy, target); - } catch (IOException e) { - if (!Files.isDirectory(target)) { - throw e; - } - } - } - - private static void moveUnlessThere(Path copy, Path target) throws IOException { - try { - Files.move(copy, target); - } catch (IOException e) { - if (!Files.isDirectory(target)) { - throw e; - } - } - } - - private static void delete(Path dir) throws IOException { - if (!Files.exists(dir)) { - return; - } - try (Stream paths = Files.walk(dir)) { - for (var path : paths.sorted(Comparator.reverseOrder()).toList()) { - Files.deleteIfExists(path); - } - } - } - private static byte[] resource(ClassLoader plugin, String name) { try (InputStream in = open(plugin, name)) { return in.readAllBytes(); diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/ExecutableTransform.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/ExecutableTransform.java deleted file mode 100644 index 947685bf8..000000000 --- a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/ExecutableTransform.java +++ /dev/null @@ -1,91 +0,0 @@ -/* - * (c) Copyright 2025 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 java.io.File; -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.nio.file.attribute.PosixFilePermission; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import org.gradle.api.artifacts.transform.InputArtifact; -import org.gradle.api.artifacts.transform.TransformAction; -import org.gradle.api.artifacts.transform.TransformOutputs; -import org.gradle.api.artifacts.transform.TransformParameters; -import org.gradle.api.file.FileSystemLocation; -import org.gradle.api.logging.Logger; -import org.gradle.api.logging.Logging; -import org.gradle.api.provider.Provider; -import org.gradle.api.tasks.PathSensitive; -import org.gradle.api.tasks.PathSensitivity; -import org.gradle.work.DisableCachingByDefault; - -/** - * A transform that makes the input artifact executable. - * - *

This is useful for native-image executables, which need to be executable in order to run. - */ -@DisableCachingByDefault( - because = "Only copies one file and sets its executable bit; caching would cost more than it saves") -public abstract class ExecutableTransform implements TransformAction { - - private static Logger logger = Logging.getLogger(ExecutableTransform.class); - - @InputArtifact - @PathSensitive(PathSensitivity.NAME_ONLY) - public abstract Provider getInputArtifact(); - - @Override - public void transform(TransformOutputs outputs) { - File inputFile = getInputArtifact().get().getAsFile(); - // The Windows binary keeps its name, so that it still ends in .exe like any Windows program. - String name = inputFile.getName().endsWith(".exe") ? inputFile.getName() : inputFile.getName() + ".executable"; - File outputFile = outputs.file(name); - try { - Files.copy(inputFile.toPath(), outputFile.toPath(), StandardCopyOption.REPLACE_EXISTING); - makeFileExecutable(outputFile.toPath()); - } catch (IOException e) { - throw new UncheckedIOException( - String.format("Failed to create executable file %s", outputFile.toPath()), e); - } - } - - private static void makeFileExecutable(Path pathToExe) { - // Windows file systems have no POSIX permissions, and an .exe needs none to run. - if (!pathToExe.getFileSystem().supportedFileAttributeViews().contains("posix")) { - return; - } - try { - Set existingPermissions = Files.getPosixFilePermissions(pathToExe); - Files.setPosixFilePermissions( - pathToExe, - Stream.concat( - existingPermissions.stream(), - Stream.of( - PosixFilePermission.OWNER_EXECUTE, - PosixFilePermission.GROUP_EXECUTE, - PosixFilePermission.OTHERS_EXECUTE)) - .collect(Collectors.toSet())); - } catch (IOException e) { - throw new UncheckedIOException("Failed to set execute permissions on native-image", e); - } - } -} diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/FormatterClasspath.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/FormatterClasspath.java index a935e63c6..20409cff6 100644 --- a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/FormatterClasspath.java +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/FormatterClasspath.java @@ -15,10 +15,13 @@ package com.palantir.javaformat.gradle; import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Supplier; import org.gradle.api.NamedDomainObjectProvider; import org.gradle.api.Project; import org.gradle.api.artifacts.Configuration; import org.gradle.api.artifacts.ModuleDependency; +import org.gradle.api.attributes.Attribute; import org.gradle.api.attributes.AttributeContainer; import org.gradle.api.attributes.Bundling; import org.gradle.api.attributes.Category; @@ -28,53 +31,91 @@ import org.gradle.api.model.ObjectFactory; /** - * The formatter a build declared in the root project's palantirJavaFormat, for the tasks of one project, resolved in - * that project. Declared, it overrides the formatter inside the plugin; most builds declare none, and this resolves to + * What a build declares in the root project for the formatter, resolved for the tasks of one project, in that project + * and through its repositories: palantirJavaFormat, which overrides the formatter inside the plugin, and + * palantirJavaFormatNative, the native image. Most builds declare nothing in palantirJavaFormat, and it resolves to * nothing, which needs no repository. * - *

The override is declared once, in the root project's palantirJavaFormat. A task of a subproject cannot resolve - * that configuration itself: in a parallel build it runs without the root project's lock, and Gradle refuses. So the - * root project offers palantirJavaFormat as a variant of its own, and every project that formats resolves it through a + *

Both are declared once, in the root project. A task of a subproject cannot resolve them there: in a parallel build + * it runs without the root project's lock, and Gradle refuses; and the root project may have no repository at all. So + * the root project offers each as a variant of its own, and every project that formats resolves it through a * dependency on the root project, which Gradle resolves in the project that asks. */ final class FormatterClasspath { - /** Picks the root project's variant out of its others, such as runtimeElements when it has java too. */ - private static final String CAPABILITY = "dev.openjavaformat.internal:formatter-classpath"; + /** The formatter's jars. The capability picks the root project's variant out of others, such as runtimeElements. */ + private static final Kind JARS = new Kind( + "dev.openjavaformat.internal:formatter-classpath", + "openJavaFormatFormatterElements", + "openJavaFormatFormatter", + "openJavaFormatFormatterClasspath", + "the open-java-format implementation", + FormatterClasspath::javaRuntime); - private static final String ELEMENTS = "openJavaFormatFormatterElements"; - private static final String DEPENDENCIES = "openJavaFormatFormatter"; - private static final String CLASSPATH = "openJavaFormatFormatterClasspath"; + /** The native image, declared in the root project's palantirJavaFormatNative. */ + private static final Kind NATIVE_IMAGE = new Kind( + "dev.openjavaformat.internal:native-image", + "openJavaFormatNativeElements", + "openJavaFormatNative", + "openJavaFormatNativeImage", + "the open-java-format native image", + FormatterClasspath::nativeImage); + + private static final Attribute NATIVE_IMAGE_ATTRIBUTE = + Attribute.of("dev.openjavaformat.native-image", String.class); private FormatterClasspath() {} /** Offers the root project's palantirJavaFormat to the projects that format. */ static void offer(Project rootProject, Configuration palantirJavaFormat) { - rootProject.getConfigurations().consumable(ELEMENTS, conf -> { - conf.setDescription("Internal: the open-java-format implementation, for the projects that format"); - conf.extendsFrom(palantirJavaFormat); - javaRuntime(conf.getAttributes(), rootProject.getObjects()); - conf.getOutgoing().capability(CAPABILITY + ":1"); - }); + offer(rootProject, JARS, () -> palantirJavaFormat); } /** The formatter's jars for the tasks of this project. */ static NamedDomainObjectProvider of(Project project) { + return of(project, JARS); + } + + /** Offers the root project's palantirJavaFormatNative to the projects that format. */ + static void offerNativeImage( + Project rootProject, NamedDomainObjectProvider palantirJavaFormatNative) { + offer(rootProject, NATIVE_IMAGE, palantirJavaFormatNative::get); + } + + /** + * The native image for the tasks of this project, resolved through this project's repositories: a build may + * declare them only where it has Java code, as it may for a mirror of Maven Central (#20). + */ + static NamedDomainObjectProvider nativeImageOf(Project project) { + return of(project, NATIVE_IMAGE); + } + + private static void offer(Project rootProject, Kind kind, Supplier declared) { + rootProject.getConfigurations().consumable(kind.elements(), conf -> { + conf.setDescription("Internal: " + kind.what() + ", for the projects that format"); + conf.extendsFrom(declared.get()); + kind.attributes().accept(conf.getAttributes(), rootProject.getObjects()); + conf.getOutgoing().capability(kind.capability() + ":1"); + }); + } + + private static NamedDomainObjectProvider of(Project project, Kind kind) { var configurations = project.getConfigurations(); - if (!configurations.getNames().contains(CLASSPATH)) { + if (!configurations.getNames().contains(kind.classpath())) { var rootProject = (ModuleDependency) project.getDependencies().project(Map.of("path", ":")); - rootProject.capabilities(capabilities -> capabilities.requireCapability(CAPABILITY)); - var dependencies = configurations.dependencyScope(DEPENDENCIES, conf -> { - conf.setDescription("Internal: the root project's open-java-format implementation"); + rootProject.capabilities(capabilities -> capabilities.requireCapability(kind.capability())); + var dependencies = configurations.dependencyScope(kind.dependencies(), conf -> { + conf.setDescription( + "Internal: the root project's " + kind.what().substring("the ".length())); conf.getDependencies().add(rootProject); }); - configurations.resolvable(CLASSPATH, conf -> { - conf.setDescription("Internal: resolves the open-java-format implementation for this project"); + configurations.resolvable(kind.classpath(), conf -> { + conf.setDescription("Internal: resolves " + kind.what() + " for this project"); conf.extendsFrom(dependencies.get()); - javaRuntime(conf.getAttributes(), project.getObjects()); + kind.attributes().accept(conf.getAttributes(), project.getObjects()); }); } - return configurations.named(CLASSPATH); + return configurations.named(kind.classpath()); } /** @@ -91,4 +132,21 @@ static void javaRuntime(AttributeContainer attributes, ObjectFactory objects) { TargetJvmEnvironment.TARGET_JVM_ENVIRONMENT_ATTRIBUTE, objects.named(TargetJvmEnvironment.class, TargetJvmEnvironment.STANDARD_JVM)); } + + /** + * The image is declared as an artifact, by classifier and extension, which Gradle resolves as it is; the attribute + * only names the variant of the root project. + */ + private static void nativeImage(AttributeContainer attributes, ObjectFactory _objects) { + attributes.attribute(NATIVE_IMAGE_ATTRIBUTE, "executable"); + } + + /** One thing the root project declares and offers, and every project that formats resolves. */ + private record Kind( + String capability, + String elements, + String dependencies, + String classpath, + String what, + BiConsumer attributes) {} } diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/NativeImageAtomicCopy.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/NativeImageAtomicCopy.java new file mode 100644 index 000000000..1c1823c07 --- /dev/null +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/NativeImageAtomicCopy.java @@ -0,0 +1,115 @@ +/* + * 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 java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * The native image as formatDiff runs it: a copy in {@code /open-java-format-caches//}, next + * to the jars of the formatter inside the plugin, made executable. Gradle hands over the image as it resolved it, from + * its own cache or from a repository on disk, and neither keeps it executable. The hash is of the image's contents: + * another image is another directory, and see {@link AtomicCacheDirectory} for builds that copy at the same time. + */ +public final class NativeImageAtomicCopy { + + /** + * The hash of an image by its path, size and time. The image is some 60 MB, and a daemon runs formatDiff build after + * build with the same one. + */ + private static final Map HASHES = new ConcurrentHashMap<>(); + + private NativeImageAtomicCopy() {} + + /** The executable copy of this image, copied under this Gradle user home if not there yet. */ + public static Path copyToCache(Path image, File gradleUserHome) { + var name = image.getFileName(); + var dir = gradleUserHome.toPath().resolve(BundledFormatter.CACHES).resolve(hash(image)); + var copy = dir.resolve(name); + if (!Files.isRegularFile(copy)) { + try { + AtomicCacheDirectory.create( + dir, + into -> { + Files.copy(image, into.resolve(name)); + makeExecutable(into.resolve(name)); + }, + existing -> Files.isRegularFile(existing.resolve(name))); + } catch (IOException e) { + throw new UncheckedIOException("Could not copy the native image into " + dir, e); + } + } + return copy; + } + + private static String hash(Path image) { + try { + var key = new Image( + image.toAbsolutePath(), + Files.size(image), + Files.getLastModifiedTime(image).toMillis()); + return HASHES.computeIfAbsent(key, _key -> sha256(image).substring(0, 16)); + } catch (IOException e) { + throw new UncheckedIOException("Could not read the native image " + image, e); + } + } + + private static String sha256(Path image) { + try { + var digest = MessageDigest.getInstance("SHA-256"); + try (InputStream in = new DigestInputStream(Files.newInputStream(image), digest)) { + in.transferTo(OutputStream.nullOutputStream()); + } + return HexFormat.of().formatHex(digest.digest()); + } catch (IOException e) { + throw new UncheckedIOException("Could not read the native image " + image, e); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Every JVM has SHA-256", e); + } + } + + private static void makeExecutable(Path file) throws IOException { + // Windows file systems have no POSIX permissions, and an .exe needs none to run. + if (!file.getFileSystem().supportedFileAttributeViews().contains("posix")) { + return; + } + Files.setPosixFilePermissions( + file, + Stream.concat( + Files.getPosixFilePermissions(file).stream(), + Stream.of( + PosixFilePermission.OWNER_EXECUTE, + PosixFilePermission.GROUP_EXECUTE, + PosixFilePermission.OTHERS_EXECUTE)) + .collect(Collectors.toUnmodifiableSet())); + } + + /** An image as it was when it was hashed: replaced in place, it changes size or time. */ + private record Image(Path path, long size, long lastModified) {} +} diff --git a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/NativeImageFormatProviderPlugin.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/NativeImageFormatProviderPlugin.java index 5c30a26b8..400edc278 100644 --- a/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/NativeImageFormatProviderPlugin.java +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/NativeImageFormatProviderPlugin.java @@ -21,7 +21,6 @@ import javax.inject.Inject; import org.gradle.api.Plugin; import org.gradle.api.Project; -import org.gradle.api.artifacts.type.ArtifactTypeDefinition; import org.gradle.api.provider.Provider; import org.gradle.api.provider.ProviderFactory; @@ -40,7 +39,7 @@ public void apply(Project rootProject) { Provider platform = NativePlatform.current(getProviderFactory()); String implementationVersion = JavaFormatExtension.class.getPackage().getImplementationVersion(); - rootProject.getConfigurations().register(NATIVE_CONFIGURATION_NAME, conf -> { + var nativeImage = rootProject.getConfigurations().register(NATIVE_CONFIGURATION_NAME, conf -> { conf.setDescription("Internal configuration for resolving the open-java-format native image"); conf.setCanBeConsumed(false); conf.setCanBeResolved(true); @@ -51,14 +50,9 @@ public void apply(Project rootProject) { "dev.openjavaformat:open-java-format-native:%s:%s@%s", implementationVersion, p.classifier(), p.extension()))))); }); - conf.getAttributes().attribute(ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE, "executable-nativeImage"); - }); - rootProject.getDependencies().registerTransform(ExecutableTransform.class, transformSpec -> { - transformSpec - .getFrom() - .attributeProvider( - ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE, platform.map(NativePlatform::extension)); - transformSpec.getTo().attribute(ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE, "executable-nativeImage"); }); + // Resolved in each project that formats, through its own repositories; formatDiff copies the image it gets + // into open-java-format-caches, executable, and runs it from there. + FormatterClasspath.offerNativeImage(rootProject, nativeImage); } } diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/NativeImageAtomicCopyTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/NativeImageAtomicCopyTest.java new file mode 100644 index 000000000..e5e56d523 --- /dev/null +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/NativeImageAtomicCopyTest.java @@ -0,0 +1,134 @@ +/* + * 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 java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.stream.Stream; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +final class NativeImageAtomicCopyTest { + + @TempDir + private Path gradleUserHome; + + @TempDir + private Path repository; + + @Test + @DisabledOnOs(value = OS.WINDOWS, disabledReason = "Windows has no executable bit to set") + void the_copy_is_executable_and_the_image_is_left_as_it_was() throws IOException { + var image = image("open-java-format-native-1.0-linux-x86_64.bin", "an image"); + + var copy = NativeImageAtomicCopy.copyToCache(image, gradleUserHome.toFile()); + + assertThat(copy.getParent().getParent()).isEqualTo(gradleUserHome.resolve("open-java-format-caches")); + assertThat(copy.getParent().getFileName().toString()).matches("[0-9a-f]{16}"); + assertThat(copy.getFileName()).isEqualTo(image.getFileName()); + assertThat(copy).hasContent("an image"); + assertThat(Files.isExecutable(copy)).isTrue(); + assertThat(Files.isExecutable(image)).isFalse(); + } + + @Test + void the_image_is_copied_once() throws IOException { + var image = image("open-java-format-native-1.0-linux-x86_64.bin", "an image"); + var copy = NativeImageAtomicCopy.copyToCache(image, gradleUserHome.toFile()); + var written = Files.getLastModifiedTime(copy); + + // A daemon may be running it: a second build leaves it as it is. + assertThat(NativeImageAtomicCopy.copyToCache(image, gradleUserHome.toFile())) + .isEqualTo(copy); + assertThat(Files.getLastModifiedTime(copy)).isEqualTo(written); + } + + @Test + void another_image_is_another_directory() throws IOException { + var copy = NativeImageAtomicCopy.copyToCache( + image("open-java-format-native-1.0-linux-x86_64.bin", "an image"), gradleUserHome.toFile()); + var other = NativeImageAtomicCopy.copyToCache( + image("open-java-format-native-2.0-linux-x86_64.bin", "another image"), gradleUserHome.toFile()); + + assertThat(other.getParent()).isNotEqualTo(copy.getParent()); + assertThat(other).hasContent("another image"); + } + + @Test + void a_deleted_copy_is_copied_again() throws IOException { + var image = image("open-java-format-native-1.0-linux-x86_64.bin", "an image"); + var copy = NativeImageAtomicCopy.copyToCache(image, gradleUserHome.toFile()); + Files.delete(copy); + + assertThat(NativeImageAtomicCopy.copyToCache(image, gradleUserHome.toFile())) + .isEqualTo(copy); + assertThat(copy).hasContent("an image"); + } + + // Repeated, so that the builds do race to rename their copies into place. + @RepeatedTest(5) + void builds_that_copy_at_the_same_time_get_the_same_copy() throws Exception { + var image = image("open-java-format-native-1.0-linux-x86_64.bin", "an image"); + var executor = Executors.newFixedThreadPool(4); + try { + var start = new CountDownLatch(1); + var builds = Stream.generate(() -> CompletableFuture.supplyAsync( + () -> { + try { + start.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + return NativeImageAtomicCopy.copyToCache(image, gradleUserHome.toFile()); + }, + executor)) + .limit(4) + .toList(); + start.countDown(); + + var copies = builds.stream().map(CompletableFuture::join).toList(); + + assertThat(copies) + .allSatisfy( + copy -> assertThat(copy).isEqualTo(copies.get(0)).hasContent("an image")); + // The copies of the builds that lost are gone. + try (Stream dirs = Files.list(gradleUserHome.resolve("open-java-format-caches"))) { + assertThat(dirs.toList()).containsExactly(copies.get(0).getParent()); + } + } finally { + executor.shutdownNow(); + } + } + + /** As Gradle hands it over: a file in a repository or in its cache, not executable where files can be. */ + private Path image(String name, String content) throws IOException { + var image = Files.writeString(repository.resolve(name), content); + if (image.getFileSystem().supportedFileAttributeViews().contains("posix")) { + Files.setPosixFilePermissions(image, PosixFilePermissions.fromString("rw-r--r--")); + } + return image; + } +} From 965bb5461bcc48ad66459698bb4405903169ecac Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Sun, 8 Nov 2026 16:13:22 +0300 Subject: [PATCH 22/22] Keep the files' line endings in every TestKit build with Spotless Spotless checks for, and writes, the platform's line endings by default, CRLF on Windows, while the tests write their files with LF. On Windows a check then reports the line endings instead of the formatting: a_file_the_formatter_cannot_parse_is_still_a_lint_of_that_file failed there, as the up-to-date test did before d289fcdb set lineEndings to PRESERVE in its own build. GradleTestProject now sets lineEndings to PRESERVE in every project of a generated build that applies Spotless, so no test has to remember it. Spotless then writes LF back, and the tests compare the files they leave exactly instead of ignoring line endings. --- .../javaformat/gradle/BuildCacheTest.java | 16 ++++++++-------- .../gradle/ConfigurationCacheTest.java | 11 +++++------ .../javaformat/gradle/MultiProjectTest.java | 3 +-- .../PalantirJavaFormatSpotlessPluginTest.java | 3 +-- .../javaformat/gradle/SpotlessExcludesTest.java | 3 +-- .../gradle/SpotlessWithoutJavacExportsTest.java | 3 +-- .../gradle/WithoutRepositoriesTest.java | 3 +-- .../gradle/testing/GradleTestProject.java | 13 +++++++++++++ 8 files changed, 31 insertions(+), 24 deletions(-) diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/BuildCacheTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/BuildCacheTest.java index 7da152970..14023d3e8 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/BuildCacheTest.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/BuildCacheTest.java @@ -67,10 +67,10 @@ final class BuildCacheTest { void spotlessJava_is_up_to_date_when_the_source_is_written_again_unchanged() { var project = project(projectDir); assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); - assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); + assertThat(project.readFile(MAIN_JAVA)).isEqualTo(FORMATTED); assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.UP_TO_DATE); - assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); + assertThat(project.readFile(MAIN_JAVA)).isEqualTo(FORMATTED); } @Test @@ -79,7 +79,7 @@ void spotlessJava_runs_again_when_the_source_changes() { assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); assertThat(spotlessApply(project, OTHER_UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); - assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(OTHER_FORMATTED); + assertThat(project.readFile(MAIN_JAVA)).isEqualTo(OTHER_FORMATTED); } @Test @@ -89,7 +89,7 @@ void spotlessJava_comes_from_the_cache_when_the_source_changes_back() { assertThat(spotlessApply(project, OTHER_UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.FROM_CACHE); - assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); + assertThat(project.readFile(MAIN_JAVA)).isEqualTo(FORMATTED); } @Test @@ -100,7 +100,7 @@ void spotlessJava_comes_from_the_cache_after_the_build_directory_is_deleted() { project.delete("build"); assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.FROM_CACHE); - assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); + assertThat(project.readFile(MAIN_JAVA)).isEqualTo(FORMATTED); } @Test @@ -111,7 +111,7 @@ void spotlessJava_comes_from_the_cache_in_another_directory() { var moved = project(otherProjectDir); assertThat(spotlessApply(moved, UNFORMATTED)).isEqualTo(TaskOutcome.FROM_CACHE); - assertThat(moved.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); + assertThat(moved.readFile(MAIN_JAVA)).isEqualTo(FORMATTED); } @Test @@ -123,7 +123,7 @@ void spotlessJava_runs_again_when_the_build_declares_a_formatter() throws IOExce project.writeFile("formatter.classpath", formatterClasspath()).delete("build"); assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); - assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); + assertThat(project.readFile(MAIN_JAVA)).isEqualTo(FORMATTED); } @Test @@ -140,7 +140,7 @@ void spotlessJava_runs_again_when_the_declared_jars_change() throws IOException, .delete("build"); assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); - assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); + assertThat(project.readFile(MAIN_JAVA)).isEqualTo(FORMATTED); } /** The formatter inside the plugin, unless the test writes formatter.classpath, which the build then declares. */ diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/ConfigurationCacheTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/ConfigurationCacheTest.java index c7004c6ef..87c3ff25a 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/ConfigurationCacheTest.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/ConfigurationCacheTest.java @@ -79,8 +79,7 @@ void spotlessApply_formats_from_a_reused_entry() throws IOException { project.writeFile(MAIN_JAVA, UNFORMATTED); assertThat(project.succeeds("spotlessApply").getOutput()).contains(entry); - // Spotless writes the platform's line endings, CRLF on Windows. - assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); + assertThat(project.readFile(MAIN_JAVA)).isEqualTo(FORMATTED); } } @@ -126,7 +125,7 @@ void spotlessApply_reuses_the_entry_after_the_extracted_formatter_is_deleted() t var reused = project.succeeds("spotlessApply"); assertThat(reused.getOutput()).contains(REUSED); - assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); + assertThat(project.readFile(MAIN_JAVA)).isEqualTo(FORMATTED); } @Test @@ -146,11 +145,11 @@ palantirJavaFormat files(file("formatter.classpath").text.split(File.pathSeparat // spotlessApply of the same source each time, and the file it leaves: a step that changes nothing is up to // date just as well, and passes a spotlessCheck of a formatted file. assertThat(spotlessJava(spotlessApply(project))).isEqualTo(TaskOutcome.SUCCESS); - assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); + assertThat(project.readFile(MAIN_JAVA)).isEqualTo(FORMATTED); var unchanged = spotlessApply(project); assertThat(unchanged.getOutput()).contains(REUSED); assertThat(spotlessJava(unchanged)).isEqualTo(TaskOutcome.UP_TO_DATE); - assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); + assertThat(project.readFile(MAIN_JAVA)).isEqualTo(FORMATTED); // Spotless signs the jars by name, size and content, as another formatter version changes them. The extra jar // is JUnit's, not one written into projectDir: the daemon keeps the formatter's jars open after the build, and @@ -160,7 +159,7 @@ palantirJavaFormat files(file("formatter.classpath").text.split(File.pathSeparat project.writeFile("formatter.classpath", jars + File.pathSeparator + extraJar); assertThat(spotlessJava(spotlessApply(project))).isEqualTo(TaskOutcome.SUCCESS); - assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); + assertThat(project.readFile(MAIN_JAVA)).isEqualTo(FORMATTED); } /** spotlessApply of the unformatted source, written again: only its time changes. */ diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/MultiProjectTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/MultiProjectTest.java index 1dd82160f..f53e1ff96 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/MultiProjectTest.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/MultiProjectTest.java @@ -177,8 +177,7 @@ private GradleTestProject project() { private static void assertFormatted(GradleTestProject project, String... files) { for (var file : files) { - // Spotless writes the platform's line endings, CRLF on Windows. - assertThat(project.readFile(file)).as(file).isEqualToNormalizingNewlines(FORMATTED); + assertThat(project.readFile(file)).as(file).isEqualTo(FORMATTED); } } diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/PalantirJavaFormatSpotlessPluginTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/PalantirJavaFormatSpotlessPluginTest.java index b5b828456..f30984917 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/PalantirJavaFormatSpotlessPluginTest.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/PalantirJavaFormatSpotlessPluginTest.java @@ -84,8 +84,7 @@ void formats_with_spotless_when_spotless_is_applied( BuildResult result = project.succeeds("spotlessApply", "--info"); - // Spotless writes the platform's line endings, CRLF on Windows. - assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(validJavaFile()); + assertThat(project.readFile(MAIN_JAVA)).isEqualTo(validJavaFile()); assertThat(result.getOutput()).contains(expectedOutput); } diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SpotlessExcludesTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SpotlessExcludesTest.java index 933d34c88..35facc407 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SpotlessExcludesTest.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SpotlessExcludesTest.java @@ -89,8 +89,7 @@ void format_ignores_excluded_directories(String srcDir) { assertThat(result.task(":spotlessJava")).isNotNull().extracting(task -> task.getOutcome()) .isEqualTo(TaskOutcome.SUCCESS); assertThat(project.readFile(srcDir + "/test/Test.java")).isEqualTo(SOURCE_FILE); - // Spotless writes the platform's line endings, CRLF on Windows. - assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED_MAIN); + assertThat(project.readFile(MAIN_JAVA)).isEqualTo(FORMATTED_MAIN); } @ParameterizedTest diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SpotlessWithoutJavacExportsTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SpotlessWithoutJavacExportsTest.java index 59f058d55..611812cd1 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SpotlessWithoutJavacExportsTest.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SpotlessWithoutJavacExportsTest.java @@ -75,8 +75,7 @@ void spotlessApply_formats_once_the_gradle_jvm_exports_javac() { var result = project.succeeds("spotlessApply"); assertThat(spotlessJava(result)).isEqualTo(TaskOutcome.SUCCESS); - // Spotless writes the platform's line endings, CRLF on Windows. - assertThat(project.readFile(MAIN_JAVA)).isEqualToNormalizingNewlines(FORMATTED); + assertThat(project.readFile(MAIN_JAVA)).isEqualTo(FORMATTED); } @Test diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/WithoutRepositoriesTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/WithoutRepositoriesTest.java index e8cfb0fc1..135b5e663 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/WithoutRepositoriesTest.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/WithoutRepositoriesTest.java @@ -182,8 +182,7 @@ private static void commitThenChange(GradleTestProject project, String... files) private static void assertFormatted(GradleTestProject project, String... files) { for (var file : files) { - // Spotless writes the platform's line endings, CRLF on Windows. - assertThat(project.readFile(file)).as(file).isEqualToNormalizingNewlines(FORMATTED); + assertThat(project.readFile(file)).as(file).isEqualTo(FORMATTED); } } diff --git a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/testing/GradleTestProject.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/testing/GradleTestProject.java index a9eabab06..d141ce5f3 100644 --- a/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/testing/GradleTestProject.java +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/testing/GradleTestProject.java @@ -245,6 +245,19 @@ private void write() { if (repositories) { script.append("repositories {\n mavenCentral()\n}\n\n"); } + // Spotless checks for, and writes, the platform's line endings by default, CRLF on Windows, while the tests + // write their files with LF: a check would report the line endings, not the formatting. PRESERVE keeps the + // file's own, wherever a test applies Spotless. + script.append(""" + allprojects { + plugins.withId('com.diffplug.spotless') { + spotless { + lineEndings = 'PRESERVE' + } + } + } + + """); script.append(buildGradle); StringBuilder settings = new StringBuilder("rootProject.name = 'test-project'\n");