Extending use-package's :bind to support evil and keymaps

Use-package is a popular macro for declaring, configuring and organising packages in your Emacs config. One of the features it offers is the :bind keyword, which allows you to declare bindings like this:

(use-package org
  :bind (:map org-mode-map
              ("C-c C-y" . org-store-link)))

This will bind C-c C-y to the function (org-store-link) in the org-mode-map keymap.

There are two limitations that I've wanted to fix for a while. The first is that I use evil to provide vim-style modal bindings. Evil's use of keymaps is slightly different to the usual Emacs convention as it contains an additional piece of state – the editing mode. It provides (evil-define-key), which lets you do something like…

(evil-define-key 'normal org-mode-map "gk" 'outline-previous-visual-heading)

…to bind to org-mode-map specifically in evil's normal state. I don't believe it's possible to make these evil-state assignments using the :bind keyword.

The second limitation is that sometimes I want to bind a key to a keymap. Eg. maybe I want to assign <leader>h to help-map:

(use-package emacs
  :bind (:map my/leader-map
              ("h" . help-map)))

This doesn't work, because when the :bind handler expands, it quotes all of the values of the associations, so you end up with (bind-key "h" 'help-map my/leader-map). This gets interpretated as a function and fails – to reference the keymap you need to pass it in without the quote[1]. Use-package does provide a :bind-keymap feature, but that expects the keymap to be defined in the package you're configuring, and it doesn't allow you to specify a :map – you can only create global keymap bindings.

1. Extending use-package with new keywords

Enter: use-package extensions, which I explored yesterday for the first time. It's pretty easy to add new keywords to use-package. You have to add three things:

;; 1. Extend use-package-keywords
(add-to-list 'use-package-keywords :my-keyword t)

;; 2. Validate and return the args passed to the keyword
(defun use-package-normalize/:my-keyword (name keyword args)
  args)

;; 3. Handle the keyword -- expand into the code you want it to run.
(defun use-package-handler/:my-keyword (name _keyword args rest state)
  (let ((body (use-package-process-keywords name rest state)))
    `((with-eval-after-load ',name)
       (message "%s" ,@args))))

;; This will print Hello, world after org has loaded.
(use-package org
  :my-keyword "Hello, world")

If you use pp-macroexpand-last-sexp, you can see how the macro expands, which makes it a lot easier to understand what use-package does under the hood. For me, our use-package declaration above expands to:

(progn
  (straight-use-package 'org)
  (defvar use-package--warning46
    #'(lambda
        (keyword err)
        (let
            ((msg
              (format "%s/%s: %s" 'org keyword
                      (error-message-string err))))
          (display-warning 'use-package msg :error))))
  (condition-case-unless-debug err
      (let
          ((now
            (current-time)))
        (message "%s..." "Loading package org")
        (prog1
            (if
                (not
                 (require 'org nil t))
                (display-warning 'use-package
                                 (format "Cannot load %s" 'org)
                                 :error)
              (with-eval-after-load 'org)
              (message "%s" "Hello, world"))
          (let
              ((elapsed
                (float-time
                 (time-subtract
                  (current-time)
                  now))))
            (if
                (> elapsed 0.001)
                (message "%s...done (%.3fs)" "Loading package org" elapsed)
              (message "%s...done" "Loading package org")))))
    (error
     (funcall use-package--warning46 :catch err))))

2. The implementation

Back to the :bind issue: I don't want to modify the default behaviour of :bind, but I can imagine a new keyword that provides similar functionality. The implementation I got to was this:

(add-to-list 'use-package-keywords :md/bind t)

(defun use-package-normalize/:md/bind (name keyword args)
  "Custom use-keyword :md/bind. I use this to provide something similar to ':bind',
but with two additional features that I miss from the default implementation:

1. Integration with 'evil-define-key', so I can extend the keymap declaration
   to specify one or more evil states that the binding should apply to.

2. The ability to detect keymaps that aren't defined as prefix commands. This
   allows me to define a binding to a keymap variable, eg. maybe I want '<leader>h'
   to trigger 'help-map'. This fails using the default ':bind', meaning that I
   have to fall back to calling 'bind-key' manually if I want to assign a
   prefix.

The expected form is slightly different to 'bind':

((:map (KEYMAP . STATE) (KEY . FUNC) (KEY . FUNC) ...)
 (:map (KEYMAP . STATE) (KEY . FUNC) (KEY . FUNC) ...) ...)

STATE is the evil state. It can be nil or omitted entirely. If given, it should be an
argument suitable for passing to 'evil-define-key' -- meaning a symbol like 'normal', or
a list like '(normal insert)'."
  (setq args (car args))
  (unless (listp args)
    (use-package-error ":md/bind expects ((:map (MAP . STATE) (KEY . FUNC) ..) ..)"))
  (dolist (def args args)
    (unless (and (eq (car def) :map)
                 (consp (cdr def))
                 (listp (cddr def)))
      (use-package-error ":md/bind expects ((:map (MAP . STATE) (KEY . FUNC) ..) ..)"))))

(defun use-package-handler/:md/bind (name _keyword args rest state)
  "Handler for ':md/bind' use-package extension. See 'use-package-normalize/:md/bind' for docs."
  (let ((body (use-package-process-keywords name rest
                (use-package-plist-delete state :md/bind))))
    (use-package-concat
     `((with-eval-after-load ',name
         ,@(mapcan
            (lambda (entry)
              (let ((keymap (car (cadr entry)))
                    (state (cdr (cadr entry)))
                    (bindings (cddr entry)))
                (mapcar
                 (lambda (binding)
                   (let ((key (car binding))
                         (val (if (and (boundp (cdr binding)) (keymapp (symbol-value (cdr binding))))
                                  ;; Keymaps need to be vars without quotes
                                  (cdr binding)
                                  ;; But functions need to be quoted symbols
                                  `(quote ,(cdr binding)))))
                     ;; When state is provided, use evil-define-key. Otherwise fall back to bind-key.
                     (if state
                         `(evil-define-key ',state ,keymap (kbd ,key) ,val)
                         `(bind-key ,key ,val ,keymap))))
                 bindings)))
            args)))
     body))))

If you can look past all the cars and cdrs, the main part of the handler logic here is that, if an evil state is provided, we pass the definition to (evil-define-key) instead of (bind-key). And, we check to see if the passed in variable is bound to a keymap – and then pass it in unquoted.

3. The result

The result is that I can finally get all of my bindings into the use-package declaration:

(use-package org
    :after (evil)
    :md/bind ((:map (org-mode-map) ;; Expands to (bind-key), similar to :bind
                    ("C-c C-y" . org-store-link))
              (:map (org-mode-map . normal) ;; Only bind in normal mode
                    ("gk" . outline-previous-visible-heading)
                    ("gj" . outline-next-visible-heading))
              (:map (org-mode-map . (normal insert))  ;; Bind in normal and insert mode
                    ("M-k" . org-metaup)
                    ("M-j" . org-metadown))
              (:map (md/leader-map)  ;; Bind to a keymap
                    ("h" . help-map))))

This is a lot cleaner than what I had previously: no more calls to (evil-define-key) and (bind-key) littered everywhere, and I can see everything defined in one place.

One nice thing about this is that I didn't need to do any special handling to support passing in a list of evil modes because (evil-define-key) supports that by default.

This implementation isn't at feature parity with the original :bind keyword. I haven't considered any kind of lazy-loading of bound commands here, and I don't pass the arguments through to the bind-keys macro, meaning that lots of the documented features aren't implemented (:repeat-map, :prefix etc.). I don't use these things though, so I think this new implementation is actually all I need.

The code can be found in my dotfiles repo.

2023-Aug-28