diff --git a/CHANGELOG.md b/CHANGELOG.md index 1278e62..2f9acf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Commit, tag and push the choco source files to the chocolatey-bucket repository during the release process +- Added formatter [`clean-that`](https://github.com/diffplug/spotless/tree/main/plugin-gradle#cleanthat) ## [0.1.1] - 2025-06-02 diff --git a/README.md b/README.md index f5dde40..8ad1d9c 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ or apply the formatting to the files. Available formatting steps: clang-format Runs clang-format + clean-that CleanThat enables automatic refactoring of Java code. format-annotations Corrects line break formatting of type annotations in java files. google-java-format Runs google java format @@ -183,6 +184,7 @@ Possible exit codes: Spotless CLI supports the following formatter steps in alphabetical order: - [clang-format](#clang-format) +- [clean-that](#clean-that) - [format-annotations](#format-annotations) - [google-java-format](#google-java-format) - [license-header](#license-header) @@ -240,6 +242,73 @@ spotless --target '**/src/**/*.cpp' clang-format --clang-version=20.1.2 --style= > [!IMPORTANT] > Running a clang-format step requires a working installation of the clang-format binary. +### clean-that + + + +[![CleanThat version](https://img.shields.io/badge/clean--that-2.23-blue.svg)](https://github.com/solven-eu/cleanthat) + + + +Cleanthat is a project enabling automatic code cleaning, from formatting to refactoring. + +To see usage instructions for the clean-that formatter, run: `spotless clean-that --help` + + + +``` +Usage: spotless clean-that [-dDhV] [-s=] [-a[=mutator[, + mutator...]...]]... [-e[=mutator[,mutator...]...]]... +CleanThat enables automatic refactoring of Java code. + -a, --add-mutator[=mutator[,mutator...]...] + Add a mutator to the list of mutators to use. Mutators are + the individual refactoring steps CleanThat applies. A list + of available mutators can be found in the "Additional Info" + section. + -d, --use-default-mutators + Use the default mutators provided by CleanThat. Default + mutators are: . + (default: true) + -D, --include-draft-mutators + Include draft mutators in the list of mutators to use. Draft + mutators are experimental and may not be fully tested or + stable. + (default: false) + -e, --exclude-mutator[=mutator[,mutator...]...] + Remove a mutator from the list of mutators to use. This might + make sense for composite mutators + -h, --help Show this help message and exit. + -s, --source-compatibility= + The source JDK version to use for the CleanThat mutators. + This is used to determine the Java language features + available. + (default: 1.8) + -V, --version Print version information and exit. + +✅ This step supports the following file type: Java + +🌎 Additional info: + * https://github.com/solven-eu/cleanthat + * https://github.com/solven-eu/cleanthat/blob/master/MUTATORS.generated.MD +``` + + + +Example usage: + +```shell +spotless --target '**/src/**/*.java' clean-that --exclude-mutator=StreamAnyMatch +``` + ### format-annotations In Java, type annotations should be on the same line as the type that they qualify. This formatter fixes this for you. diff --git a/app/src/main/java/com/diffplug/spotless/cli/SpotlessCLI.java b/app/src/main/java/com/diffplug/spotless/cli/SpotlessCLI.java index dd45aad..bfee0c1 100644 --- a/app/src/main/java/com/diffplug/spotless/cli/SpotlessCLI.java +++ b/app/src/main/java/com/diffplug/spotless/cli/SpotlessCLI.java @@ -44,6 +44,7 @@ import com.diffplug.spotless.cli.logging.output.LoggingConfigurer; import com.diffplug.spotless.cli.logging.output.Output; import com.diffplug.spotless.cli.steps.ClangFormat; +import com.diffplug.spotless.cli.steps.CleanThat; import com.diffplug.spotless.cli.steps.FormatAnnotations; import com.diffplug.spotless.cli.steps.GoogleJavaFormat; import com.diffplug.spotless.cli.steps.LicenseHeader; @@ -94,6 +95,7 @@ subcommandsRepeatable = true, subcommands = { ClangFormat.class, + CleanThat.class, FormatAnnotations.class, GoogleJavaFormat.class, LicenseHeader.class, diff --git a/app/src/main/java/com/diffplug/spotless/cli/steps/CleanThat.java b/app/src/main/java/com/diffplug/spotless/cli/steps/CleanThat.java new file mode 100644 index 0000000..b16295e --- /dev/null +++ b/app/src/main/java/com/diffplug/spotless/cli/steps/CleanThat.java @@ -0,0 +1,128 @@ +/* + * Copyright 2025 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.cli.steps; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import org.jetbrains.annotations.NotNull; + +import com.diffplug.spotless.FormatterStep; +import com.diffplug.spotless.cli.core.SpotlessActionContext; +import com.diffplug.spotless.cli.help.AdditionalInfoLinks; +import com.diffplug.spotless.cli.help.OptionConstants; +import com.diffplug.spotless.cli.help.SupportedFileTypes; +import com.diffplug.spotless.java.CleanthatJavaStep; + +import picocli.CommandLine; + +@CommandLine.Command(name = "clean-that", description = "CleanThat enables automatic refactoring of Java code.") +@SupportedFileTypes("Java") +@AdditionalInfoLinks({ + "https://github.com/solven-eu/cleanthat", + "https://github.com/solven-eu/cleanthat/blob/master/MUTATORS.generated.MD" +}) +public class CleanThat extends SpotlessFormatterStep { + + public static final String DEFAULT_MUTATORS = String.join(", ", CleanthatJavaStep.defaultMutators()); + + static { + // workaround for dynamic property resolution in help messages + System.setProperty("usage.cleanthat.defaultMutators", DEFAULT_MUTATORS); + } + + @CommandLine.Option( + names = {"--use-default-mutators", "-d"}, + defaultValue = "true", + description = + "Use the default mutators provided by CleanThat. Default mutators are: <${usage.cleanthat.defaultMutators}>." + + OptionConstants.DEFAULT_VALUE_SUFFIX) + boolean useDefaultMutators; + + @CommandLine.Option( + names = {"--add-mutator", "-a"}, + arity = "0..*", + split = OptionConstants.OPTION_LIST_SPLIT, + paramLabel = "mutator", + description = + "Add a mutator to the list of mutators to use. Mutators are the individual refactoring steps CleanThat applies. A list of available mutators can be found in the \"Additional Info\" section. ") + List addMutators; + + @CommandLine.Option( + names = {"--exclude-mutator", "-e"}, + arity = "0..*", + split = OptionConstants.OPTION_LIST_SPLIT, + paramLabel = "mutator", + description = + "Remove a mutator from the list of mutators to use. This might make sense for composite mutators") + List excludeMutators; + + @CommandLine.Option( + names = {"--include-draft-mutators", "-D"}, + defaultValue = "false", + description = + "Include draft mutators in the list of mutators to use. Draft mutators are experimental and may not be fully tested or stable." + + OptionConstants.DEFAULT_VALUE_SUFFIX) + boolean includeDraftMutators; + + @CommandLine.Option( + names = {"--source-compatibility", "-s"}, + defaultValue = "1.8", + description = + "The source JDK version to use for the CleanThat mutators. This is used to determine the Java language features available." + + OptionConstants.DEFAULT_VALUE_SUFFIX) + String sourceCompatibility; + + @Override + public @NotNull List prepareFormatterSteps(SpotlessActionContext context) { + return Collections.singletonList(CleanthatJavaStep.create( + CleanthatJavaStep.defaultGroupArtifact(), + CleanthatJavaStep.defaultVersion(), + this.sourceCompatibility, + includedMutators(), + excludedMutators(), + this.includeDraftMutators, + context.provisioner())); + } + + private List includedMutators() { + List mutators = new ArrayList<>(); + if (useDefaultMutators) { + mutators.addAll(CleanthatJavaStep.defaultMutators()); + } + if (addMutators != null) { + mutators.addAll(addMutators.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList()); + } + return mutators; + } + + private List excludedMutators() { + if (excludeMutators == null) { + return Collections.emptyList(); + } + return excludeMutators.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + } +} diff --git a/app/src/test/java/com/diffplug/spotless/cli/steps/CleanThatTest.java b/app/src/test/java/com/diffplug/spotless/cli/steps/CleanThatTest.java new file mode 100644 index 0000000..ce8d68f --- /dev/null +++ b/app/src/test/java/com/diffplug/spotless/cli/steps/CleanThatTest.java @@ -0,0 +1,125 @@ +/* + * Copyright 2025 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.cli.steps; + +import java.io.File; +import java.io.IOException; + +import org.junit.jupiter.api.Test; + +import com.diffplug.spotless.cli.CLIIntegrationHarness; +import com.diffplug.spotless.tag.CliNativeTest; +import com.diffplug.spotless.tag.CliProcessTest; + +@CliProcessTest +@CliNativeTest +public class CleanThatTest extends CLIIntegrationHarness { + + @Test + void itRunsWithDefaultOptions() { + setFile("Test.java").toResource("java/cleanthat/MultipleMutators.dirty.test"); + + cliRunner().withTargets("Test.java").withStep(CleanThat.class).run(); + + assertFile("Test.java").notSameSasResource("java/cleanthat/MultipleMutators.dirty.test"); + selfie().expectResource("Test.java").toMatchDisk(); + } + + @Test + void itLetsDisableDefaultMutators() { + setFile("Test.java").toResource("java/cleanthat/MultipleMutators.dirty.test"); + + cliRunner() + .withTargets("Test.java") + .withStep(CleanThat.class) + .withOption("--use-default-mutators", "false") + .run(); + + assertFile("Test.java").sameAsResource("java/cleanthat/MultipleMutators.dirty.test"); + } + + @Test + void itLetsEnableSpecificMutators() { + setFile("Test.java").toResource("java/cleanthat/MultipleMutators.dirty.test"); + + cliRunner() + .withTargets("Test.java") + .withStep(CleanThat.class) + .withOption("--use-default-mutators", "false") + .withOption("--add-mutator", "LiteralsFirstInComparisons") + .run(); + + assertFile("Test.java").notSameSasResource("java/cleanthat/MultipleMutators.dirty.test"); + selfie().expectResource("Test.java").toMatchDisk(); + } + + @Test + void itLetsDisableSpecificMutators() { + setFile("Test.java").toResource("java/cleanthat/MultipleMutators.dirty.test"); + + cliRunner() + .withTargets("Test.java") + .withStep(CleanThat.class) + .withOption("--exclude-mutator", "StreamAnyMatch") + .run(); + + assertFile("Test.java").sameAsResource("java/cleanthat/MultipleMutators.dirty.test"); + } + + @Test + void itLetsEnableDraftMutators() throws IOException { + File file1 = setFile("Test.java") + .toResource("java/cleanthat/MultipleMutators.dirty.test") + .getFile(); + File file2 = setFile("Test2.java") + .toResource("java/cleanthat/MultipleMutators.dirty.test") + .getFile(); + + cliRunner() + .withTargets("Test.java") + .withStep(CleanThat.class) + .withOption("--add-mutator", "RemoveAllToClearCollection") + .run(); + + var result = cliRunner() + .withTargets("Test2.java") + .withStep(CleanThat.class) + .withOption("--include-draft-mutators", "true") + .withOption("--add-mutator", "RemoveAllToClearCollection") + .run(); + + assertFile("Test.java").notSameSasResource("java/cleanthat/MultipleMutators.dirty.test"); + selfie().expectResource("Test.java").toMatchDisk("excluding draft mutators"); + selfie().expectResource("Test2.java").toMatchDisk("including draft mutators"); + + // these outcomes should be different, but they are not, problably a upstream issue in CleanThat + // assertFile(file1).notSameAsFile(file2); + } + + @Test + void itCanExecuteAllMutators() { + setFile("Test.java").toResource("java/cleanthat/MultipleMutators.dirty.test"); + + cliRunner() + .withTargets("Test.java") + .withStep(CleanThat.class) + .withOption("--add-mutator", "AllIncludingDraftSingleMutators") + .withOption("--include-draft-mutators", "true") + .run(); + + selfie().expectResource("Test.java").toMatchDisk(); + } +} diff --git a/app/src/test/resources/com/diffplug/spotless/cli/steps/CleanThatTest.ss b/app/src/test/resources/com/diffplug/spotless/cli/steps/CleanThatTest.ss new file mode 100644 index 0000000..e8173aa --- /dev/null +++ b/app/src/test/resources/com/diffplug/spotless/cli/steps/CleanThatTest.ss @@ -0,0 +1,166 @@ +╔═ itCanExecuteAllMutators ═╗ +package eu.solven.cleanthat.engine.java.refactorer.cases.do_not_format_me; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Stream; + +public class LiteralsFirstInComparisonsCases { + + public boolean isHardcoded(String input) { + return "hardcoded".equals(input); + } + + public boolean isPresent(Optional optional) { + return !optional.isEmpty(); + } + + public static boolean isPresent(List list, Predicate predicate) { + Stream stream = list.stream(); + return stream.anyMatch(predicate); + } + + public static boolean isStringAAA(String str) { + return "AAA".equals(str); + } + + public static void clean(Collection collection) { + collection.clear(); + } +} + +╔═ itLetsEnableDraftMutators/excluding draft mutators ═╗ +package eu.solven.cleanthat.engine.java.refactorer.cases.do_not_format_me; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Stream; + +public class LiteralsFirstInComparisonsCases { + + public boolean isHardcoded(String input) { + return input.equals("hardcoded"); + } + + public boolean isPresent(Optional optional) { + return !optional.isEmpty(); + } + + public static boolean isPresent(List list, Predicate predicate) { + Stream stream = list.stream(); + return stream.anyMatch(predicate); + } + + public static boolean isStringAAA(String str) { + return str.equals("AAA"); + } + + public static void clean(Collection collection) { + collection.clear(); + } +} + +╔═ itLetsEnableDraftMutators/including draft mutators ═╗ +package eu.solven.cleanthat.engine.java.refactorer.cases.do_not_format_me; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Stream; + +public class LiteralsFirstInComparisonsCases { + + public boolean isHardcoded(String input) { + return input.equals("hardcoded"); + } + + public boolean isPresent(Optional optional) { + return !optional.isEmpty(); + } + + public static boolean isPresent(List list, Predicate predicate) { + Stream stream = list.stream(); + return stream.anyMatch(predicate); + } + + public static boolean isStringAAA(String str) { + return str.equals("AAA"); + } + + public static void clean(Collection collection) { + collection.clear(); + } +} + +╔═ itLetsEnableSpecificMutators ═╗ +package eu.solven.cleanthat.engine.java.refactorer.cases.do_not_format_me; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Stream; + +public class LiteralsFirstInComparisonsCases { + + public boolean isHardcoded(String input) { + return "hardcoded".equals(input); + } + + public boolean isPresent(Optional optional) { + return !optional.isEmpty(); + } + + public static boolean isPresent(List list, Predicate predicate) { + Stream stream = list.stream(); + return stream.filter(predicate).findAny().isPresent(); + } + + public static boolean isStringAAA(String str) { + return "AAA".equals(str); + } + + public static void clean(Collection collection) { + collection.removeAll(collection); + } +} + +╔═ itRunsWithDefaultOptions ═╗ +package eu.solven.cleanthat.engine.java.refactorer.cases.do_not_format_me; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Stream; + +public class LiteralsFirstInComparisonsCases { + + public boolean isHardcoded(String input) { + return input.equals("hardcoded"); + } + + public boolean isPresent(Optional optional) { + return !optional.isEmpty(); + } + + public static boolean isPresent(List list, Predicate predicate) { + Stream stream = list.stream(); + return stream.anyMatch(predicate); + } + + public static boolean isStringAAA(String str) { + return str.equals("AAA"); + } + + public static void clean(Collection collection) { + collection.removeAll(collection); + } +} + +╔═ [end of file] ═╗ diff --git a/build-logic/src/main/groovy/buildlogic.java-graalvm-conventions.gradle b/build-logic/src/main/groovy/buildlogic.java-graalvm-conventions.gradle index 67263bc..04dfd4a 100644 --- a/build-logic/src/main/groovy/buildlogic.java-graalvm-conventions.gradle +++ b/build-logic/src/main/groovy/buildlogic.java-graalvm-conventions.gradle @@ -69,6 +69,9 @@ graalvmNative { buildArgs.add('-H:+UnlockExperimentalVMOptions') // to enable the following options buildArgs.add('-H:IncludeResourceBundles=com.sun.tools.javac.resources.compiler') buildArgs.add('-H:IncludeResourceBundles=com.sun.tools.javac.resources.javac') + + // the following options are required for clean-that + buildArgs.add('--initialize-at-build-time=com.google.common.jimfs.SystemJimfsFileSystemProvider') } } } diff --git a/build-logic/src/main/groovy/com/diffplug/spotless/cli/picocli/usage/DocumentedUsages.groovy b/build-logic/src/main/groovy/com/diffplug/spotless/cli/picocli/usage/DocumentedUsages.groovy index 27dcfac..82e4233 100644 --- a/build-logic/src/main/groovy/com/diffplug/spotless/cli/picocli/usage/DocumentedUsages.groovy +++ b/build-logic/src/main/groovy/com/diffplug/spotless/cli/picocli/usage/DocumentedUsages.groovy @@ -3,6 +3,7 @@ package com.diffplug.spotless.cli.picocli.usage enum DocumentedUsages { CLANG_FORMAT(), + CLEAN_THAT(), FORMAT_ANNOTATIONS(), MAIN(""), GOOGLE_JAVA_FORMAT(), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 117d41a..3df531e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,10 +1,12 @@ [versions] annotations = "26.0.2" assertj-core = "3.27.3" +classgraph = "4.8.180" diff-utils = "1.3.0" durian = "1.2.0" junit = "5.8.1" mockito = "5.17.0" +native-include-cleanThat = "2.23" native-include-googleJavaFormat = "1.27.0" native-include-palantirJavaFormat = "2.67.0" picocli = "4.7.6" @@ -23,6 +25,7 @@ durian-collect = { module = "com.diffplug.durian:durian-collect", version.ref = junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" } junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" } mockito = { module = "org.mockito:mockito-core", version.ref = "mockito" } +native-include-cleanThat = { module = "io.github.solven-eu.cleanthat:java", version.ref = "native-include-cleanThat" } native-include-googleJavaFormat = { module = "com.google.googlejavaformat:google-java-format", version.ref = "native-include-googleJavaFormat" } native-include-palantirJavaFormat = { module = "com.palantir.javaformat:palantir-java-format", version.ref = "native-include-palantirJavaFormat" } picocli = { module = "info.picocli:picocli", version.ref = "picocli" } @@ -37,6 +40,7 @@ spotless-plugin-gradle = { module = "com.diffplug.spotless:spotless-plugin-gradl [bundles] durian-libs = ["durian-core", "durian-io", "durian-collect"] native-includes = [ + "native-include-cleanThat", "native-include-googleJavaFormat", "native-include-palantirJavaFormat", ] diff --git a/testlib/src/main/java/com/diffplug/spotless/ResourceHarness.java b/testlib/src/main/java/com/diffplug/spotless/ResourceHarness.java index 98f699d..17d3bd4 100644 --- a/testlib/src/main/java/com/diffplug/spotless/ResourceHarness.java +++ b/testlib/src/main/java/com/diffplug/spotless/ResourceHarness.java @@ -233,6 +233,16 @@ public void matches(Consumer> conditions) String content = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); conditions.accept(assertThat(content)); } + + public void sameAsFile(File otherFile) throws IOException { + String otherFileContent = Files.readString(otherFile.toPath()); + hasContent(otherFileContent, StandardCharsets.UTF_8); + } + + public void notSameAsFile(File otherFile) throws IOException { + String otherFileContent = Files.readString(otherFile.toPath()); + notHasContent(otherFileContent, StandardCharsets.UTF_8); + } } public WriteAsserter setFile(String path) { diff --git a/testlib/src/main/resources/java/cleanthat/MultipleMutators.dirty.test b/testlib/src/main/resources/java/cleanthat/MultipleMutators.dirty.test new file mode 100644 index 0000000..26a656f --- /dev/null +++ b/testlib/src/main/resources/java/cleanthat/MultipleMutators.dirty.test @@ -0,0 +1,31 @@ +package eu.solven.cleanthat.engine.java.refactorer.cases.do_not_format_me; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Stream; + +public class LiteralsFirstInComparisonsCases { + + public boolean isHardcoded(String input) { + return input.equals("hardcoded"); + } + + public boolean isPresent(Optional optional) { + return !optional.isEmpty(); + } + + public static boolean isPresent(List list, Predicate predicate) { + Stream stream = list.stream(); + return stream.filter(predicate).findAny().isPresent(); + } + + public static boolean isStringAAA(String str) { + return str.equals("AAA"); + } + + public static void clean(Collection collection) { + collection.removeAll(collection); + } +}