Building a Flymake Backend for Clippy
Last weekend I had a great time building my own Flymake backend for Clippy (the Rust linter):
flymake-clippy. If you haven't heard of Flymake, it's old-school Emacs tech for showing squiggly-lines in your editor. You can build your own Flymake extensions by creating a function, referred to as a backend, that collects diagnostics and reports them to Flymake. Register that backend in your Emacs config and you've got squiggles whenever you open up a Rust file. Pretty cool!
flymake-clippy is an extension of the annotated example found in the Emacs Manual. While the example serves as a great starting point, I found that in practice it leaves too many details unexplained. Most of my development time was spent looking through the Emacs documentation, learning about regular expression match groups, external processes, and buffer searching. There were also some surprises when trying to integrate
flymake-clippy with Eglot, the LSP package that now ships with Emacs.
How does it work?
With Flymake mode active, each backend function included in the variable
flymake-diagnostic-functions is called on the current Emacs buffer. When the
flymake-clippy backend is invoked, the following things happen in sequence:
cargo clippyis called and its output is thrown into a temporary buffer (
- A process sentinel is triggered, invoking a callback that parses the contents of that temporary buffer and collects diagnostic information into a list
- That list of diagnostics is sent to Flymake via another callback function
- The temporary buffer and process are cleaned up
These steps are repeated many times throughout the lifecycle of a single buffer, creating and destroying squiggles as problems are introduced or addressed.
In a bit more detail, the first step is to create a process:
(make-process :name "flymake-clippy" :noquery t :connection-type 'pipe :buffer (generate-new-buffer "*flymake-clippy*") :command '("cargo" "clippy") :sentinel (lambda (proc _event) (when (memq (process-status proc) '(exit signal)) ...)))
This process runs the command
cargo clippy, our linter, and pipes its output into the buffer
*flymake-clippy* (named via the call to
generate-new-buffer). While that process is active, several events are sent to the sentinel, invoking its respective lambda.
flymake-clippy only cares about the
signal events, which are checked via
process-status. All other events are ignored.
The core of
flymake-clippy lives in the rest of that lambda function:
(lambda (proc _event) ;; ... snip ... (with-current-buffer (process-buffer proc) (goto-char (point-min)) (cl-loop while (search-forward-regexp (flymake-clippy--build-regexp) nil t) for msg = (match-string 1) for sourcefile = (match-string 2) for (beg . end) = (flymake-diag-region source (string-to-number (match-string 3))) for type = (if (string-match "^warning" msg) :warning :error) when (and sourcefile (string-match-p sourcefile filename)) collect (flymake-make-diagnostic source beg end type msg) into diags finally (funcall report-fn diags))) (kill-buffer (process-buffer proc)))
This code is pretty dense but most of it is facilitating a loop through the
*flymake-clippy* buffer and parsing the Clippy output into variables.
cl-loop is a powerhouse looping macro that actually comes from Common Lisp. It's accessible in Emacs through the library
cl-lib, a compatibility library that brings a bunch of Common Lisp functions/macros into Emacs Lisp.
while keyword has this code searching via a regular expression, looking for matches that are transformed into Flymake diagnostic output.
The regular expression for
flymake-clippy looks something like this:
Matching text like:
warning: using `clone` on type `Status` which implements the `Copy` trait --> src/foo.rs:31:29
At the core of the regexp are four match groups:
- The message, prefixed with "warning:"
- The file, prefixed with "-->"
- The line number
- The column number
Each one of these match groups is assigned to a variable in
cl-loop via the
match-string function, grabbing match data from the most recent regular expression. Most of these variables are handed off to Flymake as-is, with the exception of line number. Clippy's line number needs a little translation (via
flymake-diag-region) so it's useful in a buffer context.
Since Clippy runs against the entire Cargo project and not just a single file, I also include a
when expression to compare the open buffer filename against the filename match group. Otherwise, messages from other files will show up in the diagnostics.
cl-loop collects the diagnostic variables into a list of Flymake data structures. These data structures are handed off to Flymake via the callback:
(funcall report-fn diags).
Testing regular expressions
I found it much easier to iterate on a regular expression by writing tests rather than manually executing Emacs commands, so I used ERT to run the regular expression against a temporary buffer of Clippy output:
(require 'flymake-clippy) (require 'ert) (defun run-regexp () ;; Reset regexp match data (set-match-data nil) (search-forward-regexp (flymake-clippy--build-regexp) nil t) (list (match-string 1) (match-string 2) (match-string 3))) (ert-deftest clippy-test-regexp () "Tests regexp matches diagnostic information." (should (equal ;; Open a temp buffer with the contents of a test ;; fixture that contains Clippy output (with-temp-buffer (insert-file-contents "./test/fixture.txt") (run-regexp)) '("warning: unused variable: `user`" "src/database/foo.rs" "42"))))
This file is also a good demonstration of the regular expression match groups that are passed into
Working with Eglot
I ran into a few surprises setting up
flymake-clippy and Eglot in my configuration. It turns out that Eglot hijacks Flymake, suppressing all other Flymake backends while Eglot is running. This isn't an issue with Eglot per se, but a design decision (see eglot#268); Eglot uses Flymake to demonstrate LSP diagnostics and suppresses other backends to avoid duplicate messages.
There is a workaround that I use in my configuration that allows Clippy and Eglot to coexist:
(use-package flymake-clippy :vc (:fetcher sourcehut :repo mgmarlow/flymake-clippy) :hook (rust-mode . flymake-clippy-setup-backend)) (defun manually-activate-flymake () (add-hook 'flymake-diagnostic-functions #'eglot-flymake-backend nil t) (flymake-mode 1)) (use-package eglot :ensure t :hook ((rust-mode . eglot-ensure) (eglot--managed-mode . manually-activate-flymake)) :config (add-to-list 'eglot-stay-out-of 'flymake))
eglot-stay-out-of disables Eglot's control over Flymake. This opens up the possibility of running other Flymake backends in a buffer with Eglot, but also removes Eglot's backend from the
flymake-diagnostic-functions list. It's important to add
eglot-flymake-backend back to that list, as well as manually activate it during
Flymake vs. Flycheck
Those in the know may be wondering why I built a Flymake backend instead of one for Flycheck. The two libraries are now very similar, though in past Emacs releases they deviated substantially.
Flymake is the older of the two (since Emacs 22) and is included in Emacs as a built-in package. There was a period of time between Emacs 22 and 27 where Flymake was not receiving much love and attention, languishing with some long-standing issues. Flycheck rose during this time as a drop-in replacement.
Nowadays, thanks to the efforts of Eglot author João Távora, Flymake and Flycheck are on mostly equal footing. I chose Flymake simply because it's built into Emacs and I didn't want to introduce a new dependency. That said, Flycheck vs. Flymake discusses the pros and cons in more detail.
If you want to see Clippy warnings in your Rust buffers, check out flymake-clippy. Refer to the README for setup instructions.