Published 2026-04-10
tag(s): #emacs
A while ago I wrote about a failed attempt to reduce the amount of customization in my Emacs setup, back then it was the mode line.
I had in the back of my mind another area to (try to) simplify, and that
is pairing. Emailing with Wouter as he
tried different combinations of electric-pair-mode and third-party packages for
this, gave me the impulse to see how I would fare using only built-in commands.
Note that I am not a user of electric-pair-mode myself. I don't like how
it behaves in many scenarios. Yes, it can be customized with predicates, but I tried for a few
months and it kept surprising me here and there, at some point the predicate setup was too
fragile, adding an exception would break others cases.
Over time, I found that a more manual/intentional approach works better for me.
I can't quite recall how I found out about delete-pair, but I started using it
here and there, and eventually found out about M-( insert-parentheses
and insert-pair.
But at first I didn't understand how to bind more delimiters to the latter, so I wrote my own
command. Which as we'll find out soon enough, behaves slightly different. And now I just can't
go back. 🙂
I declared my own separate variables, that are similar to the built ins. Maybe that's a clean up opportunity? But, it feels "right" to use my vars for my custom commands. 🤷
(defvar hoagie-pair-chars
'((?\' . ?\')
(?\( . ?\))
;; a lot more pairs here
(?\{ . ?\})
(?\% . ?\%))
"Alist of pairs to insert for `hoagie-insert-pair' and `hoagie-delete-pair'.")
(defvar hoagie-pair-blink-delay 0.3
"Like `delete-pair-blink-delay', but for my own pair functions.")
And then a little helper to pick one of the "pair-opening characters" interactively:
(defun hoagie--pair-read-opener ()
(read-char (format "Pick %s :"
(mapconcat #'string
(mapcar #'car hoagie-pair-chars) ""))))
(defun hoagie-insert-pair ()
"Wrap the region or sexp at point in a pair from `hoagie-pair-chars'.
Note that using sexp at point might wrap a symbol, depending on
point position.
This started as my counterpart to `delete-pair', but I ended up
rewriting that one too.
Emacs has a built in mode for this, `electric-pair-mode', but it does
more than I want, it is more intrusive, and I couldn't get around some
of its behaviours. I eventually figured out how to use `insert-pair' (by
looking at `insert-parentheses'), but I prefer how this command works."
(interactive "*")
(with-region-or-thing 'sexp
(let* ((opener (hoagie--pair-read-opener))
(closer (alist-get opener hoagie-pair-chars)))
(if (not closer)
(message "\"%c\" is not in the pair opener list" opener)
(save-excursion
(goto-char start)
(insert opener)
(goto-char (+ 1 end))
(insert closer))))))
A lot of the work here is done by
the anaphoric
macro with-region-or-thing
(source here),
which binds start and end to the region limits or the thing at
point, in this case the sexp.
Turns out that while typing, I don't really need automatic delimiters. But wrapping something
in parentheses or quotes after I typed it, that's where the command is really
helpful. For example, if the pipe is the point position: quot|es! => call command
=> pick " => "quot|es!".
And by using the region when it is active, I can rely on the usual mark commands to wrap stuff
that doesn't fit the sexp pattern.
(defun hoagie-delete-pair ()
"Delete a pair from `hoagie-pair-chars'.
If point is on an opener character, use the sexp it delimits and unpair
it. When point isn't under an opener char, prompt for one, then search
backwards for the opener, and remove the pair."
(interactive "*")
(let* ((start-pos (point))
(use-point (member (following-char) (mapcar #'car hoagie-pair-chars)))
(opener (if use-point (following-char) (hoagie--pair-read-opener)))
(closer (alist-get opener hoagie-pair-chars)))
(save-mark-and-excursion
(save-match-data
;; in case the region was active, so the macro
;; returns the sexp at point
(deactivate-mark)
(unless use-point
(search-backward (string opener)))
(with-region-or-thing 'sexp
(unless (and (= (char-after start) opener)
(= (char-before end) closer))
(error "Can't delimit a sexp with %c ~ %c" opener closer))
(mark-sexp)
(sit-for hoagie-pair-blink-delay)
(goto-char end)
(delete-char -1)
(goto-char start)
(delete-char 1))))))
This code ends up being a little bit more involved, for the reasons outlined in the docstring.
It has a DWIM vibe: unless the character at point is a delimiter, it has to prompt. And then
it might need to "walk back" before delimiting the sexp at point.
This enables scenarios like: (well-well-we|ll (this-is-nested)) => call command
=> pick ( => well-well-we|ll (this-is-nested).
When emailing with Wouter, he tried a lot of
alternatives for sort-of similar operations, like Lispy and Paredit, plus the already
mentioned electric pair. One nice side effect of our correspondence is that I get to revisit
packages that I sort of forgot about :) and discover new ones.
It is also interesting to explain the rationale for why I like something a certain way. Helps
me reflect!
Every structural editing package adds a host of new commands that you have to remember. I
prefer less specific, and simpler commands.
The built in pairing commands don't operate on things-at-point, and require marking the region
first. That's the feature I missed the most when I tried them recently
(code
here, for reference).
I wanted to go over this code for a few reasons
One, to hear back from how others approach this editing feature.
Second, to put my code "out there" and get feedback or corrections.
And finally, because I want to keep a record of the things I modified in my configuration. Making it a public record, like a post, means that I can share a link when the topic comes up next and I want to explain myself. :)