Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6b1cd06
Test spotlessJava with the build cache
abashev Nov 8, 2026
7160468
Check the formatted files in the Spotless tests
abashev Nov 8, 2026
0ce3cbf
Remove the Spotless step for the native image
abashev Nov 8, 2026
7ac1ac6
Test the Spotless step in a build of several projects
abashev Nov 8, 2026
c97ceea
Resolve the formatter in the project that formats
abashev Nov 8, 2026
87a1f36
Test formatDiff in a parallel build of several projects
abashev Nov 8, 2026
83720db
Give formatDiff the formatter of its own project
abashev Nov 8, 2026
ec66207
Put the formatter's jars inside the Gradle plugin
abashev Nov 8, 2026
7fd8727
Extract the bundled formatter on first use
abashev Nov 8, 2026
70429fe
Test the plugin in a build without repositories
abashev Nov 8, 2026
d70aab2
Format with the formatter inside the plugin by default
abashev Nov 8, 2026
7a0a5e6
Test the configuration cache after the extracted formatter is gone
abashev Nov 8, 2026
fc9747b
Sign the formatter inside the plugin by its list, not its files
abashev Nov 8, 2026
bbe0cbc
Test the IntelliJ settings with the formatter inside the plugin
abashev Nov 8, 2026
ea0283f
List the formatter inside the plugin in the IntelliJ settings
abashev Nov 8, 2026
9dfbab8
Run the Spotless tests with the formatter inside the plugin
abashev Nov 8, 2026
bcb07d8
Run the formatDiff tests with the formatter inside the plugin
abashev Nov 8, 2026
c04a321
Test the Spotless step when the Gradle JVM does not export javac
abashev Nov 8, 2026
d4a1480
Fail spotlessJava when the Gradle JVM does not export javac
abashev Nov 8, 2026
f18e3d4
Test the native image with repositories in the subprojects only
abashev Nov 8, 2026
c1fb2fb
Resolve the native image in the project that formats
abashev Nov 8, 2026
ab3a656
Merge branch '21.X.X' into abashev/Add-support-for-build-cache
abashev Nov 8, 2026
965bb54
Keep the files' line endings in every TestKit build with Spotless
abashev Nov 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions gradle-open-java-format/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
}
Expand Down Expand Up @@ -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
Expand All @@ -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, "<sha256> <name>", 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<UpdateWorkspaceXmlFile> updateWorkspaceXml = rootProject
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<Configuration> getImplConfiguration(Project project) {
return project.getRootProject().getConfigurations().named(PalantirJavaFormatProviderPlugin.CONFIGURATION_NAME);
}

private static Provider<File> 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 {
Expand All @@ -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.
Expand All @@ -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())));
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,31 +20,69 @@
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;

@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<String> 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<URI> uris =
getImplementationConfig().getFiles().stream().map(File::toURI).toList();
List<URI> 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<Task> {
@Override
public boolean isSatisfiedBy(Task task) {
var xml = (UpdatePalantirJavaFormatIdeaXmlFile) task;
return !xml.getImplementationConfig().isEmpty()
|| BundledFormatter.isExtracted(
xml.getGradleUserHome().get().getAsFile());
}
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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<Path> 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<Path> paths = Files.walk(dir)) {
for (var path : paths.sorted(Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(path);
}
}
}
}
Loading
Loading