Testing buffer-modifying Emacs code (again)

June 2022

There are no straight lines in nature. - Antoni Gaudí

I previously published an article about testing buffer-modifying code: functions whose behavior is not encapsulated by their return value, like upcase-word or isearch-forward. Instead, they are called to modify the current buffer. We might want to test upcase-word as follows.

(should
 (equal (with-temp-buffer (insert "hi there")
                          (goto-char (point-min))
                          (upcase-word 2)
                          (buffer-string))
        "HI THERE"))
  

This works, but it's hard to read – the setup code, the test code, and the comparison are all mingled together.

So the previous post created a macro to help. This macro inserted the first argument into a temp buffer, then ran the other arguments as code. Finally, it returned the entire buffer as a string. This let us make assertions against the buffer after the body code has been run. For example, the above code can be replaced with:

(should
 (equal (old-on-temp-buffer "hi there"
                            (upcase-word 2))
        "HI THERE"))

With this macro, we can test the behavior of upcase-word on the buffer. But on a recent train trip, I thought of a way to make it more user-friendly.

Before showing the new macro, let's look at the problems with the old on-temp-buffer.

Downside 1: Placing Point

One of the downsides of this version was having to move point to the correct location. The longer the input text, the more complicated the code to move point.

(should
 (equal (old-on-temp-buffer "isn't this\nDELETE annoying?"
                            (next-line)
                            (forward-char 6)
                            (delete-char -7))
        "isn't this annoying?"))

At a glance, you can't tell which part of the code moves point to the right location, and which part is the code we are testing. Wouldn't it be nice if we could the string itself could include where point should be?

Well, now we can. If you put in a pipe character, on-temp-buffer will put point there.

(should
 (equal (on-temp-buffer "this is\nDELETE| easier!"
                        (delete-char -7))
        "this is easier!"))

Isn't that easier? If there is no pipe character, point is placed at the beginning, just as in the old version.

(should
 (equal (on-temp-buffer "should I go?"
                        (insert "where "))
        "where should I go?"))

Downside 2: Retrieving Point

Some functions move point. To check where point ends up, on-temp-buffer-point returned the value of point:

(should
 (equal (on-temp-buffer-point
          "this is a lot of words"
          (forward-word 2)
          (upcase-word 2))
        14))

But now, it places a pipe character where point is.

(should
 (equal (on-temp-buffer-point
          "this is a lot of words"
          (forward-word 2)
          (upcase-word 2))
        "this is A LOT| of words"))

And combined with setting point at the beginning, it's even easier:

(should
 (equal (on-temp-buffer-point
          "this is |a lot of words"
          (upcase-word 2))
        "this is A LOT| of words"))
    

Et tu, pipe

But what if you need pipe characters in your body text? Escape them. "\\|" will be placed into the test buffer as a single pipe character. Similarly, if the test buffer has pipes in the output string, on-temp-buffer-point escapes them.

(should
 (equal (on-temp-buffer-point
          "not\\|hing to see"
          (forward-word 2)
          (insert "|")
          (forward-word)
          (insert "|")
          (forward-word))
        "not\\|hing\\| to\\| see|"))

After running all the code, point was at the end of the buffer. So on-temp-buffer-point puts a pipe there. The other pipe characters are escaped, indicating actual pipe characters in the buffer.

New Code

This is the entire modified code.

(defmacro on-temp-buffer (string &rest body)
  "Insert STRING into a temp buffer, then run BODY on the temp buffer.

Point starts at the beginning of the buffer, or where a pipe character
occurs.  To insert an actual pipe, include two pipes.

After running BODY, the entire buffer is returned as a string."
  (declare (indent 0) (debug t))
  `(with-temp-buffer
     (insert ,string)
     (on-temp-buffer//preprocess-buffer)

     ,@body
     (buffer-string)))

(defmacro on-temp-buffer-point (string &rest body)
  "Insert STRING into a temp buffer, then run BODY on the temp buffer.

Point starts at the beginning of the buffer, or where a pipe character
occurs.  To insert an actual pipe, include two pipes.

After running BODY, the entire buffer is returned as a string.  In
this returned string, point is indicated by a pipe character.  Pipe
characters in the string are replaced with a double pipe."
  (declare (indent 0) (debug t))
  `(with-temp-buffer
     (insert ,string)
     (on-temp-buffer//preprocess-buffer)

     ,@body

     (on-temp-buffer//postprocess-buffer-for-point)
     (buffer-string)))


(defun on-temp-buffer//preprocess-buffer ()
  "Preprocess the current buffer before body code can run.

To do this:

1. Move point to the location of a single pipe by itself.
2. Replace all escaped pipe characters (\\|) with a single pipe."
  (goto-char (point-min))
  (let ((point-to-start-with (point-min)))
    (while (re-search-forward (rx (or "|" "\\")) nil t)
      (let ((string-matched (match-string 0)))
        (delete-char -1)
        (when (equal "|"
                     string-matched)
          (setf point-to-start-with (point))))
      (forward-char 1))
    (goto-char point-to-start-with)))

(defun on-temp-buffer//postprocess-buffer-for-point ()
  "Process the current buffer so it indicates where point was.

This is for use after running body, for on-temp-buffer-point.

To do this:

1. Place a backslash before each pipe character.
2. Insert a single pipe character where point was when this function
was called."
  (let ((point-to-return (point)))
         (goto-char (point-min))
         (while (search-forward "|" nil t)
           (when (< (point) point-to-return)
             ;;we're going to insert a character before point-to-return,
             ;;so increase it by one.
             (setf point-to-return (1+ point-to-return)))
           (backward-char 1)
           (insert "\\")
           (forward-char))
         (goto-char point-to-return)
         (insert "|")))

Happy testing!

If you want to hear when I publish more, sign up for my mailing list!

    previous

    < Change Emacs's Default Behavior
    tag: emacs

    next

    Re-move files in Emacs >