Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (

## [Unreleased]

### Added
- `KtLintStep` now supports additional generated rule-set JARs whose classpath is supplied at execution time. ([#2999](https://github.com/diffplug/spotless/pull/2999), fixes [#1901](https://github.com/diffplug/spotless/issues/1901))

## [4.8.0] - 2026-06-29
### Added
- Add support for custom string format for license header copyright year via `yearStringFormat()`. ([#2965](https://github.com/diffplug/spotless/pull/2965))
Expand Down
12 changes: 11 additions & 1 deletion lib/src/main/java/com/diffplug/spotless/JarState.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2016-2025 DiffPlug
* Copyright 2016-2026 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -24,8 +24,10 @@
import java.io.Serializable;
import java.net.URI;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Set;
Expand Down Expand Up @@ -143,6 +145,14 @@ public static JarState preserveOrder(Collection<File> jars) throws IOException {
return new JarState(fileSignature);
}

/** Returns a new state whose classpath also contains the given JARs. */
public JarState withAdditionalJars(Iterable<File> additionalJars) throws IOException {
List<File> jars = new ArrayList<>(fileSignature.files());
additionalJars.forEach(jars::add);
// Local project tasks that produce the requested artifacts have completed and files exist to be signed
return new JarState(FileSignature.signAsList(jars));
}

URL[] jarUrls() {
return fileSignature.files().stream().map(File::toURI).map(ThrowingEx.wrap(URI::toURL)).toArray(URL[]::new);
}
Expand Down
58 changes: 52 additions & 6 deletions lib/src/main/java/com/diffplug/spotless/kotlin/KtLintStep.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2016-2025 DiffPlug
* Copyright 2016-2026 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -15,6 +15,7 @@
*/
package com.diffplug.spotless.kotlin;

import java.io.File;
import java.io.Serial;
import java.io.Serializable;
import java.lang.reflect.Constructor;
Expand All @@ -33,6 +34,9 @@
import com.diffplug.spotless.FormatterStep;
import com.diffplug.spotless.JarState;
import com.diffplug.spotless.Provisioner;
import com.diffplug.spotless.ThrowingEx;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;

/** Wraps up <a href="https://github.com/pinterest/ktlint">ktlint</a> as a FormatterStep. */
public final class KtLintStep implements Serializable {
Expand All @@ -44,40 +48,63 @@ public final class KtLintStep implements Serializable {
private static final String MAVEN_COORDINATE_1_DOT = "com.pinterest.ktlint:ktlint-cli:";

private final JarState.Promised jarState;
@Nullable private final PromisedClasspath additionalClasspath;
@Nullable private final FileSignature.Promised config;
private final Map<String, Object> editorConfigOverride;
private final String version;

private KtLintStep(String version,
JarState.Promised jarState,
@Nullable PromisedClasspath additionalClasspath,
@Nullable FileSignature config,
Map<String, Object> editorConfigOverride) {
this.version = version;
this.jarState = jarState;
this.additionalClasspath = additionalClasspath;
this.config = config != null ? config.asPromise() : null;
this.editorConfigOverride = editorConfigOverride;
}

/** Creates a ktlint step using the default version and configuration. */
public static FormatterStep create(Provisioner provisioner) {
return create(defaultVersion(), provisioner);
}

/** Creates a ktlint step using the specified version and default configuration. */
public static FormatterStep create(String version, Provisioner provisioner) {
return create(version, provisioner, null, Collections.emptyMap(), Collections.emptyList());
}

/** Creates a ktlint step with editor configuration and custom rule sets resolved from Maven coordinates. */
public static FormatterStep create(String version,
Provisioner provisioner,
@Nullable FileSignature editorConfig,
Map<String, Object> editorConfigOverride,
List<String> customRuleSets) {
return create(version, provisioner, editorConfig, editorConfigOverride, customRuleSets, null);
}

/**
* Creates a ktlint step with generated JARs whose contents are modeled separately by the calling build system.
* The supplier may return paths which do not exist until immediately before formatter execution.
*/
public static FormatterStep create(String version,
Provisioner provisioner,
@Nullable FileSignature editorConfig,
Map<String, Object> editorConfigOverride,
List<String> customRuleSets,
@Nullable ThrowingEx.Supplier<? extends Iterable<File>> additionalClasspath) {
Objects.requireNonNull(version, "version");
Objects.requireNonNull(provisioner, "provisioner");
String ktlintCoordinate = (version.startsWith("0.") ? MAVEN_COORDINATE_0_DOT : MAVEN_COORDINATE_1_DOT) + version;
Set<String> mavenCoordinates = new HashSet<>(customRuleSets);
mavenCoordinates.add(ktlintCoordinate);
mavenCoordinates.add(mavenCoordinate(version));
return FormatterStep.create(NAME,
new KtLintStep(version, JarState.promise(() -> JarState.from(mavenCoordinates, provisioner)), editorConfig, editorConfigOverride),
new KtLintStep(
version,
JarState.promise(() -> JarState.from(mavenCoordinates, provisioner)),
additionalClasspath == null ? null : new PromisedClasspath(additionalClasspath),
editorConfig,
editorConfigOverride),
KtLintStep::equalityState,
State::createFormat);
}
Expand All @@ -86,31 +113,50 @@ public static String defaultVersion() {
return DEFAULT_VERSION;
}

/** Returns the Maven coordinate used for the specified ktlint version. */
public static String mavenCoordinate(String version) {
Objects.requireNonNull(version, "version");
return (version.startsWith("0.") ? MAVEN_COORDINATE_0_DOT : MAVEN_COORDINATE_1_DOT) + version;
}

private State equalityState() {
return new State(version, jarState.get(), config != null ? config.get() : null, editorConfigOverride);
return new State(
version,
jarState.get(),
additionalClasspath == null ? List.of() : additionalClasspath.get(),
config != null ? config.get() : null,
editorConfigOverride);
}

private static final class State implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** The jar that contains the formatter. */
private final JarState jarState;
@SuppressFBWarnings(value = "SE_TRANSIENT_FIELD_NOT_RESTORED", justification = "Project classpath contents are separate Gradle task inputs and must not enter formatter equality")
private final transient List<File> additionalClasspath;
private final TreeMap<String, Object> editorConfigOverride;
private final String version;
@Nullable private final FileSignature editorConfigPath;

State(String version,
JarState jarState,
List<File> additionalClasspath,
@Nullable FileSignature editorConfigPath,
Map<String, Object> editorConfigOverride) {
this.version = version;
this.jarState = jarState;
this.additionalClasspath = List.copyOf(additionalClasspath);
this.editorConfigOverride = new TreeMap<>(editorConfigOverride);
this.editorConfigPath = editorConfigPath;
}

FormatterFunc createFormat() throws Exception {
final ClassLoader classLoader = jarState.getClassLoader();
JarState runtimeJarState = additionalClasspath == null || additionalClasspath.isEmpty()
? jarState
: jarState.withAdditionalJars(additionalClasspath);
// At this time, it is possible to sign the generated JAR contents.
final ClassLoader classLoader = runtimeJarState.getClassLoader();
Class<?> formatterFunc = classLoader.loadClass("com.diffplug.spotless.glue.ktlint.KtlintFormatterFunc");
Constructor<?> constructor = formatterFunc.getConstructor(
String.class, FileSignature.class, Map.class);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright 2026 DiffPlug
*
* 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.diffplug.spotless.kotlin;

import java.io.File;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.io.Serial;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

import javax.annotation.Nullable;

import com.diffplug.spotless.FileSignature;
import com.diffplug.spotless.JarState;
import com.diffplug.spotless.ThrowingEx;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;

/**
* Configuration-cache-safe promise for a generated classpath.
* <p>
* {@link JarState.Promised} and {@link FileSignature.Promised} represent signed file contents when
* materialized. That is too early for project artifacts, which might not exist while Gradle serializes
* formatter state. This promise serializes only the resolved file paths; Gradle tracks their contents as
* a separate {@code @Classpath} task input, and {@link KtLintStep.State#createFormat()} signs them after
* the producer tasks have run.
* <p>
* Resolution is synchronized because Gradle can serialize the roundtrip and equality views concurrently.
*/
final class PromisedClasspath implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@SuppressFBWarnings(value = "SE_TRANSIENT_FIELD_NOT_RESTORED", justification = "Serialized file paths replace the supplier after a configuration-cache roundtrip")
private final transient ThrowingEx.Supplier<? extends Iterable<File>> supplier;
@Nullable private volatile List<File> files;

PromisedClasspath(ThrowingEx.Supplier<? extends Iterable<File>> supplier) {
this.supplier = supplier;
}

/**
* Returns the promised classpath paths, materializing the supplier at most once.
* <p>
* Materialization resolves only paths. The referenced files may not exist yet because their Gradle producer tasks
* can run after formatter-state serialization. File contents are signed later by
* {@link KtLintStep.State#createFormat()}.
* <p>
* After a serialization roundtrip, the serialized path list is reused and the transient supplier is no longer
* required.
*/
List<File> get() {
List<File> result = files;
if (result == null) {
synchronized (this) {
result = files;
if (result == null) {
ThrowingEx.Supplier<? extends Iterable<File>> availableSupplier = Objects.requireNonNull(supplier, "supplier");
List<File> suppliedFiles = new ArrayList<>();
ThrowingEx.get(availableSupplier).forEach(suppliedFiles::add);
files = result = List.copyOf(suppliedFiles);
}
}
}
return result;
}

private void writeObject(ObjectOutputStream out) throws IOException {
get();
out.defaultWriteObject();
}
}
3 changes: 3 additions & 0 deletions plugin-gradle/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (

## [Unreleased]

### Added
- `ktlint().customRuleSets(...)` now accepts local Gradle projects, allowing rule sets from the same build without dependency substitution. ([#2999](https://github.com/diffplug/spotless/pull/2999), fixes [#1901](https://github.com/diffplug/spotless/issues/1901))

## [8.8.0] - 2026-06-29
### Added
- Add support for custom string format for license header copyright year via `yearStringFormat()`. ([#2965](https://github.com/diffplug/spotless/pull/2965))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
Expand All @@ -25,6 +27,8 @@

import javax.annotation.Nullable;

import org.gradle.api.Project;

import com.diffplug.common.collect.ImmutableList;
import com.diffplug.common.collect.ImmutableSortedMap;
import com.diffplug.spotless.FileSignature;
Expand Down Expand Up @@ -179,6 +183,7 @@ public final class KtlintConfig {
private FileSignature editorConfigPath;
private Map<String, Object> editorConfigOverride;
private List<String> customRuleSets;
private GradleProvisioner.DependencyClasspath customRuleSetProjectClasspath;

private KtlintConfig(
String version,
Expand Down Expand Up @@ -216,13 +221,73 @@ public KtlintConfig editorConfigOverride(Map<String, Object> editorConfigOverrid
return this;
}

/** Uses custom rule sets published at the given Maven coordinates. */
public KtlintConfig customRuleSets(List<String> customRuleSets) {
this.customRuleSets = ImmutableList.copyOf(customRuleSets);
replaceStep(createStep());
setCustomRuleSets(customRuleSets, List.of());
return this;
}

/**
* Uses custom rule sets from Maven coordinates and/or local Gradle projects.
* Each dependency must be either a {@link String} Maven coordinate or a {@link Project}
* from this build.
*/
public KtlintConfig customRuleSets(Object... customRuleSets) {
Objects.requireNonNull(customRuleSets, "customRuleSets");
List<String> mavenCoordinates = new ArrayList<>();
List<String> projectPaths = new ArrayList<>();
for (Object customRuleSet : customRuleSets) {
Objects.requireNonNull(customRuleSet, "customRuleSets must not contain null");
if (customRuleSet instanceof String mavenCoordinate) {
mavenCoordinates.add(mavenCoordinate);
} else if (customRuleSet instanceof Project project) {
if (project.getGradle() != getProject().getGradle()) {
throw new IllegalArgumentException("Custom ktlint rule-set projects must belong to the same Gradle build.");
}
// Store the stable project path, not the Project object
projectPaths.add(project.getPath());
} else {
// FileCollection and arbitrary Dependency objects are not supported in this version
throw new IllegalArgumentException("Custom ktlint rule-set dependencies must be Maven coordinate strings or Gradle projects, but found " + customRuleSet.getClass().getName() + ".");
}
}
setCustomRuleSets(mavenCoordinates, projectPaths);
return this;
}

private void setCustomRuleSets(Collection<String> mavenCoordinates, Collection<String> projectPaths) {
this.customRuleSets = ImmutableList.copyOf(mavenCoordinates);
// Remove the previous Gradle task input when customRuleSets() is called more than once.
if (customRuleSetProjectClasspath != null) {
removeFormatterClasspath(customRuleSetProjectClasspath.projectArtifacts);
}
if (projectPaths.isEmpty()) {
// Preserve the existing, simpler Maven-only path.
customRuleSetProjectClasspath = null;
} else {
List<String> allMavenCoordinates = new ArrayList<>(mavenCoordinates);
// ktlint, published rule sets, and local rule projects must participate in the same resolution graph.
String ktlintCoordinate = KtLintStep.mavenCoordinate(version);
allMavenCoordinates.add(ktlintCoordinate);

customRuleSetProjectClasspath = dependencyClasspath(allMavenCoordinates, projectPaths, ktlintCoordinate);

// Give Gradle ownership of generated project artifacts
addFormatterClasspath(customRuleSetProjectClasspath.projectArtifacts);
}
replaceStep(createStep());
}

private FormatterStep createStep() {
if (customRuleSetProjectClasspath != null) {
return KtLintStep.create(
version,
customRuleSetProjectClasspath.externalProvisioner,
editorConfigPath,
editorConfigOverride,
customRuleSets,
customRuleSetProjectClasspath.projectArtifacts::getFiles);
}
return KtLintStep.create(
version,
provisioner(),
Expand Down
Loading
Loading