Intro to Emacs Lisp: Adding Live Previews when Editing Markdown Files (Part 3 of 3)
This post is part three of a series of 3 posts. View the other parts:
In this post, we’ll make the command more useful by:
- Having it prompt us for a file to preview when running interactively.
- Adding documentation
- Having Emacs run it automatically whenever we save a Markdown file
We’ll discuss the following Emacs Lisp concepts:
- Optional arguments
interactivecode charactersprognformsifforms- Docstrings
- Hooks
- Buffer-local variables
- What it means that Emacs Lisp is a Lisp-2
Prompting for a file to preview
Here we’ve broken out cam/preview-markdown a bit further.
This version of the command works the same as before when run programmatically,
but when executed interactively (e.g. via M-x) it will prompt you for the file
to preview, complete with autocomplete; it defaults to the current file.
(defun cam/-scroll-percentage ()
(/ (float (line-number-at-pos (window-start)))
(float (line-number-at-pos (point-max)))))
(defun cam/-set-window-start-to-percentage (scroll-percentage)
(goto-char (point-min))
(let ((target-line-number (truncate (* (line-number-at-pos (point-max)) scroll-percentage))))
(forward-line (1- target-line-number)))
(set-window-start nil (point)))
(defun cam/-render-markdown-preview-current-buffer ()
(message "Rendering Markdown preview of %s" buffer-file-name)
(shell-command-on-region (point-min) (point-max) "pandoc -f gfm" "*Preview Markdown Output*")
(switch-to-buffer-other-window "*Preview Markdown Output*")
(let ((document (libxml-parse-html-region (point) (point-max))))
(erase-buffer)
(shr-insert-document `(base ((href . ,url)) ,document))
(setq buffer-read-only t)))
(defun cam/-preview-markdown-file (filename)
(save-selected-window
(find-file filename)
(let ((url (concat "file://" filename))
(scroll-percentage (cam/-scroll-percentage)))
(cam/-render-markdown-preview-current-buffer)
(cam/-set-window-start-to-percentage scroll-percentage))))
(defun cam/preview-markdown (&optional filename)
"Render a markdown preview of FILENAME (by default, the current file) to HTML and display it with `shr-insert-document'."
(interactive "fFile: ")
(if filename
(progn
(cam/-preview-markdown-file filename)
(switch-to-buffer (current-buffer)))
(cam/-preview-markdown-file buffer-file-name)))
There are a couple of new concepts to introduce here:
&optionalarguments- arguments to the
(interactive)declaration progn- Emacs Lisp docstrings
Optional arguments
In function definitions, &optional is used to denote optional positional
arguments. If an optional argument is not passed, its value will be nil
in the body of the function:
(defun my-filename (&optional filename)
filename)
(my-filename "x.txt") ; -> "x.txt"
(my-filename) ; -> nil
In
(defun cam/preview-markdown (&optional filename)
...)
we are making the filename argument optional, because we’d like to be able to
call this function with a specific file to preview, but default to the current
file when it is called with no argument (such as when it is called as part of a
hook, as discussed below).
Specifying a prompt in the interactive declaration
The interactive declaration can optionally take an argument that tells Emacs
how to prompt for values to use as the command’s arguments:
(interactive "fFile: ")
The f code
character
tells Emacs to prompt for an existing filename, defaulting to the name of the
file in the current buffer. The rest of the string is the text of the prompt to
show the user in the minibuffer ("File: "). Note that we have to include the
space after the prompt ourselves.
The string name of the file the user chooses will be passed in as the filename
argument. When the function is invoked programmatically, Emacs will not prompt
the user for a value of filename.
if forms
In Emacs Lisp, if forms have the syntax
(if condition
then-form
else-forms...)
For example:
;; t means true and nil means null/false
(defun my-> (x y)
(if (> x y)
t
nil))
(my-> 3 2)
;; -> t
The if form in the example is roughly equivalent to this Algol-style if form:
if (x > y) {
true;
} else {
false;
}
progn forms
progn can be used to execute multiple statements as a single form. (If you’re
familiar with Clojure, this is the classic Lisp equivalent of do). Each form is
evaluated in order, and the result of a progn form is the result of the last
form contained by it:
(progn
(message "We are adding some numbers.")
(+ 3 4))
;; -> 7
Because only the value of the last form is returned, forms other than the last are usually executed for side effects.
Understanding the updated cam/preview-markdown
Now that we’ve discussed the new concepts, Let’s take a deeper look at our simplified cam/preview-markdown function:
(defun cam/preview-markdown (&optional filename)
"Render a markdown preview of FILENAME (by default, the current file) to HTML and display it with `shr-insert-document'."
(interactive "fFile: ")
(if filename
(progn
(cam/-preview-markdown-file filename)
(switch-to-buffer (current-buffer)))
(cam/-preview-markdown-file buffer-file-name)))
- If a
filenameargument was passed:- Call
cam/-preview-markdown-filewith that filename - Switch back to the current buffer (
save-selected-window, called incam/-preview-markdown-file, also restores the current buffer, but doesn’t necessarily bring it to the top).
- Call
- If no
filenameargument was passed:- Call
cam/-preview-markdown-filewith the filename of the current buffer.
- Call
Opening a file/switching to existing buffer for a file
In cam/-preview-markdown-file we’ve added a call to find-file.
(find-file filename)
switches to a buffer containing filename. In cases where we’re already looking
at that file, find-file doesn’t change anything, so this code continues to
work normally when rendering the file in the current buffer. For other files, it
will switch to a buffer for that file, creating a buffer (opening the file) if
needed.
Adding a docsting
(defun cam/preview-markdown (&optional filename)
"Render a markdown preview of FILENAME (by default, the current file) to HTML and display it with `shr-insert-document'."
(interactive "fFile: ")
...)
Emacs Lisp docstrings come after the argument list but before the
interactive declaration, if there is one. Emacs Lisp docstrings conventionally
mention arguments in all capital letters (e.g. FILENAME). When viewing the
documentation for a function, arguments mentioned this way are highlighted
automatically.
You can add hyperlinks to other Emacs Lisp functions or variables using the
`symbol-name'
syntax. These days you can also use curved single quotes instead, but figuring out how to type them involves too much effort, so I stick with the backtick-single-quote convention, which is the one you’ll encounter most commonly in the wild.
Try it yourself! C-h f cam/preview-markdown to view the documentation for
the function cam/preview-markdown.
This page of the Emacs Lisp documentation has a very good explanation of docstrings for further reading.
Adding an after-save-hook
Now all that’s left to do is telling Emacs to automatically run our command whenever we save a Markdown file.
(add-hook 'markdown-mode-hook
(lambda ()
(add-hook 'after-save-hook #'cam/preview-markdown nil t)))
Whenever we open a Markdown file, Emacs will set the major mode to
markdown-mode. Major modes and most minor modes have hooks that run whenever
the mode is entered. When we enter markdown-mode, Emacs will run any functions
in markdown-mode-hook.
To markdown-mode-hook we add a lambda (anonymous function) that itself adds
the command #'cam/preview-markdown to after-save-hook. Any functions in
after-save-hook will run after a file is saved.
A hook is just a variable that contains a list of functions that get ran at a
specific point in time. markdown-mode-hook is a list of functions to run when
entering markdown-mode and after-save-hook is a list of functions to run
after saving a file.
Add-hook and buffer-local variables
add-hook adds a function to a hook (which, again, is just a list), creating
the variable if it doesn’t already exist. add-hook has two optional args,
append (default nil) and local (default nil). append tells it to add
the function at the end of the list, meaning it gets ran last. In some cases, it
is preferable to run a certain function after others in the hook have ran. In
our case, it doesn’t really matter if our function runs before or after others,
so we’ll pass the default value of nil. local tells it to add it to the
buffer-local version of the hook rather than the global version. We’ll explore
the difference more in the future, but for now suffice to know that variables
can have global values as well as values specific to a buffer. Buffer-local
values overshadow global values.
(add-hook 'after-save-hook #'cam/preview-markdown)
without the optional args would add the function #'cam/preview-markdown to the
global after-save-hook, which means it would run after saving any file,
which is not what we want. By adding the local option, the function is only
added to the hook for the current buffer, meaning it only runs for the current buffer.
To sum it up: markdown-mode-hook gets ran once for every new Markdown file we
open, and we use that to add cam/preview-markdown to the after-save-hook for
the newly created buffer. Files that aren’t opened in markdown-mode aren’t
affected at all.
Lisp-1s and Lisp-2s
Emacs Lisp is a Lisp-2, which means that variables and functions live in separate
“namespaces”. For example, length could refer to both a variable named length
and a function named length.
;; Emacs Lisp
(length '(1 2 3))
;; -> 3
;; the length variable doesn't overshadow the length function
(let ((length 4))
(length '(1 2 3)))
;; -> 3
Contrast this to Clojure, a Lisp-1, where variables and functions share a “namespace”:
;; Clojure
;; count is the Clojure equivalent of length
(count '(1 2 3))
;; -> 3
;; let-bound count overshadows *any* usage of the symbol
(let [count 4]
(count '(1 2 3)))
;; -> [100 line stacktrace: Integer cannot be invoked as a function]
Using functions passed as arguments is much simpler in Lisp-1s, however:
;; Clojure
(defn call-f [f]
(f 100))
(call-f (fn [n] (inc n)) 100)
;; -> 101
With Lisp-2s, you have to use funcall to call a function bound to a variable:
;; Emacs Lisp
;; the symbol f refers only to variable, so to use it as a function contained in
;; f, you have to use funcall
(defun call-f (f)
(funcall f 100))
(call-f (lambda (n) (1+ n)) 100)
;; -> 101
There are pros and cons to both Lisp-1s and Lisp-2s. Lisp-1s lend themselves
more elegantly to passing around functions since you don’t need to use special
forms like funcall. However you have to be much more careful not to
unintentionally overshadow functions in Lisp-1s, which usually means
intentionally misspelling function parameter names. You’ll often see nonsense
like this in Lisp-1s:
;; clojure
;; so as to not overshadow the "type" function, we have to give our type
;; parameter a different name, such as "typ"
(defn type=
"True if the type of `x` is equal to `typ`."
[x typ]
(= (type x) typ))
There are other differences to explore in the a future post, but let’s get back to tweaking our command!
Quoting function names
Attempting to evaluate cam/preview-markdown will result in an error:
cam/preview-markdown
*** Eval error *** Symbol’s value as variable is void: cam/preview-markdown
Instead, we can quote the symbol name so the reader doesn’t attempt to evaluate it. Emacs offers an alternative syntax for quoting symbols that refer to functions:
#'cam/preview-markdown
Just as 'x is shorthand for (quote x), #'x is shorthand for (function x).
In many cases, using quote for function names will still work correctly:
(add-hook 'after-save-hook 'cam/preview-markdown)
But function is preferred over quote for symbols that name functions for a
couple of reasons: it’s clearer and more explicit, and the compiler is better
able to optimize function forms.
Final Thoughts
Complete source for the final version can be found at this GitHub Gist. Please feel free to leave comments or suggestions there, or on this Reddit thread. If there’s enough positive feedback from these posts, I’ll be sure to add more!
If you enjoyed these posts and have money burning a hole in your pocket, consider buying me a cup of coffee at GitHub Sponsors.