Introducing Extensibility with a Macro, a List, and a Reducer

Summary: A walk through of adding a new feature to an Emacs package. I layout the original state and walk through the three concepts that improved the code cohesion and also improved it’s maintainability and extensibility.

I’ve been working on my random-tables package; drawing inspiration from Howard Abrams’ EmacsConf 2023 talk “How I play TTRPGs in Emacs”. One concept that I knew I wanted was what I’ve named “inner tables”.

Drawing Inspiration from Others

Let’s say we have a simple table with two results:

Heads
A friendly [sprite/genie/ogre/nun] offers to help.
Tails
An angry [soldier/mutant/toad] is obstructing your way.

There are two “inner tables” (e.g. [spirit/genie/ogre/nun] and [soldier/mutant/toad]). And when we evaluate the result, we will pick one of those results; such that on a “Heads” result our text might say “A friendly ogre offers to help.”

I set about incorporating that concept into my random-tables package. At the point of integration I had a rather nasty case statement.

Original Case Statement
(if-let ((table (random-table/fetch text :allow_nil t)))
    (random-table/evaluate/table table roller-expression)
  (cond
   ((string-match random-table/roll/math-operation-regex text)
    (let* ((left-operand (match-string-no-properties 1 text))
           (operator (match-string-no-properties 2 text))
           (right-operand (match-string-no-properties 3 text)))
      ;; TODO: Should we be passing the roller expression?
      (funcall (intern operator)
               (string-to-number
                (random-table/roll/parse-text/replacer left-operand))
               (string-to-number
                (random-table/roll/parse-text/replacer right-operand)))))
   ((string-match random-table/roll/pass-roller-regex text)
    (let* ((table-name (match-string-no-properties 1 text))
           (roller-expression (match-string-no-properties 2 text)))
      (funcall (random-table/roll/parse-text/replacer
                table-name
                roller-expression))))
   ((and random-table/current-roll
         (string-match "current_roll" text))
    random-table/current-roll)
   (t
    ;; Ensure that we have a dice expression
    (if (string-match-p random-table/dice/regex (s-trim text))
        (format "%s" (random-table/dice/roll (s-trim text)))
      text))))

I initially added another case statement, but instead chose to refactor to a different approach. Further, the case statement had nested if statements; and implicit functions. All told, this bit of “magic code” was a lot to digest.

Refactor

The change was to go from a case statement to a list of functions that I passed to cl-reduce (see the below code):

(cl-reduce (lambda (string el) (funcall el string))
           random-table/text-replacer-functions
           :initial-value text)

The random-table/text-replacer-functions is a list of functions that each take one argument; namely a string. Each of those functions will return a string which will be passed to the next function in the list.

This refactor meant two things:

  1. I was defining a clear interface.
  2. The list was configurable; meaning extensible and perhaps easier to test it’s constituent parts.

Using a Macro to Conform to an Interface

Seeing that I had a common interface, I set about creating a macro to help produce conformant methods. Before I delve into the macro definition, let’s look at how I declared the “inner table” replacer function:

(random-table/create-text-replacer-function
 "Conditionally replace inner-table for TEXT.

Examples of inner-table are:

- \"[dog/cat/horse]\" (e.g. 3 entries)
- \"[hello world/good-bye mama jane]\" (2 entries)

This skips over inner tables that have one element (e.g. [one])."
 :name random-table/text-replacer-function/inner-table
 :regexp "\\[\\([^\]]+/[^\]]+\\)\\]"
 :replacer (lambda (matching-text inner-table)
             (seq-random-elt (s-split "/" inner-table))))

I use the “Conditionally replace…” as the docstring for the defined function.

The :name is the name of the function that I’ll explicitly add to the random-table/text-replacer-functions list.

The :regexp is the Emacs regular expression that finds the inner table.

The :replacer is a function that performs the replacement on a successful match of the regular expression.

The positional parameters of the :replacer function are the original test then each of the capture regions in the above expression.

Diving into the Macro

In earlier versions, I relied on the s-format function for evaluation. But that was inadequate in that it had a hard-coded regular expression. However, I used that source code for inspiration.

(cl-defmacro random-table/create-text-replacer-function
    (docstring &key name replacer regexp)
  "Create NAME function as a text REPLACER for REGEXP.

- NAME: A symbol naming the replacer function.
- REPLACER: A lambda with a number of args equal to one plus the
            number of capture regions of the REGEXP.  The first
            parameter is the original text, the rest are the
            capture regions of the REGEXP.
- REGEXP: The regular expression to test against the given text.
- DOCSTRING: The docstring for the newly created function.

This macro builds on the logic found in `s-format'"
  (let ((name (if (stringp name) (intern name) name)))
    `(defun ,name (text)
       ,docstring
       (let ((saved-match-data (match-data)))
         (unwind-protect
             (replace-regexp-in-string
              ,regexp
              (lambda (md)
                (let ((capture-region-text-list
                       ;; Convert the matched data results into a list,
                       ;; with the `car' being the original text and the
                       ;; `cdr' being a list of each capture region.
                       (mapcar (lambda (i) (match-string i md))
                               (number-sequence
                                0
                                (- (/ (length (match-data)) 2)
                                   1))))
                      (replacer-match-data (match-data)))
                  (unwind-protect
                      (let ((replaced-text
                             (cond
                              (t
                               (set-match-data saved-match-data)
                               (apply ,replacer
                                      capture-region-text-list)))))
                        (if replaced-text
                            (format "%s" replaced-text)
                          (signal 's-format-resolve md)))
                    (set-match-data replacer-match-data))))
              text t t)
           (set-match-data saved-match-data))))))

In the above macro the ,regexp replaces the hard-coded regexp of s-format (and is provided by the :regexp named parameter). I also removed quite a bit of logic, but needed to introduce extracting positional arguments to then pass to the :replacer function.

The code for creating the list of positional arguments is buried above, but I present it below for further discussion:

(let ((capture-region-text-list
       (mapcar (lambda (i) (match-string i md))
               (number-sequence 0 (- (/ (length (match-data)) 2) 1))))))

In the above code fragment, given that we have a :regexp hit, the (match-data) is a list that has 2N elements, where N is 1 plus the number of capture regions of the regular expression. The first two elements of the list are the beginning and ending character positions of the entire string. The next two elements are the beginning and ending character positions of the text that matches the capture; and so on.

I map the matches to a list of values, which are then passed as positional parameters via (apply ,replacer capture-region-text-list); in where the ,replacer is specified as the :replacer named parameter of the random-table/create-text-replacer-function macro call.

The List

Below is the declaration of the random-table/text-replacer-functions:

(defcustom random-table/text-replacer-functions
  '(random-table/text-replacer-function/current-roll
    random-table/text-replacer-function/dice-expression
    random-table/text-replacer-function/from-interactive-prompt
    random-table/text-replacer-function/named-table
    random-table/text-replacer-function/inner-table
    random-table/text-replacer-function/table-math)
  "Functions that each have one positional string parameter returning a string.

The function is responsible for finding and replacing any matches
within the text.

See `random-table/create-text-replacer-function' macro for creating one of these
functions."
  :group 'random-table
  :package-version '(random-table . "0.4.0")
  :type '(list :value-type (group function)))

The list helps document how those functions are expected to behave.

Conclusion

This weekend, I spent a handful of hours on the refactor. The goal of the refactor was to introduce handling of an “inner table”. The result was that I added new functionality and created a crease for further extensibility.

Another side-effect of the interface is that I can easily document, at the function definition level, the constituent parts and can explain how those parts fit together.