Phones-to-Words Challenge V: Clojure-inspired Emacs Lisp

(This is part of the Phones-to-Words Challenge series.)

Having barely finished solving it in Clojure, I decided to translate that code into a new Emacs Lisp solution. I suspected that I’d like it much more than my Elisp one from years ago — and that turned out to be true.

I wanted to make it as close to my Clojure solution as possible, so one by one I copied the top-level functions from there and proceeded to manually replace things with the closest Elisp equivalent that came to mind — plus other idiomatic adjustments, such as prefixing function names, capitalizing variables in docstrings, etc.

The code

Excluding commented lines and empty lines, the size of Lispers’ versions ranged from 50 to 182 lines of code, with median 134. It’s unclear to me whether these numbers include docstrings.

Excluding commented lines and empty lines, my new Emacs Lisp solution has 77 LoC. Excluding also docstrings, it has 61 LoC.

;;; p2w.el --- Convert phone numbers to words   -*- lexical-binding: t -*-
;;
;; SPDX-FileCopyrightText: © flandrew <https://flandrew.srht.site/listful>

;;---------------------------------------------------------------------------
;; Author:    flandrew
;; Updated:   2026-04-10
;; Keywords:  lisp
;; Homepage:  <https://flandrew.srht.site/listful/software.html>
;;---------------------------------------------------------------------------
;; Package-Version:  0.2.0
;; Package-Requires: ((emacs "26.1") (xht "2") (dash "2.15") (s "1.12"))
;;---------------------------------------------------------------------------

;; SPDX-License-Identifier: GPL-3.0-or-later

;;; Commentary:
;;
;; Emacs Lisp solution to: "Convert phone numbers to words",
;;                         a programming challenge by Ron Garret.
;;
;; Original instructions: <https://flownet.com/ron/papers/lisp-java/>
;;
;; To run it, put the input and dict files in dir ./in and compile this file:
;;   (emacs-lisp-native-compile)
;;
;; Then, from a terminal:
;;   emacs --batch --load "$HOME/.emacs.d/init.el" --load p2w.elc  \
;;         --funcall p2w-xlate-phones   <in/input.txt 2>/dev/null
;; or:
;;   emacs --batch --load "$HOME/.emacs.d/init.el" --load p2w.elc  \
;;         --eval '(p2w-xlate-phones (h* :dict "in/sample-dict"))' \
;;         <in/sample-input 2>/dev/null
;;
;; The above assumes that init.el sets load paths for xht, dash, and s.
;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;


;;; Code:
;;;; Libraries

(require 'xht)  ; by the same author   (dash and s are transitively required)


;;;; Functions
;;;;; Make translation trees

(defun p2w--split (s c)
  "Split a string S in two at C characters from the left."
  (list (substring s 0 c) (substring s c (length s))))

(defun p2w--xlate-deep (s m ok-digit)
  "Return tree of all licit translations of string S and its substrings.
M is a digits-string->matching-words map.
Through OK-DIGIT, keep track of whether it'd be ok to use a digit."
  (->> (list () () (length s))
       (-fix (-lambda ((res hits sub))
               (if (zerop sub)
                   (list res hits sub)
                 (-let* (((s1 s2) (p2w--split s sub))
                         ((x1 ok) (--> (h-get m s1)  (-list it)
                                       (if (and (= sub 1) (null it)
                                                ok-digit  (null hits))
                                           `((,s1) nil)
                                         `(,it t))))
                         (new-res (when x1
                                    (if (s-blank? s2)
                                        `(,x1)
                                      (let ((xa2 (p2w--xlate-deep s2 m ok)))
                                        (when xa2
                                          `(,x1 ,@xa2)))))))
                   (list (if new-res (cons new-res res) res)
                         (append hits x1)
                         (1- sub))))))
       car  nreverse))

(defun p2w--paths (tree)
  "All root-to-leaf paths of a translation TREE."
  (-mapcat (-lambda ((b . bs))
             (-table-flat (lambda (b n) `(,b ,@n))
                          b (if bs (p2w--paths bs) `(,bs))))
           tree))


;;;;; Build dictionary

(defun p2w--only-lower (s) (s-replace-regexp "[^a-z]" "" s))

(defun p2w--word->num (word a2d)
  "Translate WORD to numeric string using alphas-to-digits map A2D."
  (->> word  downcase  p2w--only-lower  string-to-list
       (--map (h-get a2d it))  concat))

(defun p2w--dict->num-words (dict a2d)
  "Convert a DICT string into a map of numeric-string to dict-words.
Use A2D — an alpha-to-digits conversion map."
  (let* ((words (s-lines (s-trim dict)))
         (res (h-new (length words))))
    (while-let ((word (pop words)))
      (h-put-add! res (p2w--word->num word a2d) word))
    res))

(defun p2w--as->ds (alphas digits)
  "Create mapping from lowercase ALPHAS to DIGITS."
  (h-zip-lists (string-to-list alphas)
               (string-to-list digits)))


;;;;; Make final strings

(defun p2w--only-digit (s) (s-replace-regexp "[^0-9]" "" s))

(defun p2w--xlation-lines-1 (s m)
  "String of translation line(s) for a given phone S.
M is a digits-string->matching-words map."
  (->> (p2w--xlate-deep (p2w--only-digit s) m t)  p2w--paths
       (--map (format "%s: %s\n" s (s-join " " it)))
       (s-join "")))

(defun p2w--slurp (file)
  "Read the contents of FILE."
  (with-temp-buffer (insert-file-contents file) (buffer-string)))

(defun p2w--read-line ()
  "Read a line from stream."
  (ignore-errors (read-from-minibuffer "")))

(defun p2w-xlate-phones (&optional htbl)
  "Print translations for lines of phones.
Optional argument HTBL is a hash table with values for dictionary file
and alphas-to-digits translation. If nil, use defaults."
  (h-let (h-mix (h* :alphas "ejnqrwxdsyftamcivbkulopghz"
                    :digits "01112223334455666777888999"
                    :dict   "in/dictionary.txt")
                (or htbl (h*)))
    (let* ((a2d (p2w--as->ds .alphas .digits))
           (map (p2w--dict->num-words (p2w--slurp .dict) a2d)))
      (while-let ((phone (p2w--read-line)))
        (princ (p2w--xlation-lines-1 phone map))))))


;;;; Wrapping up

(provide 'p2w)

;; Local Variables:
;; coding:                     utf-8
;; indent-tabs-mode:           nil
;; sentence-end-double-space:  nil
;; outline-regexp:             ";;;;* "
;; End:

;;; p2w.el ends here

From a terminal

Compiling the above and running this in a terminal would then output the results from the standard dictionary and input files:

emacs --batch --load "$HOME/.emacs.d/init.el" --load p2w.elc \
      --funcall p2w-xlate-phones   <in/input.txt 2>/dev/null

There was an allowed degree of freedom regarding the exact order of the solution lines for a single phone. With that in mind, my sorted results match the given expected output’s sorted results. This returns empty:

diff <(sort in/output.txt) \
     <(emacs --batch --load "$HOME/.emacs.d/init.el" --load p2w.elc \
             --funcall p2w-xlate-phones   <in/input.txt 2>/dev/null | sort)

And this uses the small sample input and dictionary, whose 12 results also match the sample output:

emacs --batch --load "$HOME/.emacs.d/init.el" --load p2w.elc  \
      --eval '(p2w-xlate-phones (h* :dict "in/sample-dict"))' \
      <in/sample-input 2>/dev/null

The comments

You may find it interesting to look at these two Lisps’ solutions side by side. Here are a few things that I find noteworthy.

Slurp it or not?

Here’s one of the challenge’s constraints:

The dictionary must be read into main memory entirely, but you must not do the same with the phone number file, as that may be arbitrarily large.

Nevertheless, Emacs Lisp usually reads files all at once into a buffer. Trying to make it read files by line feels a bit awkward.

It’s probably for that reason that in my previous Elisp solution I ended up ignoring this constraint, and slurped the input. So in that respect it was flawed, although it did produce the expected results with the given input.

This time I also found myself writing an input-slurping function:

;; Alternative
(defun p2w-xlate-phones (&optional htbl)
  "Print translations for lines of phones.
Optional argument HTBL is a hash table with values for input file,
dictionary file, and alphas-to-digits translation. If nil, use defaults."
  (h-let (h-mix (h* :alphas "ejnqrwxdsyftamcivbkulopghz"
                    :digits "01112223334455666777888999"
                    :input  "in/input.txt"
                    :dict   "in/dictionary.txt")
                (or htbl (h*)))
    (let* ((inp (p2w--slurp .input))
           (a2d (p2w--as->ds .alphas .digits))
           (map (p2w--dict->num-words (p2w--slurp .dict) a2d)))
      (with-output-to-string
        (dolist (phone (s-lines (s-trim inp)))
          (princ (p2w--xlation-lines-1 phone map)))))))

And simply C-x C-e’ing (p2w-xlate-phones) does quickly produce the expected results from the comfort of your Elisp buffer.

Yet I wanted to comply with the exact instructions, so I changed it to this:

(defun p2w--read-line ()
  "Read a line from stream."
  (ignore-errors (read-from-minibuffer "")))

(defun p2w-xlate-phones (&optional htbl)
  "Print translations for lines of phones.
Optional argument HTBL is a hash table with values for dictionary file
and alphas-to-digits translation. If nil, use defaults."
  (h-let (h-mix (h* :alphas "ejnqrwxdsyftamcivbkulopghz"
                    :digits "01112223334455666777888999"
                    :dict   "in/dictionary.txt")
                (or htbl (h*)))
    (let* ((a2d (p2w--as->ds .alphas .digits))
           (map (p2w--dict->num-words (p2w--slurp .dict) a2d)))
      (while-let ((phone (p2w--read-line)))
        (princ (p2w--xlation-lines-1 phone map))))))

Since we actually want to read from the standard input (which will be fed our input.txt file) instead of the minibuffer, we need to run emacs in batch mode, as shown above.

Destructuring keyword arguments

How to express, in Elisp, Clojure’s destructuring of keyword arguments (as in [& {:keys [foo bar] :or {foo 42 bar 42}}])?

In p2w-late-phones, I use a combination of xht’s functions (see above):

  • (or htbl (h*)) means: if no htbl input, use an empty hash table — equivalent to Clojure’s {}.
  • (h* :alphas) is the default to use — equivalent to {:or {alphas …}}.
  • (h-mix (h* :alphas) (or htbl (h*))) non-destructively merges them right-to-left, thereby possibly using anything in htbl to update the defaults.
  • (h-let) destructures the merged map, so that a dotted variable can be used: .alpha will have the value stored by the key :alpha.

While I do find (h-let) to be remarkably short and handy (no variable-listing needed), and almost always suitable, sometimes dot-binding may not be the best option. In these cases, Dash’s (-let) can do the job through their &hash option.

List comprehension

How to express, in Elisp, Clojure’s for?

I’ve found Dash’s -table-flat to be a good option — at least for simpler cases.

Looping and recurring

How to express, in Elisp, Clojure’s loop + recur?

I like using Dash’s -fix for that. Compare my Clojure’s xlate-deep with my Elisp’s p2w--xlate-deep.

Ok, let me try to express some translation recipes for this one.

With more than one argument

This:

(loop [res [], quux initquux]
  (if (frobnicated? quux)
    res
    (recur (conj res (bar-do quux))
           (frobnicate-a-bit quux))))

could be roughly translated to this:

(->> (list () initquux)
     (-fix (-lambda ((res quux))
             (if (frobnicated? quux)
                 (list res quux)
               (list (cons (bar-do quux) res)
                     (frobnicate-a-bit quux)))))
     car  nreverse)

or, if you prefer, this:

(->> `(() ,initquux)
     (-fix (-lambda ((res quux))
             (if (frobnicated? quux)
                 `(,res ,quux)
               `(,(cons (bar-do quux) res)
                 ,(frobnicate-a-bit quux)))))
     car  nreverse)

Since -fix terminates when two iterations in a row are the same, you repeat the -lambda-destructured items to force the termination. Then you pick the first element — assuming that you have conveniently put the accumulating result res at the beginning. And if that car is a list that you’ve been consing, nreverse it at the end.

If you don’t want to use Dash, you can try Emacs native’s letrec or named-let. You can see some comparisons here. But with more than one item, you won’t have -lambda, so if you want to destructure it you’ll have to use the noisy pcase-lambda instead.

With one argument

(loop [quux 0]
  (if (= quux 42)
    quux
    (recur (inc quux))))

In this case, the anaphoric version of -fix is more straightforward:

(--fix (if (= it 42)
           it
         (1+ it))
       0)
📆 2026-W15-5📆 2026-04-10