diff --git a/CHANGELOG.md b/CHANGELOG.md index f21c73896..32cd9376a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - [PR#132](https://github.com/jenkinsci/analysis-model/pull/132): Added a parser for CMake warnings. +- [PR#137](https://github.com/jenkinsci/analysis-model/pull/137): +Added a parser for JSON output from Cargo. ### Fixed - [JENKINS-56333](https://issues.jenkins-ci.org/browse/JENKINS-56333): diff --git a/src/main/java/edu/hm/hafner/analysis/parser/CargoCheckParser.java b/src/main/java/edu/hm/hafner/analysis/parser/CargoCheckParser.java new file mode 100644 index 000000000..3229f2768 --- /dev/null +++ b/src/main/java/edu/hm/hafner/analysis/parser/CargoCheckParser.java @@ -0,0 +1,158 @@ +package edu.hm.hafner.analysis.parser; + +import java.util.Optional; +import java.util.stream.Stream; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.json.JSONTokener; + +import edu.hm.hafner.analysis.Issue; +import edu.hm.hafner.analysis.IssueBuilder; +import edu.hm.hafner.analysis.IssueParser; +import edu.hm.hafner.analysis.ParsingCanceledException; +import edu.hm.hafner.analysis.ParsingException; +import edu.hm.hafner.analysis.ReaderFactory; +import edu.hm.hafner.analysis.Report; +import edu.hm.hafner.analysis.Severity; + +/** + * A parser for {@code rustc} compiler messages in the JSON format emitted by {@code cargo check --message-format + * json}. + * + * @author Gary Tierney + */ +public class CargoCheckParser extends IssueParser { + private static final long serialVersionUID = 7953467739178377581L; + + /** The {@link #REASON} associated with messages that have code analysis information. */ + private static final String ANALYSIS_MESSAGE_REASON = "compiler-message"; + + /** Top-level key indicating the reason for a message to be emitted, we only care about compiler-message. */ + private static final String REASON = "reason"; + + /** Top-level key containing the code analysis message. */ + private static final String MESSAGE = "message"; + + /** Key for {@code message.code}, an object containing the message category. */ + private static final String MESSAGE_CODE = "code"; + + /** Key for {@code message.code.code}, a string representation of the message category. */ + private static final String MESSAGE_CODE_CATEGORY = "code"; + + /** Key for {@code message.rendered}, the rendered string representation of the message. */ + private static final String MESSAGE_RENDERED = "message"; + + /** Key for {@code message.level}, the string representation of the message severity. */ + private static final String MESSAGE_LEVEL = "level"; + + /** Key for {@code message.spans}, an array of message location information. */ + private static final String MESSAGE_SPANS = "spans"; + + /** Key for {@code message.spans.is_primary}, a boolean indicating if this is the primary error location". */ + private static final String MESSAGE_SPAN_IS_PRIMARY = "is_primary"; + + /** Key for {@code message.spans.file_name}, a relative path to the file the message was emitted for. */ + private static final String MESSAGE_SPAN_FILE_NAME = "file_name"; + + /** Key for {@code message.spans.line_start}, the line number where the associated code starts. */ + private static final String MESSAGE_SPAN_LINE_START = "line_start"; + + /** Key for {@code message.spans.line_end}, the line number where the associated code ends. */ + private static final String MESSAGE_SPAN_LINE_END = "line_end"; + + /** Key for {@code message.spans.column_start}, the column number where the associated code starts. */ + private static final String MESSAGE_SPAN_COLUMN_START = "column_start"; + + /** Key for {@code message.spans.column_end}, the column number where the associated code ends. */ + private static final String MESSAGE_SPAN_COLUMN_END = "column_end"; + + @Override + public Report parse(final ReaderFactory readerFactory) throws ParsingException, ParsingCanceledException { + Report report = new Report(); + + try (Stream lines = readerFactory.readStream()) { + lines.map(line -> (JSONObject) new JSONTokener(line).nextValue()) + .map(this::extractIssue) + .filter(Optional::isPresent) + .map(Optional::get) + .forEach(report::add); + } + + return report; + } + + /** + * Extract the compiler message from a cargo event if any is present. + * + * @param object + * A cargo event that may contain a compiler message. + * + * @return a built {@link Issue} object if any was present. + */ + private Optional extractIssue(final JSONObject object) { + String reason = object.getString(REASON); + + if (!ANALYSIS_MESSAGE_REASON.equals(reason)) { + return Optional.empty(); + } + + JSONObject message = object.getJSONObject(MESSAGE); + JSONObject code = message.getJSONObject(MESSAGE_CODE); + String category = code.getString(MESSAGE_CODE_CATEGORY); + String renderedMessage = message.getString(MESSAGE_RENDERED); + Severity severity = Severity.guessFromString(message.getString(MESSAGE_LEVEL)); + + return parseDetails(message) + .map(details -> new IssueBuilder() + .setFileName(details.fileName) + .setLineStart(details.lineStart) + .setLineEnd(details.lineEnd) + .setColumnStart(details.columnStart) + .setColumnEnd(details.columnEnd) + .setCategory(category) + .setMessage(renderedMessage) + .setSeverity(severity) + .build()); + } + + private Optional parseDetails(final JSONObject message) { + JSONArray spans = message.getJSONArray(MESSAGE_SPANS); + + for (int index = 0; index < spans.length(); index++) { + JSONObject span = spans.getJSONObject(index); + + if (span.getBoolean(MESSAGE_SPAN_IS_PRIMARY)) { + String fileName = span.getString(MESSAGE_SPAN_FILE_NAME); + int lineStart = span.getInt(MESSAGE_SPAN_LINE_START); + int lineEnd = span.getInt(MESSAGE_SPAN_LINE_END); + int columnStart = span.getInt(MESSAGE_SPAN_COLUMN_START); + int columnEnd = span.getInt(MESSAGE_SPAN_COLUMN_END); + + return Optional.of(new CompilerMessageDetails(fileName, lineStart, lineEnd, columnStart, columnEnd)); + } + } + + return Optional.empty(); + } + + /** + * A simplified representation of a primary {@code span} object in the {@code message.spans} an array. + */ + private static final class CompilerMessageDetails { + private final String fileName; + private final int lineStart; + private final int lineEnd; + private final int columnStart; + private final int columnEnd; + + CompilerMessageDetails(final String fileName, final int lineStart, final int lineEnd, final int columnStart, + final int columnEnd) { + this.fileName = fileName; + this.lineStart = lineStart; + this.lineEnd = lineEnd; + this.columnStart = columnStart; + this.columnEnd = columnEnd; + } + } +} diff --git a/src/test/java/edu/hm/hafner/analysis/parser/CargoCheckParserTest.java b/src/test/java/edu/hm/hafner/analysis/parser/CargoCheckParserTest.java new file mode 100644 index 000000000..6f98de485 --- /dev/null +++ b/src/test/java/edu/hm/hafner/analysis/parser/CargoCheckParserTest.java @@ -0,0 +1,48 @@ +package edu.hm.hafner.analysis.parser; + +import edu.hm.hafner.analysis.AbstractParserTest; +import edu.hm.hafner.analysis.IssueParser; +import edu.hm.hafner.analysis.Report; +import edu.hm.hafner.analysis.Severity; +import edu.hm.hafner.analysis.assertj.SoftAssertions; + +/** + * Tests the class {@link CargoCheckParser}. + * + * @author Gary Tierney + */ +class CargoCheckParserTest extends AbstractParserTest { + CargoCheckParserTest() { + super("CargoCheck.json"); + } + + @Override + protected void assertThatIssuesArePresent(final Report report, final SoftAssertions softly) { + softly.assertThat(report).hasSize(2); + + softly.assertThat(report.get(0)) + .hasFileName("packages/secspc/src/main.rs") + .hasMessage("unused import: `secsp_analysis::input::FileId`") + .hasCategory("unused_imports") + .hasSeverity(Severity.WARNING_NORMAL) + .hasLineStart(14) + .hasLineEnd(14) + .hasColumnStart(5) + .hasColumnEnd(34); + + softly.assertThat(report.get(1)) + .hasFileName("packages/secspc/src/main.rs") + .hasMessage("redundant closure found") + .hasCategory("clippy::redundant_closure") + .hasSeverity(Severity.WARNING_NORMAL) + .hasLineStart(68) + .hasLineEnd(68) + .hasColumnStart(14) + .hasColumnEnd(34); + } + + @Override + protected IssueParser createParser() { + return new CargoCheckParser(); + } +} diff --git a/src/test/resources/edu/hm/hafner/analysis/parser/CargoCheck.json b/src/test/resources/edu/hm/hafner/analysis/parser/CargoCheck.json new file mode 100644 index 000000000..a87949366 --- /dev/null +++ b/src/test/resources/edu/hm/hafner/analysis/parser/CargoCheck.json @@ -0,0 +1,4 @@ +{"reason":"compiler-artifact","package_id":"smol_str 0.1.9 (registry+https://github.com/rust-lang/crates.io-index)","target":{"kind":["lib"],"crate_types":["lib"],"name":"smol_str","src_path":".cargo/registry/src/github.com-1ecc6299db9ec823/smol_str-0.1.9/src/lib.rs","edition":"2015"},"profile":{"opt_level":"0","debuginfo":2,"debug_assertions":true,"overflow_checks":true,"test":false},"features":[],"filenames":["/target/debug/deps/libsmol_str-60034903e8f9c710.rmeta"],"executable":null,"fresh":false} +{"reason":"compiler-message","package_id":"dummy-pkg","target":{"kind":["bin"],"crate_types":["bin"],"name":"secspc","src_path":"src/main.rs","edition":"2018"},"message":{"message":"unused import: `secsp_analysis::input::FileId`","code":{"code":"unused_imports","explanation":null},"level":"warning","spans":[{"file_name":"packages/secspc/src/main.rs","byte_start":199,"byte_end":228,"line_start":14,"line_end":14,"column_start":5,"column_end":34,"is_primary":true,"text":[{"text":"use secsp_analysis::input::FileId;","highlight_start":5,"highlight_end":34}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"#[warn(unused_imports)] on by default","code":null,"level":"note","spans":[],"children":[],"rendered":null}],"rendered":"warning: unused import: `secsp_analysis::input::FileId`\n --> packages/secspc/src/main.rs:14:5\n |\n14 | use secsp_analysis::input::FileId;\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n |\n = note: #[warn(unused_imports)] on by default\n\n"}} +{"reason":"compiler-message","package_id":"dummy-pkg","target":{"kind":["bin"],"crate_types":["bin"],"name":"secspc","src_path":"packages/secspc/src/main.rs","edition":"2018"},"message":{"message":"dummy message","code":{"code":"dummy_code","explanation":null},"level":"warning","spans":[],"children":[],"rendered":"dummy message"}} +{"reason":"compiler-message","package_id":"dummy-pkg","target":{"kind":["bin"],"crate_types":["bin"],"name":"secspc","src_path":"packages/secspc/src/main.rs","edition":"2018"},"message":{"message":"redundant closure found","code":{"code":"clippy::redundant_closure","explanation":null},"level":"warning","spans":[{"file_name":"packages/secspc/src/main.rs","byte_start":1651,"byte_end":1671,"line_start":68,"line_end":68,"column_start":14,"column_end":34,"is_primary":false,"label":"secondary text here","suggested_replacement":null,"suggestion_applicability":null,"expansion":null},{"file_name":"packages/secspc/src/main.rs","byte_start":1651,"byte_end":1671,"line_start":68,"line_end":68,"column_start":14,"column_end":34,"is_primary":true,"text":[{"text":" .map(|i| PathBuf::from(i))","highlight_start":14,"highlight_end":34}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"#[warn(clippy::redundant_closure)] on by default","code":null,"level":"note","spans":[],"children":[],"rendered":null},{"message":"for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#redundant_closure","code":null,"level":"help","spans":[],"children":[],"rendered":null},{"message":"remove closure as shown","code":null,"level":"help","spans":[{"file_name":"packages/secspc/src/main.rs","byte_start":1651,"byte_end":1671,"line_start":68,"line_end":68,"column_start":14,"column_end":34,"is_primary":true,"text":[{"text":" .map(|i| PathBuf::from(i))","highlight_start":14,"highlight_end":34}],"label":null,"suggested_replacement":"PathBuf::from","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"warning: redundant closure found\n --> packages/secspc/src/main.rs:68:14\n |\n68 | .map(|i| PathBuf::from(i))\n | ^^^^^^^^^^^^^^^^^^^^ help: remove closure as shown: `PathBuf::from`\n |\n = note: #[warn(clippy::redundant_closure)] on by default\n = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#redundant_closure\n\n"}} \ No newline at end of file