Skip to content

Commit 707fec8

Browse files
authored
feat(protocol): expose analyzer quick fixes in scan output (#9)
1 parent 976a8c4 commit 707fec8

14 files changed

Lines changed: 550 additions & 11 deletions

File tree

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<groupId>io.github.randomcodespace.sonarpredict</groupId>
88
<artifactId>sonar-predictor</artifactId>
9-
<version>0.2.0-SNAPSHOT</version>
9+
<version>0.3.0-SNAPSHOT</version>
1010
<!--
1111
Packaging is jar so the default Maven lifecycle binds compile, test,
1212
and jar plugins automatically. The "primary" jar this produces is

src/main/java/io/github/randomcodespace/sonarpredict/cli/JsonReporter.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@
1313
import io.github.randomcodespace.sonarpredict.protocol.Json;
1414
import io.github.randomcodespace.sonarpredict.protocol.dto.AnalysisWarning;
1515
import io.github.randomcodespace.sonarpredict.protocol.dto.AnalyzeResponse;
16+
import io.github.randomcodespace.sonarpredict.protocol.dto.FileEdit;
1617
import io.github.randomcodespace.sonarpredict.protocol.dto.Issue;
18+
import io.github.randomcodespace.sonarpredict.protocol.dto.QuickFix;
1719
import io.github.randomcodespace.sonarpredict.protocol.dto.RuleMetadata;
20+
import io.github.randomcodespace.sonarpredict.protocol.dto.TextEdit;
1821

1922
/**
2023
* Renders an {@link AnalyzeResponse} as compact, single-line JSON, parseable by
@@ -97,6 +100,10 @@ private static double round(double percent) {
97100
/**
98101
* Builds one issue node with a fixed, deterministic field order. When
99102
* {@code metadata} is non-null the issue gains a nested {@code rule} object.
103+
* When the issue carries any analyzer-supplied {@link QuickFix}es, the
104+
* issue gains a {@code quickFixes} array; the field is omitted (rather
105+
* than emitted as {@code []}) to keep the wire format token-lean for
106+
* the common case where rules don't supply machine-applicable fixes.
100107
*/
101108
private static ObjectNode issueNode(Issue issue, RuleMetadata metadata) {
102109
ObjectNode node = Json.mapper().createObjectNode();
@@ -116,6 +123,37 @@ private static ObjectNode issueNode(Issue issue, RuleMetadata metadata) {
116123
rule.put("language", metadata.language());
117124
rule.put("howToFix", metadata.howToFix());
118125
}
126+
if (!issue.quickFixes().isEmpty()) {
127+
ArrayNode quickFixes = node.putArray("quickFixes");
128+
for (QuickFix qf : issue.quickFixes()) {
129+
quickFixes.add(quickFixNode(qf));
130+
}
131+
}
132+
return node;
133+
}
134+
135+
/**
136+
* Builds one {@code {message, fileEdits:[{filePath, edits:[…]}]}} node
137+
* for an analyzer-supplied quick fix. Field order matches the DTO so
138+
* the emitted JSON can roundtrip through {@code Json.mapper().readValue}.
139+
*/
140+
private static ObjectNode quickFixNode(QuickFix qf) {
141+
ObjectNode node = Json.mapper().createObjectNode();
142+
node.put("message", qf.message());
143+
ArrayNode fileEdits = node.putArray("fileEdits");
144+
for (FileEdit fe : qf.fileEdits()) {
145+
ObjectNode feNode = fileEdits.addObject();
146+
feNode.put("filePath", fe.filePath());
147+
ArrayNode edits = feNode.putArray("edits");
148+
for (TextEdit te : fe.edits()) {
149+
ObjectNode teNode = edits.addObject();
150+
teNode.put("startLine", te.startLine());
151+
teNode.put("startColumn", te.startColumn());
152+
teNode.put("endLine", te.endLine());
153+
teNode.put("endColumn", te.endColumn());
154+
teNode.put("replacement", te.replacement());
155+
}
156+
}
119157
return node;
120158
}
121159
}

src/main/java/io/github/randomcodespace/sonarpredict/daemon/IssueMapper.java

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
package io.github.randomcodespace.sonarpredict.daemon;
22

33
import java.nio.file.Path;
4+
import java.util.List;
45

56
import org.sonarsource.sonarlint.core.analysis.api.ClientInputFile;
7+
import org.sonarsource.sonarlint.core.analysis.api.ClientInputFileEdit;
68
import org.sonarsource.sonarlint.core.analysis.api.Issue;
79
import org.sonarsource.sonarlint.core.commons.api.TextRange;
810

11+
import io.github.randomcodespace.sonarpredict.protocol.dto.FileEdit;
12+
import io.github.randomcodespace.sonarpredict.protocol.dto.QuickFix;
913
import io.github.randomcodespace.sonarpredict.protocol.dto.RuleMetadata;
14+
import io.github.randomcodespace.sonarpredict.protocol.dto.TextEdit;
1015

1116
/**
1217
* Maps an engine {@link Issue} to the protocol {@link io.github.randomcodespace.sonarpredict.protocol.dto.Issue}.
@@ -45,18 +50,32 @@ public static io.github.randomcodespace.sonarpredict.protocol.dto.Issue toDto(
4550
resolveFilePath(engineIssue.getInputFile(), baseDir),
4651
engineIssue.getTextRange(),
4752
engineIssue.getMessage(),
48-
catalog);
53+
catalog,
54+
mapQuickFixes(engineIssue.quickFixes(), baseDir));
4955
}
5056

5157
/**
52-
* Pure mapping from primitive issue fields to a protocol DTO. A {@code null}
53-
* {@code range} (file-level issue) yields zero positions. Severity and type
54-
* are resolved from {@code catalog} by {@code ruleKey}, falling back to
55-
* {@link #DEFAULT_SEVERITY}/{@link #DEFAULT_TYPE} for unknown rules.
58+
* Pure mapping from primitive issue fields to a protocol DTO with no quick
59+
* fixes. A {@code null} {@code range} (file-level issue) yields zero
60+
* positions. Severity and type are resolved from {@code catalog} by
61+
* {@code ruleKey}, falling back to {@link #DEFAULT_SEVERITY}/{@link #DEFAULT_TYPE}
62+
* for unknown rules.
5663
*/
5764
static io.github.randomcodespace.sonarpredict.protocol.dto.Issue map(
5865
String ruleKey, String filePath, TextRange range, String message,
5966
RuleCatalog catalog) {
67+
return map(ruleKey, filePath, range, message, catalog, List.of());
68+
}
69+
70+
/**
71+
* Pure mapping from primitive issue fields to a protocol DTO with the
72+
* supplied {@code quickFixes}. Used by {@link #toDto} after extracting
73+
* the engine's quick fixes; tests can call this overload directly to
74+
* exercise the full DTO shape without spinning up the engine.
75+
*/
76+
static io.github.randomcodespace.sonarpredict.protocol.dto.Issue map(
77+
String ruleKey, String filePath, TextRange range, String message,
78+
RuleCatalog catalog, List<QuickFix> quickFixes) {
6079
int startLine = range != null ? range.getStartLine() : 0;
6180
int startColumn = range != null ? range.getStartLineOffset() : 0;
6281
int endLine = range != null ? range.getEndLine() : 0;
@@ -79,7 +98,39 @@ static io.github.randomcodespace.sonarpredict.protocol.dto.Issue map(
7998
endColumn,
8099
severity,
81100
type,
82-
message);
101+
message,
102+
quickFixes);
103+
}
104+
105+
/**
106+
* Maps the engine's {@link org.sonarsource.sonarlint.core.analysis.api.QuickFix}
107+
* list to the protocol's {@link QuickFix} DTOs.
108+
*
109+
* <p>Engine {@code QuickFix.message()} is preserved verbatim. Each
110+
* {@link ClientInputFileEdit}'s target is path-resolved the same way the
111+
* primary issue's input file is (via {@link #resolveFilePath}), so quick-fix
112+
* targets share the {@code baseDir}-relative, '/'-separated convention with
113+
* {@link io.github.randomcodespace.sonarpredict.protocol.dto.Issue#filePath}.
114+
*/
115+
static List<QuickFix> mapQuickFixes(
116+
List<org.sonarsource.sonarlint.core.analysis.api.QuickFix> engineQuickFixes,
117+
Path baseDir) {
118+
return engineQuickFixes.stream()
119+
.map(qf -> new QuickFix(
120+
qf.message(),
121+
qf.inputFileEdits().stream()
122+
.map(ife -> new FileEdit(
123+
resolveFilePath(ife.target(), baseDir),
124+
ife.textEdits().stream()
125+
.map(te -> new TextEdit(
126+
te.range().getStartLine(),
127+
te.range().getStartLineOffset(),
128+
te.range().getEndLine(),
129+
te.range().getEndLineOffset(),
130+
te.newText()))
131+
.toList()))
132+
.toList()))
133+
.toList();
83134
}
84135

85136
private static String resolveFilePath(ClientInputFile inputFile, Path baseDir) {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package io.github.randomcodespace.sonarpredict.protocol.dto;
2+
3+
import java.util.List;
4+
5+
/**
6+
* All {@link TextEdit}s a {@link QuickFix} applies to one specific file.
7+
*
8+
* <p>The compact constructor null-normalises and defensively copies {@code edits}
9+
* so the record stays immutable regardless of how callers (Jackson, tests,
10+
* direct construction) hand it in.
11+
*
12+
* @param filePath path relative to the analysis base directory, matching the
13+
* convention used by {@link Issue#filePath}
14+
* @param edits the in-file edits to apply; never {@code null}
15+
*/
16+
public record FileEdit(
17+
String filePath,
18+
List<TextEdit> edits
19+
) {
20+
public FileEdit {
21+
edits = edits == null ? List.of() : List.copyOf(edits);
22+
}
23+
}
Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
package io.github.randomcodespace.sonarpredict.protocol.dto;
22

3+
import java.util.List;
4+
35
/**
46
* A single analysis finding.
57
*
6-
* @param filePath path relative to the analysis base directory
7-
* @param severity one of BLOCKER, CRITICAL, MAJOR, MINOR, INFO
8-
* @param type one of BUG, CODE_SMELL, VULNERABILITY, SECURITY_HOTSPOT
8+
* @param ruleKey rule identifier, e.g. {@code java:S1118}
9+
* @param filePath path relative to the analysis base directory
10+
* @param startLine 1-indexed inclusive
11+
* @param startColumn 0-indexed inclusive
12+
* @param endLine 1-indexed inclusive
13+
* @param endColumn 0-indexed exclusive
14+
* @param severity one of BLOCKER, CRITICAL, MAJOR, MINOR, INFO
15+
* @param type one of BUG, CODE_SMELL, VULNERABILITY, SECURITY_HOTSPOT
16+
* @param message human-readable, often imperative ("Use isEmpty() to check…")
17+
* @param quickFixes analyzer-supplied machine-applicable remediations; never
18+
* {@code null} — empty list when none are attached
919
*/
1020
public record Issue(
1121
String ruleKey,
@@ -16,6 +26,28 @@ public record Issue(
1626
int endColumn,
1727
String severity,
1828
String type,
19-
String message
29+
String message,
30+
List<QuickFix> quickFixes
2031
) {
32+
public Issue {
33+
quickFixes = quickFixes == null ? List.of() : List.copyOf(quickFixes);
34+
}
35+
36+
/**
37+
* Backwards-compatible 9-arg constructor used by call sites that pre-date
38+
* the {@code quickFixes} field. Defaults the new field to an empty list.
39+
*/
40+
public Issue(
41+
String ruleKey,
42+
String filePath,
43+
int startLine,
44+
int startColumn,
45+
int endLine,
46+
int endColumn,
47+
String severity,
48+
String type,
49+
String message) {
50+
this(ruleKey, filePath, startLine, startColumn, endLine, endColumn,
51+
severity, type, message, List.of());
52+
}
2153
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package io.github.randomcodespace.sonarpredict.protocol.dto;
2+
3+
import java.util.List;
4+
5+
/**
6+
* An analyzer-supplied, machine-applicable remediation for an {@link Issue}.
7+
*
8+
* <p>A quick fix carries a human-readable {@code message} describing the change
9+
* and a list of {@link FileEdit}s — usually one file, but multi-file edits are
10+
* permitted by the SonarSource analyzer contract.
11+
*
12+
* <p>Not every issue has quick fixes — rule coverage is uneven across
13+
* analyzers. {@link Issue#quickFixes()} returns an empty list when none are
14+
* attached, so consumers can iterate uniformly.
15+
*
16+
* @param message short, imperative remediation description (e.g.
17+
* "Add a private constructor")
18+
* @param fileEdits per-file edits this fix applies; never {@code null}
19+
*/
20+
public record QuickFix(
21+
String message,
22+
List<FileEdit> fileEdits
23+
) {
24+
public QuickFix {
25+
fileEdits = fileEdits == null ? List.of() : List.copyOf(fileEdits);
26+
}
27+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package io.github.randomcodespace.sonarpredict.protocol.dto;
2+
3+
/**
4+
* A single replacement edit inside one file — the smallest unit of a {@link QuickFix}.
5+
*
6+
* <p>Positions are 1-indexed lines and 0-indexed columns to match the convention
7+
* used by {@link Issue#startLine}/{@link Issue#startColumn} so consumers that
8+
* already render issue positions can render edits the same way.
9+
*
10+
* @param startLine 1-indexed inclusive start line
11+
* @param startColumn 0-indexed inclusive start column on {@code startLine}
12+
* @param endLine 1-indexed inclusive end line
13+
* @param endColumn 0-indexed exclusive end column on {@code endLine}
14+
* @param replacement the text to substitute for the range; the empty string
15+
* means "delete the range"
16+
*/
17+
public record TextEdit(
18+
int startLine,
19+
int startColumn,
20+
int endLine,
21+
int endColumn,
22+
String replacement
23+
) {
24+
}

src/test/java/io/github/randomcodespace/sonarpredict/cli/ReportersTest.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@
1616
import io.github.randomcodespace.sonarpredict.protocol.Json;
1717
import io.github.randomcodespace.sonarpredict.protocol.dto.AnalysisWarning;
1818
import io.github.randomcodespace.sonarpredict.protocol.dto.AnalyzeResponse;
19+
import io.github.randomcodespace.sonarpredict.protocol.dto.FileEdit;
1920
import io.github.randomcodespace.sonarpredict.protocol.dto.Issue;
21+
import io.github.randomcodespace.sonarpredict.protocol.dto.QuickFix;
2022
import io.github.randomcodespace.sonarpredict.protocol.dto.RuleMetadata;
23+
import io.github.randomcodespace.sonarpredict.protocol.dto.TextEdit;
2124

2225
/**
2326
* Unit tests for the {@link Reporter} implementations: {@link TextReporter}
@@ -163,4 +166,48 @@ void jsonRuleBlockOmitsHtmlDescription() throws Exception {
163166
"the compact JSON rule block must not carry an HTML 'description'");
164167
assertNotNull(ruleBlock.get("name"), "the rule name stays in the compact JSON");
165168
}
169+
170+
@Test
171+
@DisplayName("JsonReporter omits 'quickFixes' for issues that have none (token-lean)")
172+
void jsonOmitsEmptyQuickFixes() throws Exception {
173+
String json = new JsonReporter().render(WITH_ISSUES);
174+
175+
assertFalse(json.contains("\"quickFixes\""),
176+
"an issue with no analyzer-supplied quick fix must not emit an empty array; "
177+
+ "the field should be absent. got: " + json);
178+
}
179+
180+
@Test
181+
@DisplayName("JsonReporter emits a quickFixes array when the issue carries one")
182+
void jsonEmitsQuickFixes() throws Exception {
183+
Issue withFix = new Issue(
184+
"java:S1118", "src/Util.java", 3, 13, 3, 17,
185+
"MAJOR", "CODE_SMELL", "Add a private constructor.",
186+
List.of(new QuickFix(
187+
"Add a private constructor",
188+
List.of(new FileEdit(
189+
"src/Util.java",
190+
List.of(new TextEdit(3, 0, 3, 0,
191+
" private Util() {}\n")))))));
192+
AnalyzeResponse response = new AnalyzeResponse(List.of(withFix), List.of());
193+
194+
String json = new JsonReporter().render(response);
195+
JsonNode root = Json.mapper().readTree(json);
196+
JsonNode issue = root.get("files").get(0).get("issues").get(0);
197+
198+
JsonNode qfs = issue.get("quickFixes");
199+
assertNotNull(qfs, "the issue must carry a quickFixes array; got: " + json);
200+
assertEquals(1, qfs.size(), "exactly one quick fix was supplied");
201+
assertEquals("Add a private constructor", qfs.get(0).get("message").asText());
202+
203+
JsonNode fileEdit = qfs.get(0).get("fileEdits").get(0);
204+
assertEquals("src/Util.java", fileEdit.get("filePath").asText());
205+
206+
JsonNode edit = fileEdit.get("edits").get(0);
207+
assertEquals(3, edit.get("startLine").asInt());
208+
assertEquals(0, edit.get("startColumn").asInt());
209+
assertEquals(3, edit.get("endLine").asInt());
210+
assertEquals(0, edit.get("endColumn").asInt());
211+
assertEquals(" private Util() {}\n", edit.get("replacement").asText());
212+
}
166213
}

src/test/java/io/github/randomcodespace/sonarpredict/daemon/AnalysisServiceTest.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.github.randomcodespace.sonarpredict.daemon;
22

3+
import static org.junit.jupiter.api.Assertions.assertEquals;
34
import static org.junit.jupiter.api.Assertions.assertFalse;
45
import static org.junit.jupiter.api.Assertions.assertNotNull;
56
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -46,6 +47,32 @@ void analyze_java_stillWorks() {
4647
}
4748
}
4849

50+
@Test
51+
@DisplayName("java:S1118 on UtilityClass.java carries an analyzer-supplied quick fix")
52+
void analyze_java_s1118_carriesQuickFix() {
53+
try (AnalysisService service = new AnalysisService()) {
54+
AnalyzeResponse response = service.analyze(request("java/UtilityClass.java"));
55+
56+
Issue s1118 = response.issues().stream()
57+
.filter(i -> "java:S1118".equals(i.ruleKey()))
58+
.findFirst()
59+
.orElseThrow(() -> new AssertionError(
60+
"expected java:S1118 in: " + response.issues()));
61+
assertFalse(s1118.quickFixes().isEmpty(),
62+
"java:S1118 must surface at least one analyzer-supplied quick fix; got: "
63+
+ s1118);
64+
assertFalse(s1118.quickFixes().get(0).fileEdits().isEmpty(),
65+
"quick-fix must have at least one file edit; got: " + s1118.quickFixes());
66+
assertFalse(
67+
s1118.quickFixes().get(0).fileEdits().get(0).edits().isEmpty(),
68+
"file edit must have at least one text edit; got: " + s1118.quickFixes());
69+
// The edit's path must match the issue's path (relative to baseDir).
70+
assertEquals(s1118.filePath(),
71+
s1118.quickFixes().get(0).fileEdits().get(0).filePath(),
72+
"quick-fix file path must match the issue's file path");
73+
}
74+
}
75+
4976
@Test
5077
@DisplayName("a warm AnalysisService is constructed once and reused across many analyze() calls")
5178
void analyze_warmServiceReusedAcrossCalls() {

0 commit comments

Comments
 (0)