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\">

Reply via email to