branch: elpa/flycheck
commit a4ede1ad832322472a02f16682343f5881c7d696
Author: Bozhidar Batsov <[email protected]>
Commit: Bozhidar Batsov <[email protected]>
Add flycheck-parse-sarif error parser
SARIF (Static Analysis Results Interchange Format) is the industry
standard JSON output format for analyzers, and many linters can emit
it. Add a reusable :error-parser for it, alongside the existing
CheckStyle XML parser, so checker definitions can consume structured
output instead of matching human-readable text with fragile error
patterns. Handles multiple runs, rule-index level/id resolution,
SARIF level mapping, end positions and file URIs.
---
CHANGES.rst | 5 +++
flycheck.el | 95 ++++++++++++++++++++++++++++++++++++++++
test/specs/test-error-parsers.el | 94 +++++++++++++++++++++++++++++++++++++++
3 files changed, 194 insertions(+)
diff --git a/CHANGES.rst b/CHANGES.rst
index b53880ff66..dd2cac81eb 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,11 @@
=======================
- Drop support for Emacs 27; Flycheck now requires Emacs 28.1 or newer.
+- Add ``flycheck-parse-sarif``, a ready-made ``:error-parser`` for the
+ SARIF output format that many analyzers can emit. Like the existing
+ ``flycheck-parse-checkstyle``, checker definitions can point
+ ``:error-parser`` at it instead of matching output text with
+ ``:error-patterns``.
- [#1129]: ``javascript-eslint`` no longer probes for a configuration
file with a blocking ``--print-config`` call before the first check in
every buffer, which used to freeze Emacs for its duration (the
diff --git a/flycheck.el b/flycheck.el
index 67943f401f..4c8505df23 100644
--- a/flycheck.el
+++ b/flycheck.el
@@ -79,6 +79,7 @@
(require 'help-mode) ; `define-button-type'
(require 'find-func) ; `find-function-regexp-alist'
(require 'ansi-color) ; `flycheck-parse-with-patterns-without-color'
+(require 'url-util) ; `url-unhex-string' for
`flycheck-parse-sarif'
;; Declare a bunch of dynamic variables that we need from other modes
@@ -6514,6 +6515,13 @@ of command checkers is `flycheck-sanitize-errors'.
must return a list of `flycheck-error' objects parsed from
OUTPUT.
+ Flycheck provides ready-made parsers for common structured
+ output formats: `flycheck-parse-checkstyle' for Checkstyle
+ XML and `flycheck-parse-sarif' for SARIF, which many
+ analyzers can emit. Prefer these over `:error-patterns'
+ when a checker offers such an output format, as they are
+ more robust than matching human-readable text.
+
This property is optional. If omitted, it defaults to
`flycheck-parse-with-patterns'. In this case,
`:error-patterns' is mandatory.
@@ -7968,6 +7976,93 @@ lines, and returns the parsed JSON lines in a list."
(forward-line)))
(nreverse objects)))
+(defun flycheck-parse-sarif--level (level)
+ "Map a SARIF result LEVEL string to a Flycheck error level."
+ (pcase level
+ ("error" 'error)
+ ("warning" 'warning)
+ ;; \"note\" is advisory, \"none\" carries no severity of its own
+ ((or "note" "none") 'info)
+ ;; SARIF defaults an unspecified level to \"warning\"
+ (_ 'warning)))
+
+(defun flycheck-parse-sarif (output checker buffer)
+ "Parse SARIF errors from OUTPUT.
+
+Parse output in the Static Analysis Results Interchange Format
+\(SARIF) 2.1.0. Use this error parser for checkers that have an
+option to output errors in this format.
+
+CHECKER and BUFFER denote the CHECKER that returned OUTPUT and
+the BUFFER that was checked respectively.
+
+See URL `https://sarifweb.azurewebsites.net/' for more
+information about SARIF."
+ (let-alist (car (flycheck-parse-json output))
+ (seq-mapcat
+ (lambda (run)
+ (let-alist run
+ ;; The rules of the run's driver supply the id and default level
+ ;; of a result that omits them
+ (let ((rules .tool.driver.rules))
+ (seq-mapcat
+ (lambda (result)
+ (let-alist result
+ (let* ((rule
+ ;; A result references its rule by index into the
+ ;; rules array, or by id
+ (or (and (natnump .ruleIndex)
+ (nth .ruleIndex rules))
+ (and .ruleId
+ (seq-find (lambda (r)
+ (equal (alist-get 'id r)
+ .ruleId))
+ rules))))
+ (id (or .ruleId (alist-get 'id rule)))
+ (level (flycheck-parse-sarif--level
+ (or .level
+ (let-alist rule
+ .defaultConfiguration.level))))
+ (message .message.text))
+ (if .locations
+ (seq-map
+ (lambda (location)
+ (let-alist location
+ (flycheck-error-new-at
+ .physicalLocation.region.startLine
+ .physicalLocation.region.startColumn
+ level message
+ :id id
+ :checker checker
+ :buffer buffer
+ :filename
+ (flycheck-parse-sarif--uri
+ .physicalLocation.artifactLocation.uri)
+ :end-line .physicalLocation.region.endLine
+ :end-column .physicalLocation.region.endColumn)))
+ .locations)
+ ;; A result without a location applies to the whole run
+ (list (flycheck-error-new-at
+ nil nil level message
+ :id id :checker checker :buffer buffer))))))
+ .results))))
+ .runs)))
+
+(defun flycheck-parse-sarif--uri (uri)
+ "Turn a SARIF artifact-location URI into a file name.
+
+Strip a `file://' scheme and percent-decode URI; return relative
+URIs unchanged, for Flycheck to expand against the working
+directory."
+ (when uri
+ ;; Strip a file:// scheme and any authority: file:///abs/path and
+ ;; file://host/abs/path both leave the leading slash of the path
+ (when (string-match "\\`file://[^/]*" uri)
+ (setq uri (substring uri (match-end 0))))
+ ;; `url-unhex-string' returns the raw bytes of percent escapes, which
+ ;; must be decoded, as file URIs percent-encode UTF-8
+ (decode-coding-string (url-unhex-string uri) 'utf-8)))
+
(defun flycheck-parse-rustc (output checker buffer)
"Parse rustc errors from OUTPUT and return a list of `flycheck-error'.
diff --git a/test/specs/test-error-parsers.el b/test/specs/test-error-parsers.el
index 7c982760ad..37b036c3f9 100644
--- a/test/specs/test-error-parsers.el
+++ b/test/specs/test-error-parsers.el
@@ -26,6 +26,100 @@
(require 'flycheck-buttercup)
(describe "Error parsers"
+ (describe "The SARIF parser"
+ (it "parses results with locations and rule metadata"
+ (let ((sarif "{\"version\":\"2.1.0\",\"runs\":[{\"tool\":{\"driver\":\
+{\"name\":\"demo\",\"rules\":[{\"id\":\"no-undef\",\
+\"defaultConfiguration\":{\"level\":\"error\"}}]}},\"results\":[\
+{\"ruleIndex\":0,\"message\":{\"text\":\"undefined name\"},\"locations\":[\
+{\"physicalLocation\":{\"artifactLocation\":{\"uri\":\"src/a.c\"},\"region\":\
+{\"startLine\":3,\"startColumn\":5,\"endLine\":3,\"endColumn\":9}}}]},\
+{\"ruleId\":\"style/x\",\"level\":\"warning\",\"message\":{\"text\":\"style\"},\
+\"locations\":[{\"physicalLocation\":{\"artifactLocation\":{\"uri\":\"src/b.c\"},\
+\"region\":{\"startLine\":7}}}]}]}]}"))
+ (expect (flycheck-parse-sarif sarif 'checker 'buffer)
+ :to-be-equal-flycheck-errors
+ (list
+ ;; Level comes from the referenced rule's default
+ (flycheck-error-new
+ :filename "src/a.c" :checker 'checker :buffer 'buffer
+ :line 3 :column 5 :end-line 3 :end-column 9
+ :level 'error :message "undefined name" :id "no-undef")
+ ;; Level comes from the result, region has only a line
+ (flycheck-error-new
+ :filename "src/b.c" :checker 'checker :buffer 'buffer
+ :line 7 :column nil :level 'warning
+ :message "style" :id "style/x")))))
+
+ (it "maps SARIF levels and handles missing levels"
+ (let ((sarif "{\"runs\":[{\"tool\":{\"driver\":{\"name\":\"d\"}},\
+\"results\":[\
+{\"level\":\"note\",\"message\":{\"text\":\"n\"}},\
+{\"level\":\"none\",\"message\":{\"text\":\"z\"}},\
+{\"message\":{\"text\":\"unspecified\"}}]}]}"))
+ (expect (mapcar #'flycheck-error-level
+ (flycheck-parse-sarif sarif 'checker 'buffer))
+ ;; note and none map to info; a missing level defaults to
+ ;; warning, as the SARIF spec prescribes
+ :to-equal '(info info warning))))
+
+ (it "decodes file URIs and percent-escapes"
+ (let ((sarif "{\"runs\":[{\"tool\":{\"driver\":{\"name\":\"d\"}},\
+\"results\":[{\"message\":{\"text\":\"m\"},\"locations\":[{\"physicalLocation\":\
+{\"artifactLocation\":{\"uri\":\"file:///home/me/a%20b.c\"},\
+\"region\":{\"startLine\":1}}}]}]}]}"))
+ (expect (flycheck-error-filename
+ (car (flycheck-parse-sarif sarif 'checker 'buffer)))
+ :to-equal "/home/me/a b.c")))
+
+ (it "handles multiple runs and empty results"
+ (let ((sarif "{\"runs\":[\
+{\"tool\":{\"driver\":{\"name\":\"a\"}},\"results\":[]},\
+{\"tool\":{\"driver\":{\"name\":\"b\"}},\"results\":[\
+{\"message\":{\"text\":\"from second run\"}}]}]}"))
+ (expect (mapcar #'flycheck-error-message
+ (flycheck-parse-sarif sarif 'checker 'buffer))
+ :to-equal '("from second run"))))
+
+ (it "tolerates an out-of-range or negative ruleIndex"
+ (let ((sarif "{\"runs\":[{\"tool\":{\"driver\":{\"name\":\"d\",\
+\"rules\":[{\"id\":\"R1\"}]}},\"results\":[\
+{\"ruleIndex\":-1,\"message\":{\"text\":\"negative\"}},\
+{\"ruleIndex\":5,\"message\":{\"text\":\"out of range\"}}]}]}"))
+ ;; The sentinel index -1 and an out-of-range index must not crash;
+ ;; the result just carries no rule id
+ (expect (mapcar #'flycheck-error-id
+ (flycheck-parse-sarif sarif 'checker 'buffer))
+ :to-equal '(nil nil))))
+
+ (it "resolves a rule referenced by id alone"
+ (let ((sarif "{\"runs\":[{\"tool\":{\"driver\":{\"name\":\"d\",\
+\"rules\":[{\"id\":\"E001\",\"defaultConfiguration\":{\"level\":\"error\"}}]}},\
+\"results\":[{\"ruleId\":\"E001\",\"message\":{\"text\":\"m\"}}]}]}"))
+ ;; No ruleIndex and no explicit level: the rule's default level
+ ;; must still be found via the id
+ (expect (flycheck-error-level
+ (car (flycheck-parse-sarif sarif 'checker 'buffer)))
+ :to-be 'error)))
+
+ (it "strips a non-localhost file URI authority"
+ (let ((sarif "{\"runs\":[{\"tool\":{\"driver\":{\"name\":\"d\"}},\
+\"results\":[{\"message\":{\"text\":\"m\"},\"locations\":[{\"physicalLocation\":\
+{\"artifactLocation\":{\"uri\":\"file://myhost/abs/path.py\"},\
+\"region\":{\"startLine\":1}}}]}]}]}"))
+ (expect (flycheck-error-filename
+ (car (flycheck-parse-sarif sarif 'checker 'buffer)))
+ :to-equal "/abs/path.py")))
+
+ (it "utf-8-decodes percent escapes in file URIs"
+ (let ((sarif "{\"runs\":[{\"tool\":{\"driver\":{\"name\":\"d\"}},\
+\"results\":[{\"message\":{\"text\":\"m\"},\"locations\":[{\"physicalLocation\":\
+{\"artifactLocation\":{\"uri\":\"file:///src/caf%C3%A9.py\"},\
+\"region\":{\"startLine\":1}}}]}]}]}"))
+ (expect (flycheck-error-filename
+ (car (flycheck-parse-sarif sarif 'checker 'buffer)))
+ :to-equal "/src/café.py"))))
+
(describe "The checkstyle parser"
(let ((checkstyle-xml "<?xml version=\"1.0\" encoding=\"utf-8\"?>
<checkstyle version=\"4.3\">