Wiring Flymake Diagnostics into a Follow Mode

Apr 09, 2026 : 838 words
emacs linux 🏷️ emacs elisp 2026

Flymake has been quietly sitting in my config for years doing exactly what it says on the tin, squiggly lines under things that are wrong, and I mostly left it alone. But recently I noticed I was doing the same little dance over and over: spot a warning, squint at the modeline counter, run `M-x flymake-show-buffer-diagnostics`, scroll through the list to find the thing I was actually looking at, then flip back. Two windows, zero connection between them.

So I wired it up properly, and while I was in there I gave it a set of keybindings that feel right to my muscle memory.

The obvious bindings for stepping through errors are `M-n` and `M-p`, and most people using flymake bind exactly those. The problem is that in my config `M-n` and `M-p` are already taken, they step through simply-annotate annotations (which is itself a very handy thing and I am not giving it up!). So I shifted a key up and went with the shifted variants: `M-N` for next, `M-P` for previous, and `M-M` to toggle the diagnostics buffer.

  (setq flymake-show-diagnostics-at-end-of-line nil)
  (with-eval-after-load 'flymake
    (define-key flymake-mode-map (kbd "M-N") #'flymake-goto-next-error)
    (define-key flymake-mode-map (kbd "M-P") #'flymake-goto-prev-error))

With M-M I wanted it to be a bit smarter than just “open the buffer”. If it is already visible I want it gone, if it is not I want it up. The standard toggle pattern:

  (defun my/flymake--diag-buffer ()
    "Return the visible flymake diagnostics buffer, or nil."
    (seq-some (lambda (b)
                (and (with-current-buffer b
                       (derived-mode-p 'flymake-diagnostics-buffer-mode))
                     (get-buffer-window b)
                     b))
              (buffer-list)))

  (defun my/flymake-toggle-diagnostics ()
    "Toggle the flymake diagnostics buffer."
    (interactive)
    (let ((buf (my/flymake--diag-buffer)))
      (if buf
          (quit-window nil (get-buffer-window buf))
        (flymake-show-buffer-diagnostics)
        (my/flymake-sync-diagnostics))))

Now the interesting bit. What I really wanted was a follow mode, something like how the compilation buffer tracks position or how Occur highlights the current hit. When my point lands on an error in the source buffer, the corresponding row in the diagnostics buffer should light up. That way the diagnostics window becomes a live index of where I am rather than a static dump and think in general this is how a lot of other IDEs work.

I tried the lazy route first, turning on hl-line-mode in the diagnostics buffer and calling hl-line-highlight from a post-command-hook in the source buffer. The line lit up once and then refused to move. Nothing I did would shift it. This is because hl-line-highlight is really only designed to be driven from the window whose line is being highlighted, and I was firing it from afar.

Ok, so why not just manage my own overlay:

  (defvar my/flymake--sync-overlay nil
    "Overlay used to highlight the current entry in the diagnostics buffer.")

  (defun my/flymake-sync-diagnostics ()
    "Highlight the diagnostics buffer entry matching the error at point."
    (when-let* ((buf (my/flymake--diag-buffer))
                (win (get-buffer-window buf))
                (diag (or (car (flymake-diagnostics (point)))
                          (car (flymake-diagnostics (line-beginning-position)
                                                    (line-end-position))))))
      (with-current-buffer buf
        (save-excursion
          (goto-char (point-min))
          (let ((found nil))
            (while (and (not found) (not (eobp)))
              (let ((id (tabulated-list-get-id)))
                (if (and (listp id) (eq (plist-get id :diagnostic) diag))
                    (setq found (point))
                  (forward-line 1))))
            (when found
              (unless (overlayp my/flymake--sync-overlay)
                (setq my/flymake--sync-overlay (make-overlay 1 1))
                (overlay-put my/flymake--sync-overlay 'face 'highlight)
                (overlay-put my/flymake--sync-overlay 'priority 100))
              (move-overlay my/flymake--sync-overlay
                            found
                            (min (point-max) (1+ (line-end-position)))
                            buf)
              (set-window-point win found)))))))

My first pass at the walk through the tabulated list did not work. I was comparing (tabulated-list-get-id) directly against the diagnostic returned by flymake-diagnostics using eq, and it was always false, which meant found stayed nil forever and the overlay never moved. A dive into flymake.el revealed why. Each row in the diagnostics buffer stores its ID as a plist, not as the diagnostic itself:

  (list :diagnostic diag
        :line line
        :severity ...)

So I need to pluck out :diagnostic before comparing. Obvious in hindsight, as these things always are. With plist-get in place the comparison lines up and the overlay moves exactly where I want it, tracking every navigation command.

The fallback lookup using line-beginning-position and line-end-position is there because flymake-diagnostics (point) only returns something if point is strictly inside the diagnostic span. When I land between errors or on the same line as an error but a few columns off, I still want the diagnostics buffer to track, so I widen the search to the whole line.

Finally, wrap the hook in a minor mode so I can toggle it per buffer and enable it automatically whenever flymake comes up:

  (define-minor-mode my/flymake-follow-mode
    "Sync the diagnostics buffer to the error at point."
    :lighter nil
    (if my/flymake-follow-mode
        (add-hook 'post-command-hook #'my/flymake-sync-diagnostics nil t)
      (remove-hook 'post-command-hook #'my/flymake-sync-diagnostics t)))

  (add-hook 'flymake-mode-hook #'my/flymake-follow-mode)
  (define-key flymake-mode-map (kbd "M-M") #'my/flymake-toggle-diagnostics)

The end result is nice. M-M pops the diagnostics buffer, M-N and M-P walk through the errors, and as I navigate the source the matching row in the diagnostics buffer highlights in step with me. If I close the buffer with another M-M everything goes quiet, and I can still step through with M-N/M-P on their own.

Three little keybindings and twenty lines of elisp, but they turn flymake from a static reporter into something that actually feels connected to where I am in the buffer.