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

    Modernize the error list: side window, filters, dynamic columns
    
    The error list now pops up in a bottom side window by default (a
    display-buffer action users can override or disable), can be filtered
    by syntax checker and by message/ID regexp on top of the existing
    minimum-level filter, and sizes its File and ID columns to fit the
    errors on display instead of truncating at fixed widths.
---
 CHANGES.rst                   |  10 +++
 doc/user/error-list.rst       |  44 ++++++----
 flycheck.el                   | 190 +++++++++++++++++++++++++++++++++++++-----
 test/specs/test-error-list.el | 122 ++++++++++++++++++++++++++-
 4 files changed, 326 insertions(+), 40 deletions(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 51292f41e8..78e99f202f 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,16 @@
 =======================
 
 - Drop support for Emacs 27; Flycheck now requires Emacs 28.1 or newer.
+- The error list pops up in a bottom side window by default, a quarter of
+  the frame tall.  Customize the new option
+  ``flycheck-error-list-display-buffer-action`` to change that;
+  ``display-buffer-alist`` entries still take precedence.  Note that side
+  windows are unaffected by ``C-x 1``; dismiss the list with ``q``.
+- The error list can now also be filtered by syntax checker (``c``) and
+  by a regexp matching the error message or ID (``/``), in addition to
+  the existing minimum-level filter (``f``); ``F`` resets all filters.
+- The File and ID columns of the error list adapt their width to the
+  errors on display instead of truncating at fixed widths.
 - [#1787]: Add the ``:handle-suspicious`` property to command checkers.
   It lets a checker translate a "suspicious state" (a non-zero exit
   status with no parsable errors, e.g. a missing dependency) into
diff --git a/doc/user/error-list.rst b/doc/user/error-list.rst
index 2c6eff9e15..833b7c1fb3 100644
--- a/doc/user/error-list.rst
+++ b/doc/user/error-list.rst
@@ -55,12 +55,18 @@ post-jump actions like recentering:
 Filter the list
 ===============
 
-By default the error list shows all errors but sometimes you'd like to hide
-warnings to focus only on real errors.  The error list lets you hide all errors
-below a certain level with :kbd:`f`.  This key prompts for an error level and
-will remove all errors of lower levels from the list.  The filter is permanent
-as long as the error list buffer stays alive or the filter is reset with
-:kbd:`F`.
+By default the error list shows all errors but sometimes you'd like to narrow
+it down.  The error list supports three filters, which combine and stay in
+effect as long as the error list buffer stays alive, until you reset them all
+with :kbd:`F`:
+
+* :kbd:`f` prompts for an error level and hides all errors of lower levels.
+* :kbd:`c` prompts for a syntax checker and shows only its errors, e.g. only
+  the type errors from your type checker while ignoring stylistic complaints.
+* :kbd:`/` prompts for a regular expression and shows only errors whose
+  message or ID matches it.
+
+The active filters are shown in the error list's mode line.
 
 Sort the list
 =============
@@ -79,15 +85,22 @@ to descending or vice versa.
 Tune error list display
 =======================
 
-By default the error list buffer pops up like any other buffer.  Flycheck does
-not enforce special rules on how it's displayed and where it's located in the
-frame so essentially the error list pops up at arbitrary places wherever Emacs
-can find a window for it.
+By default the error list pops up in a side window at the bottom of the frame,
+a quarter of the frame tall, like similar lists in contemporary IDEs.  Side
+windows are not affected by :kbd:`C-x 1` (`delete-other-windows`); dismiss the
+error list with :kbd:`q` in its window instead.  You can change or disable
+this behavior:
+
+.. defcustom:: flycheck-error-list-display-buffer-action
 
-However you can tell Emacs to obey certain rules when displaying buffers by
-customizing the built-in option `display-buffer-alist`.  You can use this 
option
-to make the error list display like similar lists in contemporary IDEs like
-VisualStudio, Eclipse, etc. with the following code in your :term:`init file`:
+   The `display-buffer` action used to display the error list.  Set to ``nil``
+   to fall back to the default behavior of `display-buffer`, where the error
+   list pops up at an arbitrary place wherever Emacs finds a window for it.
+
+Entries in the built-in option `display-buffer-alist` matching the error list
+buffer take precedence over this action, so any existing window-management
+configuration keeps working.  For example, to display the error list at the
+bottom with a third of the frame height instead:
 
 .. code-block:: elisp
 
@@ -99,9 +112,6 @@ VisualStudio, Eclipse, etc. with the following code in your 
:term:`init file`:
                  (reusable-frames . visible)
                  (window-height   . 0.33)))
 
-This display rule tells Emacs to always display the error list at the bottom
-side of the frame, occupying a third of the entire height of the frame.
-
 .. seealso::
 
    Shackle_
diff --git a/flycheck.el b/flycheck.el
index 7cd9c23915..6dfd9e4f55 100644
--- a/flycheck.el
+++ b/flycheck.el
@@ -1294,6 +1294,25 @@ currently listed."
   :risky t
   :package-version '(flycheck . "0.20"))
 
+(defcustom flycheck-error-list-display-buffer-action
+  '((display-buffer-reuse-window display-buffer-in-side-window)
+    (side . bottom)
+    (window-height . 0.25)
+    (preserve-size . (nil . t)))
+  "The `display-buffer' action for the error list buffer.
+
+By default the error list pops up in a side window at the bottom
+of the frame, a quarter of the frame tall.  Set to nil to fall
+back to the default behavior of `display-buffer'.
+
+Entries in `display-buffer-alist' matching the error list buffer
+take precedence over this action, so this option composes with
+window-management configurations."
+  :group 'flycheck
+  :type 'sexp
+  :risky t
+  :package-version '(flycheck . "37"))
+
 (defcustom flycheck-global-modes t
   "Modes for which option `flycheck-mode' is turned on.
 
@@ -5242,6 +5261,15 @@ the beginning of the buffer."
 (defconst flycheck-error-list-buffer "*Flycheck errors*"
   "The name of the buffer to show error lists.")
 
+(defvar-local flycheck-error-list--checker-filter nil
+  "When non-nil, show only errors from this syntax checker.")
+
+(defvar-local flycheck-error-list--message-filter nil
+  "When non-nil, show only errors matching this regexp.
+
+The regexp is matched against the error message and the error
+ID.")
+
 (defmacro flycheck-error-list-with-buffer (&rest body)
   "Evaluate BODY in flycheck-error-list-buffer, if it exists."
   (declare (indent 0) (debug t))
@@ -5252,6 +5280,8 @@ the beginning of the buffer."
 (defvar flycheck-error-list-mode-map
   (let ((map (make-sparse-keymap)))
     (define-key map (kbd "f") #'flycheck-error-list-set-filter)
+    (define-key map (kbd "c") #'flycheck-error-list-set-checker-filter)
+    (define-key map (kbd "/") #'flycheck-error-list-set-message-filter)
     (define-key map (kbd "F") #'flycheck-error-list-reset-filter)
     (define-key map (kbd "n") #'flycheck-error-list-next-error)
     (define-key map (kbd "p") #'flycheck-error-list-previous-error)
@@ -5279,26 +5309,76 @@ message to stretch arbitrarily far."
     ("Level" 8 flycheck-error-list-entry-level-<)
     ("ID" 6 t)
     (,(flycheck-error-list-make-last-column "Message" 'Checker) 0 t)]
-  "Table format for the error list.")
+  "Default table format for the error list.
+
+The File and ID columns are resized to fit the errors being
+displayed; see `flycheck-error-list--update-format'.")
 
 (defconst flycheck-error-list-padding 1
   "Padding used in error list.")
 
-(defconst flycheck--error-list-msg-offset
+(defun flycheck--error-list-compute-msg-offset (format)
+  "Compute the space before the message column in FORMAT."
   (seq-reduce
    (lambda (offset fmt)
      (pcase-let* ((`(,_ ,width ,_ . ,props) fmt)
                   (padding (or (plist-get props :pad-right) 1)))
        (+ offset width padding)))
-   (seq-subseq flycheck-error-list-format 0 -1)
-   flycheck-error-list-padding)
+   (seq-subseq format 0 -1)
+   flycheck-error-list-padding))
+
+(defvar-local flycheck--error-list-msg-offset
+    (flycheck--error-list-compute-msg-offset flycheck-error-list-format)
   "Amount of space to use in `flycheck-flush-multiline-message'.")
 
+(defun flycheck-error-list--column-widths (errors)
+  "Compute the File and ID column widths for ERRORS.
+
+Return a cons cell (FILE-WIDTH . ID-WIDTH)."
+  (let ((file-width 4) (id-width 2))
+    (dolist (err errors)
+      (when-let* ((file (flycheck-error-filename err)))
+        (setq file-width (max file-width
+                              (length (file-name-nondirectory file)))))
+      (when-let* ((id (flycheck-error-id err)))
+        (setq id-width (max id-width (length (format "%s" id))))))
+    (cons (min file-width 40) (min id-width 24))))
+
+(defun flycheck-error-list--set-column-width (format name width)
+  "Set the width of the column NAME in FORMAT to WIDTH.
+
+FORMAT is a tabulated list format vector; the other column
+properties are left unchanged.  Unknown column names are
+ignored."
+  (when-let* ((index (seq-position format name
+                                   (lambda (column name)
+                                     (equal (car column) name)))))
+    (setf (aref format index)
+          (cons name (cons width (cddr (aref format index)))))))
+
+(defun flycheck-error-list--update-format ()
+  "Fit the File and ID column widths to the displayed errors."
+  (pcase-let ((`(,file-width . ,id-width)
+               (flycheck-error-list--column-widths
+                (flycheck-error-list-apply-filter
+                 (flycheck-error-list-current-errors)))))
+    (let ((format (copy-sequence flycheck-error-list-format)))
+      (flycheck-error-list--set-column-width format "File" file-width)
+      (flycheck-error-list--set-column-width format "ID" id-width)
+      (setq tabulated-list-format format))
+    (setq flycheck--error-list-msg-offset
+          (flycheck--error-list-compute-msg-offset tabulated-list-format))
+    (tabulated-list-init-header)))
+
 (define-derived-mode flycheck-error-list-mode tabulated-list-mode
   "Flycheck errors"
   "Major mode for listing Flycheck errors.
 
 \\{flycheck-error-list-mode-map}"
+  ;; Fit the column widths on every revert, including a manual
+  ;; `revert-buffer', so that they never go stale
+  (add-hook 'tabulated-list-revert-hook
+            #'flycheck-error-list--update-format nil t)
   (setq tabulated-list-format flycheck-error-list-format
         ;; Sort by location initially
         tabulated-list-sort-key (cons "Line" nil)
@@ -5528,10 +5608,14 @@ list."
       (flycheck-error-list-highlight-errors preserve-pos))))
 
 (defun flycheck-error-list-mode-line-filter-indicator ()
-  "Create a string representing the current error list filter."
-  (if flycheck-error-list-minimum-level
-      (format " [>= %s]" flycheck-error-list-minimum-level)
-    ""))
+  "Create a string representing the current error list filters."
+  (concat
+   (when flycheck-error-list-minimum-level
+     (format " [>= %s]" flycheck-error-list-minimum-level))
+   (when flycheck-error-list--checker-filter
+     (format " [%s]" flycheck-error-list--checker-filter))
+   (when flycheck-error-list--message-filter
+     (format " [/%s/]" flycheck-error-list--message-filter))))
 
 (defun flycheck-error-list-mode-line-suppressed-indicator ()
   "Create a string for the mode line about suppressed errors.
@@ -5545,6 +5629,14 @@ suppressed over `flycheck-checker-error-threshold', if 
any."
         (format " (+%d suppressed)" count)
       "")))
 
+(defun flycheck-error-list--apply-filter-change (thunk)
+  "Call THUNK in the error list buffer and refresh the list."
+  (flycheck-error-list-with-buffer
+    (funcall thunk)
+    (force-mode-line-update)
+    (flycheck-error-list-refresh)
+    (flycheck-error-list-recenter-at (point-min))))
+
 (defun flycheck-error-list-set-filter (level)
   "Restrict the error list to errors at level LEVEL or higher.
 
@@ -5554,11 +5646,45 @@ LEVEL is either an error level symbol, or nil, to 
remove the filter."
           "Minimum error level (errors at lower levels will be hidden): ")))
   (when (and level (not (flycheck-error-level-p level)))
     (user-error "Invalid level: %s" level))
-  (flycheck-error-list-with-buffer
-    (setq-local flycheck-error-list-minimum-level level)
-    (force-mode-line-update)
-    (flycheck-error-list-refresh)
-    (flycheck-error-list-recenter-at (point-min))))
+  (flycheck-error-list--apply-filter-change
+   (lambda () (setq-local flycheck-error-list-minimum-level level))))
+
+(defun flycheck-error-list-set-checker-filter (checker)
+  "Restrict the error list to errors from CHECKER.
+
+CHECKER is a syntax checker symbol, or nil to remove the filter."
+  (interactive
+   (list (flycheck-error-list-with-buffer
+           (let ((checkers (seq-uniq
+                            (seq-map
+                             (lambda (err)
+                               (symbol-name (flycheck-error-checker err)))
+                             (flycheck-error-list-current-errors)))))
+             (unless checkers
+               (user-error "The error list contains no errors"))
+             (let ((name (completing-read "Show only errors from checker: "
+                                          checkers nil t)))
+               ;; Empty input removes the filter
+               (and (not (string-empty-p name)) (intern name)))))))
+  (flycheck-error-list--apply-filter-change
+   (lambda () (setq flycheck-error-list--checker-filter checker))))
+
+(defun flycheck-error-list-set-message-filter (regexp)
+  "Restrict the error list to errors matching REGEXP.
+
+REGEXP is matched against the error messages and IDs.  An empty
+or nil REGEXP removes the filter."
+  (interactive (list (read-regexp "Show only errors matching regexp")))
+  (setq regexp (and regexp (not (string-empty-p regexp)) regexp))
+  ;; Reject invalid regexps here; storing one would break every
+  ;; subsequent refresh of the error list
+  (when regexp
+    (condition-case err
+        (string-match-p regexp "")
+      (invalid-regexp
+       (user-error "Invalid regexp: %s" (cadr err)))))
+  (flycheck-error-list--apply-filter-change
+   (lambda () (setq flycheck-error-list--message-filter regexp))))
 
 (defun flycheck-error-list-reset-filter (&optional refresh)
   "Remove local error filters and reset to the default filter.
@@ -5567,20 +5693,39 @@ Interactively, or with non-nil REFRESH, refresh the 
error list."
   (interactive '(t))
   (flycheck-error-list-with-buffer
     (kill-local-variable 'flycheck-error-list-minimum-level)
+    (setq flycheck-error-list--checker-filter nil
+          flycheck-error-list--message-filter nil)
     (when refresh
       (flycheck-error-list-refresh)
       (flycheck-error-list-recenter-at (point-min))
       (force-mode-line-update))))
 
 (defun flycheck-error-list-apply-filter (errors)
-  "Filter ERRORS according to `flycheck-error-list-minimum-level'."
-  (if-let* ((min-level flycheck-error-list-minimum-level)
-            (min-severity (flycheck-error-level-severity min-level)))
-      (seq-filter (lambda (err) (>= (flycheck-error-level-severity
-                                     (flycheck-error-level err))
-                                    min-severity))
-                  errors)
-    errors))
+  "Filter ERRORS according to the error list filters.
+
+Combines `flycheck-error-list-minimum-level',
+`flycheck-error-list--checker-filter' and
+`flycheck-error-list--message-filter'."
+  (when-let* ((min-level flycheck-error-list-minimum-level)
+              (min-severity (flycheck-error-level-severity min-level)))
+    (setq errors
+          (seq-filter (lambda (err) (>= (flycheck-error-level-severity
+                                         (flycheck-error-level err))
+                                        min-severity))
+                      errors)))
+  (when-let* ((checker flycheck-error-list--checker-filter))
+    (setq errors
+          (seq-filter (lambda (err) (eq (flycheck-error-checker err) checker))
+                      errors)))
+  (when-let* ((regexp flycheck-error-list--message-filter))
+    (setq errors
+          (seq-filter
+           (lambda (err)
+             (or (string-match-p regexp (or (flycheck-error-message err) ""))
+                 (when-let* ((id (flycheck-error-id err)))
+                   (string-match-p regexp (format "%s" id)))))
+           errors)))
+  errors)
 
 (defcustom flycheck-error-list-after-jump-hook nil
   "Functions to run after jumping to an error from the error list.
@@ -5766,7 +5911,8 @@ avoid slowing down editing when the error list is hidden."
     ;; Show the error list in a side window.  Under some configurations of
     ;; `display-buffer', this may select `flycheck-error-list-buffer' (see URL
     ;; `https://github.com/flycheck/flycheck/issues/1776').
-    (display-buffer flycheck-error-list-buffer)
+    (display-buffer flycheck-error-list-buffer
+                    flycheck-error-list-display-buffer-action)
     ;; Adjust the source, causing a refresh
     (flycheck-error-list-set-source source)))
 
diff --git a/test/specs/test-error-list.el b/test/specs/test-error-list.el
index e793aaa2bc..07f544c41d 100644
--- a/test/specs/test-error-list.el
+++ b/test/specs/test-error-list.el
@@ -195,6 +195,126 @@
       (it "shows the level filter in the mode line if set"
         (let ((flycheck-error-list-minimum-level 'warning))
           (expect (flycheck-error-list-mode-line-filter-indicator)
-                  :to-equal " [>= warning]"))))))
+                  :to-equal " [>= warning]")))
+
+      (it "shows the checker and message filters in the mode line"
+        (let ((flycheck-error-list-minimum-level nil)
+              (flycheck-error-list--checker-filter 'python-ruff)
+              (flycheck-error-list--message-filter "unused"))
+          (expect (flycheck-error-list-mode-line-filter-indicator)
+                  :to-equal " [python-ruff] [/unused/]"))))
+
+    (describe "Checker and message filters"
+      (it "filters errors by checker"
+        (let ((flycheck-error-list--checker-filter 'foo)
+              (errors (list (flycheck-error-new-at 1 1 'error "a" :checker 
'foo)
+                            (flycheck-error-new-at 2 1 'error "b"
+                                                   :checker 'bar))))
+          (expect (flycheck-error-list-apply-filter errors)
+                  :to-be-equal-flycheck-errors
+                  (list (flycheck-error-new-at 1 1 'error "a"
+                                               :checker 'foo)))))
+
+      (it "filters errors by message and id regexp"
+        (let ((flycheck-error-list--message-filter "unused\\|W10")
+              (errors (list (flycheck-error-new-at 1 1 'error "unused var")
+                            (flycheck-error-new-at 2 1 'error "bad" :id "W101")
+                            (flycheck-error-new-at 3 1 'error "other"))))
+          (expect (flycheck-error-list-apply-filter errors)
+                  :to-be-equal-flycheck-errors
+                  (list (flycheck-error-new-at 1 1 'error "unused var")
+                        (flycheck-error-new-at 2 1 'error "bad" :id "W101")))))
+
+      (it "combines all filters"
+        (let ((flycheck-error-list-minimum-level 'warning)
+              (flycheck-error-list--checker-filter 'foo)
+              (flycheck-error-list--message-filter "spam")
+              (errors (list (flycheck-error-new-at 1 1 'error "spam"
+                                                   :checker 'foo)
+                            (flycheck-error-new-at 2 1 'info "spam"
+                                                   :checker 'foo)
+                            (flycheck-error-new-at 3 1 'error "spam"
+                                                   :checker 'bar)
+                            (flycheck-error-new-at 4 1 'error "eggs"
+                                                   :checker 'foo))))
+          (expect (flycheck-error-list-apply-filter errors)
+                  :to-be-equal-flycheck-errors
+                  (list (flycheck-error-new-at 1 1 'error "spam"
+                                               :checker 'foo)))))
+
+      (it "resets all filters"
+        (flycheck/with-error-list-buffer
+          (setq-local flycheck-error-list-minimum-level 'error)
+          (setq flycheck-error-list--checker-filter 'foo
+                flycheck-error-list--message-filter "spam")
+          (flycheck-error-list-reset-filter)
+          (expect flycheck-error-list-minimum-level :not :to-be 'error)
+          (expect flycheck-error-list--checker-filter :not :to-be-truthy)
+          (expect flycheck-error-list--message-filter :not :to-be-truthy))))
+
+    (describe "Dynamic columns"
+      (it "fits the File and ID columns to the errors"
+        (expect (flycheck-error-list--column-widths
+                 (list (flycheck-error-new-at
+                        1 1 'error "a" :filename "path/to/some-file.el")
+                       (flycheck-error-new-at
+                        2 1 'error "b" :id "reportGeneralTypeIssues")))
+                :to-equal '(12 . 23)))
+
+      (it "caps the column widths"
+        (expect (flycheck-error-list--column-widths
+                 (list (flycheck-error-new-at
+                        1 1 'error "a"
+                        :filename (make-string 100 ?x)
+                        :id (make-string 100 ?y))))
+                :to-equal '(40 . 24)))
+
+      (it "uses minimum widths without errors"
+        (expect (flycheck-error-list--column-widths nil)
+                :to-equal '(4 . 2)))
+
+      (it "fits the format to the displayed errors"
+        (flycheck-buttercup-with-temp-buffer
+          (setq flycheck-current-errors
+                (list (flycheck-error-new-at
+                       1 1 'error "a" :checker 'foo
+                       :filename "a-rather-long-file-name.el")
+                      (flycheck-error-new-at
+                       2 1 'error "b" :checker 'bar :filename "s.el")))
+          (let ((source (current-buffer)))
+            (flycheck/with-error-list-buffer
+              (setq flycheck-error-list-source-buffer source)
+              (flycheck-error-list--update-format)
+              (expect (cadr (aref tabulated-list-format 0))
+                      :to-equal (length "a-rather-long-file-name.el"))
+              ;; Errors hidden by a filter don't inflate the columns
+              (setq flycheck-error-list--checker-filter 'bar)
+              (flycheck-error-list--update-format)
+              (expect (cadr (aref tabulated-list-format 0))
+                      :to-equal (length "s.el"))
+              (expect flycheck--error-list-msg-offset
+                      :to-equal (+ (length "s.el") 1  ; file + padding
+                                   5 1                ; line
+                                   3 1                ; col
+                                   8 1                ; level
+                                   2 1                ; id
+                                   1))))))            ; list padding
+
+      (it "rejects an invalid message filter regexp"
+        (expect (flycheck-error-list-set-message-filter "[")
+                :to-throw 'user-error)))
+
+    (describe "Display"
+      (it "displays the error list according to the display action"
+        (let (received-action)
+          (cl-letf (((symbol-function 'display-buffer)
+                     (lambda (_buffer &optional action &rest _)
+                       (setq received-action action)
+                       nil)))
+            (flycheck-buttercup-with-temp-buffer
+              (flycheck-mode)
+              (flycheck-list-errors)))
+          (expect received-action
+                  :to-equal flycheck-error-list-display-buffer-action))))))
 
 ;;; test-error-list.el ends here

Reply via email to