2019-02-04 A simple template mechanism in Elisp

A long time ago I asked on the Emacs mailing list about a templating mechanism for Emacs Lisp. Of course, there is format. However, I don’t like it as a template engine, since the entries are identified by their order and not names. Then, there is YASnippet and skeleton.el, but they are (probably) better suited for interactive use. (At least Yasnippet can be used programmatically, but it seemed to be too complicated for my needs anyway back then.) Some people suggested other solutions, none of which really appealed to me.

So, I set out to write my own. Notice how we compute pos for the next iteration in string-expand-template (this simple trick is due to Pascal J. Bourguignon) – we can’t just use match-end, since this doesn’t take into account the possible difference in lengths of the replaced string and the replacement.

(defun string-expand-template (template values)
  "Expand TEMPLATE, replacing occurrences of \"{{{beep}}}\" using
the alist VALUES.  The text to use as the replacement is the cdr
of the cons with car equal to the string \"beep\".  If no such
string is present in VALUES, use the empty string as the
replacement.  To insert literal \"{{{\" in the template, escape
the first one with a backslash."
  (let ((pos 0) (result template) prev-result)
    (while (string-match
	    "\\(?:^\\|[^\\]\\)\\({{{\\([a-z0-9-]+\\)}}}\\)"
	    result pos)
      (setq prev-result result)
      (setq result (replace-match
		    (or (cdr (assoc (match-string 2 result) values)) "")
		    t t result 1))
      (setq pos (+ (match-beginning 2)
		   (- (length result) (length prev-result)))))
    result))

(defun perform-template-expansion-here (values)
  "Perform template expansion, using the VALUES alist, from the point
to the end of (visible part of) the buffer.  This is used by both
`expand-template' and `insert-and-expand-template' functions."
  (while (re-search-forward "\\(?:^\\|[^\\]\\)\\({{{\\([a-z0-9-]+\\)\\(?:,\\(.*?\\)\\)?}}}\\)" nil t)
    (replace-match (or (cdr (assoc (match-string 2)
				   values))
		       (match-string 3)
		       "")
		   t t nil 1)))

(defun expand-template (template values)
  "Expand TEMPLATE, replacing occurrences of \"{{{beep}}}\" using
the alist VALUES.  The text to use as the replacement is the cdr
of the cons with car equal to the string \"beep\".  If no such
string is present in VALUES, use the empty string as the
replacement.  To insert literal \"{{{\" in the template, escape
the first one with a backslash."
  (with-temp-buffer
    (insert template)
    (goto-char 0)
    (perform-template-expansion-here values)
    (buffer-string)))

(defun insert-and-expand-template (template values)
  "Expand TEMPLATE, replacing occurrences of \"{{{beep}}}\" using
the alist VALUES.  The text to use as the replacement is the cdr
of the cons with car equal to the string \"beep\".  If no such
string is present in VALUES, use the empty string as the
replacement.  To insert literal \"{{{\" in the template, escape
the first one with a backslash."
  (save-restriction
    (narrow-to-region (point) (point))
    (insert template)
    (goto-char (point-min))
    (perform-template-expansion-here values)
    (goto-char (point-max))))

It turned out to be easier than I suspected – not completely without pitfalls, though. I decided to base it on regexen (“A man had a problem, and he decided to use reular expressions…;-)”), which is a bit restrictive, but not too much for my needs.

So, let me start with a short howto. The main entry point is the function expand-template. It receives two arguments: the template itself and an alist specifying the substitutions. In the template (which is an ordinary string), anything consisting of letters, digits and/or hyphens between triple braces is the key searched for in the alist. If none is found, an empty string is inserted. As a bonus, you can write a default value after a comma; it may contain anything except a newline or three closing braces. Finally, a backslash before the opening triple braces escapes the whole thing. And that’s it.

Let us look at a few examples.

(expand-template
 "This is {{{beep}}} and that is {{{cling}}}."
 '(("beep" . "an apple")
   ("cling" . "an orange")))
(expand-template
 "{{{beep}}} is tasty, and I like how the glasses go \\{{{cling}}}."
 '(("beep" . "Wine")
   ("cling" . "an orange")))
(expand-template
 "This is a {{{beep}}} and that is {{{nothing}}}."
 '(("beep" . "beep")
   ("cling" . "cling")))
(expand-template
 "This is a {{{beep}}} and that is a {{{cling,CLING}}}."
 '(("beep" . "beep")))

Since very often the templates are expanded only to be then inserted into a buffer, and expand-template uses a (with-temp-buffer ... (buffer-string)) approach, using it to generate a string only to be inserted somewhere is a waste, so there is also the insert-and-expand-template. It takes exactly the same arguments, but instead of returning an (expanded) string, it just expands the template in place.

I mentioned some pitfalls. I wanted to implement expand-template using replace-regexp-in-string at first. It’s rep argument (specifying the replacement text) can be a function, which seemed to suit my needs. However, this function receives only the text that matched, and while you can refer to the match data in it, they are not very useful: match-beginning and match-end, for some strange reason, refer not to the searched string, but certain its substring! (Admittedly, this is documented, but it’s not something I like.) Finally, I decided that doing the replacements in a buffer (as opposed to a string) will be both easier to code (and the code will be more readable) and (probably) more effective.

It turned out that things were a bit more complicated – but this will wait for another post.

CategoryEnglish, CategoryBlog, CategoryEmacs