Emacs Recipes With Yasnippet

A common pattern while working on computer projects is running through a sequence of “semi-automatic” operations, where you can’t quite automate all the steps fully, but the formula is basically the same every time. I call these workflows “recipes,” and they have these shared properties:

  • There’s a set of steps that’s almost always the same (but not always)
  • Frequently, especially when they’re software development, they take hours / days, long enough for interruptions to happen in the middle

I’m easily distracted, so it’s common for me to lose track of where I am following a recipe and burn some time picking myself back up.

To that end, I created an emacs minor mode recipe-mode that provides an interactive function recipe-do. This function evals an elisp sexp under the header at point and then marks that header DONE with the current timestamp. The minor mode is here:

;;; recipe-mode --- Recipe minor mode
;;;
;;; Commentary:
;;; Minor mode intended to be run alongside org-mode, which defines "recipes."
;;; These are org headings with elisp code under them; the elisp is executed when recipe-do is executed.
;;;
;;; ISC License
;;;
;;; Copyright 2024 Mark T. Tomczak
;;;
;;; Permission to use, copy, modify, and/or distribute this software for any
;;; purpose with or without fee is hereby granted, provided that the above
;;; copyright notice and this permission notice appear in all copies.
;;;
;;; THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
;;; WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
;;; MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
;;; SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
;;; WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
;;; OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
;;; CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

;;; Code:
(require 'org)

(defun recipe--update-completion-timestamp ()
  "Update completion timestamp for DONE on current line."
  (beginning-of-line)
  (search-forward "DONE" (point-at-eol))
  (forward-char)
  (if (eq (char-after) ?\[)
      ;; clear existing timestamp
      (progn
            (let ((beg (point)))
              (search-forward "] " (point-at-eol))
              (delete-region beg (point)))))
  (insert "[" (format-time-string "%Y-%m-%d %H:%M:%S") "] "))


(defun recipe-do ()
  "Execute the command under the current org header.

If the command succeeds without issue, the header will be updated to DONE.

On an error signal, DONE update is skipped."
  (interactive)
  (save-excursion
    (cl-labels ((is-folded () (and (or (org-at-heading-p) (org-at-item-p)) (invisible-p (point-at-eol)))))
      (let ((was-folded (is-folded)))
	;; exec
	(beginning-of-line)
	(if was-folded (org-cycle))
	(save-excursion
	  (forward-line)
	  (forward-sexp)
	  (eval-last-sexp nil))
	;; successful exec, so cleanup the org entry.
	(org-todo 'done)
	(recipe--update-completion-timestamp)
	(if (and was-folded (not (is-folded))) (org-cycle))))))

(define-minor-mode recipe-mode
  "Toggle recipe-mode.
Interactively with no argument, this command toggles the mode.
A positive prefix argument enables the mode, any other prefix
argument disables it.  From Lisp, argument omitted or nil enables
the mode, `toggle' toggles the state.

When recipe-mode is enabled, `C-c d` runs `recipe-do`, which
evaluates an elisp expression under the org-header at point."
  :init-value nil
  :lighter " recipe"
  :keymap '(("\C-cd" . recipe-do))
)

(provide 'recipe-mode)
;;; recipe-mode.el ends here

I attach this mode to the org-mode with (add-hook 'org-mode-hook 'recipe-mode).

Using yasnippet to manage recipes

Now that I have a mode in place to make it easy to run commands under org headers, I want a way to make it easy to create and manage new recipes.

It turns out there’s a great tool called yasnippet that lets you easily insert snippets of text into existing buffers or new buffers. Each of my recipes starts as a yasnippet template with a fairly common format, and I just peel off the template to get started.

Here’s an example template I use for creating and publishing new Hugo blogposts.

# -*- mode: snippet -*-
# key: recipe-new-blogpost
# name: New blog post recipe
# --
$0* Init
  (progn (hack-local-variables)
  (org-cycle-global))
* Create blog post
  (mthugo-create-post "${1:hypthenated-post-name}")
* Edit blog post
  (find-file (format "content/posts/%d/%s/index.md" post-year post-name))
* Preview blog post
  (compile "hugo serve -D")
* Set draft to false on blog post
  (message "For now, do this by hand.")
* Build blog post
  (compile "hugo")
* Commit post
  (let ((commit-msg (read-from-minibuffer "Commit message: "))
        (new-post-path (format "content/posts/%d/%s/" post-year post-name)))
   (compile (format "git add --update && git add \"%s*\" public/* && git commit -m \"%s\"" new-post-path commit-msg)))
* Push post
  (compile "git push origin main")
* Publish entry to Mastodon
  (message "For now, do this by hand.")
* Update comments id
  (let ((id-string (read-from-minibuffer "Comments ID string: ")))
   (find-file (format "content/posts/%d/%s/index.md" post-year post-name))
   (goto-char (point-min))
   (search-forward "---")
   (search-forward "---")
   (beginning-of-line)
   (insert "commentsId: " id-string "\n"))
* Commit post with comments ID
  (compile (format "hugo && git add --update && git add public/* && git commit -m \"Added comments for %s\"" post-name))
* Push post
  (compile "git push origin main")
* DONE [footer]
#+STARTUP: fold
Local Variables:
mode: org
post-name: $1
post-year: `(nth 5 (decode-time))`
default-directory: "`default-directory`"
End:

There’s a couple fun bits going on here:

  • Text between backticks is elisp that gets evaluated when the template is loaded.
  • The $1 are insertion points for the post name in hyphenated-format (all $n in a given snippet are mirrors of the same value; the ${1:hyphenated-post-name} form at the top just uses “hyphenated-post-name” as a default value to remind me of the format I have to use here.
  • The $0 shows where filling in yasnippet fields ends; when I hit tab after entering the post name, yasnippet drops the cursor at $0, ready to use C-c d to run Init.
  • The “Local Variables” portion at the bottom is a local variables list, which configures a bunch of variable values in the buffer when the file is loaded. I have to force them to be evaluated when the file is first created (which is why we run (hack-local-variables) at the top of the recipe). But after that, they’ll auto-load after every subsequent loading of the file into a buffer. I find this to be a convenient pattern for scripting things like the path to relevant control programs, etc. We also cache the default-directory from when the template is created so no matter where the recipe is saved, it will work.

This last point brings up a good question: Where do you save these? I keep mine in a ~/current-work directory so I can always know what I’m working on right now. But they’re just files, so you can do whatever with them; I can imagine it being useful to keep finished ones in a past-work directory to track your progress, remember what you worked on for a given quarter, etc.

Some things I’ve found recipes to be useful for (besides managing blog posts) include:

  • Adding features: I have the git workflow (including merging or deleting the feature branch) encoded into a recipe.
  • Operations with stable patterns like database migration script generation.
  • Automating repetitive maintenance tasks that system admins / sysops / SREs have to do that aren’t quite fully-automated yet.

Basically anything that can be reduced to a sequence of named steps you normally carry out one-at-a-time by hand can work.

Let me know if you find these useful! I haven’t put recipe-mode up on elpa or source control yet, but if people find it helpful I can be more formal about it.

Comments