Skip to content

Commit 978a992

Browse files
A parser for Staticcheck JSON output (#1486)
1 parent 8b37ad6 commit 978a992

7 files changed

Lines changed: 446 additions & 1 deletion

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package edu.hm.hafner.analysis.parser;
2+
3+
import org.json.JSONArray;
4+
import org.json.JSONObject;
5+
6+
import edu.hm.hafner.analysis.Issue;
7+
import edu.hm.hafner.analysis.IssueBuilder;
8+
import edu.hm.hafner.analysis.Report;
9+
10+
import java.io.Serial;
11+
12+
/**
13+
* A parser for Staticcheck JSON output.
14+
*
15+
* @author Akash Manna
16+
* @see <a href="https://staticcheck.dev/">Staticcheck</a>
17+
* @see <a href="https://github.com/dominikh/go-tools">Staticcheck on GitHub</a>
18+
*/
19+
public class StaticcheckParser extends JsonIssueParser {
20+
@Serial
21+
private static final long serialVersionUID = 413414650434266749L;
22+
23+
private static final String DIAGNOSTICS = "diagnostics";
24+
private static final String LOCATION = "location";
25+
private static final String END = "end";
26+
private static final String FILE = "file";
27+
private static final String LINE = "line";
28+
private static final String COLUMN = "column";
29+
private static final String CODE = "code";
30+
private static final String MESSAGE = "message";
31+
private static final String SEVERITY = "severity";
32+
33+
@Override
34+
protected void parseJsonObject(final Report report, final JSONObject jsonReport, final IssueBuilder issueBuilder) {
35+
if (jsonReport.has(DIAGNOSTICS)) {
36+
parseDiagnostics(report, jsonReport.getJSONArray(DIAGNOSTICS), issueBuilder);
37+
}
38+
else if (looksLikeIssue(jsonReport)) {
39+
report.add(convertToIssue(jsonReport, issueBuilder));
40+
}
41+
}
42+
43+
@Override
44+
protected void parseJsonArray(final Report report, final JSONArray jsonReport, final IssueBuilder issueBuilder) {
45+
parseDiagnostics(report, jsonReport, issueBuilder);
46+
}
47+
48+
private void parseDiagnostics(final Report report, final JSONArray diagnostics, final IssueBuilder issueBuilder) {
49+
for (int i = 0; i < diagnostics.length(); i++) {
50+
var issue = diagnostics.optJSONObject(i);
51+
if (issue != null) {
52+
report.add(convertToIssue(issue, issueBuilder));
53+
}
54+
}
55+
}
56+
57+
private boolean looksLikeIssue(final JSONObject jsonReport) {
58+
return jsonReport.has(MESSAGE) || jsonReport.has(CODE) || jsonReport.has(LOCATION);
59+
}
60+
61+
private Issue convertToIssue(final JSONObject jsonIssue, final IssueBuilder issueBuilder) {
62+
issueBuilder.setType(jsonIssue.optString(CODE, "-"))
63+
.guessSeverity(jsonIssue.optString(SEVERITY, "warning"))
64+
.setMessage(jsonIssue.optString(MESSAGE));
65+
66+
applyLocation(jsonIssue.optJSONObject(LOCATION), issueBuilder);
67+
applyEnd(jsonIssue.optJSONObject(END), issueBuilder, LINE, COLUMN);
68+
69+
return issueBuilder.buildAndClean();
70+
}
71+
72+
private void applyLocation(final JSONObject location, final IssueBuilder issueBuilder) {
73+
if (location == null) {
74+
return;
75+
}
76+
77+
issueBuilder.setFileName(location.optString(FILE));
78+
applyStart(location, issueBuilder, LINE, COLUMN);
79+
applyEnd(location.optJSONObject(END), issueBuilder, LINE, COLUMN);
80+
}
81+
}

src/main/java/edu/hm/hafner/analysis/registry/ParserRegistry.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ public class ParserRegistry {
166166
new SimianDescriptor(),
167167
new SimulinkCheckDescriptor(),
168168
new SnykDescriptor(),
169+
new StaticcheckDescriptor(),
169170
new SonarQubeDescriptor(),
170171
new SpectralDescriptor(),
171172
new SphinxBuildDescriptor(),
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package edu.hm.hafner.analysis.registry;
2+
3+
import edu.hm.hafner.analysis.IssueParser;
4+
import edu.hm.hafner.analysis.parser.StaticcheckParser;
5+
6+
/**
7+
* A descriptor for Staticcheck JSON reports.
8+
*
9+
* @author Akash Manna
10+
*/
11+
class StaticcheckDescriptor extends ParserDescriptor {
12+
private static final String ID = "staticcheck";
13+
private static final String NAME = "Staticcheck";
14+
15+
StaticcheckDescriptor() {
16+
super(ID, NAME);
17+
}
18+
19+
@Override
20+
public IssueParser create(final Option... options) {
21+
return new StaticcheckParser();
22+
}
23+
24+
@Override
25+
public String getPattern() {
26+
return "**/staticcheck-report.json";
27+
}
28+
29+
@Override
30+
public String getHelp() {
31+
return "Use commandline <code>staticcheck -f json ./... > staticcheck-report.json</code> to generate JSON output.<br/>"
32+
+ "See <a href='https://github.com/dominikh/go-tools'>Staticcheck on GitHub</a> for usage details.";
33+
}
34+
35+
@Override
36+
public String getUrl() {
37+
return "https://github.com/dominikh/go-tools";
38+
}
39+
}
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
package edu.hm.hafner.analysis.parser;
2+
3+
import java.nio.file.FileSystems;
4+
5+
import org.junit.jupiter.api.Test;
6+
7+
import edu.hm.hafner.analysis.FileReaderFactory;
8+
import edu.hm.hafner.analysis.IssueParser;
9+
import edu.hm.hafner.analysis.ParsingException;
10+
import edu.hm.hafner.analysis.Report;
11+
import edu.hm.hafner.analysis.Severity;
12+
import edu.hm.hafner.analysis.assertions.SoftAssertions;
13+
import edu.hm.hafner.analysis.registry.AbstractParserTest;
14+
import edu.hm.hafner.analysis.registry.ParserRegistry;
15+
16+
import static edu.hm.hafner.analysis.assertions.Assertions.*;
17+
18+
/**
19+
* Tests the class {@link StaticcheckParser}.
20+
*
21+
* @author Akash Manna
22+
*/
23+
class StaticcheckParserTest extends AbstractParserTest {
24+
StaticcheckParserTest() {
25+
super("staticcheck-report.json");
26+
}
27+
28+
@Override
29+
protected void assertThatIssuesArePresent(final Report report, final SoftAssertions softly) {
30+
assertThat(report).hasSize(4);
31+
32+
softly.assertThat(report.get(0))
33+
.hasFileName("internal/config/config.go")
34+
.hasLineStart(42)
35+
.hasColumnStart(2)
36+
.hasLineEnd(42)
37+
.hasColumnEnd(13)
38+
.hasType("SA4006")
39+
.hasMessage("this value of err is never used")
40+
.hasSeverity(Severity.WARNING_NORMAL);
41+
42+
softly.assertThat(report.get(1))
43+
.hasFileName("pkg/http/server.go")
44+
.hasLineStart(18)
45+
.hasColumnStart(6)
46+
.hasLineEnd(18)
47+
.hasColumnEnd(19)
48+
.hasType("ST1005")
49+
.hasMessage("error strings should not be capitalized")
50+
.hasSeverity(Severity.WARNING_LOW);
51+
52+
softly.assertThat(report.get(2))
53+
.hasFileName("cmd/app/main.go")
54+
.hasLineStart(65)
55+
.hasColumnStart(3)
56+
.hasLineEnd(65)
57+
.hasColumnEnd(21)
58+
.hasType("S1002")
59+
.hasMessage("should omit comparison to bool constant")
60+
.hasSeverity(Severity.WARNING_NORMAL);
61+
62+
softly.assertThat(report.get(3))
63+
.hasFileName("pkg/cache/cache.go")
64+
.hasLineStart(90)
65+
.hasColumnStart(1)
66+
.hasLineEnd(95)
67+
.hasColumnEnd(2)
68+
.hasType("U1000")
69+
.hasMessage("func warmupCache is unused")
70+
.hasSeverity(Severity.ERROR);
71+
}
72+
73+
@Override
74+
protected IssueParser createParser() {
75+
return new StaticcheckParser();
76+
}
77+
78+
@Test
79+
void accepts() {
80+
var parser = new StaticcheckParser();
81+
assertThat(parser.accepts(read("staticcheck-report.json"))).isTrue();
82+
assertThat(parser.accepts(read("foo.txt"))).isFalse();
83+
}
84+
85+
private FileReaderFactory read(final String first) {
86+
return new FileReaderFactory(FileSystems.getDefault().getPath(first));
87+
}
88+
89+
@Test
90+
void brokenInput() {
91+
assertThatThrownBy(() -> parse("eclipse.txt"))
92+
.isInstanceOf(ParsingException.class);
93+
}
94+
95+
@Test
96+
void emptyInput() throws ParsingException {
97+
var report = parse("issues-no-issues.json");
98+
99+
assertThat(report).isEmpty();
100+
}
101+
102+
@Test
103+
void shouldHandleMissingFields() {
104+
var report = parseStringContent("""
105+
[
106+
{
107+
"message": "fallback handling"
108+
}
109+
]
110+
""");
111+
112+
assertThat(report).hasSize(1);
113+
assertThat(report.get(0))
114+
.hasType("-")
115+
.hasSeverity(Severity.WARNING_NORMAL)
116+
.hasMessage("fallback handling")
117+
.hasFileName("-")
118+
.hasLineStart(0)
119+
.hasLineEnd(0)
120+
.hasColumnStart(0)
121+
.hasColumnEnd(0);
122+
}
123+
124+
@Test
125+
void shouldHandleWrappedDiagnosticsObject() {
126+
var report = parseStringContent("""
127+
{
128+
"diagnostics": [
129+
{
130+
"code": "SA4010",
131+
"severity": "warning",
132+
"message": "wrapped payload",
133+
"location": {
134+
"file": "service.go",
135+
"line": 7,
136+
"column": 4
137+
},
138+
"end": {
139+
"line": 7,
140+
"column": 16
141+
}
142+
}
143+
]
144+
}
145+
""");
146+
147+
assertThat(report).hasSize(1);
148+
assertThat(report.get(0))
149+
.hasFileName("service.go")
150+
.hasType("SA4010")
151+
.hasSeverity(Severity.WARNING_NORMAL)
152+
.hasMessage("wrapped payload")
153+
.hasLineStart(7)
154+
.hasColumnStart(4)
155+
.hasLineEnd(7)
156+
.hasColumnEnd(16);
157+
}
158+
159+
@Test
160+
void shouldParseSingleIssueObject() {
161+
var report = parseStringContent("""
162+
{
163+
"code": "SA9999",
164+
"severity": "warning",
165+
"message": "single issue object",
166+
"location": {
167+
"file": "single.go",
168+
"line": 11,
169+
"column": 2
170+
}
171+
}
172+
""");
173+
174+
assertThat(report).hasSize(1);
175+
assertThat(report.get(0))
176+
.hasFileName("single.go")
177+
.hasType("SA9999")
178+
.hasSeverity(Severity.WARNING_NORMAL)
179+
.hasMessage("single issue object")
180+
.hasLineStart(11)
181+
.hasColumnStart(2);
182+
}
183+
184+
@Test
185+
void shouldIgnoreNonIssueRootObject() {
186+
var report = parseStringContent("""
187+
{
188+
"metadata": {
189+
"tool": "staticcheck"
190+
}
191+
}
192+
""");
193+
194+
assertThat(report).isEmpty();
195+
}
196+
197+
@Test
198+
void shouldHandleNullDiagnosticEntry() {
199+
var report = parseStringContent("""
200+
{
201+
"diagnostics": [
202+
null
203+
]
204+
}
205+
""");
206+
207+
assertThat(report).isEmpty();
208+
}
209+
210+
@Test
211+
void shouldDetectIssueByLocationOnly() {
212+
var report = parseStringContent("""
213+
{
214+
"location": {
215+
"file": "location-only.go",
216+
"line": 5,
217+
"column": 9
218+
}
219+
}
220+
""");
221+
222+
assertThat(report).hasSize(1);
223+
assertThat(report.get(0))
224+
.hasFileName("location-only.go")
225+
.hasType("-")
226+
.hasSeverity(Severity.WARNING_NORMAL)
227+
.hasMessage("")
228+
.hasLineStart(5)
229+
.hasColumnStart(9);
230+
}
231+
232+
@Test
233+
void shouldDetectIssueByCodeOnly() {
234+
var report = parseStringContent("""
235+
{
236+
"code": "SA5000"
237+
}
238+
""");
239+
240+
assertThat(report).hasSize(1);
241+
assertThat(report.get(0))
242+
.hasType("SA5000")
243+
.hasSeverity(Severity.WARNING_NORMAL)
244+
.hasMessage("")
245+
.hasFileName("-")
246+
.hasLineStart(0)
247+
.hasColumnStart(0);
248+
}
249+
250+
@Test
251+
void shouldProvideDescriptorMetadata() {
252+
var descriptor = new ParserRegistry().get("staticcheck");
253+
254+
assertThat(descriptor.getPattern()).isEqualTo("**/staticcheck-report.json");
255+
assertThat(descriptor.getHelp()).contains("staticcheck -f json");
256+
assertThat(descriptor.getUrl()).isEqualTo("https://github.com/dominikh/go-tools");
257+
assertThat(descriptor.hasHelp()).isTrue();
258+
assertThat(descriptor.hasUrl()).isTrue();
259+
}
260+
}

0 commit comments

Comments
 (0)