Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>io.github.randomcodespace.sonarpredict</groupId>
<artifactId>sonar-predictor</artifactId>
<version>0.2.0-SNAPSHOT</version>
<version>0.3.0-SNAPSHOT</version>
<!--
Packaging is jar so the default Maven lifecycle binds compile, test,
and jar plugins automatically. The "primary" jar this produces is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@
import io.github.randomcodespace.sonarpredict.protocol.Json;
import io.github.randomcodespace.sonarpredict.protocol.dto.AnalysisWarning;
import io.github.randomcodespace.sonarpredict.protocol.dto.AnalyzeResponse;
import io.github.randomcodespace.sonarpredict.protocol.dto.FileEdit;
import io.github.randomcodespace.sonarpredict.protocol.dto.Issue;
import io.github.randomcodespace.sonarpredict.protocol.dto.QuickFix;
import io.github.randomcodespace.sonarpredict.protocol.dto.RuleMetadata;
import io.github.randomcodespace.sonarpredict.protocol.dto.TextEdit;

/**
* Renders an {@link AnalyzeResponse} as compact, single-line JSON, parseable by
Expand Down Expand Up @@ -56,7 +59,7 @@
for (AnalysisWarning warning : responseWarnings) {
ObjectNode warningNode = warnings.addObject();
warningNode.put("file", warning.filePath());
warningNode.put("message", warning.message());

Check failure on line 62 in src/main/java/io/github/randomcodespace/sonarpredict/cli/JsonReporter.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "message" 3 times.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_sonar-predict&issues=AZ5fH4txgbS7DYtyZBfm&open=AZ5fH4txgbS7DYtyZBfm&pullRequest=9
}

if (coverage != null) {
Expand Down Expand Up @@ -97,6 +100,10 @@
/**
* Builds one issue node with a fixed, deterministic field order. When
* {@code metadata} is non-null the issue gains a nested {@code rule} object.
* When the issue carries any analyzer-supplied {@link QuickFix}es, the
* issue gains a {@code quickFixes} array; the field is omitted (rather
* than emitted as {@code []}) to keep the wire format token-lean for
* the common case where rules don't supply machine-applicable fixes.
*/
private static ObjectNode issueNode(Issue issue, RuleMetadata metadata) {
ObjectNode node = Json.mapper().createObjectNode();
Expand All @@ -116,6 +123,37 @@
rule.put("language", metadata.language());
rule.put("howToFix", metadata.howToFix());
}
if (!issue.quickFixes().isEmpty()) {
ArrayNode quickFixes = node.putArray("quickFixes");
for (QuickFix qf : issue.quickFixes()) {
quickFixes.add(quickFixNode(qf));
}
}
return node;
}

/**
* Builds one {@code {message, fileEdits:[{filePath, edits:[…]}]}} node
* for an analyzer-supplied quick fix. Field order matches the DTO so
* the emitted JSON can roundtrip through {@code Json.mapper().readValue}.
*/
private static ObjectNode quickFixNode(QuickFix qf) {
ObjectNode node = Json.mapper().createObjectNode();
node.put("message", qf.message());
ArrayNode fileEdits = node.putArray("fileEdits");
for (FileEdit fe : qf.fileEdits()) {
ObjectNode feNode = fileEdits.addObject();
feNode.put("filePath", fe.filePath());
ArrayNode edits = feNode.putArray("edits");
for (TextEdit te : fe.edits()) {
ObjectNode teNode = edits.addObject();
teNode.put("startLine", te.startLine());
teNode.put("startColumn", te.startColumn());
teNode.put("endLine", te.endLine());
teNode.put("endColumn", te.endColumn());
teNode.put("replacement", te.replacement());
}
}
return node;
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
package io.github.randomcodespace.sonarpredict.daemon;

import java.nio.file.Path;
import java.util.List;

import org.sonarsource.sonarlint.core.analysis.api.ClientInputFile;
import org.sonarsource.sonarlint.core.analysis.api.ClientInputFileEdit;
import org.sonarsource.sonarlint.core.analysis.api.Issue;
import org.sonarsource.sonarlint.core.commons.api.TextRange;

import io.github.randomcodespace.sonarpredict.protocol.dto.FileEdit;
import io.github.randomcodespace.sonarpredict.protocol.dto.QuickFix;
import io.github.randomcodespace.sonarpredict.protocol.dto.RuleMetadata;
import io.github.randomcodespace.sonarpredict.protocol.dto.TextEdit;

/**
* Maps an engine {@link Issue} to the protocol {@link io.github.randomcodespace.sonarpredict.protocol.dto.Issue}.
Expand Down Expand Up @@ -45,18 +50,32 @@ public static io.github.randomcodespace.sonarpredict.protocol.dto.Issue toDto(
resolveFilePath(engineIssue.getInputFile(), baseDir),
engineIssue.getTextRange(),
engineIssue.getMessage(),
catalog);
catalog,
mapQuickFixes(engineIssue.quickFixes(), baseDir));
}

/**
* Pure mapping from primitive issue fields to a protocol DTO. A {@code null}
* {@code range} (file-level issue) yields zero positions. Severity and type
* are resolved from {@code catalog} by {@code ruleKey}, falling back to
* {@link #DEFAULT_SEVERITY}/{@link #DEFAULT_TYPE} for unknown rules.
* Pure mapping from primitive issue fields to a protocol DTO with no quick
* fixes. A {@code null} {@code range} (file-level issue) yields zero
* positions. Severity and type are resolved from {@code catalog} by
* {@code ruleKey}, falling back to {@link #DEFAULT_SEVERITY}/{@link #DEFAULT_TYPE}
* for unknown rules.
*/
static io.github.randomcodespace.sonarpredict.protocol.dto.Issue map(
String ruleKey, String filePath, TextRange range, String message,
RuleCatalog catalog) {
return map(ruleKey, filePath, range, message, catalog, List.of());
}

/**
* Pure mapping from primitive issue fields to a protocol DTO with the
* supplied {@code quickFixes}. Used by {@link #toDto} after extracting
* the engine's quick fixes; tests can call this overload directly to
* exercise the full DTO shape without spinning up the engine.
*/
static io.github.randomcodespace.sonarpredict.protocol.dto.Issue map(
String ruleKey, String filePath, TextRange range, String message,
RuleCatalog catalog, List<QuickFix> quickFixes) {
int startLine = range != null ? range.getStartLine() : 0;
int startColumn = range != null ? range.getStartLineOffset() : 0;
int endLine = range != null ? range.getEndLine() : 0;
Expand All @@ -79,7 +98,39 @@ static io.github.randomcodespace.sonarpredict.protocol.dto.Issue map(
endColumn,
severity,
type,
message);
message,
quickFixes);
}

/**
* Maps the engine's {@link org.sonarsource.sonarlint.core.analysis.api.QuickFix}
* list to the protocol's {@link QuickFix} DTOs.
*
* <p>Engine {@code QuickFix.message()} is preserved verbatim. Each
* {@link ClientInputFileEdit}'s target is path-resolved the same way the
* primary issue's input file is (via {@link #resolveFilePath}), so quick-fix
* targets share the {@code baseDir}-relative, '/'-separated convention with
* {@link io.github.randomcodespace.sonarpredict.protocol.dto.Issue#filePath}.
*/
static List<QuickFix> mapQuickFixes(
List<org.sonarsource.sonarlint.core.analysis.api.QuickFix> engineQuickFixes,
Path baseDir) {
return engineQuickFixes.stream()
.map(qf -> new QuickFix(
qf.message(),
qf.inputFileEdits().stream()
.map(ife -> new FileEdit(
resolveFilePath(ife.target(), baseDir),
ife.textEdits().stream()
.map(te -> new TextEdit(
te.range().getStartLine(),
te.range().getStartLineOffset(),
te.range().getEndLine(),
te.range().getEndLineOffset(),
te.newText()))
.toList()))
.toList()))
.toList();
}

private static String resolveFilePath(ClientInputFile inputFile, Path baseDir) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package io.github.randomcodespace.sonarpredict.protocol.dto;

import java.util.List;

/**
* All {@link TextEdit}s a {@link QuickFix} applies to one specific file.
*
* <p>The compact constructor null-normalises and defensively copies {@code edits}
* so the record stays immutable regardless of how callers (Jackson, tests,
* direct construction) hand it in.
*
* @param filePath path relative to the analysis base directory, matching the
* convention used by {@link Issue#filePath}
* @param edits the in-file edits to apply; never {@code null}
*/
public record FileEdit(
String filePath,
List<TextEdit> edits
) {
public FileEdit {
edits = edits == null ? List.of() : List.copyOf(edits);
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
package io.github.randomcodespace.sonarpredict.protocol.dto;

import java.util.List;

/**
* A single analysis finding.
*
* @param filePath path relative to the analysis base directory
* @param severity one of BLOCKER, CRITICAL, MAJOR, MINOR, INFO
* @param type one of BUG, CODE_SMELL, VULNERABILITY, SECURITY_HOTSPOT
* @param ruleKey rule identifier, e.g. {@code java:S1118}
* @param filePath path relative to the analysis base directory
* @param startLine 1-indexed inclusive
* @param startColumn 0-indexed inclusive
* @param endLine 1-indexed inclusive
* @param endColumn 0-indexed exclusive
* @param severity one of BLOCKER, CRITICAL, MAJOR, MINOR, INFO
* @param type one of BUG, CODE_SMELL, VULNERABILITY, SECURITY_HOTSPOT
* @param message human-readable, often imperative ("Use isEmpty() to check…")
* @param quickFixes analyzer-supplied machine-applicable remediations; never
* {@code null} — empty list when none are attached
*/
public record Issue(
String ruleKey,
Expand All @@ -16,6 +26,28 @@ public record Issue(
int endColumn,
String severity,
String type,
String message
String message,
List<QuickFix> quickFixes
) {
public Issue {
quickFixes = quickFixes == null ? List.of() : List.copyOf(quickFixes);
}

/**
* Backwards-compatible 9-arg constructor used by call sites that pre-date
* the {@code quickFixes} field. Defaults the new field to an empty list.
*/
public Issue(
String ruleKey,
String filePath,
int startLine,
int startColumn,
int endLine,
int endColumn,
String severity,
String type,
String message) {
this(ruleKey, filePath, startLine, startColumn, endLine, endColumn,
severity, type, message, List.of());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package io.github.randomcodespace.sonarpredict.protocol.dto;

import java.util.List;

/**
* An analyzer-supplied, machine-applicable remediation for an {@link Issue}.
*
* <p>A quick fix carries a human-readable {@code message} describing the change
* and a list of {@link FileEdit}s — usually one file, but multi-file edits are
* permitted by the SonarSource analyzer contract.
*
* <p>Not every issue has quick fixes — rule coverage is uneven across
* analyzers. {@link Issue#quickFixes()} returns an empty list when none are
* attached, so consumers can iterate uniformly.
*
* @param message short, imperative remediation description (e.g.
* "Add a private constructor")
* @param fileEdits per-file edits this fix applies; never {@code null}
*/
public record QuickFix(
String message,
List<FileEdit> fileEdits
) {
public QuickFix {
fileEdits = fileEdits == null ? List.of() : List.copyOf(fileEdits);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package io.github.randomcodespace.sonarpredict.protocol.dto;

/**
* A single replacement edit inside one file — the smallest unit of a {@link QuickFix}.
*
* <p>Positions are 1-indexed lines and 0-indexed columns to match the convention
* used by {@link Issue#startLine}/{@link Issue#startColumn} so consumers that
* already render issue positions can render edits the same way.
*
* @param startLine 1-indexed inclusive start line
* @param startColumn 0-indexed inclusive start column on {@code startLine}
* @param endLine 1-indexed inclusive end line
* @param endColumn 0-indexed exclusive end column on {@code endLine}
* @param replacement the text to substitute for the range; the empty string
* means "delete the range"
*/
public record TextEdit(
int startLine,
int startColumn,
int endLine,
int endColumn,
String replacement
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@
import io.github.randomcodespace.sonarpredict.protocol.Json;
import io.github.randomcodespace.sonarpredict.protocol.dto.AnalysisWarning;
import io.github.randomcodespace.sonarpredict.protocol.dto.AnalyzeResponse;
import io.github.randomcodespace.sonarpredict.protocol.dto.FileEdit;
import io.github.randomcodespace.sonarpredict.protocol.dto.Issue;
import io.github.randomcodespace.sonarpredict.protocol.dto.QuickFix;
import io.github.randomcodespace.sonarpredict.protocol.dto.RuleMetadata;
import io.github.randomcodespace.sonarpredict.protocol.dto.TextEdit;

/**
* Unit tests for the {@link Reporter} implementations: {@link TextReporter}
Expand Down Expand Up @@ -163,4 +166,48 @@
"the compact JSON rule block must not carry an HTML 'description'");
assertNotNull(ruleBlock.get("name"), "the rule name stays in the compact JSON");
}

@Test
@DisplayName("JsonReporter omits 'quickFixes' for issues that have none (token-lean)")
void jsonOmitsEmptyQuickFixes() throws Exception {

Check warning on line 172 in src/test/java/io/github/randomcodespace/sonarpredict/cli/ReportersTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the declaration of thrown exception 'java.lang.Exception', as it cannot be thrown from method's body.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_sonar-predict&issues=AZ5fH4x2gbS7DYtyZBfn&open=AZ5fH4x2gbS7DYtyZBfn&pullRequest=9
String json = new JsonReporter().render(WITH_ISSUES);

assertFalse(json.contains("\"quickFixes\""),
"an issue with no analyzer-supplied quick fix must not emit an empty array; "
+ "the field should be absent. got: " + json);
}

@Test
@DisplayName("JsonReporter emits a quickFixes array when the issue carries one")
void jsonEmitsQuickFixes() throws Exception {
Issue withFix = new Issue(
"java:S1118", "src/Util.java", 3, 13, 3, 17,
"MAJOR", "CODE_SMELL", "Add a private constructor.",
List.of(new QuickFix(
"Add a private constructor",
List.of(new FileEdit(
"src/Util.java",
List.of(new TextEdit(3, 0, 3, 0,
" private Util() {}\n")))))));
AnalyzeResponse response = new AnalyzeResponse(List.of(withFix), List.of());

String json = new JsonReporter().render(response);
JsonNode root = Json.mapper().readTree(json);
JsonNode issue = root.get("files").get(0).get("issues").get(0);

JsonNode qfs = issue.get("quickFixes");
assertNotNull(qfs, "the issue must carry a quickFixes array; got: " + json);
assertEquals(1, qfs.size(), "exactly one quick fix was supplied");
assertEquals("Add a private constructor", qfs.get(0).get("message").asText());

JsonNode fileEdit = qfs.get(0).get("fileEdits").get(0);
assertEquals("src/Util.java", fileEdit.get("filePath").asText());

JsonNode edit = fileEdit.get("edits").get(0);
assertEquals(3, edit.get("startLine").asInt());
assertEquals(0, edit.get("startColumn").asInt());
assertEquals(3, edit.get("endLine").asInt());
assertEquals(0, edit.get("endColumn").asInt());
assertEquals(" private Util() {}\n", edit.get("replacement").asText());
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.github.randomcodespace.sonarpredict.daemon;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
Expand Down Expand Up @@ -46,6 +47,32 @@ void analyze_java_stillWorks() {
}
}

@Test
@DisplayName("java:S1118 on UtilityClass.java carries an analyzer-supplied quick fix")
void analyze_java_s1118_carriesQuickFix() {
try (AnalysisService service = new AnalysisService()) {
AnalyzeResponse response = service.analyze(request("java/UtilityClass.java"));

Issue s1118 = response.issues().stream()
.filter(i -> "java:S1118".equals(i.ruleKey()))
.findFirst()
.orElseThrow(() -> new AssertionError(
"expected java:S1118 in: " + response.issues()));
assertFalse(s1118.quickFixes().isEmpty(),
"java:S1118 must surface at least one analyzer-supplied quick fix; got: "
+ s1118);
assertFalse(s1118.quickFixes().get(0).fileEdits().isEmpty(),
"quick-fix must have at least one file edit; got: " + s1118.quickFixes());
assertFalse(
s1118.quickFixes().get(0).fileEdits().get(0).edits().isEmpty(),
"file edit must have at least one text edit; got: " + s1118.quickFixes());
// The edit's path must match the issue's path (relative to baseDir).
assertEquals(s1118.filePath(),
s1118.quickFixes().get(0).fileEdits().get(0).filePath(),
"quick-fix file path must match the issue's file path");
}
}

@Test
@DisplayName("a warm AnalysisService is constructed once and reused across many analyze() calls")
void analyze_warmServiceReusedAcrossCalls() {
Expand Down
Loading
Loading