branch: elpa/flycheck
commit 0fa52902d94b7c7342f580f2b2c6485e78111d6a
Author: Bozhidar Batsov <[email protected]>
Commit: Bozhidar Batsov <[email protected]>

    Truncate excessive errors instead of disabling the checker
    
    Past flycheck-checker-error-threshold, Flycheck used to discard all
    errors, disable the checker in the buffer and pop the *Warnings* buffer
    with a message few users know how to act on.  Now it keeps the most
    severe errors up to the threshold and signals the truncation in the
    mode line (a + after the counts) and the error list, with a one-time
    echo-area notice.
    
    The old behavior remains available by setting the new option
    flycheck-checker-error-threshold-action to disable, minus the
    *Warnings* popup: the notification is an actionable echo-area message
    now.
---
 CHANGES.rst                        |  11 +++
 doc/user/error-reports.rst         |  21 ++++-
 flycheck.el                        | 169 +++++++++++++++++++++++++++++++------
 test/specs/test-command-checker.el |  76 +++++++++++++++++
 test/specs/test-helpers.el         |  20 +++++
 5 files changed, 269 insertions(+), 28 deletions(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index ff83cca0bd..7bb1c8155c 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -7,6 +7,17 @@
   status with no parsable errors, e.g. a missing dependency) into
   regular errors shown in the buffer, instead of the generic
   suspicious-state warning.
+- Syntax checkers that exceed ``flycheck-checker-error-threshold`` are no
+  longer silently disabled.  Flycheck now shows the most severe errors up
+  to the threshold and signals the truncation in the mode line (e.g.
+  ``FlyC:400|12|3+``) and the error list.  Set the new option
+  ``flycheck-checker-error-threshold-action`` to ``disable`` to get the
+  old behavior, minus the ``*Warnings*`` popup: the notification is now a
+  plain echo-area message that says how to re-enable the checker.  Note
+  that with the new default, level-conditioned ``:next-checkers`` see the
+  kept errors and behave as they would in any buffer with that many real
+  errors; previously they ran unconditionally because the whole result
+  was discarded.
 - [#2161]: Fix the ``org-lint`` checker erroring out on Emacs 31, where
   ``org-lint`` reports line numbers as strings.
 - [#2174]: Fix the ``haskell-ghc`` and ``haskell-stack-ghc`` checkers
diff --git a/doc/user/error-reports.rst b/doc/user/error-reports.rst
index ccd588186b..7f16676222 100644
--- a/doc/user/error-reports.rst
+++ b/doc/user/error-reports.rst
@@ -305,10 +305,23 @@ checkers that report a large number of errors exceeding
 
    The maximum number of errors a syntax checker is allowed to report.
 
-   If a syntax checker reports more errors the error information is
-   **discarded**.  To not run into the same issue again on the next syntax 
check
-   the syntax checker is automatically added to `flycheck-disabled-checkers` in
-   this case to disable it for the next syntax check.
+   What happens to the excessive errors is controlled by
+   `flycheck-checker-error-threshold-action`.
+
+.. defcustom:: flycheck-checker-error-threshold-action
+
+   What to do when a syntax checker exceeds the error threshold.
+
+   By default (``truncate``), Flycheck keeps the most severe errors up to the
+   threshold and discards the rest.  The mode line signals the truncation with
+   a ``+`` after the error counts (e.g. ``FlyC:400|12|3+``), and the error
+   list shows how many errors were suppressed.
+
+   When set to ``disable``, Flycheck instead discards **all** errors from the
+   syntax checker and disables it in the buffer for subsequent syntax checks,
+   like older Flycheck versions did.  Use :kbd:`C-u C-c ! x` to re-enable a
+   disabled checker.  This avoids re-parsing excessive checker output on every
+   syntax check, which can be preferable for pathological cases.
 
 Clear results
 =============
diff --git a/flycheck.el b/flycheck.el
index 168b2d5f0f..80630a4258 100644
--- a/flycheck.el
+++ b/flycheck.el
@@ -286,11 +286,25 @@ respectively."
 A checker can be automatically disabled in two cases:
 
 1. Its `:enabled' predicate returned false.
-2. It returned too many errors (see `flycheck-checker-error-threshold').
+2. It returned too many errors (see `flycheck-checker-error-threshold')
+   and `flycheck-checker-error-threshold-action' is set to `disable'.
 
 To trigger a reverification from Emacs Lisp code, do not modify
 this variable: use `flycheck-reset-enabled-checker'.")
 
+(defvar-local flycheck--suppressed-error-count 0
+  "Number of errors suppressed in the last syntax check.
+
+Incremented when a syntax checker exceeds
+`flycheck-checker-error-threshold' and the excessive errors are
+truncated, per `flycheck-checker-error-threshold-action'.")
+
+(defvar-local flycheck--excessive-checkers nil
+  "Syntax checkers that last exceeded the error threshold.
+
+Used to notify about the threshold only when a checker newly
+exceeds it, instead of after every syntax check.")
+
 (defvar-local flycheck-checker nil
   "Syntax checker to use for the current buffer.
 
@@ -341,16 +355,37 @@ maximum number of errors per syntax checker and buffer, 
or nil to
 not limit the errors reported from a syntax checker.
 
 If this variable is a number and a syntax checker reports more
-errors than the value of this variable, its errors are
-discarded, and not highlighted in the buffer or available in the
-error list.  The affected syntax checker is also disabled for
-future syntax checks of the buffer."
+errors than the value of this variable,
+`flycheck-checker-error-threshold-action' determines what happens
+to the excessive errors."
   :group 'flycheck
   :type '(choice (const :tag "Do not limit reported errors" nil)
                  (integer :tag "Maximum number of errors"))
   :risky t
   :package-version '(flycheck . "0.22"))
 
+(defcustom flycheck-checker-error-threshold-action 'truncate
+  "What to do when a checker exceeds `flycheck-checker-error-threshold'.
+
+`truncate'
+     Keep the most severe errors up to the threshold and discard
+     the rest.  The mode line indicates that some errors were
+     suppressed.  This is the default.
+
+`disable'
+     Discard all errors reported by the syntax checker and
+     disable it in the buffer for subsequent syntax checks.
+     The checker can be re-enabled with
+     \\[universal-argument] \\[flycheck-disable-checker].
+     Compared to `truncate' this avoids re-parsing excessive
+     output on every syntax check, at the cost of no feedback at
+     all."
+  :group 'flycheck
+  :type '(choice (const :tag "Keep the most severe errors" truncate)
+                 (const :tag "Disable the checker in the buffer" disable))
+  :safe #'symbolp
+  :package-version '(flycheck . "37"))
+
 (defcustom flycheck-process-error-functions nil
   "Functions to process errors.
 
@@ -1154,7 +1189,8 @@ function must be updated to use this variable."
   `(,(propertized-buffer-identification "%12b")
     " for buffer "
     (:eval (flycheck-error-list-propertized-source-name))
-    (:eval (flycheck-error-list-mode-line-filter-indicator)))
+    (:eval (flycheck-error-list-mode-line-filter-indicator))
+    (:eval (flycheck-error-list-mode-line-suppressed-indicator)))
   "Mode line construct for Flycheck error list.
 
 The value of this variable is a mode line template as in
@@ -1309,7 +1345,8 @@ Mode line status for the current buffer:
   FlyC        Not been checked yet
   FlyC*       Flycheck is running
   FlyC:0      Last check resulted in no errors and no warnings
-  FlyC:3|5    Checker reported three errors and five warnings
+  FlyC:3|5|1  Checker reported three errors, five warnings and one info
+  FlyC:3|5|1+ Some errors were suppressed over the error threshold
   FlyC-       No checker available
   FlyC!       The checker crashed
   FlyC.       The last syntax check was manually interrupted
@@ -3186,6 +3223,7 @@ Get a syntax checker for the current buffer with
         ;; completed.
         (run-hooks 'flycheck-before-syntax-check-hook)
         (flycheck-clear-errors)
+        (setq flycheck--suppressed-error-count 0)
         (flycheck-mark-all-overlays-for-deletion)
         (condition-case err
             (let* ((checker (flycheck-get-checker-for-buffer)))
@@ -3279,9 +3317,9 @@ current syntax check in `flycheck-current-syntax-check'.
 
 Report all ERRORS and potentially start any next syntax checkers.
 
-If the current syntax checker reported excessive errors, it is
-disabled via `flycheck-disable-excessive-checker' for subsequent
-syntax checks.
+If the current syntax checker reported excessive errors, they are
+truncated or discarded via `flycheck--handle-excessive-errors',
+according to `flycheck-checker-error-threshold-action'.
 
 Relative file names in ERRORS will be expanded relative to
 WORKING-DIR."
@@ -3292,8 +3330,8 @@ WORKING-DIR."
                    (flycheck-filter-errors
                     (flycheck-assert-error-list-p errors) checker)
                    working-dir))))
-    (unless (flycheck-disable-excessive-checker checker errors)
-      (flycheck-report-current-errors errors))
+    (flycheck-report-current-errors
+     (flycheck--handle-excessive-errors checker errors))
     (let ((next-checker (flycheck-get-next-checker-for-buffer checker)))
       (if next-checker
           (flycheck-start-current-syntax-check next-checker)
@@ -3312,25 +3350,77 @@ WORKING-DIR."
         ;; that we quickly refine outdated error information
         (flycheck-perform-deferred-syntax-check)))))
 
+(defun flycheck--handle-excessive-errors (checker errors)
+  "Handle ERRORS from CHECKER exceeding the error threshold.
+
+Return the errors to report: ERRORS when
+`flycheck-checker-error-threshold' is not exceeded, the most
+severe errors up to the threshold when
+`flycheck-checker-error-threshold-action' is `truncate', or nil
+when it is `disable'."
+  (let ((total (length errors)))
+    (if (or (null flycheck-checker-error-threshold)
+            (<= total flycheck-checker-error-threshold))
+        (progn
+          (setq flycheck--excessive-checkers
+                (remq checker flycheck--excessive-checkers))
+          errors)
+      (if (eq flycheck-checker-error-threshold-action 'disable)
+          (progn
+            (setq flycheck--excessive-checkers
+                  (remq checker flycheck--excessive-checkers))
+            (flycheck-disable-excessive-checker checker errors)
+            nil)
+        (flycheck--truncate-excessive-errors checker errors total)))))
+
+(defun flycheck--excessive-errors-< (err1 err2)
+  "Determine the truncation order of ERR1 and ERR2.
+
+Orders by severity, from most to least severe; errors of equal
+severity keep their buffer-position order, so that truncation
+drops the errors furthest down in the buffer."
+  (let ((severity1 (flycheck-error-level-severity
+                    (flycheck-error-level err1)))
+        (severity2 (flycheck-error-level-severity
+                    (flycheck-error-level err2))))
+    (if (= severity1 severity2)
+        (flycheck-error-< err1 err2)
+      (> severity1 severity2))))
+
+(defun flycheck--truncate-excessive-errors (checker errors total)
+  "Truncate ERRORS from CHECKER to the error threshold.
+
+TOTAL is the length of ERRORS.  Keep the most severe errors up to
+`flycheck-checker-error-threshold' and record the number of
+suppressed errors in `flycheck--suppressed-error-count'."
+  (let* ((threshold flycheck-checker-error-threshold)
+         (kept (seq-take (sort (copy-sequence errors)
+                               #'flycheck--excessive-errors-<)
+                         threshold)))
+    (cl-incf flycheck--suppressed-error-count (- total threshold))
+    (unless (memq checker flycheck--excessive-checkers)
+      (push checker flycheck--excessive-checkers)
+      (message "Flycheck: %s reported %d errors; showing the %d most severe \
+(see `flycheck-checker-error-threshold')"
+               checker total threshold))
+    kept))
+
 (defun flycheck-disable-excessive-checker (checker errors)
   "Disable CHECKER if it reported excessive ERRORS.
 
 If ERRORS has more items than `flycheck-checker-error-threshold',
 add CHECKER to `flycheck--automatically-disabled-checkers', and
-show a warning.
+say so in the echo area.
 
 Return t when CHECKER was disabled, or nil otherwise."
   (when (and flycheck-checker-error-threshold
              (> (length errors) flycheck-checker-error-threshold))
     ;; Disable CHECKER for this buffer
     ;; (`flycheck--automatically-disabled-checkers' is a local variable).
-    (lwarn '(flycheck syntax-checker) :warning
-           (substitute-command-keys
-            "Syntax checker %s reported too many errors (%s) and is disabled.
-Use `\\[customize-variable] RET flycheck-checker-error-threshold' to
-change the threshold or `\\[universal-argument] \
-\\[flycheck-disable-checker]' to re-enable the checker.")
-           checker (length errors))
+    (message (substitute-command-keys
+              "Flycheck: %s reported %d errors and was disabled in this \
+buffer; \\[universal-argument] \\[flycheck-disable-checker] re-enables it")
+             checker (length errors))
     (push checker flycheck--automatically-disabled-checkers)
     t))
 
@@ -3344,6 +3434,10 @@ current syntax check."
     (flycheck-stop))
   (flycheck-delete-all-overlays)
   (flycheck-clear-errors)
+  ;; Note: `flycheck--excessive-checkers' deliberately survives a clear,
+  ;; so that the truncation notification doesn't re-fire after every
+  ;; manual clear or transient no-checker pass
+  (setq flycheck--suppressed-error-count 0)
   (flycheck-clear-displayed-error-messages)
   (flycheck-error-list-refresh)
   (flycheck-hide-error-buffer))
@@ -3367,6 +3461,7 @@ buffer), and if so then clean up global hooks."
   (flycheck-stop)
   (flycheck-clean-deferred-check)
   (flycheck-clear)
+  (setq flycheck--excessive-checkers nil)
   (flycheck-cancel-error-display-error-at-point-timer)
   (flycheck--clear-idle-trigger-timer)
   (flycheck--empty-variables)
@@ -4149,10 +4244,17 @@ nil."
                       (`errored "!")
                       (`finished
                        (let-alist (flycheck-count-errors 
flycheck-current-errors)
-                         (if (or .error .warning .info)
-                             (format ":%s|%s|%s" (or .error 0) (or .warning 0)
-                                     (or .info 0))
-                           flycheck-mode-success-indicator)))
+                         (concat
+                          (if (or .error .warning .info)
+                              (format ":%s|%s|%s" (or .error 0) (or .warning 0)
+                                      (or .info 0))
+                            flycheck-mode-success-indicator)
+                          ;; Signal that some errors were suppressed over
+                          ;; `flycheck-checker-error-threshold', even when
+                          ;; the kept errors have no built-in level
+                          (if (> flycheck--suppressed-error-count 0)
+                              "+"
+                            ""))))
                       (`interrupted ".")
                       (`suspicious "?")))
          (face (when flycheck-mode-line-color
@@ -4164,6 +4266,13 @@ nil."
          (text (format " %s%s" flycheck-mode-line-prefix indicator)))
     (when face
       (setq text (propertize text 'face face)))
+    (when (and (eq current-status 'finished)
+               (> flycheck--suppressed-error-count 0))
+      (setq text (propertize
+                  text 'help-echo
+                  (format "%d more errors not shown\
+ (see flycheck-checker-error-threshold)"
+                          flycheck--suppressed-error-count))))
     text))
 
 
@@ -5334,6 +5443,18 @@ list."
       (format " [>= %s]" flycheck-error-list-minimum-level)
     ""))
 
+(defun flycheck-error-list-mode-line-suppressed-indicator ()
+  "Create a string for the mode line about suppressed errors.
+
+Shows the number of errors in the source buffer that were
+suppressed over `flycheck-checker-error-threshold', if any."
+  (let ((count (and (buffer-live-p flycheck-error-list-source-buffer)
+                    (buffer-local-value 'flycheck--suppressed-error-count
+                                        flycheck-error-list-source-buffer))))
+    (if (and count (> count 0))
+        (format " (+%d suppressed)" count)
+      "")))
+
 (defun flycheck-error-list-set-filter (level)
   "Restrict the error list to errors at level LEVEL or higher.
 
diff --git a/test/specs/test-command-checker.el 
b/test/specs/test-command-checker.el
index cba1fc89cd..b201fd0969 100644
--- a/test/specs/test-command-checker.el
+++ b/test/specs/test-command-checker.el
@@ -170,6 +170,82 @@
           (insert "hello\n")
           (expect (shut-up (flycheck-buttercup-should-syntax-check-in-buffer))
                   :to-throw 'flycheck-buttercup-suspicious-checker))))
+    (it "truncates excessive errors keeping the most severe"
+      (assume (or (executable-find "python3") (executable-find "python")))
+      (cl-letf* ((flycheck-checker 'many-errors)
+                 ((symbol-plist 'many-errors) flycheck-test--many-errors)
+                 (flycheck-checker-error-threshold 3))
+        (flycheck-buttercup-with-temp-buffer
+          (many-errors-mode)
+          (insert "hello\n")
+          (flycheck-mode)
+          (shut-up (flycheck-buttercup-buffer-sync))
+          (expect (length flycheck-current-errors) :to-equal 3)
+          ;; The two error-level errors outrank the six infos, and among
+          ;; the infos the one closest to the top of the buffer survives
+          (expect (seq-sort #'< (seq-map #'flycheck-error-line
+                                         flycheck-current-errors))
+                  :to-equal '(1 7 8))
+          (expect flycheck--suppressed-error-count :to-equal 5)
+          (expect (flycheck-mode-line-status-text) :to-match (rx "2|0|1+" eos))
+          (expect (flycheck-automatically-disabled-checker-p 'many-errors)
+                  :not :to-be-truthy)
+          ;; Under the threshold everything is reported again
+          (let ((flycheck-checker-error-threshold 100))
+            (flycheck-buttercup-buffer-sync))
+          (expect (length flycheck-current-errors) :to-equal 8)
+          (expect flycheck--suppressed-error-count :to-equal 0))))
+
+    (it "disables the checker over the threshold with the disable action"
+      (assume (or (executable-find "python3") (executable-find "python")))
+      (cl-letf* ((flycheck-checker 'many-errors)
+                 ((symbol-plist 'many-errors) flycheck-test--many-errors)
+                 (flycheck-checker-error-threshold 3)
+                 (flycheck-checker-error-threshold-action 'disable))
+        (flycheck-buttercup-with-temp-buffer
+          (many-errors-mode)
+          (insert "hello\n")
+          (flycheck-mode)
+          (shut-up (flycheck-buttercup-buffer-sync))
+          (expect flycheck-current-errors :not :to-be-truthy)
+          (expect (flycheck-automatically-disabled-checker-p 'many-errors)
+                  :to-be-truthy))))
+
+    (it "notifies only when a checker newly exceeds the threshold"
+      (flycheck-buttercup-with-temp-buffer
+        (let ((flycheck-checker-error-threshold 1)
+              (messages 0)
+              (errs (list (flycheck-error-new-at 1 1 'info "spam")
+                          (flycheck-error-new-at 2 1 'error "boom"))))
+          (cl-letf (((symbol-function 'message)
+                     (lambda (&rest _) (cl-incf messages))))
+            ;; Truncation keeps the most severe error
+            (let ((kept (flycheck--truncate-excessive-errors 'foo errs 2)))
+              (expect (mapcar #'flycheck-error-level kept) :to-equal '(error)))
+            ;; Repeated checks over the threshold notify only once
+            (flycheck--truncate-excessive-errors 'foo errs 2)
+            (expect messages :to-equal 1)
+            ;; Passing under the threshold re-arms the notification
+            (flycheck--handle-excessive-errors 'foo (cdr errs))
+            (flycheck--truncate-excessive-errors 'foo errs 2)
+            (expect messages :to-equal 2)))))
+
+    (it "shows the suppressed error count in the error list mode line"
+      (flycheck-buttercup-with-temp-buffer
+        (setq flycheck--suppressed-error-count 5)
+        (let ((source (current-buffer)))
+          (with-temp-buffer
+            (setq flycheck-error-list-source-buffer source)
+            (expect (flycheck-error-list-mode-line-suppressed-indicator)
+                    :to-equal " (+5 suppressed)")))))
+
+    (it "shows no suppressed count without suppressed errors"
+      (flycheck-buttercup-with-temp-buffer
+        (let ((source (current-buffer)))
+          (with-temp-buffer
+            (setq flycheck-error-list-source-buffer source)
+            (expect (flycheck-error-list-mode-line-suppressed-indicator)
+                    :to-equal "")))))
 
     (it "forces English messages without overriding the character set"
       ;; LC_MESSAGES=C gives English output, while LC_ALL is left alone so
diff --git a/test/specs/test-helpers.el b/test/specs/test-helpers.el
index a9ac73768d..33123a9800 100644
--- a/test/specs/test-helpers.el
+++ b/test/specs/test-helpers.el
@@ -197,6 +197,26 @@ The manifest path is relative to
   "Saved plist for the suspicious-exit checker.")
 
 (setf (symbol-plist 'suspicious-exit) nil)
+;;; Excessive-errors test helpers (for error-threshold tests)
+
+(define-derived-mode many-errors-mode prog-mode "many")
+
+(flycheck-define-command-checker 'many-errors
+  "Report six info-level and two error-level errors."
+  :command `(,(or (executable-find "python3") "python")
+             "-c" ,(concat
+                    "print('\\n'.join("
+                    "['many:%d:1:info:spam' % i for i in range(1, 7)]"
+                    " + ['many:7:1:error:boom', 'many:8:1:error:bang']))"))
+  :error-patterns '((info bol "many:" line ":" column ":info:" (message))
+                    (error bol "many:" line ":" column ":error:" (message)))
+  :modes '(many-errors-mode))
+
+(defconst flycheck-test--many-errors
+  (symbol-plist 'many-errors)
+  "Saved plist for the many-errors checker.")
+
+(setf (symbol-plist 'many-errors) nil)
 
 
 ;;; Custom error level for error-level tests

Reply via email to