diff --git a/gradle-open-java-format/build.gradle b/gradle-open-java-format/build.gradle index 3713d8d50..90e87e731 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']) } @@ -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 @@ -136,6 +137,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/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/PalantirJavaFormatPlugin.java b/gradle-open-java-format/src/main/groovy/com/palantir/javaformat/gradle/PalantirJavaFormatPlugin.java index 22dffc61c..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,19 +17,16 @@ 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; 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; 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,31 +50,23 @@ public void apply(Project project) { project.getPlugins().apply(PalantirJavaFormatSpotlessPlugin.class); 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(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() - .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 { @@ -89,10 +78,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, and the native image copied 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. @@ -112,13 +108,16 @@ 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( projectDir, - FormatterServiceLoader.load(getFormatterClasspath().getFiles())); + FormatterServiceLoader.load(BundledFormatter.jarsUnlessOverridden( + getFormatterClasspath().getFiles(), + getGradleUserHome().get().getAsFile()))); } } } 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/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 new file mode 100644 index 000000000..6caba53d8 --- /dev/null +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/BundledFormatter.java @@ -0,0 +1,163 @@ +/* + * 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.Files; +import java.nio.file.Path; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Collection; +import java.util.HexFormat; +import java.util.List; + +/** + * 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: see {@link AtomicCacheDirectory}. + */ +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()); + } + + /** 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. + */ + 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); + } + + static List jars(Path gradleUserHome, ClassLoader plugin) { + var list = list(plugin); + var target = gradleUserHome.resolve(CACHES).resolve(hash(plugin)); + if (!isComplete(target, list)) { + extract(plugin, list, target); + } + 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()))); + } + + private static void extract(ClassLoader plugin, List list, Path target) { + try { + 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); + } + } + + 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())); + } + } + + 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/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 new file mode 100644 index 000000000..20409cff6 --- /dev/null +++ b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/FormatterClasspath.java @@ -0,0 +1,152 @@ +/* + * 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 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; +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; + +/** + * 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. + * + *

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 { + + /** 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); + + /** 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) { + 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(kind.classpath())) { + var rootProject = (ModuleDependency) project.getDependencies().project(Map.of("path", ":")); + 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(kind.classpath(), conf -> { + conf.setDescription("Internal: resolves " + kind.what() + " for this project"); + conf.extendsFrom(dependencies.get()); + kind.attributes().accept(conf.getAttributes(), project.getObjects()); + }); + } + return configurations.named(kind.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)); + } + + /** + * 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/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/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/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/main/java/com/palantir/javaformat/gradle/PalantirJavaFormatProviderPlugin.java b/gradle-open-java-format/src/main/java/com/palantir/javaformat/gradle/PalantirJavaFormatProviderPlugin.java index ae5ebdb29..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,17 +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()); }); - - rootProject.getExtensions().create("palantirJavaFormat", JavaFormatExtension.class, configuration); + FormatterClasspath.offer(rootProject, 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 3b183eb24..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 { @@ -43,11 +45,26 @@ public void apply(Project project) { project.getProjectDir(), project.getExtensions() .getByType(JavaPluginExtension.class) - .getSourceSets()); + .getSourceSets(), + FormatterClasspath.of(project), + project.getGradle().getGradleUserHomeDir()); 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(); + } + } } 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..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 @@ -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,13 @@ 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.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; import org.gradle.api.model.ObjectFactory; import org.gradle.api.specs.Spec; -import org.gradle.api.tasks.Nested; import org.gradle.api.tasks.SourceSetContainer; /** @@ -55,20 +53,22 @@ public abstract class SpotlessInterop implements Action { private final File projectDir; private final SourceSetContainer sourceSets; - - @Nested - protected abstract NativeImageSupport getNativeImageSupport(); - - @Inject - protected abstract ConfigurationContainer getConfigurations(); + private final NamedDomainObjectProvider formatterClasspath; + private final File gradleUserHome; @Inject protected abstract ObjectFactory getObjects(); @Inject - public SpotlessInterop(File projectDir, SourceSetContainer sourceSets) { + public SpotlessInterop( + File projectDir, + SourceSetContainer sourceSets, + NamedDomainObjectProvider formatterClasspath, + File gradleUserHome) { this.projectDir = projectDir; this.sourceSets = sourceSets; + this.formatterClasspath = formatterClasspath; + this.gradleUserHome = gradleUserHome; } @Override @@ -90,16 +90,13 @@ 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()); - return PalantirJavaFormatStep.create( - getConfigurations().getByName(PalantirJavaFormatProviderPlugin.CONFIGURATION_NAME)); + logger.info("Using the Java-based formatter"); + return PalantirJavaFormatStep.create(formatterClasspath.get(), gradleUserHome); } /** 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..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 @@ -18,10 +18,13 @@ 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.BundledFormatter; import com.palantir.javaformat.gradle.FormatterServiceLoader; import com.palantir.javaformat.java.FormatterService; -import java.util.function.Supplier; +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 { @@ -32,36 +35,77 @@ 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 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, 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 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, - () -> FileSignature.promise(palantirJavaFormat.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 plugin's own. */ + 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; + } + + Signature sign() throws IOException { + return new Signature( + override.isEmpty() ? BundledFormatter.hash() : "", + FileSignature.signAsList(override), + gradleUserHome); + } + } + /** - * Creates the same step as {@link #create(Configuration)}. - * - * @deprecated the step loads the formatter from the configuration's jars, and ignores {@code memoizedService}. + * 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. */ - @Deprecated - @InlineMe( - replacement = "PalantirJavaFormatStep.create(palantirJavaFormat)", - imports = "com.palantir.javaformat.gradle.spotless.PalantirJavaFormatStep") - public static FormatterStep create(Configuration palantirJavaFormat, Supplier memoizedService) { - return create(palantirJavaFormat); + 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; } 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..14023d3e8 --- /dev/null +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/BuildCacheTest.java @@ -0,0 +1,173 @@ +/* + * 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, 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 + * 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: 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"; + 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() { + var project = project(projectDir); + assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); + assertThat(project.readFile(MAIN_JAVA)).isEqualTo(FORMATTED); + + assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.UP_TO_DATE); + assertThat(project.readFile(MAIN_JAVA)).isEqualTo(FORMATTED); + } + + @Test + void spotlessJava_runs_again_when_the_source_changes() { + var project = project(projectDir); + assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); + + assertThat(spotlessApply(project, OTHER_UNFORMATTED)).isEqualTo(TaskOutcome.SUCCESS); + assertThat(project.readFile(MAIN_JAVA)).isEqualTo(OTHER_FORMATTED); + } + + @Test + 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); + + assertThat(spotlessApply(project, UNFORMATTED)).isEqualTo(TaskOutcome.FROM_CACHE); + assertThat(project.readFile(MAIN_JAVA)).isEqualTo(FORMATTED); + } + + @Test + void spotlessJava_comes_from_the_cache_after_the_build_directory_is_deleted() { + 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)).isEqualTo(FORMATTED); + } + + @Test + 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. + var moved = project(otherProjectDir); + + assertThat(spotlessApply(moved, UNFORMATTED)).isEqualTo(TaskOutcome.FROM_CACHE); + assertThat(moved.readFile(MAIN_JAVA)).isEqualTo(FORMATTED); + } + + @Test + 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)).isEqualTo(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. + 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)).isEqualTo(FORMATTED); + } + + /** 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() + .buildGradle(""" + if (file("formatter.classpath").exists()) { + 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/BundledFormatterTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/BundledFormatterTest.java new file mode 100644 index 000000000..baa6165c2 --- /dev/null +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/BundledFormatterTest.java @@ -0,0 +1,229 @@ +/* + * 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 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. + */ + 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)) + .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); + } + } + + @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(); + } 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); + } + } +} 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..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 @@ -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; /** @@ -36,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."; @@ -77,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); } } @@ -96,6 +97,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)).isEqualTo(FORMATTED); + } + @Test void spotlessJava_is_up_to_date_until_the_formatter_jars_change() throws IOException, URISyntaxException { var jars = Files.readString(CLASSPATH_FILE).trim(); @@ -104,22 +136,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)).isEqualTo(FORMATTED); + var unchanged = spotlessApply(project); assertThat(unchanged.getOutput()).contains(REUSED); assertThat(spotlessJava(unchanged)).isEqualTo(TaskOutcome.UP_TO_DATE); + 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 @@ -128,7 +158,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)).isEqualTo(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 @@ -164,17 +200,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/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/MultiProjectTest.java b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/MultiProjectTest.java new file mode 100644 index 000000000..f53e1ff96 --- /dev/null +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/MultiProjectTest.java @@ -0,0 +1,262 @@ +/* + * 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"); + } + + @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"); + } + + @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) + .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) { + assertThat(project.readFile(file)).as(file).isEqualTo(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); + } + } + + 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/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; + } +} 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); + } + } +} 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"); 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..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 @@ -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('\\', '/'); @@ -51,11 +47,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 = '|', @@ -85,16 +77,14 @@ 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"); - // 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 7487f5a26..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 @@ -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,16 +32,21 @@ @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; 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; @@ -56,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 @@ -81,12 +80,16 @@ 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); + assertThat(project.readFile(MAIN_JAVA)).isEqualTo(FORMATTED_MAIN); } @ParameterizedTest @@ -108,5 +111,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"); } } 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..611812cd1 --- /dev/null +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/SpotlessWithoutJavacExportsTest.java @@ -0,0 +1,122 @@ +/* + * 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); + assertThat(project.readFile(MAIN_JAVA)).isEqualTo(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(); + } +} 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"); } } 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..135b5e663 --- /dev/null +++ b/gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/WithoutRepositoriesTest.java @@ -0,0 +1,200 @@ +/* + * 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.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; +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); + } + + @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) + .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) { + assertThat(project.readFile(file)).as(file).isEqualTo(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 f7c8fe50a..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 @@ -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; @@ -57,10 +60,14 @@ 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; private boolean spotlessOnClasspath = true; + private boolean repositories = true; + private Optional buildCacheDir = Optional.empty(); public GradleTestProject(Path projectDir) { this.projectDir = projectDir; @@ -75,6 +82,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'); @@ -109,6 +131,21 @@ 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 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 @@ -134,6 +171,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 +211,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) @@ -184,9 +240,41 @@ private static List pluginClasspathWithoutSpotless() { private void write() { StringBuilder script = new StringBuilder("plugins {\n"); plugins.forEach(id -> script.append(" id '").append(id).append("'\n")); - script.append("}\n\nrepositories {\n mavenCentral()\n}\n\n").append(buildGradle); + pluginsNotApplied.forEach(id -> script.append(" id '").append(id).append("' apply false\n")); + script.append("}\n\n"); + 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); - writeFile("settings.gradle", "rootProject.name = 'test-project'\n"); + StringBuilder settings = new StringBuilder("rootProject.name = 'test-project'\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('\\', '/')) + .append("')\n }\n}\n")); + writeFile("settings.gradle", settings.toString()); writeFile("build.gradle", script.toString()); writeFile("gradle.properties", gradleProperties.toString()); }