|
1 | 1 | /* |
2 | | - * Copyright 2016-2025 DiffPlug |
| 2 | + * Copyright 2025 DiffPlug |
3 | 3 | * |
4 | 4 | * Licensed under the Apache License, Version 2.0 (the "License"); |
5 | 5 | * you may not use this file except in compliance with the License. |
|
15 | 15 | */ |
16 | 16 | package com.diffplug.spotless.extra.middleware; |
17 | 17 |
|
18 | | -import java.io.File; |
19 | | -import java.io.IOException; |
20 | | -import java.nio.charset.StandardCharsets; |
21 | | -import java.util.ArrayList; |
22 | 18 | import java.util.List; |
23 | | -import java.util.Map; |
24 | | -import java.util.Objects; |
25 | | -import java.util.regex.Matcher; |
26 | | -import java.util.regex.Pattern; |
27 | 19 |
|
28 | | -import com.diffplug.spotless.Formatter; |
29 | 20 | import com.diffplug.spotless.FormatterStep; |
30 | | -import com.diffplug.spotless.LineEnding; |
31 | 21 | import com.diffplug.spotless.Lint; |
32 | | -import com.diffplug.spotless.LintState; |
33 | | -import com.diffplug.spotless.extra.integration.DiffMessageFormatter; |
34 | 22 |
|
35 | 23 | /** |
36 | | - * ReviewDogGenerator generates ReviewDog formatted output for linting issues |
37 | | - * based on Spotless formatting results. |
| 24 | + * Utility class for generating ReviewDog compatible output in the rdjsonl format. |
| 25 | + * This class provides methods to create diff and lint reports that can be used by ReviewDog. |
38 | 26 | */ |
39 | 27 | public final class ReviewDogGenerator { |
40 | | - private final Formatter formatter; |
41 | | - private final File projectDir; |
42 | 28 |
|
43 | | - /** |
44 | | - * Constructor for ReviewDogGenerator. |
45 | | - * |
46 | | - * @param projectDir the root directory of the project |
47 | | - * @param steps the list of FormatterStep to apply |
48 | | - */ |
49 | | - public ReviewDogGenerator(File projectDir, List<FormatterStep> steps) { |
50 | | - this.projectDir = projectDir; |
51 | | - this.formatter = Formatter.builder() |
52 | | - .encoding(StandardCharsets.UTF_8) |
53 | | - .lineEndingsPolicy(LineEnding.UNIX.createPolicy()) |
54 | | - .steps(steps) |
55 | | - .build(); |
| 29 | + private ReviewDogGenerator() { |
| 30 | + // Prevent instantiation |
56 | 31 | } |
57 | 32 |
|
58 | 33 | /** |
59 | | - * Generates ReviewDog formatted output for the given list of files. |
| 34 | + * Generates a ReviewDog compatible JSON line (rdjsonl) for a diff between |
| 35 | + * the actual content and the formatted content of a file. |
60 | 36 | * |
61 | | - * @param files the list of files to check |
62 | | - * @return a String containing ReviewDog formatted lint messages with code suggestions |
63 | | - * @throws IOException if file reading fails |
| 37 | + * @param path The file path |
| 38 | + * @param actualContent The content as it currently exists in the file |
| 39 | + * @param formattedContent The content after formatting is applied |
| 40 | + * @return A string in rdjsonl format representing the diff |
64 | 41 | */ |
65 | | - public String generateReviewDogFormat(List<File> files) throws IOException { |
66 | | - StringBuilder reviewDogOutput = new StringBuilder(); |
67 | | - |
68 | | - for (File file : files) { |
69 | | - LintState lintState = LintState.of(formatter, file); |
70 | | - |
71 | | - if (!lintState.getDirtyState().isClean()) { |
72 | | - String relativePath = getRelativePath(file); |
73 | | - |
74 | | - Map.Entry<Integer, String> diffResult = DiffMessageFormatter.diff( |
75 | | - projectDir.toPath(), formatter, file); |
76 | | - |
77 | | - List<DiffHunk> hunks = parseDiffHunks(diffResult.getValue()); |
78 | | - List<ReviewDogIssue> issues = processLints(lintState, hunks); |
79 | | - |
80 | | - for (ReviewDogIssue issue : issues) { |
81 | | - reviewDogOutput.append(formatReviewDogLine(relativePath, issue)); |
82 | | - } |
83 | | - } |
| 42 | + public static String rdjsonlDiff(String path, String actualContent, String formattedContent) { |
| 43 | + if (actualContent.equals(formattedContent)) { |
| 44 | + return ""; |
84 | 45 | } |
85 | 46 |
|
86 | | - return reviewDogOutput.toString(); |
87 | | - } |
| 47 | + String diff = createUnifiedDiff(path, actualContent, formattedContent); |
88 | 48 |
|
89 | | - /** |
90 | | - * Converts an absolute file path to a relative path based on the project directory. |
91 | | - * |
92 | | - * @param file the file to convert |
93 | | - * @return relative path string |
94 | | - */ |
95 | | - private String getRelativePath(File file) { |
96 | | - return projectDir.toURI().relativize(file.toURI()).getPath(); |
| 49 | + return String.format( |
| 50 | + "{\"message\":{\"path\":\"%s\",\"message\":\"File requires formatting\",\"diff\":\"%s\"}}", |
| 51 | + escapeJson(path), |
| 52 | + escapeJson(diff)); |
97 | 53 | } |
98 | 54 |
|
99 | 55 | /** |
100 | | - * Parses git-style diff content into structured diff hunks. |
| 56 | + * Generates ReviewDog compatible JSON lines (rdjsonl) for lint issues |
| 57 | + * identified by formatting steps. |
101 | 58 | * |
102 | | - * @param diffContent the git-style diff content |
103 | | - * @return list of parsed DiffHunk objects |
| 59 | + * @param path The file path |
| 60 | + * @param formattedContent The content after formatting is applied |
| 61 | + * @param steps The list of formatter steps applied |
| 62 | + * @param lintsPerStep The list of lints produced by each step |
| 63 | + * @return A string in rdjsonl format representing the lints |
104 | 64 | */ |
105 | | - private List<DiffHunk> parseDiffHunks(String diffContent) { |
106 | | - List<DiffHunk> hunks = new ArrayList<>(); |
107 | | - if (diffContent == null || diffContent.isEmpty()) { |
108 | | - return hunks; |
| 65 | + public static String rdjsonlLints(String path, String formattedContent, |
| 66 | + List<FormatterStep> steps, List<List<Lint>> lintsPerStep) { |
| 67 | + if (lintsPerStep == null || lintsPerStep.isEmpty()) { |
| 68 | + return ""; |
109 | 69 | } |
110 | 70 |
|
111 | | - Pattern hunkHeaderPattern = Pattern.compile("@@ -(\\d+),(\\d+) \\+(\\d+),(\\d+) @@"); |
112 | | - String[] lines = diffContent.split("\n"); |
113 | | - |
114 | | - DiffHunk currentHunk = null; |
115 | | - StringBuilder hunkContent = new StringBuilder(); |
| 71 | + StringBuilder builder = new StringBuilder(); |
116 | 72 |
|
117 | | - for (String line : lines) { |
118 | | - // Skip file header lines (--- and +++) |
119 | | - if (line.startsWith("---") || line.startsWith("+++")) { |
| 73 | + for (int i = 0; i < lintsPerStep.size(); i++) { |
| 74 | + List<Lint> lints = lintsPerStep.get(i); |
| 75 | + if (lints == null || lints.isEmpty()) { |
120 | 76 | continue; |
121 | 77 | } |
122 | 78 |
|
123 | | - if (line.startsWith("@@")) { |
124 | | - if (currentHunk != null) { |
125 | | - currentHunk.content = hunkContent.toString().trim(); |
126 | | - hunks.add(currentHunk); |
127 | | - hunkContent = new StringBuilder(); |
128 | | - } |
129 | | - |
130 | | - Matcher matcher = hunkHeaderPattern.matcher(line); |
131 | | - if (matcher.find()) { |
132 | | - int originalStart = Integer.parseInt(matcher.group(1)); |
133 | | - int originalLength = Integer.parseInt(matcher.group(2)); |
134 | | - int newStart = Integer.parseInt(matcher.group(3)); |
135 | | - int newLength = Integer.parseInt(matcher.group(4)); |
136 | | - |
137 | | - currentHunk = new DiffHunk(originalStart, originalLength, newStart, newLength); |
138 | | - currentHunk.header = line; |
139 | | - } |
140 | | - hunkContent.append(line).append("\n"); |
141 | | - } else if (currentHunk != null) { |
142 | | - hunkContent.append(line).append("\n"); |
143 | | - } |
144 | | - } |
145 | | - |
146 | | - if (currentHunk != null) { |
147 | | - currentHunk.content = hunkContent.toString().trim(); |
148 | | - hunks.add(currentHunk); |
149 | | - } |
150 | | - |
151 | | - return hunks; |
152 | | - } |
153 | | - |
154 | | - /** |
155 | | - * Processes lint information and associates them with appropriate diff hunks. |
156 | | - * |
157 | | - * @param lintState the LintState containing all lint information |
158 | | - * @param hunks list of diff hunks |
159 | | - * @return list of ReviewDogIssue |
160 | | - */ |
161 | | - private List<ReviewDogIssue> processLints(LintState lintState, List<DiffHunk> hunks) { |
162 | | - List<ReviewDogIssue> issues = new ArrayList<>(); |
163 | | - |
164 | | - List<List<Lint>> lintsPerStep = lintState.getLintsPerStep(); |
165 | | - |
166 | | - for (List<Lint> stepLints : Objects.requireNonNull(lintsPerStep)) { |
167 | | - for (Lint lint : stepLints) { |
168 | | - DiffHunk relevantHunk = findRelevantHunk(hunks, lint.getLineStart()); |
169 | | - |
170 | | - String suggestion = ""; |
171 | | - if (relevantHunk != null) { |
172 | | - suggestion = extractSuggestionFromHunk(relevantHunk); |
173 | | - } |
174 | | - |
175 | | - ReviewDogIssue issue = new ReviewDogIssue( |
176 | | - lint.getLineStart(), |
177 | | - 1, |
178 | | - lint.getShortCode() + ": " + lint.getDetail(), |
179 | | - suggestion); |
180 | | - |
181 | | - issues.add(issue); |
182 | | - } |
183 | | - } |
184 | | - |
185 | | - // If no specific lints were found but file is dirty, add a general formatting issue |
186 | | - if (issues.isEmpty() && !hunks.isEmpty()) { |
187 | | - DiffHunk firstHunk = hunks.get(0); |
188 | | - String suggestion = extractSuggestionFromHunk(firstHunk); |
189 | | - |
190 | | - issues.add(new ReviewDogIssue( |
191 | | - firstHunk.originalStart, |
192 | | - 1, |
193 | | - "General formatting issue: file needs to be reformatted.", |
194 | | - suggestion)); |
195 | | - } |
196 | | - |
197 | | - return issues; |
198 | | - } |
199 | | - |
200 | | - /** |
201 | | - * Finds the hunk that is relevant for a specific line number. |
202 | | - * |
203 | | - * @param hunks list of diff hunks |
204 | | - * @param lineNumber the line number to find (1-based) |
205 | | - * @return the relevant hunk or null if not found |
206 | | - */ |
207 | | - private DiffHunk findRelevantHunk(List<DiffHunk> hunks, int lineNumber) { |
208 | | - for (DiffHunk hunk : hunks) { |
209 | | - if (lineNumber >= hunk.originalStart && |
210 | | - lineNumber < hunk.originalStart + hunk.originalLength) { |
211 | | - return hunk; |
| 79 | + String stepName = (i < steps.size()) ? steps.get(i).getName() : "unknown"; |
| 80 | + for (Lint lint : lints) { |
| 81 | + builder.append(formatLintAsJson(path, lint, stepName)).append('\n'); |
212 | 82 | } |
213 | 83 | } |
214 | 84 |
|
215 | | - // If no exact match is found, find the closest hunk before the line number |
216 | | - DiffHunk closestHunk = null; |
217 | | - int closestDistance = Integer.MAX_VALUE; |
218 | | - |
219 | | - for (DiffHunk hunk : hunks) { |
220 | | - if (hunk.originalStart <= lineNumber) { |
221 | | - int distance = lineNumber - hunk.originalStart; |
222 | | - if (distance < closestDistance) { |
223 | | - closestDistance = distance; |
224 | | - closestHunk = hunk; |
225 | | - } |
226 | | - } |
227 | | - } |
228 | | - return closestHunk; |
| 85 | + return builder.toString().trim(); |
229 | 86 | } |
230 | 87 |
|
231 | 88 | /** |
232 | | - * Extracts suggestion content from a diff hunk. |
233 | | - * |
234 | | - * @param hunk the diff hunk |
235 | | - * @return the suggestion content |
| 89 | + * Creates a unified diff between two text contents. |
236 | 90 | */ |
237 | | - private String extractSuggestionFromHunk(DiffHunk hunk) { |
238 | | - StringBuilder suggestion = new StringBuilder(); |
| 91 | + private static String createUnifiedDiff(String path, String actualContent, String formattedContent) { |
| 92 | + String[] actualLines = actualContent.split("\\r?\\n", -1); |
| 93 | + String[] formattedLines = formattedContent.split("\\r?\\n", -1); |
239 | 94 |
|
240 | | - String[] lines = hunk.content.split("\n"); |
241 | | - boolean headerProcessed = false; |
| 95 | + StringBuilder diff = new StringBuilder(); |
| 96 | + diff.append("--- a/").append(path).append('\n'); |
| 97 | + diff.append("+++ b/").append(path).append('\n'); |
| 98 | + diff.append("@@ -1,").append(actualLines.length).append(" +1,").append(formattedLines.length).append(" @@\n"); |
242 | 99 |
|
243 | | - for (String line : lines) { |
244 | | - if (!headerProcessed && line.startsWith("@@")) { |
245 | | - headerProcessed = true; |
246 | | - continue; |
247 | | - } |
248 | | - |
249 | | - if (headerProcessed) { |
250 | | - if (line.startsWith("+") && !line.startsWith("+++")) { |
251 | | - suggestion.append(line.substring(1)); |
252 | | - suggestion.append("\n"); |
253 | | - } else if (!line.startsWith("-") && !line.startsWith("---")) { |
254 | | - suggestion.append(line); |
255 | | - suggestion.append("\n"); |
256 | | - } |
257 | | - } |
| 100 | + for (String line : actualLines) { |
| 101 | + diff.append('-').append(line).append('\n'); |
258 | 102 | } |
259 | 103 |
|
260 | | - return suggestion.toString().trim(); |
261 | | - } |
262 | | - |
263 | | - /** |
264 | | - * Formats a single ReviewDog issue line according to ReviewDog's expected format |
265 | | - * with suggestion support. |
266 | | - * |
267 | | - * @param filePath the relative file path |
268 | | - * @param issue the ReviewDogIssue object |
269 | | - * @return formatted string line with suggestion |
270 | | - */ |
271 | | - private String formatReviewDogLine(String filePath, ReviewDogIssue issue) { |
272 | | - StringBuilder builder = new StringBuilder(); |
273 | | - |
274 | | - builder.append(String.format("%s:%d:%d: %s\n", |
275 | | - filePath, issue.lineNumber, issue.column, issue.message)); |
276 | | - |
277 | | - if (issue.suggestion != null && !issue.suggestion.isEmpty()) { |
278 | | - builder.append("```suggestion\n"); |
279 | | - builder.append(issue.suggestion); |
280 | | - builder.append("\n```\n"); |
| 104 | + for (String line : formattedLines) { |
| 105 | + diff.append('+').append(line).append('\n'); |
281 | 106 | } |
282 | 107 |
|
283 | | - return builder.toString(); |
| 108 | + return diff.toString(); |
284 | 109 | } |
285 | 110 |
|
286 | 111 | /** |
287 | | - * Inner class representing a diff hunk. |
| 112 | + * Formats a single lint issue as a JSON line. |
288 | 113 | */ |
289 | | - private static class DiffHunk { |
290 | | - final int originalStart; |
291 | | - final int originalLength; |
292 | | - final int newStart; |
293 | | - final int newLength; |
294 | | - String header; |
295 | | - String content; |
296 | | - |
297 | | - DiffHunk(int originalStart, int originalLength, int newStart, int newLength) { |
298 | | - this.originalStart = originalStart; |
299 | | - this.originalLength = originalLength; |
300 | | - this.newStart = newStart; |
301 | | - this.newLength = newLength; |
302 | | - } |
| 114 | + private static String formatLintAsJson(String path, Lint lint, String stepName) { |
| 115 | + return String.format( |
| 116 | + "{\"message\":{\"path\":\"%s\",\"line\":%d,\"column\":1,\"message\":\"%s: %s\"}}", |
| 117 | + escapeJson(path), |
| 118 | + lint.getLineStart(), |
| 119 | + escapeJson(lint.getShortCode()), |
| 120 | + escapeJson(lint.getDetail())); |
303 | 121 | } |
304 | 122 |
|
305 | 123 | /** |
306 | | - * Inner class representing a ReviewDog issue. |
| 124 | + * Escapes special characters in a string for JSON compatibility. |
307 | 125 | */ |
308 | | - private static class ReviewDogIssue { |
309 | | - final int lineNumber; |
310 | | - final int column; |
311 | | - final String message; |
312 | | - final String suggestion; |
313 | | - |
314 | | - ReviewDogIssue(int lineNumber, int column, String message, String suggestion) { |
315 | | - this.lineNumber = lineNumber; |
316 | | - this.column = column; |
317 | | - this.message = message; |
318 | | - this.suggestion = suggestion; |
| 126 | + private static String escapeJson(String str) { |
| 127 | + if (str == null) { |
| 128 | + return ""; |
319 | 129 | } |
| 130 | + return str |
| 131 | + .replace("\\", "\\\\") |
| 132 | + .replace("\"", "\\\"") |
| 133 | + .replace("\n", "\\n") |
| 134 | + .replace("\r", "\\r") |
| 135 | + .replace("\t", "\\t") |
| 136 | + .replace("\b", "\\b") |
| 137 | + .replace("\f", "\\f"); |
320 | 138 | } |
321 | 139 | } |
0 commit comments