Nix+LaTeX Elisp autoloader

As I am currently writing my thesis manuscript, I needed a comfy and easy-to-use environment to write LaTeX without too much hassle. However, as I am a computer scientist, I obviously failed and instead conjured a complicated setup made of Nix https://nixos.org/ and Emacs. Since it adds non-trivial functionalities to my editor, I thought it could be useful to someone if I shared the config.

Setup & context

I write my thesis in a git repository with a Nix flake configuration. If you are unfamiliar with Nix, or even Nix flakes in particular, know that they allow me to create a software environment per-folder, instead of having every single dependency of every single project available everywhere at any time on my machine. It's tidier, and it allows for multiple versions of the same software to co-exist peacefully. My Nix flake basically pulls up the most barebone LaTeX stack, with hand-picked libraries that only satisfy my own dependencies. This avoids pulling the entirety of the LaTeX live repository and ending up with gigabytes of dependencies. Since I have this Nix flake, building my thesis document is also nixified: through nix build, I can launch the building process, calling the LaTeX compiler and ending up with a single PDF file.

I write my thesis using Emacs, since it's the editor I have used for most of my life. This blog post will probably not interest you if you don't use it, since it pertains to writing macros and functions for Emacs. A neat feature of Emacs is that it allows you to specify a line at the top of a file that basically tells the editor: "Ask the user if they want to execute this piece of Emacs Lisp code right now". This allows us to execute specific code when opening specific files, without needing to write a fully-fledged Emacs library.

Small disclaimer: the autoloader I present has been created over the years of my PhD and is not perfect by any means. It is also tailored to the structure of my project: LaTeX files are potentially in subdirectories, and the root of the project always contains a flake.nix file. Feel free to modify it and improve it yourself!

Executing Elisp when opening a file

The first thing we need to do is to allow executing Elisp directly when opening a file in Emacs. You could execute the code manually yourself whenever you open a project file, but it's tedious. Fortunately, Emacs allows you to do just that, by writing a comment at the top of any file with this format:

% -*- eval: ...  -*-

I used LaTeX here since this is what I use, but any kind of comment in any language containing this syntax works. When opening a file that contains such a comment, Emacs will ask you if you want to execute the code specified there. Since it's dangerous to auto-execute code directly, it will always ask first, unless you manually specify that this file is to be treated as safe and have the code loaded automatically.

With this, it's easy to create an auto-loaded script: use (load-file "autoloader.el") after the eval directive to execute the code contained within the autoloader.el located in the current directory when opening the LaTeX file!

Triggering compilation when saving a file

Emacs has this very handy tool called "hooks". They allow to register functions to be executed whenever a certain action happens. For example, you can set up a hook to execute a function whenever a file is opened. In our case, the hook that interests us is the one that is executed whenever a specific file is saved. It is used like this:

(add-hook 'after-save-hook (lambda () (message "I'm saved!")) nil t)

The first argument specifies which hook to use (in our case, after-save-hook). The second is the function to execute; here I use a lambda to create an anonymous function, but you can also specify a named function. I have no idea what the third argument is supposed to be (the manual says it's depth, but no explanation is given). The fourth however specifies that this hook is buffer-local, i.e. only for the current file and not for every file.

Executing shell commands asynchronously

As my document grew, LaTeX compilation time increased by a lot. If I used the standard command spawning mechanism of Emacs, I would have to wait a minute or so whenever I save my file, preventing me to do other stuff while waiting for the PDF to actualize. Instead, we need to meddle with asynchronous commands. The syntax is quite simple: there is a high-level wrapper around the nasty low-level stuff, start-process. It returns a process object, which you can use to perform checks and other process management operations. One such operation is adding a "sentinel": a function executed whenever the process changes (more details here https://www.gnu.org/software/emacs/manual/html_node/elisp/Sentinels.html). Since we need the process object, we can do a let binding:

(let
    ((proc (start-process "build-pdf" "build-pdf" "nix" "build")))
  (if process-live-p proc)
    (set-process-sentinel proc (lambda () (message "Process has changed!"))))

Display errors instead of silently failing

For the longest time, I accepted that I needed to wait for the compilation to end, and if the PDF didn't look like it changed, I had to run nix build manually, get the command that outputs the full log, and execute it myself. However I eventually grew tired of that, and I automated everything. In the process sentinel function, you can check whether the process has exited with a non-zero code (which happens when nix build fails). The problem is that the entire log is not displayed when using nix build; instead, the program outputs a command with the path to the log (which can't be determined easily as the Nix hash of the project is part of that path) that you have to execute to get the entire thing. So we need to do the following:

  1. Check if the process has exited with an error.
  2. Extract the log command from the output of the process
  3. Execute the log command and capture the output
  4. Display the output to the user

I ended up with this:

;; Consume all output of the process
(while (accept-process-output process))
(let*
    ;; Get the last line of the error output, which contains the command to display the logs
    ((line (with-current-buffer "build-pdf" (goto-char (point-max))
                        (forward-line -1)
                        (thing-at-point 'line)))
    ;; Parse the line to get the log command only
    (command (save-match-data (and (string-match "'\\(.*?\\)'" line)
                (match-string 1 line))))
    ;; Setup a buffer to redirect the log command output to
    (output-buffer (progn
            (kill-buffer "*latex-error*")
            (get-buffer-create "*latex-error*")))
    ;; Split the command into tokens, since we need to apply them to the command spawning function
    (command-words (split-string command))
    ;; Spawn the log process, set PAGER to cat to disable paging.
    (err-process
      (with-environment-variables
          (("PAGER" "cat"))
        ;; Clear the log buffer in case it wasn't empty.
        (with-current-buffer "*latex-error*" (erase-buffer))
        ;; Execute the log command and get output into *latex-error*.
        (apply 'start-process (append (list "latex-error" "*latex-error*") command-words (list "-L"))))))
  ;; Display the popup window.
  (with-help-window "*latex-error*" ""))

Hopefully the comments are self-explanatory enough; but basically, based on the buffer we set up to capture the output of the compilation process (here build-pdf), we get its last line, and get the substring in parenthesis, since the command is within the parenthesis of the last line of the output. Then, we setup another buffer, and call the command to get the log. The actual command executed is nix log <path>, and by default it uses the system pager to display the file, which completely breaks output capture by Emacs. So we need to specify to not use the pager, which is done by setting the environment variable PAGER to cat. At the end, we display the buffer containing the full logs to the user using with-help-window, which sets up a bunch of minor modes that allow, among other things, to quickly close the buffer using the Q key.

Putting it all together

Here is the entirety of the script. It is also available as a standalone file here ./autoloader.el. Feel free to use it! Small detail: as of now, this only works if you have a thesis.pdf buffer opened in Emacs, since I use Emacs' PDF viewer to look at the end result side-by-side with the LaTeX code. You can remove the part about reverting the buffer if that is not important for you! You'll need to modify how my/copy-pdf works though.

(defun my/copy-pdf ()
  "Copies the PDF from the ./result/ folder to ./"
  ;; Travel up until at the root.
  (while (not (file-exists-p "flake.nix"))
    (cd "../"))
  ;; Copy file and set up permissions.
  (copy-file buffer-file-name "./" t nil nil nil)
  (chmod (concat "./" (file-name-nondirectory buffer-file-name)) #o644))

(defun my/refresh-pdf (process signal)
  "Reverts the presentation.pdf buffer and copy PDF to ./, or display errors"
  ;; Confirm the process is complete
  (when (memq (process-status process) '(exit signal))
    ;; Check if compilation ended with an error
    (if (eq (process-exit-status process) 0)
      ;; No error: revert any buffer named "thesis.pdf" and copy it to ./.
      (with-current-buffer "thesis.pdf"
        (my/copy-pdf)
        (revert-buffer nil t)
        (message "PDF compiled successfully"))
    ;; Compilation failed: let's setup an error message
    (progn
      ;; Consume all output of the process
      (while (accept-process-output process))
      (let*
        ;; Get the last line of the error output, which contains the command to display the logs
        ((line (with-current-buffer "build-pdf" (goto-char (point-max))
                               (forward-line -1)
                               (thing-at-point 'line)))
        ;; Parse the line to get the log command only
         (command (save-match-data (and (string-match "'\\(.*?\\)'" line)
                        (match-string 1 line))))
                
        ;; Setup a buffer to redirect the log command output to
         (output-buffer (progn
                  (kill-buffer "*latex-error*")
                  (get-buffer-create "*latex-error*")))
        ;; Split the command into tokens, since we need to apply them to the command spawning function
         (command-words (split-string command))
        ;; Spawn the log process, set PAGER to cat to disable paging.
         (err-process
          (with-environment-variables
           (("PAGER" "cat"))
           ;; Clear the log buffer in case it wasn't empty.
           (with-current-buffer "*latex-error*" (erase-buffer))
           ;; Execute the log command and get output into *latex-error*.
           (apply 'start-process (append (list "latex-error" "*latex-error*") command-words (list "-L"))))))
      ;; Display the popup window.
      (with-help-window "*latex-error*" ""))))))

(defun my/build-pdf-and-revert ()
  "Builds the PDF with nix build and refreshes the output"
  ;; Travel up to the root.
  (while (not (file-exists-p "flake.nix"))
    (cd "../"))
  (let* 
      ;; Start the process calling nix build
      ((proc (start-process "build-pdf" "build-pdf" "nix" "build")))
    (if (process-live-p proc)
      ;; Setup hook to execute when the process is finished
      (set-process-sentinel proc #'my/refresh-pdf))))

;; Setup a hook to execute the build function whenever the current file is saved.
(add-hook 'after-save-hook (lambda () (my/build-pdf-and-revert)) nil t)