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
interactive
code charactersprogn
formsif
forms- 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:
&optional
arguments- 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
filename
argument was passed:- Call
cam/-preview-markdown-file
with 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
filename
argument was passed:- Call
cam/-preview-markdown-file
with 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.