Binding Symbols in Maps: with-map!

Three of the most common data structures in emacs-lisp are alists, plists, and hash-tables. They are used so frequently that it is not surprising that situations arise in which you have to access elements from these. And although each corresponding data structure has their own getter, fetching more than two elements in a single lisp form can quickly get out of hand as is apparent from the repetition of (plist-get SOME-PLIST KEY) from the following example.

                
(let ((a (plist-get some-plist 'a))
      (b (plist-get some-plist 'b))
      (c (plist-get some-plist 'c)))
  (+ a b c))
                
            

The -let macro from dash actually has specific syntax to make this kind of thing easier. With it you can specify syntax of the form (&THING key1 key2...keyN) where THING can be one of alist, plist or hash and each key is a the key of the data structure. This syntax tells dash to let-bind a symbol that is by default the name of the key to its corresponding value. And, as you can see below, it is much more concise than the example above.

                
(-let (((&plist 'a 'b 'c) '(a 1 b 2 c 3)))
  (list a b c))
;; => 6

(-let (((&alist 'a 'b 'c) '((a . 1) (b . 2) (c . 3))))
  (+ a b c))
;; => 6

(-let (((&hash 'a 'b 'c) (aprog1 (make-hash-table)
                           (puthash 'a 1 it)
                           (puthash 'b 2 it)
                           (puthash 'c 3 it))))
  (+ a b c))
;; => 6
                
            

In the corresponding macroexpansions we see that the -let expands to getting the corresponding getters from the data structures.

                
(let ((input0 '(a 1 b 2 c 3)))
  (let* ((a (plist-get input0 'a))
         (b (plist-get input0 'b))
         (c (plist-get input0 'c)))
    (+ a b c)))

(let ((input0 '((a . 1) (b . 2) (c . 3))))
  (let* ((a (cdr (assoc 'a input0)))
         (b (cdr (assoc 'b input0)))
         (c (cdr (assoc 'c input0))))
    (+ a b c)))

(let ((input0 (aprog1 (make-hash-table)
                (puthash 'a 1 it)
                (puthash 'b 2 it)
                (puthash 'c 3 it))))
  (let* ((a (gethash 'a input0))
         (b (gethash 'b input0))
         (c (gethash 'c input0)))
    (+ a b c)))
                
            

There is a built-in solution as well: the macro let-alist. With it you can access the values of an alist using a .KEY. Unlike dash's -let, let-alist does not make you declare the key symbols beforehand; rather let-alist infers which keys your using based on the symbols prefixed by a period in the macro body–making the syntax much more concise. On the other hand, as the name "let-alist" suggests it only works on alists which is certainly a downgrade from -let. Additionally, according to this reddit post the period notation it uses to prefix its symbols is prone to being confused with emacs-lisp cons cell syntax when evaluating symbols in a backquoted form.

                
(let-alist '((a . 1) (b . 2) (c . 3))
  (+ .a .b .c))

;; => 6
                
            

I wanted to combine the best of both dash's -let and Emacs's built-in let-alist. Specifically, I wanted it to have the brevity of let-alist while avoiding its dot syntax and the ability of -let to work with all three data structures. Thus I came up with the macro with-map!. It accepts any one of the three aforementioned data structures as well as a body of forms and let-binds specially-named symbols in its body. Unlike let-alist it uses symbols prefixed by the bang symbol thereby avoiding the aforementioned problem with dot symbols.

                
(with-map! '(a 1 b 2 c 3)
  (+ !a !b !c))

;; => 6

(with-map! '((a . 1) (b . 2) (c . 3))
  (+ !a !b !c))

6

;; => 6

(with-map! (aprog1 (make-hash-table)
             (puthash 'a 1 it)
             (puthash 'b 2 it)
             (puthash 'c 3 it))
  (+ !a !b !c))

;; => 6
                
            

The following macroexpansion provides insight in how my macro works. I bind the alist to a generated symbol in this case map85. In this example because the map is a static form that evaluates to itself it would not make a difference. But if the map was a lisp expression that returns a map, then having that expression on each binding would cause multiple unintended evaluation.

Notably, my expansions are virtually homogeneous despite using different maps because I am deferring the built-in map.el, a library that provides a generic interface between three main emacs-lisp data structures alists, plists and hash-tables. The function map-elt checks what kind of map it has and uses the appropriate getter to return the correct value. Consequently, in contrast to -let the user does not need to specify which kind of map (alist, plist or hash-table) they need to use beforehand–in fact, they do not even need to know which map their dealing with themselves.

                
(let* ((map85 '(a 1 b 2 c 3))
       (!a (map-elt map85 'a))
       (!b (map-elt map85 'b))
       (!c (map-elt map85 'c)))
  (+ !a !b !c))

(let* ((map402 '((a . 1) (b . 2) (c . 3)))
       (!a (map-elt map402 'a))
       (!b (map-elt map402 'b))
       (!c (map-elt map402 'c)))
  (+ !a !b !c))

(let* ((map403 (aprog1 (make-hash-table)
                 (puthash 'a 1 it)
                 (puthash 'b 2 it)
                 (puthash 'c 3 it)))
       (!a (map-elt map403 'a))
       (!b (map-elt map403 'b))
       (!c (map-elt map403 'c)))
  (+ !a !b !c))
                
            

The following helper is what I use to actually generate the macro expansion. The function loops through all the symbols in the body of the macro, collecting any symbols that match the regular expression regexp; then, it binds them to the key which is given by their name. The optional argument use-keywords tells the function to get the value !SYMBOL by using the key :SYMBOL as opposed to SYMBOL as normal. Maps often use keywords–symbols prepened by a :–as keys and although it is possible to specify keywords as !:KEY I did not find it aesthetically pleasing or satisfactorily concise to type prefix symbols with !:. Accordingly, I created this use-keywords argument so that in the future I can add an option to the with-map! macro or even create a spin-off macro that assumes keyword keys.

                
(defun oo--map-let-binds (map body regexp &optional use-keywords)
  "Return a list of let-bindings for `with-map!'.
                    Collect symbols matching REGEXP in BODY into an alist."
  (let* ((mapsym (cl-gensym "map"))
         (let-binds `((,mapsym ,map)))
         (name nil)
         (key nil))
    (dolist (obj (flatten-tree body))
      (when (and obj
                 (symbolp obj)
                 (setq name (symbol-name obj))
                 (string-match regexp name)
                 (not (assoc obj let-binds)))
        (setq key (funcall (if use-keywords #'oo-into-keyword #'oo-into-symbol)
                           (match-string 1 name)))
        (push `(,obj (map-elt ,mapsym ',key)) let-binds)))
    (nreverse let-binds)))
                
            

Overall, I am quite pleased with my implementation of with-map! (shown below).

                
(defmacro with-map! (map &rest body)
  "Let-bind bang symbols in BODY to corresponding keys in MAP."
  (declare (indent 1))
  `(let* ,(oo--map-let-binds map body "!\\([^[:space:]]+\\)" nil)
     ,@body))