Skip to content

Commit 4fb1577

Browse files
committed
feat(protocol): expose analyzer quick fixes in scan output
Engine.Issue has carried quickFixes() since sonarlint-core 11.x and the sonar-plugin-api has shipped the NewIssue.addQuickFix(...) builder for several major versions. Until now the daemon silently dropped them at the protocol-DTO boundary, leaving downstream consumers (the agent pipeline and the JSON CLI users) to guess at remediations. This change carries quick fixes end-to-end: Protocol DTOs (new) - TextEdit(startLine, startColumn, endLine, endColumn, replacement) — a single in-file replacement edit - FileEdit(filePath, edits) — all TextEdits a quick fix applies to one file; null-normalised and defensively copied - QuickFix(message, fileEdits) — an analyzer-supplied remediation, possibly spanning multiple files Issue DTO - Added List<QuickFix> quickFixes field (compact ctor null-normalises and copies). A backwards-compatible 9-arg constructor delegates with List.of(), so the ~15 existing test call sites keep compiling. IssueMapper - mapQuickFixes(List<engine.QuickFix>, baseDir) translates the engine's QuickFix / ClientInputFileEdit / TextEdit tree into our DTOs, resolving each edit's target file path through the same resolveFilePath() the primary issue uses (so quick-fix paths share the baseDir-relative, '/'-separated convention). - The original 5-arg map(...) overload is preserved and delegates to a new 6-arg overload that takes the quickFixes list. Test coverage (356/356 pass, +20 new): - Per-DTO Jackson roundtrip + null-normalisation + defensive-copy contracts (TextEditTest x3, FileEditTest x5, QuickFixTest x5, IssueTest +3). - IssueMapperTest +3: single-edit roundtrip, empty list, multi-file. - AnalysisServiceTest +1: the existing UtilityClass.java fixture raises java:S1118; assert the resulting protocol Issue carries at least one QuickFix with one FileEdit + TextEdit, and that the quick-fix file path matches the issue's file path. This is the canary that proves the full daemon -> JSON pipeline emits real analyzer-supplied edits, not just the schema. JSON shape is additive: existing consumers that don't read .quickFixes remain unaffected; new consumers gain a per-issue remediation list. Targeting 0.3.0-SNAPSHOT (0.2.0 was tagged at HEAD of main).
1 parent 976a8c4 commit 4fb1577

12 files changed

Lines changed: 465 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/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/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() {

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

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,23 @@
22

33
import static org.junit.jupiter.api.Assertions.assertEquals;
44

5+
import java.nio.file.Files;
56
import java.nio.file.Path;
67
import java.nio.file.Paths;
8+
import java.util.List;
79

810
import org.junit.jupiter.api.BeforeAll;
911
import org.junit.jupiter.api.DisplayName;
1012
import org.junit.jupiter.api.Test;
13+
import org.junit.jupiter.api.io.TempDir;
14+
import org.sonarsource.sonarlint.core.analysis.api.ClientInputFileEdit;
15+
import org.sonarsource.sonarlint.core.commons.api.SonarLanguage;
1116
import org.sonarsource.sonarlint.core.commons.api.TextRange;
1217

18+
import io.github.randomcodespace.sonarpredict.protocol.dto.FileEdit;
19+
import io.github.randomcodespace.sonarpredict.protocol.dto.QuickFix;
20+
import io.github.randomcodespace.sonarpredict.protocol.dto.TextEdit;
21+
1322
class IssueMapperTest {
1423

1524
private static final Path PLUGINS_DIR = Paths.get("plugins");
@@ -75,4 +84,75 @@ void map_withoutRange_usesZeroPositions() {
7584
assertEquals(0, dto.endColumn());
7685
assertEquals("java:S1118", dto.ruleKey());
7786
}
87+
88+
@Test
89+
@DisplayName("mapQuickFixes() preserves message, target path and text-edit positions")
90+
void mapQuickFixes_singleEditRoundtrip(@TempDir Path baseDir) throws Exception {
91+
Path pkgDir = Files.createDirectories(baseDir.resolve("com/example"));
92+
Path src = pkgDir.resolve("UtilityClass.java");
93+
Files.writeString(src, "package com.example;\npublic class UtilityClass {}\n");
94+
FileInputFile input = new FileInputFile(src, baseDir, SonarLanguage.JAVA, false);
95+
96+
org.sonarsource.sonarlint.core.analysis.api.TextEdit engineEdit =
97+
new org.sonarsource.sonarlint.core.analysis.api.TextEdit(
98+
new TextRange(3, 0, 3, 0),
99+
" private UtilityClass() {}\n");
100+
ClientInputFileEdit fileEdit = new ClientInputFileEdit(input, List.of(engineEdit));
101+
org.sonarsource.sonarlint.core.analysis.api.QuickFix engineQf =
102+
new org.sonarsource.sonarlint.core.analysis.api.QuickFix(
103+
List.of(fileEdit), "Add a private constructor");
104+
105+
List<QuickFix> mapped = IssueMapper.mapQuickFixes(List.of(engineQf), baseDir);
106+
107+
assertEquals(1, mapped.size());
108+
QuickFix qf = mapped.get(0);
109+
assertEquals("Add a private constructor", qf.message());
110+
assertEquals(1, qf.fileEdits().size());
111+
FileEdit fe = qf.fileEdits().get(0);
112+
assertEquals("com/example/UtilityClass.java", fe.filePath());
113+
assertEquals(1, fe.edits().size());
114+
TextEdit te = fe.edits().get(0);
115+
assertEquals(3, te.startLine());
116+
assertEquals(0, te.startColumn());
117+
assertEquals(3, te.endLine());
118+
assertEquals(0, te.endColumn());
119+
assertEquals(" private UtilityClass() {}\n", te.replacement());
120+
}
121+
122+
@Test
123+
@DisplayName("an empty engine quick-fix list maps to an empty DTO list")
124+
void mapQuickFixes_emptyList(@TempDir Path baseDir) {
125+
assertEquals(List.of(), IssueMapper.mapQuickFixes(List.of(), baseDir));
126+
}
127+
128+
@Test
129+
@DisplayName("a multi-file quick-fix preserves both files in order")
130+
void mapQuickFixes_multiFile(@TempDir Path baseDir) throws Exception {
131+
Path a = baseDir.resolve("A.java");
132+
Path b = baseDir.resolve("B.java");
133+
Files.writeString(a, "class A {}\n");
134+
Files.writeString(b, "class B {}\n");
135+
FileInputFile inA = new FileInputFile(a, baseDir, SonarLanguage.JAVA, false);
136+
FileInputFile inB = new FileInputFile(b, baseDir, SonarLanguage.JAVA, false);
137+
138+
org.sonarsource.sonarlint.core.analysis.api.QuickFix qf =
139+
new org.sonarsource.sonarlint.core.analysis.api.QuickFix(
140+
List.of(
141+
new ClientInputFileEdit(inA, List.of(
142+
new org.sonarsource.sonarlint.core.analysis.api.TextEdit(
143+
new TextRange(1, 6, 1, 7), "X"))),
144+
new ClientInputFileEdit(inB, List.of(
145+
new org.sonarsource.sonarlint.core.analysis.api.TextEdit(
146+
new TextRange(1, 6, 1, 7), "Y")))),
147+
"Rename across two files");
148+
149+
List<QuickFix> mapped = IssueMapper.mapQuickFixes(List.of(qf), baseDir);
150+
151+
assertEquals(1, mapped.size());
152+
assertEquals(2, mapped.get(0).fileEdits().size());
153+
assertEquals("A.java", mapped.get(0).fileEdits().get(0).filePath());
154+
assertEquals("B.java", mapped.get(0).fileEdits().get(1).filePath());
155+
assertEquals("X", mapped.get(0).fileEdits().get(0).edits().get(0).replacement());
156+
assertEquals("Y", mapped.get(0).fileEdits().get(1).edits().get(0).replacement());
157+
}
78158
}

0 commit comments

Comments
 (0)