diff --git a/CHANGES.md b/CHANGES.md index 5b0e8c042a..d3287f4a01 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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)) diff --git a/lib/src/main/java/com/diffplug/spotless/JarState.java b/lib/src/main/java/com/diffplug/spotless/JarState.java index bcf55208b2..e69b586e99 100644 --- a/lib/src/main/java/com/diffplug/spotless/JarState.java +++ b/lib/src/main/java/com/diffplug/spotless/JarState.java @@ -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. @@ -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; @@ -143,6 +145,14 @@ public static JarState preserveOrder(Collection jars) throws IOException { return new JarState(fileSignature); } + /** Returns a new state whose classpath also contains the given JARs. */ + public JarState withAdditionalJars(Iterable additionalJars) throws IOException { + List 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); } diff --git a/lib/src/main/java/com/diffplug/spotless/kotlin/KtLintStep.java b/lib/src/main/java/com/diffplug/spotless/kotlin/KtLintStep.java index 1da50be59e..f623545be1 100644 --- a/lib/src/main/java/com/diffplug/spotless/kotlin/KtLintStep.java +++ b/lib/src/main/java/com/diffplug/spotless/kotlin/KtLintStep.java @@ -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. @@ -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; @@ -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 ktlint as a FormatterStep. */ public final class KtLintStep implements Serializable { @@ -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 editorConfigOverride; private final String version; private KtLintStep(String version, JarState.Promised jarState, + @Nullable PromisedClasspath additionalClasspath, @Nullable FileSignature config, Map 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 editorConfigOverride, List 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 editorConfigOverride, + List customRuleSets, + @Nullable ThrowingEx.Supplier> additionalClasspath) { Objects.requireNonNull(version, "version"); Objects.requireNonNull(provisioner, "provisioner"); - String ktlintCoordinate = (version.startsWith("0.") ? MAVEN_COORDINATE_0_DOT : MAVEN_COORDINATE_1_DOT) + version; Set 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); } @@ -86,8 +113,19 @@ 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 { @@ -95,22 +133,30 @@ private static final class State implements Serializable { 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 additionalClasspath; private final TreeMap editorConfigOverride; private final String version; @Nullable private final FileSignature editorConfigPath; State(String version, JarState jarState, + List additionalClasspath, @Nullable FileSignature editorConfigPath, Map 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); diff --git a/lib/src/main/java/com/diffplug/spotless/kotlin/PromisedClasspath.java b/lib/src/main/java/com/diffplug/spotless/kotlin/PromisedClasspath.java new file mode 100644 index 0000000000..0a156ac264 --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/kotlin/PromisedClasspath.java @@ -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. + *

+ * {@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. + *

+ * 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> supplier; + @Nullable private volatile List files; + + PromisedClasspath(ThrowingEx.Supplier> supplier) { + this.supplier = supplier; + } + + /** + * Returns the promised classpath paths, materializing the supplier at most once. + *

+ * 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()}. + *

+ * After a serialization roundtrip, the serialized path list is reused and the transient supplier is no longer + * required. + */ + List get() { + List result = files; + if (result == null) { + synchronized (this) { + result = files; + if (result == null) { + ThrowingEx.Supplier> availableSupplier = Objects.requireNonNull(supplier, "supplier"); + List 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(); + } +} diff --git a/plugin-gradle/CHANGES.md b/plugin-gradle/CHANGES.md index 9665b4068c..0df868b2bc 100644 --- a/plugin-gradle/CHANGES.md +++ b/plugin-gradle/CHANGES.md @@ -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)) diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/BaseKotlinExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/BaseKotlinExtension.java index c3dc731215..09a40f8110 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/BaseKotlinExtension.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/BaseKotlinExtension.java @@ -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; @@ -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; @@ -179,6 +183,7 @@ public final class KtlintConfig { private FileSignature editorConfigPath; private Map editorConfigOverride; private List customRuleSets; + private GradleProvisioner.DependencyClasspath customRuleSetProjectClasspath; private KtlintConfig( String version, @@ -216,13 +221,73 @@ public KtlintConfig editorConfigOverride(Map editorConfigOverrid return this; } + /** Uses custom rule sets published at the given Maven coordinates. */ public KtlintConfig customRuleSets(List 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 mavenCoordinates = new ArrayList<>(); + List 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 mavenCoordinates, Collection 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 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(), diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java index 48f3e50eb9..ac386ba327 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java @@ -98,6 +98,14 @@ protected final Provisioner provisioner() { return spotless.getSpotlessTaskService().get().provisionerFor(spotless); } + final GradleProvisioner.DependencyClasspath dependencyClasspath( + Collection mavenCoordinates, + Collection projectPaths, + String strictlyEnforcedCoordinate) { + return spotless.getSpotlessTaskService().get().dependencyClasspathFor( + spotless, mavenCoordinates, projectPaths, strictlyEnforcedCoordinate); + } + protected final P2Provisioner p2Provisioner() { return spotless.getSpotlessTaskService().get().p2ProvisionerFor(spotless); } @@ -359,6 +367,23 @@ private static void relativizeIfSubdir(List relativePaths, File root, Fi /** The steps that need to be added. */ protected final List steps = new ArrayList<>(); + /** + * Individual formatter-owned classpath sources. + * + * FileCollection sources remain separate so a formatter can replace or remove its own classpath + * without disturbing sources registered by other steps. Gradle flattens them into the task's + * {@link org.gradle.api.file.ConfigurableFileCollection}. + */ + final List additionalFormatterClasspaths = new ArrayList<>(); + + protected final void addFormatterClasspath(FileCollection classpath) { + additionalFormatterClasspaths.add(requireNonNull(classpath)); + } + + protected final void removeFormatterClasspath(FileCollection classpath) { + additionalFormatterClasspaths.remove(classpath); + } + /** Adds a new step. */ public void addStep(FormatterStep newStep) { requireNonNull(newStep); @@ -403,6 +428,7 @@ protected void replaceStep(FormatterStep replacementStep) { /** Clears all of the existing steps. */ public void clearSteps() { steps.clear(); + additionalFormatterClasspaths.clear(); } /** @@ -1054,6 +1080,7 @@ private void withinBlocksHelper(FenceStep fence, Cla // create a step which applies all of those steps as sub-steps FormatterStep step = fence.applyWithin(formatExtension.steps); addStep(step); + additionalFormatterClasspaths.addAll(formatExtension.additionalFormatterClasspaths); } /** @@ -1086,6 +1113,7 @@ public void toggleOffOnDisable() { /** Sets up a format task according to the values in this extension. */ protected void setupTask(SpotlessTask task) { + task.getAdditionalFormatterClasspath().from(additionalFormatterClasspaths); task.setEncoding(getEncoding().name()); task.setLintSuppressions(lintSuppressions); FileCollection totalTarget = targetExclude == null ? target : target.minus(targetExclude); diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/GradleProvisioner.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/GradleProvisioner.java index 52196faf83..c0bb6abd40 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/GradleProvisioner.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/GradleProvisioner.java @@ -17,10 +17,13 @@ import java.io.File; import java.io.IOException; +import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import javax.annotation.Nullable; @@ -30,11 +33,17 @@ import org.gradle.api.artifacts.Configuration; import org.gradle.api.artifacts.ConfigurationContainer; import org.gradle.api.artifacts.Dependency; +import org.gradle.api.artifacts.ExternalDependency; +import org.gradle.api.artifacts.component.ModuleComponentSelector; +import org.gradle.api.artifacts.component.ProjectComponentIdentifier; import org.gradle.api.artifacts.dsl.DependencyHandler; +import org.gradle.api.artifacts.result.DependencyResult; import org.gradle.api.attributes.Bundling; import org.gradle.api.attributes.Category; import org.gradle.api.attributes.java.TargetJvmEnvironment; +import org.gradle.api.file.FileCollection; import org.gradle.api.initialization.dsl.ScriptHandler; +import org.gradle.api.provider.Provider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -53,16 +62,16 @@ enum Policy { public DedupingProvisioner dedupingProvisioner(Project project) { return switch (this) { - case ROOT_PROJECT -> new DedupingProvisioner(forProject(project)); - case ROOT_BUILDSCRIPT -> new DedupingProvisioner(forRootProjectBuildscript(project)); - default -> throw Unhandled.enumException(this); + case ROOT_PROJECT -> new DedupingProvisioner(forProject(project)); + case ROOT_BUILDSCRIPT -> new DedupingProvisioner(forRootProjectBuildscript(project)); + default -> throw Unhandled.enumException(this); }; } public DedupingP2Provisioner dedupingP2Provisioner(Project project) { return switch (this) { - case ROOT_PROJECT, ROOT_BUILDSCRIPT -> new DedupingP2Provisioner(P2Provisioner.createDefault(), defaultP2CacheDirectory(project)); - default -> throw Unhandled.enumException(this); + case ROOT_PROJECT, ROOT_BUILDSCRIPT -> new DedupingP2Provisioner(P2Provisioner.createDefault(), defaultP2CacheDirectory(project)); + default -> throw Unhandled.enumException(this); }; } } @@ -70,11 +79,58 @@ public DedupingP2Provisioner dedupingP2Provisioner(Project project) { static class DedupingProvisioner implements Provisioner { private final Provisioner provisioner; private final Map> cache = new HashMap<>(); + private final Map dependencyClasspathCache = new HashMap<>(); DedupingProvisioner(Provisioner provisioner) { this.provisioner = provisioner; } + DependencyClasspath dependencyClasspath( + Collection mavenCoordinates, + Collection projectPaths, + String strictlyEnforcedCoordinate) { + ConfigurationProvisioner configurationProvisioner = configurationProvisioner(); + DependencyClasspathRequest request = new DependencyClasspathRequest(mavenCoordinates, projectPaths, strictlyEnforcedCoordinate); + synchronized (dependencyClasspathCache) { + // Do not use a concurrent map here: different keys could create detached configurations concurrently, + // while calls into Gradle's mutable project model must remain serialized. + return dependencyClasspathCache.computeIfAbsent( + request, + unused -> configurationProvisioner.dependencyClasspath( + request.mavenCoordinates, request.projectPaths, request.strictlyEnforcedCoordinate)); + } + } + + DependencyClasspath cachedOnlyDependencyClasspath( + Collection mavenCoordinates, + Collection projectPaths, + String strictlyEnforcedCoordinate) { + ConfigurationProvisioner configurationProvisioner = configurationProvisioner(); + DependencyClasspathRequest request = new DependencyClasspathRequest(mavenCoordinates, projectPaths, strictlyEnforcedCoordinate); + Provider cached = configurationProvisioner.project.getProviders().provider(() -> { + synchronized (dependencyClasspathCache) { + DependencyClasspath result = dependencyClasspathCache.get(request); + if (result != null) { + return result; + } + } + throw new GradleException("Add a step with " + request.mavenCoordinates + " and projects " + request.projectPaths + + " into the `spotlessPredeclare` block in the root project."); + }); + Provisioner externalProvisioner = (withTransitives, requestedCoordinates) -> cached.get().externalProvisioner + .provisionWithTransitives(withTransitives, requestedCoordinates); + FileCollection projectArtifacts = configurationProvisioner.project.getObjects().fileCollection() + .from(cached.map(classpath -> classpath.projectArtifacts)); + return new DependencyClasspath(externalProvisioner, projectArtifacts); + } + + private ConfigurationProvisioner configurationProvisioner() { + if (provisioner instanceof ConfigurationProvisioner configurationProvisioner) { + return configurationProvisioner; + } + throw new IllegalStateException("Project dependencies require a Gradle configuration-backed provisioner."); + } + @Override public Set provisionWithTransitives(boolean withTransitives, Collection mavenCoordinates) { Request req = new Request(withTransitives, mavenCoordinates); @@ -111,47 +167,197 @@ public Set provisionWithTransitives(boolean withTransitives, Collection { + /** + * A provisioner bound to a specific Gradle dependency-resolution scope. + *

+ * Retaining the {@link ConfigurationContainer} and {@link DependencyHandler} is necessary when a formatter uses + * both Maven and project dependencies. It lets both dependency types participate in one detached configuration, + * so Gradle resolves version conflicts before the selected artifacts are separated into external and project + * classpaths. The binding also preserves whether dependencies must resolve from the consuming project's or the + * root buildscript's repositories, when using {@code predeclareDepsFromBuildscript()}. + */ + private static final class ConfigurationProvisioner implements Provisioner { + private final Project project; + private final ConfigurationContainer configurations; + private final DependencyHandler dependencies; + + private ConfigurationProvisioner(Project project, ConfigurationContainer configurations, DependencyHandler dependencies) { + this.project = project; + this.configurations = configurations; + this.dependencies = dependencies; + } + + @Override + public Set provisionWithTransitives(boolean withTransitives, Collection mavenCoords) { try { Request request = new Request(withTransitives, mavenCoords); - Dependency[] deps = mavenCoords.stream() - .map(dependencies::create) - .toArray(Dependency[]::new); - // Detached configurations avoid mutating the target configuration container, which Gradle 9 forbids - // for the root buildscript container during task execution. See https://github.com/diffplug/spotless/issues/2599. - Configuration config = configurations.detachedConfiguration(deps); - config.setDescription("Spotless internal dependency resolution for " + request); - config.setTransitive(withTransitives); - config.setCanBeConsumed(false); - config.setVisible(false); - config.attributes(attr -> { - attr.attribute(Category.CATEGORY_ATTRIBUTE, project.getObjects().named(Category.class, Category.LIBRARY)); - attr.attribute(Bundling.BUNDLING_ATTRIBUTE, project.getObjects().named(Bundling.class, Bundling.EXTERNAL)); - // Add this attribute for resolving Guava dependency, see https://github.com/google/guava/issues/6801. - attr.attribute(TargetJvmEnvironment.TARGET_JVM_ENVIRONMENT_ATTRIBUTE, project.getObjects().named(TargetJvmEnvironment.class, TargetJvmEnvironment.STANDARD_JVM)); - }); + Configuration config = configuration( + mavenCoords, List.of(), withTransitives, "Spotless internal dependency resolution for " + request, null); return config.resolve(); } catch (Exception e) { - String projName = project.getPath().substring(1).replace(':', '/'); - if (!projName.isEmpty()) { - projName = projName + "/"; + throw repositoryException(mavenCoords, e); + } + } + + DependencyClasspath dependencyClasspath( + Collection mavenCoordinates, + Collection projectPaths, + String strictlyEnforcedCoordinate) { + StrictVersion strictVersion = strictVersion(strictlyEnforcedCoordinate); + // Create every dependency before creating the configuration. + // This produces one conflict-resolution graph. + Configuration config = configuration( + mavenCoordinates, + projectPaths, + true, + "Spotless internal dependency resolution for " + mavenCoordinates + " and projects " + projectPaths, + strictVersion); + // This view contains ktlint and external libraries. + FileCollection externalArtifacts = config.getIncoming() + .artifactView(view -> view.componentFilter(identifier -> !(identifier instanceof ProjectComponentIdentifier))) + .getFiles(); + // This view contains local project artifacts. + FileCollection projectArtifacts = config.getIncoming() + .artifactView(view -> view.componentFilter(ProjectComponentIdentifier.class::isInstance)) + .getFiles(); + Set expectedCoordinates = Set.copyOf(mavenCoordinates); + Provisioner externalProvisioner = new DedupingProvisioner((withTransitives, requestedCoordinates) -> { + if (!withTransitives || !expectedCoordinates.equals(new HashSet<>(requestedCoordinates))) { + throw new IllegalArgumentException("Unexpected dependency request for unified ktlint classpath: " + requestedCoordinates); + } + strictVersion.rejectConflicts(config.getIncoming().getResolutionResult().getAllDependencies()); + return externalArtifacts.getFiles(); + }); + return new DependencyClasspath(externalProvisioner, projectArtifacts); + } + + private Configuration configuration( + Collection mavenCoordinates, + Collection projectPaths, + boolean withTransitives, + String description, + @Nullable StrictVersion strictVersion) { + List requestedDependencies = new ArrayList<>(mavenCoordinates.size() + projectPaths.size()); + boolean strictDependencyFound = strictVersion == null; + for (String coordinate : mavenCoordinates) { + Dependency dependency = dependencies.create(coordinate); + if (strictVersion != null && strictVersion.coordinate.equals(coordinate)) { + strictVersion.enforce(dependency); + strictDependencyFound = true; } - throw new GradleException(String.format( - "You need to add a repository containing the '%s' artifact in '%sbuild.gradle'.%n" - + "E.g.: 'repositories { mavenCentral() }'", - mavenCoords, projName), e); + requestedDependencies.add(dependency); } - }; + if (!strictDependencyFound) { + throw new IllegalArgumentException("Strictly enforced dependency is not part of the request: " + strictVersion.coordinate); + } + projectPaths.stream() + .map(projectPath -> project.getDependencies().project(Map.of("path", projectPath))) + .forEach(requestedDependencies::add); + Configuration config = configurations.detachedConfiguration(requestedDependencies.toArray(Dependency[]::new)); + configure(config, withTransitives, description); + return config; + } + + private StrictVersion strictVersion(String coordinate) { + Dependency dependency = dependencies.create(coordinate); + if (!(dependency instanceof ExternalDependency externalDependency)) { + throw new IllegalArgumentException("Cannot strictly enforce non-module dependency " + coordinate); + } + return new StrictVersion( + Objects.requireNonNull(externalDependency.getGroup(), "group"), + Objects.requireNonNull(externalDependency.getName(), "name"), + Objects.requireNonNull(externalDependency.getVersion(), "version"), + coordinate); + } + + private void configure(Configuration config, boolean withTransitives, String description) { + // Detached configurations avoid mutating configuration containers during task execution, which Gradle 9 + // forbids for buildscript configurations. See https://github.com/diffplug/spotless/issues/2599. + config.setDescription(description); + config.setTransitive(withTransitives); + config.setCanBeConsumed(false); + config.setVisible(false); + config.attributes(attr -> { + attr.attribute(Category.CATEGORY_ATTRIBUTE, project.getObjects().named(Category.class, Category.LIBRARY)); + attr.attribute(Bundling.BUNDLING_ATTRIBUTE, project.getObjects().named(Bundling.class, Bundling.EXTERNAL)); + // Add this attribute for resolving Guava dependency, see https://github.com/google/guava/issues/6801. + attr.attribute(TargetJvmEnvironment.TARGET_JVM_ENVIRONMENT_ATTRIBUTE, project.getObjects().named(TargetJvmEnvironment.class, TargetJvmEnvironment.STANDARD_JVM)); + }); + } + + private GradleException repositoryException(Collection mavenCoordinates, Exception cause) { + String projName = project.getPath().substring(1).replace(':', '/'); + if (!projName.isEmpty()) { + projName = projName + "/"; + } + return new GradleException(String.format( + "You need to add a repository containing the '%s' artifact in '%sbuild.gradle'.%n" + + "E.g.: 'repositories { mavenCentral() }'", + mavenCoordinates, projName), cause); + } + } + + static final class DependencyClasspath { + final Provisioner externalProvisioner; + final FileCollection projectArtifacts; + + private DependencyClasspath(Provisioner externalProvisioner, FileCollection projectArtifacts) { + this.externalProvisioner = externalProvisioner; + this.projectArtifacts = projectArtifacts; + } + } + + private record DependencyClasspathRequest( + ImmutableList mavenCoordinates, + ImmutableList projectPaths, + String strictlyEnforcedCoordinate) { + private DependencyClasspathRequest( + Collection mavenCoordinates, + Collection projectPaths, + String strictlyEnforcedCoordinate) { + this(ImmutableList.copyOf(mavenCoordinates), ImmutableList.copyOf(projectPaths), strictlyEnforcedCoordinate); + } + } + + private record StrictVersion(String group, String name, String version, String coordinate) { + private void enforce(Dependency dependency) { + if (!(dependency instanceof ExternalDependency externalDependency) + || !group.equals(externalDependency.getGroup()) + || !name.equals(externalDependency.getName()) + || !version.equals(externalDependency.getVersion())) { + throw new IllegalArgumentException("Cannot strictly enforce " + coordinate + " on " + dependency); + } + externalDependency.version(constraint -> constraint.strictly(version)); + } + + /** + * Rejects any dependencies that conflict with the strictly enforced version. + * + *

Current limitation: ktlint 0.x and 1.x use different modules. Which is not currently handled, + * i.e. it won't be detected / treated as a version conflict.

+ * + * @param dependencies + */ + private void rejectConflicts(Collection dependencies) { + for (DependencyResult dependency : dependencies) { + if (dependency.getRequested() instanceof ModuleComponentSelector requested + && group.equals(requested.getGroup()) + && name.equals(requested.getModule()) + && !version.equals(requested.getVersion())) { + throw new GradleException("The dependency graph requests '" + requested + "', but Spotless is configured to use '" + + coordinate + "'. Remove the conflicting ktlint dependency or align it with the version requested by the Spotless DSL."); + } + } + } } private static final Logger LOGGER = LoggerFactory.getLogger(GradleProvisioner.class); diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTask.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTask.java index 713c9a2e86..73d924ed58 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTask.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTask.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2025 DiffPlug + * Copyright 2020-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,10 +24,12 @@ import org.eclipse.jgit.lib.ObjectId; import org.gradle.api.DefaultTask; +import org.gradle.api.file.ConfigurableFileCollection; import org.gradle.api.file.DirectoryProperty; import org.gradle.api.file.FileCollection; import org.gradle.api.provider.Property; import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.Classpath; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.InputFiles; import org.gradle.api.tasks.Internal; @@ -152,6 +154,9 @@ public void setTarget(Iterable target) { } } + @Classpath + public abstract ConfigurableFileCollection getAdditionalFormatterClasspath(); + protected File cleanDirectory = new File(getProject().getLayout().getBuildDirectory().getAsFile().get(), "spotless-clean/" + getName()); diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java index 90f282d873..082bf2cefd 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java @@ -16,12 +16,14 @@ package com.diffplug.gradle.spotless; import java.io.File; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import javax.annotation.Nullable; @@ -47,6 +49,7 @@ import com.diffplug.common.base.Unhandled; import com.diffplug.spotless.Lint; import com.diffplug.spotless.Provisioner; +import com.diffplug.spotless.SpotlessCache; import com.diffplug.spotless.extra.P2Provisioner; /** @@ -58,8 +61,9 @@ public abstract class SpotlessTaskService implements BuildService, AutoCloseable, OperationCompletionListener { private final Map apply = Collections.synchronizedMap(new HashMap<>()); private final Map source = Collections.synchronizedMap(new HashMap<>()); - private final Map provisioner = Collections.synchronizedMap(new HashMap<>()); + private final Map provisioner = Collections.synchronizedMap(new HashMap<>()); private final Map p2Provisioner = Collections.synchronizedMap(new HashMap<>()); + private final AtomicBoolean hasProjectDependencies = new AtomicBoolean(); @Nullable GradleProvisioner.DedupingProvisioner predeclaredProvisioner; @Nullable GradleProvisioner.DedupingP2Provisioner predeclaredP2Provisioner; @@ -77,6 +81,32 @@ Provisioner provisionerFor(SpotlessExtension spotless) { } } + GradleProvisioner.DependencyClasspath dependencyClasspathFor( + SpotlessExtension spotless, + Collection mavenCoordinates, + Collection projectPaths, + String strictlyEnforcedCoordinate) { + if (!projectPaths.isEmpty()) { + hasProjectDependencies.set(true); + } + if (spotless instanceof SpotlessExtensionPredeclare) { + // spotlessPredeclare owns creation of the configuration. + // The resulting classpath is cached under the exact request. + return predeclaredProvisioner.dependencyClasspath(mavenCoordinates, projectPaths, strictlyEnforcedCoordinate); + } else if (predeclaredProvisioner != null) { + // A concrete formatter may only reuse what spotlessPredeclare created. + // It must not silently resolve an omitted dependency itself. + return predeclaredProvisioner.cachedOnlyDependencyClasspath(mavenCoordinates, projectPaths, strictlyEnforcedCoordinate); + } else { + // Without predeclaration, resolve using the consuming project's + // configurations, dependencies, and repositories. + GradleProvisioner.DedupingProvisioner selectedProvisioner = provisioner.computeIfAbsent( + spotless.project.getPath(), + unused -> new GradleProvisioner.DedupingProvisioner(GradleProvisioner.forProject(spotless.project))); + return selectedProvisioner.dependencyClasspath(mavenCoordinates, projectPaths, strictlyEnforcedCoordinate); + } + } + P2Provisioner p2ProvisionerFor(SpotlessExtension spotless) { if (spotless instanceof SpotlessExtensionPredeclare) { return predeclaredP2Provisioner; @@ -112,7 +142,15 @@ public void onFinish(FinishEvent var1) { @Override public void close() throws Exception { - ratchet.close(); + try { + ratchet.close(); + } finally { + if (hasProjectDependencies.get()) { + // Project artifacts can live in directories which are deleted immediately after the build. + // Release the URLClassLoader file handles first, which is required on Windows. + SpotlessCache.clearOnce(null); + } + } } // diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/KotlinExtensionTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/KotlinExtensionTest.java index f986d83717..aebfe4fcbf 100644 --- a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/KotlinExtensionTest.java +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/KotlinExtensionTest.java @@ -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. @@ -20,7 +20,11 @@ import java.io.File; import java.io.IOException; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.TaskOutcome; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; class KotlinExtensionTest extends GradleIntegrationHarness { private static final String HEADER = "// License Header"; @@ -191,6 +195,290 @@ void withCustomRuleSetApply() throws IOException { assertThat(buildOutput).contains("Composable functions that return Unit should start with an uppercase letter."); } + @Test + void issue1901CustomRuleSetSupportsProjectDependency() throws IOException { + setFile("settings.gradle.kts").toContent("include(\"ktlint-rules\", \"rule-support\")"); + setFile("rule-support/build.gradle.kts").toContent("plugins { java }"); + setFile("rule-support/src/main/java/support/Marker.java").toContent("package support; public final class Marker { public static int version() { return 1; } }\n"); + setFile("ktlint-rules/build.gradle.kts").toContent(""" + plugins { java } + repositories { mavenCentral() } + dependencies { + implementation(project(":rule-support")) + compileOnly("com.pinterest.ktlint:ktlint-cli-ruleset-core:1.0.1") + compileOnly("com.pinterest.ktlint:ktlint-rule-engine-core:1.0.1") + } + """); + setFile("ktlint-rules/src/main/java/rules/LocalRuleSetProvider.java").toContent(""" + package rules; + + import java.nio.file.Files; + import java.nio.file.Path; + import java.util.Collections; + import java.util.Set; + + import com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3; + import com.pinterest.ktlint.rule.engine.core.api.RuleProvider; + import com.pinterest.ktlint.rule.engine.core.api.RuleSetId; + import support.Marker; + + public final class LocalRuleSetProvider extends RuleSetProviderV3 { + public LocalRuleSetProvider() { + super(new RuleSetId("local-project")); + try { + Files.writeString(Path.of(System.getProperty("spotless.test.rule.version")), Integer.toString(Marker.version())); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + public Set getRuleProviders() { + return Collections.emptySet(); + } + } + """); + setFile("ktlint-rules/src/main/resources/META-INF/services/com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3") + .toContent("rules.LocalRuleSetProvider\n"); + setFile("build.gradle.kts").toContent(""" + plugins { + id("com.diffplug.spotless") + } + repositories { mavenCentral() } + spotless { + kotlin { + target("src/**/*.kt") + ktlint("1.0.1").customRuleSets(project(":ktlint-rules")) + } + } + """); + setFile("src/main/kotlin/Main.kt").toContent("fun main() {}\n"); + String ruleVersionProperty = "-Dspotless.test.rule.version=" + newFile("rule-version.txt").getAbsolutePath(); + + BuildResult firstRun = gradleRunner() + .withGradleVersion("9.5.1") + .withArguments("spotlessCheck", "--configuration-cache", "--stacktrace", ruleVersionProperty) + .build(); + assertThat(firstRun.getOutput()).contains("Configuration cache entry stored."); + assertThat(firstRun.task(":ktlint-rules:jar")).isNotNull(); + assertThat(firstRun.task(":rule-support:jar")).isNotNull(); + assertThat(firstRun.task(":spotlessKotlin")).isNotNull(); + assertFile("rule-version.txt").hasContent("1"); + + setFile("rule-support/src/main/java/support/Marker.java").toContent("package support; public final class Marker { public static int version() { return 2; } }\n"); + BuildResult secondRun = gradleRunner() + .withGradleVersion("9.5.1") + .withArguments("spotlessCheck", "--configuration-cache", "--stacktrace", ruleVersionProperty) + .build(); + assertThat(secondRun.getOutput()).contains("Reusing configuration cache."); + assertThat(secondRun.task(":rule-support:jar").getOutcome()).isNotEqualTo(TaskOutcome.UP_TO_DATE); + assertThat(secondRun.task(":spotlessKotlin").getOutcome()).isNotEqualTo(TaskOutcome.UP_TO_DATE); + assertFile("rule-version.txt").hasContent("2"); + } + + @Test + void ktlintProjectDependenciesShareConflictResolutionWithMavenRuleSets() throws IOException { + // The Maven rule graph requests support v1, while the project rule graph requests v2 and calls a v2-only method. + setFile("settings.gradle.kts").toContent("include(\"support-v1\", \"support-v2\", \"conflict-anchor\", \"ktlint-rules\")"); + setFile("support-v1/build.gradle").toContent(publishedSupportBuild("1")); + setFile("support-v2/build.gradle").toContent(publishedSupportBuild("2")); + setFile("support-v1/src/main/java/support/Api.java").toContent(""" + package support; + public final class Api { + public static String existing() { return "v1"; } + } + """); + setFile("support-v2/src/main/java/support/Api.java").toContent(""" + package support; + public final class Api { + public static String existing() { return "v2"; } + public static String addedInV2() { return "v2"; } + } + """); + setFile("conflict-anchor/build.gradle").toContent(""" + plugins { + id 'java-library' + id 'maven-publish' + } + group = 'test' + version = '1' + dependencies { api 'test:support:1' } + publishing { + publications { + mavenJava(MavenPublication) { + from components.java + artifactId = 'conflict-anchor' + } + } + repositories { + maven { url = rootProject.layout.projectDirectory.dir('repo') } + } + } + """); + setFile("ktlint-rules/build.gradle").toContent(""" + plugins { id 'java-library' } + dependencies { + implementation 'test:support:2' + compileOnly 'com.pinterest.ktlint:ktlint-cli-ruleset-core:1.0.1' + compileOnly 'com.pinterest.ktlint:ktlint-rule-engine-core:1.0.1' + } + """); + setFile("ktlint-rules/src/main/java/rules/LocalRuleSetProvider.java").toContent(""" + package rules; + + import java.nio.file.Files; + import java.nio.file.Path; + import java.util.Collections; + import java.util.Set; + + import com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3; + import com.pinterest.ktlint.rule.engine.core.api.RuleProvider; + import com.pinterest.ktlint.rule.engine.core.api.RuleSetId; + import support.Api; + + public final class LocalRuleSetProvider extends RuleSetProviderV3 { + public LocalRuleSetProvider() { + super(new RuleSetId("local-project")); + try { + Files.writeString( + Path.of(System.getProperty("spotless.test.rule.version")), + Api.addedInV2()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + public Set getRuleProviders() { + return Collections.emptySet(); + } + } + """); + setFile("ktlint-rules/src/main/resources/META-INF/services/com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3") + .toContent("rules.LocalRuleSetProvider\n"); + setFile("build.gradle.kts").toContent(""" + plugins { + id("com.diffplug.spotless") + } + allprojects { + repositories { + maven { url = uri(rootProject.layout.projectDirectory.dir("repo")) } + mavenCentral() + } + } + spotless { + kotlin { + target("src/**/*.kt") + ktlint("1.0.1").customRuleSets( + "test:conflict-anchor:1", + project(":ktlint-rules")) + } + } + """); + setFile("src/main/kotlin/Main.kt").toContent("fun main() {}\n"); + + gradleRunner() + .withGradleVersion("9.5.1") + .withArguments( + ":support-v1:publishMavenJavaPublicationToMavenRepository", + ":support-v2:publishMavenJavaPublicationToMavenRepository", + ":conflict-anchor:publishMavenJavaPublicationToMavenRepository") + .build(); + + String ruleVersionProperty = "-Dspotless.test.rule.version=" + newFile("rule-version.txt").getAbsolutePath(); + BuildResult result = gradleRunner() + .withGradleVersion("9.5.1") + .withArguments("spotlessCheck", "--configuration-cache", "--stacktrace", ruleVersionProperty) + .build(); + + assertThat(result.getOutput()).contains("Configuration cache entry stored."); + assertFile("rule-version.txt").hasContent("v2"); + } + + @ParameterizedTest(name = "rejects conflicting ktlint {0}") + @ValueSource(strings = {"1.0.0", "1.1.0"}) + void ktlintProjectDependencyCannotOverrideRequestedVersion(String conflictingVersion) throws IOException { + setFile("settings.gradle.kts").toContent("include(\"ktlint-rules\")"); + setFile("ktlint-rules/build.gradle.kts").toContent(""" + plugins { java } + dependencies { + runtimeOnly("com.pinterest.ktlint:ktlint-cli:%s") + } + """.formatted(conflictingVersion)); + setFile("build.gradle.kts").toContent(""" + plugins { + id("com.diffplug.spotless") + } + repositories { mavenCentral() } + spotless { + kotlin { + target("src/**/*.kt") + ktlint("1.0.1").customRuleSets(project(":ktlint-rules")) + } + } + """); + setFile("src/main/kotlin/Main.kt").toContent("fun main() {}\n"); + + BuildResult result = gradleRunner() + .withGradleVersion("9.5.1") + .withArguments("spotlessCheck", "--stacktrace") + .buildAndFail(); + + assertThat(result.getOutput()).contains( + "The dependency graph requests 'com.pinterest.ktlint:ktlint-cli:" + conflictingVersion + "'", + "Spotless is configured to use 'com.pinterest.ktlint:ktlint-cli:1.0.1'"); + } + + @Test + void ktlintProjectDependencyPreservesGradleVariantFailure() throws IOException { + setFile("settings.gradle.kts").toContent("include(\"ktlint-rules\")"); + setFile("ktlint-rules/build.gradle.kts").toContent(""); + setFile("build.gradle.kts").toContent(""" + plugins { + id("com.diffplug.spotless") + } + repositories { mavenCentral() } + spotless { + kotlin { + target("src/**/*.kt") + ktlint("1.0.1").customRuleSets(project(":ktlint-rules")) + } + } + """); + setFile("src/main/kotlin/Main.kt").toContent("fun main() {}\n"); + + BuildResult result = gradleRunner() + .withGradleVersion("9.5.1") + .withArguments("spotlessCheck", "--stacktrace") + .buildAndFail(); + + assertThat(result.getOutput()) + .contains("No matching variant of project :ktlint-rules was found") + .doesNotContain("You need to add a repository containing"); + } + + private static String publishedSupportBuild(String version) { + return """ + plugins { + id 'java-library' + id 'maven-publish' + } + group = 'test' + version = '%s' + publishing { + publications { + mavenJava(MavenPublication) { + from components.java + artifactId = 'support' + } + } + repositories { + maven { url = rootProject.layout.projectDirectory.dir('repo') } + } + } + """.formatted(version); + } + @Test void testWithHeader() throws IOException { setFile("build.gradle").toLines( diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/SpotlessPredeclareIntegrationTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/SpotlessPredeclareIntegrationTest.java index 796c8896ec..c613633148 100644 --- a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/SpotlessPredeclareIntegrationTest.java +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/SpotlessPredeclareIntegrationTest.java @@ -20,6 +20,7 @@ import java.io.IOException; import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.TaskOutcome; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -510,6 +511,149 @@ target file('test.java') @Nested class Gradle9Compatibility { + @Test + void gradle951CanPredeclareKtlintRuleSetProjectFromBuildscript() throws IOException { + setFile("settings.gradle.kts").toContent("include(\"ktlint-rules\")"); + setFile("ktlint-rules/build.gradle.kts").toContent(""" + plugins { java } + repositories { mavenCentral() } + dependencies { + compileOnly("com.pinterest.ktlint:ktlint-cli-ruleset-core:1.0.1") + compileOnly("com.pinterest.ktlint:ktlint-rule-engine-core:1.0.1") + } + """); + setFile("ktlint-rules/src/main/java/rules/LocalRuleSetProvider.java") + .toContent(localRuleSetProvider("1")); + setFile("ktlint-rules/src/main/resources/META-INF/services/com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3") + .toContent("rules.LocalRuleSetProvider\n"); + setFile("build.gradle.kts").toContent(""" + buildscript { + repositories { mavenCentral() } + } + plugins { + id("com.diffplug.spotless") + } + spotless { + predeclareDepsFromBuildscript() + kotlin { + target("src/**/*.kt") + ktlint("1.0.1").customRuleSets(project(":ktlint-rules")) + } + } + spotlessPredeclare { + kotlin { + ktlint("1.0.1").customRuleSets(project(":ktlint-rules")) + } + } + """); + setFile("src/main/kotlin/Main.kt").toContent("fun main() {}\n"); + String ruleVersionProperty = "-Dspotless.test.rule.version=" + newFile("rule-version.txt").getAbsolutePath(); + + BuildResult firstRun = gradleRunner() + .withGradleVersion("9.5.1") + .withArguments("spotlessCheck", "--configuration-cache", "--stacktrace", ruleVersionProperty) + .build(); + + assertThat(firstRun.getOutput()).contains("Configuration cache entry stored."); + assertThat(firstRun.task(":ktlint-rules:jar")).isNotNull(); + assertThat(firstRun.task(":ktlint-rules:jar").getOutcome()).isEqualTo(TaskOutcome.SUCCESS); + assertFile("rule-version.txt").hasContent("1"); + + setFile("ktlint-rules/src/main/java/rules/LocalRuleSetProvider.java") + .toContent(localRuleSetProvider("2")); + BuildResult secondRun = gradleRunner() + .withGradleVersion("9.5.1") + .withArguments("spotlessCheck", "--configuration-cache", "--stacktrace", ruleVersionProperty) + .build(); + + assertThat(secondRun.getOutput()).contains("Reusing configuration cache."); + assertThat(secondRun.task(":ktlint-rules:jar").getOutcome()).isNotEqualTo(TaskOutcome.UP_TO_DATE); + assertThat(secondRun.task(":spotlessKotlin").getOutcome()).isNotEqualTo(TaskOutcome.UP_TO_DATE); + assertFile("rule-version.txt").hasContent("2"); + } + + @Test + void mixedKtlintProjectDependenciesMustBePredeclared() throws IOException { + setFile("settings.gradle.kts").toContent("include(\"ktlint-rules\")"); + setFile("ktlint-rules/build.gradle.kts").toContent("plugins { java }"); + setFile("build.gradle.kts").toContent(""" + buildscript { + repositories { mavenCentral() } + } + plugins { + id("com.diffplug.spotless") + } + spotless { + predeclareDepsFromBuildscript() + kotlin { + target("src/**/*.kt") + ktlint("1.0.1").customRuleSets(project(":ktlint-rules")) + } + } + spotlessPredeclare { + kotlin { + ktlint("1.0.1") + } + } + """); + setFile("src/main/kotlin/Main.kt").toContent("fun main() {}\n"); + + BuildResult result = gradleRunner() + .withGradleVersion("9.5.1") + .withArguments("spotlessCheck", "--configuration-cache", "--stacktrace") + .buildAndFail(); + + assertThat(result.getOutput()) + .contains("Add a step with", "projects [:ktlint-rules]", "spotlessPredeclare"); + } + + @Test + void predeclaredKtlintProjectIsNotBuiltForUnrelatedFormat() throws IOException { + setFile("settings.gradle.kts").toContent("include(\"ktlint-rules\")"); + setFile("ktlint-rules/build.gradle.kts").toContent("plugins { java }"); + setFile("build.gradle.kts").toContent(""" + buildscript { + repositories { mavenCentral() } + } + plugins { + id("com.diffplug.spotless") + } + spotless { + predeclareDepsFromBuildscript() + } + spotlessPredeclare { + kotlin { + ktlint("1.0.1").customRuleSets(project(":ktlint-rules")) + } + } + spotless { + java { + target("src/**/*.java") + trimTrailingWhitespace() + } + } + """); + setFile("src/main/java/Main.java").toContent("class Main {}\n"); + + BuildResult first = gradleRunner() + .withGradleVersion("9.5.1") + .withArguments("spotlessJavaCheck", "--configuration-cache", "--stacktrace") + .build(); + + assertThat(first.getOutput()).contains("Configuration cache entry stored."); + assertThat(first.task(":spotlessInternalRegisterDependencies")).isNotNull(); + assertThat(first.task(":ktlint-rules:jar")).isNull(); + + BuildResult second = gradleRunner() + .withGradleVersion("9.5.1") + .withArguments("spotlessJavaCheck", "--configuration-cache", "--stacktrace") + .build(); + + assertThat(second.getOutput()).contains("Reusing configuration cache."); + assertThat(second.task(":spotlessInternalRegisterDependencies")).isNotNull(); + assertThat(second.task(":ktlint-rules:jar")).isNull(); + } + @Test void issue2599_Gradle951_CanUsePredeclareDepsFromBuildscript() throws IOException { setFile("build.gradle.kts").toContent(""" @@ -669,4 +813,37 @@ void emptyPredeclareBlockIsValid() throws IOException { assertThat(result.getOutput()).contains("BUILD SUCCESSFUL"); } } + + private static String localRuleSetProvider(String version) { + return """ + package rules; + + import java.nio.file.Files; + import java.nio.file.Path; + import java.util.Collections; + import java.util.Set; + + import com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3; + import com.pinterest.ktlint.rule.engine.core.api.RuleProvider; + import com.pinterest.ktlint.rule.engine.core.api.RuleSetId; + + public final class LocalRuleSetProvider extends RuleSetProviderV3 { + public LocalRuleSetProvider() { + super(new RuleSetId("local-project")); + try { + Files.writeString( + Path.of(System.getProperty("spotless.test.rule.version")), + "%s"); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + public Set getRuleProviders() { + return Collections.emptySet(); + } + } + """.formatted(version); + } }