Emacs as a Rust IDE

Introduction

One of the joys of Emacs is the ability to customize it in such a way as to get any given workflow juuuuust the way you want it. This carries with it the time cost of developing that configuration while navigating a multitude of packages whose maintainers may or may not share your priorities & opinions.

I've been revamping my Emacs configuration around coding, beginning with Rust. The good news is that the ecosystem is healthy, with competing packages in almost every aspect of the problem. The bad news is that there's no roadmap; no guide to navigating the landscape & I spent entirely too much time scratching my head and reading the code of the packages I was trying to use (although I have to say that Robert Krahn's Configuring Emacs for Rust development was particularly thorough).

Therefore, my hope is that this post will not only lay-out my particular Emacs Rust configuration (which may or may not work for anyone else), but will also serve as a small guide to the broader Emacs/Rust landscape.

I do assume familiarity with Emacs, Emacs Lisp & Rust development.

Picking the Major Mode

The first step in configuring Emacs for authoriing code in pretty-much any language is choosing your major mode. I found three separate major modes for writing Rust:

  • rust-ts-mode
  • rust-mode
  • rustic-mode

rust-ts-mode

I stumbled upon rust-ts-mode while including treesitter support in my Emacs builds and it took me down the garden path. TL;DR; this is preliminary work that offers some very interesting possibilities for the future, but at the moment is bare-bones (certainly by comparison to its more mature competition).

The background here is that tree-sitter is a parser generator & parsing library that is appealing for use with source code editors because the parse tree can be updated incrementally as the file is edited. Emacs support began with a third-party package, but since Emacs 29 support has been built-in. As noted here, new major modes named "<language>-ts-mode" have been introduced, but they are separate & distinct from the existing modes. Today, the new modes "[support] font-lock, indentation, Imenu, which-func, and defun navigation."

I decided to wait until more development has taken-place on the treesitter modes before incorporating them into my development environment.

rust-mode & rustic-mode

The other two major modes for authoring Rust code are rust-mode & rustic-mode. They are related in that the latter builds upon the former.

rust-mode is part of the rust-lang Github project (which I take to mean it has some sort of imprimatur) & handles the basics of a major mode. It is lightweight by design, and refers users to rustic-mode if they are looking for more: "If you are missing features in rust-mode, please check out rustic before you open a feature request. It depends on rust-mode and provides additional features. This allows us to keep rust-mode light-weight for users that are happy with basic functionality." That said, there is an open issue suggesting that rustic-mode be moved over to rust-ts-mode, although at the time of this writing it's been inactive for several months.

I experimented with rustic-mode, but found that it didn't work well with tramp. A lot of my professional coding is done on my nice ergonomic desktop while ssh'd into my work laptop where all the proprietary stuff resides. I submitted a patch for the first problem I found getting rustfmt to work, but abandoned the effort when I found this comment from the author: "I just don't use rust on remote hosts. I've taken a look at the issue but I don't know how tramp works and I don't have the motivation for a serious attempt to fix it." He's the maintainer, and it's clearly his right to decline to support this feature, but this was a deal-breaker for me.

Setup

That settled the first question– I have a major mode:

(defun sp1ff/rust/mode-hook ()
 "My rust-mode hook"
 ;; Style per the Rust Style Guide:
 ;; <https://github.com/rust-lang-nursery/fmt-rfcs/blob/master/guide/guide.md>
 (setq indent-tabs-mode nil
       tab-width 4
       c-basic-offset 4
       fill-column 100))

(use-package rust-mode
  :ensure t
  :hook (rust-mode . sp1ff/rust/mode-hook)
  :config
  (let ((dot-cargo-bin (expand-file-name "~/.cargo/bin")))
    (setq rust-rustfmt-bin (concat dot-cargo-bin "rustfmt")
          rust-cargo-bin (concat dot-cargo-bin "cargo")
          rust-format-on-save t)))

As an aside, if you'd like to experiment with rustic-mode, take care. Merely installing it will cause it to eject rust-mode from auto-modes-alist.

Picking the Language Server

Language servers are a more powerful replacement for the inveterate "tags" functionality. Whereas tags were static & could find definitions & references, a language server is far more powerful. It runs continuously, monitoring changes (no more out-of-date TAGS files) and can do things like: automatically import a type you've just introduced, provide code completion, provide diagnostics & much more.

The de facto language server for Rust is rust-analyzer, but I needed to choose between two Emacs integrations:

  • eglot which is Emacs' built-in LSP support package
  • lsp-mode which is a third-party package

The lsp-mode docs refer to eglot as "[an] alternative minimal LSP implementation" and is indeed the LSP integration preferred by rustic-mode, but my choice was again constrained by tramp support: lsp-mode didn't work with tramp: "Unfortunately, lsp-mode doesn't seem to work with tramp. Hopefully somebody can fix this issue at some point. If you need to work with tramp the only option is eglot".

Eglot it was:

(defun sp1ff/rust/mode-hook ()
 "My rust-mode hook"
 ;; Style per the Rust Style Guide:
 ;; <https://github.com/rust-lang-nursery/fmt-rfcs/blob/master/guide/guide.md>
 (setq indent-tabs-mode nil
       tab-width 4
       c-basic-offset 4
       fill-column 100))

(use-package rust-mode
  :ensure t
  :hook (rust-mode . sp1ff/rust/mode-hook)
  :config
  (let ((dot-cargo-bin (expand-file-name "~/.cargo/bin")))
    (setq rust-rustfmt-bin (concat dot-cargo-bin "rustfmt")
          rust-cargo-bin (concat dot-cargo-bin "cargo")
          rust-format-on-save t)))

(use-package eglot
  :ensure t
  :hook (rust-mode . eglot-ensure))

Getting Clippy Lints Into Flymake

I'm not a big fan of linters. In my experience, one spends more time suppressing false positives than getting useful feedback. Clippy (despite the regrettable associations I have with the name) is a striking exception to this. I have found it to be very useful– too useful to be relegated to a Continuous Integration job that only runs on push, or even an on-demand check (C-c C-c C-l for rust-run-clippy in rust-mode)– I wanted it running continously, alongside my language server.

eglot uses flymake, so that means getting Clippy output into flymake, somehow. Graham Marlow did exactly this with his clippy-flymake flymake backend, and he documented it in a very helpful write-up which guided me.

The first problem is that eglot by default suppresses all flymake backends other than its own, but you can customize that behavior:

(use-package eglot
    :ensure t
    :hook (rust-mode . eglot-ensure)
    :config
    (add-to-list 'eglot-stay-out-of 'flymake))

This will also cause eglot to not even turn flymake on, so we're going to introduce a little thunk to work around this:

(defun clippy-flymake-manually-activate-flymake ()
    "Shim for working around eglot's tendency to suppress flymake backends."
    (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 . clippy-flymake-manually-activate-flymake))
    :config
    (add-to-list 'eglot-stay-out-of 'flymake))

Finally, we need to load clippy-flymake:

(use-package clippy-flymake
  :vc (:url "https://git.sr.ht/~mgmarlow/clippy-flymake" :branch main)
  :hook (rust-mode . clippy-flymake-setup-backend))

(defun clippy-flymake-manually-activate-flymake ()
  "Shim for working around eglot's tendency to suppress flymake backends."
  (add-hook 'flymake-diagnostic-functions #'eglot-flymake-backend nil t)
  (flymake-mode 1))

;; `eglot' by default will suppress all other flymake backends than its own
;; <https://github.com/joaotavora/eglot/issues/268> This workaround will
;; add `flymake-clippy'
(use-package eglot
  :ensure t
  :hook ((rust-mode . eglot-ensure)
         (eglot-managed-mode . clippy-flymake-manually-activate-flymake))
  :config
  (add-to-list 'eglot-stay-out-of 'flymake))

Other Improvements

At this point, we've got a major mode, a language server & clippy all up & running, both locally & remotely. Herewith a few more "quality of life" improvements.

Smartparens

I use paredit when authoring Lisp, and don't know how I ever lived without it. I often found myself wishing for a "paredit for C++" or a "paredit for Rust"– well, there is: smartparens, especially with language-specific customizations.

(use-package smartparens :ensure t
  :config (require 'smartparens-rust))

;; ...

(defun sp1ff/rust/mode-hook ()
      "My rust-mode hook"
      (smartparens-mode)
      (define-key rust-mode-map "\C-ca" 'eglot-code-actions)
      (define-key rust-mode-map (kbd "C-<right>")   'sp-forward-slurp-sexp)
      (define-key rust-mode-map (kbd "C-<left>")    'sp-forward-barf-sexp)
      (define-key rust-mode-map (kbd "C-M-<right>") 'sp-backward-slurp-sexp)
      (define-key rust-mode-map (kbd "C-M-<left>")  'sp-backward-barf-sexp)
      ;;   Style per the Rust Style Guide:
      ;;   <https://github.com/rust-lang-nursery/fmt-rfcs/blob/master/guide/guide.md>
      (setq indent-tabs-mode nil
            tab-width 4
            c-basic-offset 4
            fill-column 100))

paredit is a minor mode for keeping S-expressions balanced: when you type '(' for example, a closing parenthesis will be inserted automatically and point placed in between the two. If you're inside a sexp and you want to pull the next sexp into this one, you can "slurp" it in, or "barf" the current sexp out from the enclosing sexp. It's easier to see than to read, so I'd recommend Dan Midwood's great visualization here.

smartparens is a functionally equivalent package for non-Lisp languages– it can keep all sorts of Rust symbols "balanaced": the quotation marks around strings, curly braces & so forth.

Hideshow

Hideshow is a built-in minor mode for folding code. I find it to be a great way to get an overview of a large file, or help focus in on just the logic in which I'm interested in a large method.

;; No need for `use-package'-- it's built-in

;; ...

(defun sp1ff/rust/mode-hook ()
  "My rust-mode hook"

  (setq hs-isearch-open t)
  (setq
   hs-set-up-overlay 
   (defun sp1ff/hs/display-code-line-counts (ov)
     (when (eq 'code (overlay-get ov 'hs))
       (overlay-put ov 'display
                    (format "... / %d"
                            (count-lines (overlay-start ov)
                                         (overlay-end ov)))))))
  (smartparens-mode)
  (hs-minor-mode)
  (define-key rust-mode-map "\C-ca" 'eglot-code-actions)
  (define-key rust-mode-map (kbd "C-<right>")   'sp-forward-slurp-sexp)
  (define-key rust-mode-map (kbd "C-<left>")    'sp-forward-barf-sexp)
  (define-key rust-mode-map (kbd "C-M-<right>") 'sp-backward-slurp-sexp)
  (define-key rust-mode-map (kbd "C-M-<left>")  'sp-backward-barf-sexp)
  (define-key rust-mode-map "\C-c>" 'hs-show-all)
  (define-key rust-mode-map "\C-c<" 'hs-hide-all)
  (define-key rust-mode-map "\C-c;" 'hs-toggle-hiding)
  (define-key rust-mode-map "\C-c'" 'hs-hide-level)
  ;;   Style per the Rust Style Guide:
  ;;   <https://github.com/rust-lang-nursery/fmt-rfcs/blob/master/guide/guide.md>
  (setq indent-tabs-mode nil
        tab-width 4
        c-basic-offset 4
        fill-column 100))

Putting It All Together

emacs-as-rust-ide.png

Full configuration for Rust development in Emacs:

(use-package smartparens :ensure t
  :config (require 'smartparens-rust))

(defun sp1ff/rust/mode-hook ()
  "My rust-mode hook"

  (column-number-mode)
  (display-line-numbers-mode)
  (hs-minor-mode)
  (smartparens-mode)
  (define-key rust-mode-map "\C-ca" 'eglot-code-actions)
  (define-key rust-mode-map (kbd "C-<right>")   'sp-forward-slurp-sexp)
  (define-key rust-mode-map (kbd "C-<left>")    'sp-forward-barf-sexp)
  (define-key rust-mode-map (kbd "C-M-<right>") 'sp-backward-slurp-sexp)
  (define-key rust-mode-map (kbd "C-M-<left>")  'sp-backward-barf-sexp)
  (define-key rust-mode-map "\C-c>" 'hs-show-all)
  (define-key rust-mode-map "\C-c<" 'hs-hide-all)
  (define-key rust-mode-map "\C-c;" 'hs-toggle-hiding)
  (define-key rust-mode-map "\C-c'" 'hs-hide-level)
  (setq indent-tabs-mode nil
        tab-width 4
        c-basic-offset 4
        fill-column 100))

(use-package rust-mode
  :ensure t
  :hook (rust-mode . sp1ff/rust/mode-hook)
  :config
  (let ((dot-cargo-bin (expand-file-name "~/.cargo/bin/")))
    (setq rust-rustfmt-bin (concat dot-cargo-bin "rustfmt")
          rust-cargo-bin (concat dot-cargo-bin "cargo")
          rust-format-on-save t)))

(use-package clippy-flymake
  :vc (:url "https://git.sr.ht/~mgmarlow/clippy-flymake" :branch main)
  :hook (rust-mode . clippy-flymake-setup-backend))

(defun clippy-flymake-manually-activate-flymake ()
  "Shim for working around eglot's tendency to suppress flymake backends."
  (add-hook 'flymake-diagnostic-functions #'eglot-flymake-backend nil t)
  (flymake-mode 1))

;; `eglot' by default will suppress all other flymake backends than its own
;; <https://github.com/joaotavora/eglot/issues/268> This workaround will
;; add `flymake-clippy'
(use-package eglot
  :ensure t
  :hook ((rust-mode . eglot-ensure)
         (eglot-managed-mode . clippy-flymake-manually-activate-flymake))
  :config
  (add-to-list 'eglot-stay-out-of 'flymake))

Next, I'm on to C++.

07/30/23 13:56

Update <2023-12-14 Thu> Embarrassingly, I managed to lose this post while moving development environments around between hosts. Restoring it now.