diff --git a/lib-extra/src/main/java/com/diffplug/spotless/extra/middleware/ReviewDogGenerator.java b/lib-extra/src/main/java/com/diffplug/spotless/extra/middleware/ReviewDogGenerator.java new file mode 100644 index 0000000000..ddcf3723b7 --- /dev/null +++ b/lib-extra/src/main/java/com/diffplug/spotless/extra/middleware/ReviewDogGenerator.java @@ -0,0 +1,165 @@ +/* + * Copyright 2022-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.extra.middleware; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import com.diffplug.spotless.FormatterStep; +import com.diffplug.spotless.Lint; + +/** + * Utility class for generating ReviewDog compatible output in the rdjsonl format. + * This class provides methods to create diff and lint reports that can be used by ReviewDog. + */ +public final class ReviewDogGenerator { + + private static final String SOURCE = "spotless"; + + private ReviewDogGenerator() { + // Prevent instantiation + } + + /** + * Generates a ReviewDog compatible JSON line (rdjsonl) for a diff between + * the actual content and the formatted content of a file. + * + * @param path The file path + * @param actualContent The content as it currently exists in the file + * @param formattedContent The content after formatting is applied + * @return A string in rdjsonl format representing the diff + */ + public static String rdjsonlDiff(String path, String actualContent, String formattedContent) { + if (actualContent.equals(formattedContent)) { + return ""; + } + + String diff = createUnifiedDiff(path, actualContent, formattedContent); + + return String.format( + "{\"message\":{\"path\":\"%s\",\"message\":\"File requires formatting\",\"diff\":\"%s\"}}", + escapeJson(path), + escapeJson(diff)); + } + + /** + * Generates ReviewDog compatible JSON lines (rdjsonl) for lint issues + * identified by formatting steps. + * + * @param path The file path + * @param steps The list of formatter steps applied + * @param lintsPerStep The list of lints produced by each step + * @return A string in rdjsonl format representing the lints + */ + public static String rdjsonlLintsFromSteps(String path, List steps, List> lintsPerStep) { + if (steps == null || steps.isEmpty()) { + return rdjsonlLintsFromStrings(path, Collections.emptyList(), lintsPerStep); + } + List stepNames = steps.stream() + .map(FormatterStep::getName) + .collect(Collectors.toList()); + + if (lintsPerStep == null || lintsPerStep.isEmpty()) { + return rdjsonlLintsFromStrings(path, stepNames, Collections.emptyList()); + } + return rdjsonlLintsFromStrings(path, stepNames, lintsPerStep); + } + + private static String rdjsonlLintsFromStrings(String path, List stepNames, List> lintsPerStep) { + if (lintsPerStep == null || lintsPerStep.isEmpty()) { + return ""; + } + + StringBuilder builder = new StringBuilder(); + + for (int i = 0; i < lintsPerStep.size(); i++) { + List lints = lintsPerStep.get(i); + if (lints == null || lints.isEmpty()) { + continue; + } + + String stepName = (i < stepNames.size()) ? stepNames.get(i) : "unknown"; + for (Lint lint : lints) { + builder.append(formatLintAsJson(path, lint, stepName)).append('\n'); + } + } + + return builder.toString().trim(); + } + + /** + * Creates a unified diff between two text contents. + */ + private static String createUnifiedDiff(String path, String actualContent, String formattedContent) { + String[] actualLines = actualContent.split("\\r?\\n", -1); + String[] formattedLines = formattedContent.split("\\r?\\n", -1); + + StringBuilder diff = new StringBuilder(); + diff.append("--- a/").append(path).append('\n'); + diff.append("+++ b/").append(path).append('\n'); + diff.append("@@ -1,").append(actualLines.length).append(" +1,").append(formattedLines.length).append(" @@\n"); + + for (String line : actualLines) { + diff.append('-').append(line).append('\n'); + } + + for (String line : formattedLines) { + diff.append('+').append(line).append('\n'); + } + + return diff.toString(); + } + + /** + * Formats a single lint issue as a JSON line. + */ + private static String formatLintAsJson(String path, Lint lint, String ruleCode) { + return String.format( + "{" + + "\"source\":\"%s\"," + + "\"code\":\"%s\"," + + "\"level\":\"warning\"," + + "\"message\":\"%s\"," + + "\"path\":\"%s\"," + + "\"line\":%d," + + "\"column\":%d" + + "}", + escapeJson(SOURCE), + escapeJson(ruleCode), + escapeJson(lint.getDetail()), + escapeJson(path), + lint.getLineStart(), + 1); + } + + /** + * Escapes special characters in a string for JSON compatibility. + */ + private static String escapeJson(String str) { + if (str == null) { + return ""; + } + return str + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + .replace("\b", "\\b") + .replace("\f", "\\f"); + } +} diff --git a/lib-extra/src/main/java/com/diffplug/spotless/extra/middleware/package-info.java b/lib-extra/src/main/java/com/diffplug/spotless/extra/middleware/package-info.java new file mode 100644 index 0000000000..803126c322 --- /dev/null +++ b/lib-extra/src/main/java/com/diffplug/spotless/extra/middleware/package-info.java @@ -0,0 +1,7 @@ +@ParametersAreNonnullByDefault +@ReturnValuesAreNonnullByDefault +package com.diffplug.spotless.extra.middleware; + +import javax.annotation.ParametersAreNonnullByDefault; + +import com.diffplug.spotless.annotations.ReturnValuesAreNonnullByDefault; diff --git a/lib-extra/src/test/java/com/diffplug/spotless/extra/middleware/ReviewDogGeneratorTest.java b/lib-extra/src/test/java/com/diffplug/spotless/extra/middleware/ReviewDogGeneratorTest.java new file mode 100644 index 0000000000..107fcc6530 --- /dev/null +++ b/lib-extra/src/test/java/com/diffplug/spotless/extra/middleware/ReviewDogGeneratorTest.java @@ -0,0 +1,112 @@ +/* + * Copyright 2022-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.extra.middleware; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.diffplug.selfie.Selfie; +import com.diffplug.spotless.FormatterStep; +import com.diffplug.spotless.Lint; + +public class ReviewDogGeneratorTest { + + @Test + public void diffSingleLine() { + String result = ReviewDogGenerator.rdjsonlDiff("test.txt", "dirty", "clean"); + Selfie.expectSelfie(result).toBe("{\"message\":{\"path\":\"test.txt\",\"message\":\"File requires formatting\",\"diff\":\"--- a/test.txt\\n+++ b/test.txt\\n@@ -1,1 +1,1 @@\\n-dirty\\n+clean\\n\"}}"); + } + + @Test + public void diffNoChange() { + String result = ReviewDogGenerator.rdjsonlDiff("test.txt", "same", "same"); + Selfie.expectSelfie(result).toBe(""); + } + + @Test + public void diffMultipleLines() { + String actual = "Line 1\nLine 2\nDirty line\nLine 4"; + String formatted = "Line 1\nLine 2\nClean line\nLine 4"; + + String result = ReviewDogGenerator.rdjsonlDiff("src/main.java", actual, formatted); + Selfie.expectSelfie(result).toBe("{\"message\":{\"path\":\"src/main.java\",\"message\":\"File requires formatting\",\"diff\":\"--- a/src/main.java\\n+++ b/src/main.java\\n@@ -1,4 +1,4 @@\\n-Line 1\\n-Line 2\\n-Dirty line\\n-Line 4\\n+Line 1\\n+Line 2\\n+Clean line\\n+Line 4\\n\"}}"); + } + + @Test + public void lintsEmpty() { + List steps = new ArrayList<>(); + List> lintsPerStep = new ArrayList<>(); + + String result = ReviewDogGenerator.rdjsonlLintsFromSteps("test.txt", steps, lintsPerStep); + Selfie.expectSelfie(result).toBe(""); + } + + @Test + public void lintsSingleIssue() { + FormatterStep step = FormatterStep.create( + "testStep", + "formatter-state", + state -> rawUnix -> rawUnix); + List steps = Collections.singletonList(step); + + Lint lint = Lint.atLine(1, "TEST001", "Test lint message"); + List> lintsPerStep = Collections.singletonList(Collections.singletonList(lint)); + + String result = ReviewDogGenerator.rdjsonlLintsFromSteps("src/main.java", steps, lintsPerStep); + Selfie.expectSelfie(result).toBe("{\"source\":\"spotless\",\"code\":\"testStep\",\"level\":\"warning\",\"message\":\"Test lint message\",\"path\":\"src/main.java\",\"line\":1,\"column\":1}"); + } + + @Test + public void lintsMultipleIssues() { + FormatterStep step1 = new FormatterStep() { + @Override + public String getName() { + return "step1"; + } + + @Override + public String format(String rawUnix, File file) { + return rawUnix; + } + + @Override + public void close() {} + }; + + FormatterStep step2 = FormatterStep.create( + "step2", + "formatter-state", + state -> rawUnix -> rawUnix); + + List steps = Arrays.asList(step1, step2); + + Lint lint1 = Lint.atLine(1, "RULE1", "First issue"); + Lint lint2 = Lint.atLine(5, "RULE2", "Second issue"); + + List> lintsPerStep = Arrays.asList( + Collections.singletonList(lint1), + Collections.singletonList(lint2)); + + String result = ReviewDogGenerator.rdjsonlLintsFromSteps("src/main.java", steps, lintsPerStep); + Selfie.expectSelfie(result).toBe("{\"source\":\"spotless\",\"code\":\"step1\",\"level\":\"warning\",\"message\":\"First issue\",\"path\":\"src/main.java\",\"line\":1,\"column\":1}", + "{\"source\":\"spotless\",\"code\":\"step2\",\"level\":\"warning\",\"message\":\"Second issue\",\"path\":\"src/main.java\",\"line\":5,\"column\":1}"); + } +} diff --git a/lib/src/main/java/com/diffplug/spotless/LintState.java b/lib/src/main/java/com/diffplug/spotless/LintState.java index 3068872256..e4a003f97a 100644 --- a/lib/src/main/java/com/diffplug/spotless/LintState.java +++ b/lib/src/main/java/com/diffplug/spotless/LintState.java @@ -1,5 +1,5 @@ /* - * Copyright 2024 DiffPlug + * Copyright 2024-2025 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/plugin-gradle/CHANGES.md b/plugin-gradle/CHANGES.md index e35da34bfc..63e275d960 100644 --- a/plugin-gradle/CHANGES.md +++ b/plugin-gradle/CHANGES.md @@ -10,7 +10,9 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format ( ### Changed * Bump default `eclipse` version to latest `4.34` -> `4.35`. ([#2458](https://github.com/diffplug/spotless/pull/2458)) * Bump default `greclipse` version to latest `4.32` -> `4.35`. ([#2458](https://github.com/diffplug/spotless/pull/2458)) +### Added * pgp key had expired, this and future releases will be signed by new key ([details](https://github.com/diffplug/spotless/discussions/2464)) +* Implement conversion of diff content to ReviewDog format ([#2478](https://github.com/diffplug/spotless/pull/2478)) ## [7.0.3] - 2025-04-07 ### Changed diff --git a/plugin-gradle/README.md b/plugin-gradle/README.md index e3bf3df8fd..fd409b01cd 100644 --- a/plugin-gradle/README.md +++ b/plugin-gradle/README.md @@ -88,6 +88,7 @@ Spotless supports all of Gradle's built-in performance features (incremental bui - [Multiple (or custom) language-specific blocks](#multiple-or-custom-language-specific-blocks) - [Inception (languages within languages within...)](#inception-languages-within-languages-within) - [Disabling warnings and error messages](#disabling-warnings-and-error-messages) + - [Reviewdog integration for CI](#reviewdog-integration-for-ci) - [Dependency resolution modes](#dependency-resolution-modes) - [How do I preview what `spotlessApply` will do?](#how-do-i-preview-what-spotlessapply-will-do) - [Example configurations (from real-world projects)](#example-configurations-from-real-world-projects) @@ -1768,6 +1769,47 @@ spotless { ignoreErrorForPath('path/to/file.java') // ignore errors by all steps on this specific file ``` + + +## Reviewdog integration for CI + +**CURRENTLY IN BETA** – bug reports are welcome! This is a challenging feature to test comprehensively, so we anticipate needing a few releases to get everything right. +Spotless can generate reports compatible with [Reviewdog](https://github.com/reviewdog/reviewdog), a tool that automates code review tasks by posting formatting issues as comments on pull requests. + +### Enabling Reviewdog integration + +To enable Reviewdog output: + +```gradle +spotless { + reviewdogOutput file("${buildDir}/spotless-reviewdog.json") +} +``` + +### Automatic report generation on check failure + +To generate reports when `spotlessCheck` fails: + +```gradle +tasks.named('spotlessCheck').configure { + ignoreFailures = true + doLast { + if (state.failure != null) { + logger.lifecycle("Spotless check failed – generating Reviewdog report") + + if (project.hasProperty('reviewdogOutput')) { + // Insert logic here to generate reviewdogOutput + // Example: file(project.reviewdogOutput).text = spotlessCheckOutput + } + } + } +} + +``` + +For Reviewdog setup instructions, visit: https://github.com/reviewdog/reviewdog#installation + + ## Dependency resolution modes diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessCheck.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessCheck.java index 175a828a66..9285a28627 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessCheck.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessCheck.java @@ -31,13 +31,16 @@ import org.gradle.api.provider.Property; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.Optional; import org.gradle.api.tasks.TaskAction; import org.gradle.work.DisableCachingByDefault; import org.jetbrains.annotations.NotNull; import com.diffplug.spotless.FileSignature; +import com.diffplug.spotless.FormatterStep; import com.diffplug.spotless.ThrowingEx; import com.diffplug.spotless.extra.integration.DiffMessageFormatter; +import com.diffplug.spotless.extra.middleware.ReviewDogGenerator; @DisableCachingByDefault(because = "not worth caching") public abstract class SpotlessCheck extends SpotlessTaskService.ClientTask { @@ -47,6 +50,17 @@ public abstract class SpotlessCheck extends SpotlessTaskService.ClientTask { @Input public abstract Property getRunToFixMessage(); + @Input + public abstract Property getReviewDog(); + + @Input + @Optional + public abstract Property getReviewDogOutputDir(); + + @Input + @Optional + public abstract Property> getSteps(); + public void performActionTest() throws IOException { performAction(true); } @@ -61,29 +75,59 @@ private void performAction(boolean isTest) throws IOException { ConfigurableFileTree lintsFiles = getConfigCacheWorkaround().fileTree().from(getSpotlessLintsDirectory().get()); if (cleanFiles.isEmpty() && lintsFiles.isEmpty()) { getState().setDidWork(sourceDidWork()); - } else if (!isTest && applyHasRun()) { + return; + } + if (!isTest && applyHasRun()) { // if our matching apply has already run, then we don't need to do anything getState().setDidWork(false); - } else { - List unformattedFiles = getUncleanFiles(cleanFiles); - if (!unformattedFiles.isEmpty()) { - // if any files are unformatted, we show those - throw new GradleException(DiffMessageFormatter.builder() - .runToFix(getRunToFixMessage().get()) - .formatterFolder( - getProjectDir().get().getAsFile().toPath(), - getSpotlessCleanDirectory().get().toPath(), - getEncoding().get()) - .problemFiles(unformattedFiles) - .getMessage()); - } else { - // We only show lints if there are no unformatted files. - // This is because lint line numbers are relative to the - // formatted content, and formatting often fixes lints. - boolean detailed = false; - throw new GradleException(super.allLintsErrorMsgDetailed(lintsFiles, detailed)); + return; + } + + List unformattedFiles = getUncleanFiles(cleanFiles); + if (!unformattedFiles.isEmpty()) { + if (getReviewDog().get()) { + for (File file : unformattedFiles) { + String originalContent = new String(Files.readAllBytes(file.toPath()), getEncoding().get()); + File cleanFile = new File(getSpotlessCleanDirectory().get().getName(), getProjectDir().get().getAsFile().toPath().relativize(file.toPath()).toString()); + String formattedContent = new String(Files.readAllBytes(cleanFile.toPath()), getEncoding().get()); + + File outputDir = getReviewDogOutputDir().isPresent() ? getReviewDogOutputDir().get() : new File(getProject().getRootDir(), "build/reviewdog"); + + if (!outputDir.exists()) { + outputDir.mkdirs(); + } + + String relativePath = getProjectDir().get().getAsFile().toPath().relativize(file.toPath()).toString(); + File outputFile = new File(outputDir, relativePath + ".rdjsonl"); + outputFile.getParentFile().mkdirs(); + + String rdjsonl = ReviewDogGenerator.rdjsonlDiff(file.getPath(), originalContent, formattedContent); + Files.write(outputFile.toPath(), rdjsonl.getBytes(getEncoding().get())); + } } + + throw new GradleException(DiffMessageFormatter.builder() + .runToFix(getRunToFixMessage().get()) + .formatterFolder( + getProjectDir().get().getAsFile().toPath(), + getSpotlessCleanDirectory().get().toPath(), + getEncoding().get()) + .problemFiles(unformattedFiles) + .getMessage()); } + + if (getReviewDog().get()) { + for (File file : lintsFiles.getFiles()) { + String path = file.getPath(); + ReviewDogGenerator.rdjsonlLintsFromSteps(path, getSteps().get(), null); + } + } + + // We only show lints if there are no unformatted files. + // This is because lint line numbers are relative to the + // formatted content, and formatting often fixes lints. + boolean detailed = false; + throw new GradleException(super.allLintsErrorMsgDetailed(lintsFiles, detailed)); } private @NotNull List getUncleanFiles(ConfigurableFileTree cleanFiles) { diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtension.java index e883953eaa..7a27422d46 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtension.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtension.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2024 DiffPlug + * Copyright 2016-2025 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ import static java.util.Objects.requireNonNull; +import java.io.File; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; @@ -267,6 +268,45 @@ public void setEnforceCheck(boolean enforceCheck) { this.enforceCheck = enforceCheck; } + boolean reviewDog = false; + + /** + * Returns {@code true} if ReviewDog output should be generated; {@code false} otherwise. + */ + public boolean isReviewDog() { + return reviewDog; + } + + /** + * Configures Spotless to generate ReviewDog output if {@code true}. + *

+ * {@code false} by default. + */ + public void setReviewDog(boolean reviewDog) { + this.reviewDog = reviewDog; + } + + @Nullable + File reviewDogOutputDir; + + /** + * Returns the directory where ReviewDog output will be written. + * If not set, defaults to {@code build/reviewdog} in the root project directory. + */ + public @Nullable File getReviewDogOutputDir() { + return reviewDogOutputDir; + } + + /** + * Sets the directory where ReviewDog output will be written. + * If not set, defaults to {@code build/reviewdog} in the root project directory. + *

+ * If the directory does not exist, it will be created. + */ + public void setReviewDogOutputDir(File reviewDogOutputDir) { + this.reviewDogOutputDir = reviewDogOutputDir; + } + @SuppressWarnings("unchecked") public void format(String name, Class clazz, Action configure) { maybeCreate(name, clazz).lazyActions.add((Action) configure); diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtensionImpl.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtensionImpl.java index 75168f690a..4c84dce396 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtensionImpl.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtensionImpl.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2024 DiffPlug + * Copyright 2016-2025 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,8 @@ */ package com.diffplug.gradle.spotless; +import java.util.ArrayList; + import org.gradle.api.Action; import org.gradle.api.Project; import org.gradle.api.plugins.BasePlugin; @@ -91,6 +93,12 @@ protected void createFormatTasks(String name, FormatExtension formatExtension) { // if the user runs both, make sure that apply happens first, task.mustRunAfter(applyTask); + + // if the user enables the review dog, spotlessCheck will return the review dog format output + task.getReviewDog().set(this.reviewDog); + task.getReviewDogOutputDir().set(this.reviewDogOutputDir); + + task.getSteps().set(new ArrayList<>(source.getStepsInternalRoundtrip().getSteps())); }); rootCheckTask.configure(task -> task.dependsOn(checkTask));