cl-loop beats map-keymap

cl-loop beats map-keymap

Did you ever need to make many lookups or changes in #emacs key bindings, but balked at the complexity of programming with map-keymap?

Me too. Good news, you can skip that mess! There's a keyword in cl-loop that is much more human-friendly, and it's still blisteringly fast. It's been around since 1993, but I've found no blog posts about it.

There's a gotcha I want to tell you about, I'll get to that in a moment.

First, typical usage goes like this example:

(Behold! Lisp gone ugly!)

(cl-loop
 for seq being the key-seqs of KEYMAP
 using (key-bindings cmd)
 as key = (key-description seq)
 do ...)

In the above loop, you'll get access to KEY and its bound command, CMD. And that brings me to the gotcha.

Here's what happened to me: if KEYMAP has many adjacent keys bound to the same command (which is typically either nil or self-insert-command), then (keymap-lookup KEYMAP key) will error out saying that this key is invalid. Some example values of key are SPC..~, ESC i..j, C-x (..*, \200..\377.

Yes, a string "SPC..~" is being provided as key. Can you guess what's going on?

I can't, but these seem to be ranges of keys. I think it might only happen in global-map, actually.

Filter them out with a simple (not (string-search ".." key)).

And it turns out there's a couple more more sanitizations you'll probably want.

  1. Command bindings that are actually nil.
  2. Command bindings that are actually lists of the form (menu-item ... ... ...). At least, I assume you have no interest in menu bindings.

Thus, sanitized form:

(cl-loop
 for seq being the key-seqs of KEYMAP
 using (key-bindings cmd)
 as key = (key-description seq)
 when cmd
 when (not (string-search ".." key))
 when (or (not (listp cmd))
          (member (car cmd) '(closure lambda)))
 do ...)

If you're like me and never bind a key to a lambda, the third when can be simplified to just

when (not (listp cmd))

Warning for cleverness. You might think this could be

when (commandp cmd)

but many autoloaded commands won't satisfy commandp nor functionp until called the first time. At init time, they only positively satisfy symbolp.

Anyway, that's it! No more keeping mental track of subkeymaps nor the finer details of recursing into them!

Bonus: get all keys for the current buffer

OK great, we can unnest a single keymap variable (such as org-mode-map or global-map) and its children into a flat list of key seqs. But what about listing all the current buffer's keys, not just one mode-map's keys?

You know that any given key can usually be found several times in multiple keymaps, where it's bound to different commands. The current buffer has a sort of keymap composite calculated by merging all relevant keymaps. That composite is not stored as a variable anywhere.

Fortunately we can know which binding is correct for the current buffer thanks to the fact that (current-active-maps) seems to return maps in the order in which Emacs selects them. So what comes earlier in the list is what would in fact be used. Then we can run (delete-dups) to declutter the list, which likewise keeps the first instance of each set of duplicates.

Presto – list current buffer keys:

(delete-dups
 (cl-loop
  for map in (current-active-maps)
  append (cl-loop
          for seq being the key-seqs of map
          using (key-bindings cmd)
          as key = (key-description seq)
          when cmd
          when (not (string-search ".." key))
          when (or (not (listp cmd))
                   (member (car cmd) '(closure lambda)))
          collect key)))

Bonus: get all keys and bindings for the current buffer

To list the keys together with their command bindings, we need a bit more complexity as delete-dups will not work. While we're at it, I'll throw in a key-valid-p check to represent a somewhat compute-heavy filter:

(cl-loop
 for map in (current-active-maps)
 append (cl-loop
         for seq being the key-seqs of map
         using (key-bindings cmd)
         as key = (key-description seq)
         when cmd
         when (not (string-search ".." key))
         when (or (not (listp cmd))
                  (member (car cmd) '(closure lambda)))
         unless (assoc key map-bindings)
         unless (assoc key buffer-bindings)
         when (ignore-errors (key-valid-p key))
         collect (cons key cmd)
         into map-bindings
         finally return map-bindings)
 into buffer-bindings
 finally return buffer-bindings)

Uncompiled, this is somewhat slow (0.5 seconds on a Surface Pro 7 @ 1.1GHz). Compiled is faster, but we can also speed it up somewhat with caching. That trick really comes into its own when you want to filter the result with all sorts of inefficient checks; add your custom checks inside the defun seq-to-key-if-legal in the following snippet.

Final version!

(defvar legal-key-cache (make-hash-table :size 2000 :test #'equal))
(defun seq-to-key-if-legal (seq)
  (let ((cached-value (gethash seq legal-key-cache 'not-found)))
    (if (eq 'not-found cached-value)
        (puthash seq
                 (let ((key (key-description seq)))
                   (when (and
                          ;; Ignore menu-bar/tool-bar/tab-bar
                          (not (string-search "-bar>" key))
                          ;; All anomalies with ".." like "ESC 3..9" are bound
                          ;; to self-insert-command or nil, ok to filter out.
                          (not (string-search ".." key))
                          (ignore-errors (key-valid-p key)))
                     key))
                 legal-key-cache)
      cached-value)))

(cl-loop
 for map in (current-active-maps)
 append (cl-loop
         for seq being the key-seqs of map
         using (key-bindings cmd)
         as key = (seq-to-key-if-legal seq)
         when (and key cmd)
         when (or (not (listp cmd))
                  (member (car cmd) '(closure lambda)))
         unless (assoc key map-bindings)
         unless (assoc key buffer-bindings)
         collect (cons key cmd)
         into map-bindings
         finally return map-bindings)
 into buffer-bindings
 finally return buffer-bindings)

Alternatives

Keymap-utils ships kmu-map-keymap which seems easier to use than the builtin map-keymap. Although it doesn't ship an explicit utility for buffer bindings. For that, you may be able to borrow which-key's which-key--get-current-bindings.

What links here

  • 2023-11-06
Created (13 months ago)
Updated (10 months ago)