From 33cc3b9dd7bf3dae1c6cf86e46bb4923f96e7fff Mon Sep 17 00:00:00 2001
From: トトも <85485984+ElectronicsArchiver@users.noreply.github.com>
Date: Sat, 16 Apr 2022 20:18:56 +0200
Subject: Source / Include Folder (#88)

Moved Source Files Into `src` Folder
---
 src/bar.c     |  248 ++++
 src/bar.h     |   57 +
 src/bfs.h     |   32 +
 src/bftw.c    | 1494 ++++++++++++++++++++++
 src/bftw.h    |  223 ++++
 src/color.c   | 1125 ++++++++++++++++
 src/color.h   |  120 ++
 src/ctx.c     |  311 +++++
 src/ctx.h     |  212 +++
 src/darray.c  |  103 ++
 src/darray.h  |  110 ++
 src/diag.c    |  233 ++++
 src/diag.h    |  108 ++
 src/dir.c     |  303 +++++
 src/dir.h     |  124 ++
 src/dstring.c |  220 ++++
 src/dstring.h |  194 +++
 src/eval.c    | 1644 ++++++++++++++++++++++++
 src/eval.h    |  113 ++
 src/exec.c    |  715 +++++++++++
 src/exec.h    |  121 ++
 src/expr.h    |  235 ++++
 src/fsade.c   |  392 ++++++
 src/fsade.h   |   83 ++
 src/main.c    |  141 ++
 src/mtab.c    |  246 ++++
 src/mtab.h    |   71 ++
 src/opt.c     | 1088 ++++++++++++++++
 src/opt.h     |   37 +
 src/parse.c   | 3959 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/parse.h   |   36 +
 src/printf.c  |  927 ++++++++++++++
 src/printf.h  |   68 +
 src/pwcache.c |  293 +++++
 src/pwcache.h |  117 ++
 src/stat.c    |  376 ++++++
 src/stat.h    |  155 +++
 src/trie.c    |  693 ++++++++++
 src/trie.h    |  156 +++
 src/typo.c    |  176 +++
 src/typo.h    |   31 +
 src/util.c    |  510 ++++++++
 src/util.h    |  317 +++++
 src/xregex.c  |  301 +++++
 src/xregex.h  |   97 ++
 src/xspawn.c  |  318 +++++
 src/xspawn.h  |  123 ++
 src/xtime.c   |  323 +++++
 src/xtime.h   |   86 ++
 49 files changed, 19165 insertions(+)
 create mode 100644 src/bar.c
 create mode 100644 src/bar.h
 create mode 100644 src/bfs.h
 create mode 100644 src/bftw.c
 create mode 100644 src/bftw.h
 create mode 100644 src/color.c
 create mode 100644 src/color.h
 create mode 100644 src/ctx.c
 create mode 100644 src/ctx.h
 create mode 100644 src/darray.c
 create mode 100644 src/darray.h
 create mode 100644 src/diag.c
 create mode 100644 src/diag.h
 create mode 100644 src/dir.c
 create mode 100644 src/dir.h
 create mode 100644 src/dstring.c
 create mode 100644 src/dstring.h
 create mode 100644 src/eval.c
 create mode 100644 src/eval.h
 create mode 100644 src/exec.c
 create mode 100644 src/exec.h
 create mode 100644 src/expr.h
 create mode 100644 src/fsade.c
 create mode 100644 src/fsade.h
 create mode 100644 src/main.c
 create mode 100644 src/mtab.c
 create mode 100644 src/mtab.h
 create mode 100644 src/opt.c
 create mode 100644 src/opt.h
 create mode 100644 src/parse.c
 create mode 100644 src/parse.h
 create mode 100644 src/printf.c
 create mode 100644 src/printf.h
 create mode 100644 src/pwcache.c
 create mode 100644 src/pwcache.h
 create mode 100644 src/stat.c
 create mode 100644 src/stat.h
 create mode 100644 src/trie.c
 create mode 100644 src/trie.h
 create mode 100644 src/typo.c
 create mode 100644 src/typo.h
 create mode 100644 src/util.c
 create mode 100644 src/util.h
 create mode 100644 src/xregex.c
 create mode 100644 src/xregex.h
 create mode 100644 src/xspawn.c
 create mode 100644 src/xspawn.h
 create mode 100644 src/xtime.c
 create mode 100644 src/xtime.h

(limited to 'src')

diff --git a/src/bar.c b/src/bar.c
new file mode 100644
index 0000000..b0e595e
--- /dev/null
+++ b/src/bar.c
@@ -0,0 +1,248 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2020-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+#include "bar.h"
+#include "dstring.h"
+#include "util.h"
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <signal.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/ioctl.h>
+#include <unistd.h>
+
+struct bfs_bar {
+	int fd;
+	volatile sig_atomic_t width;
+	volatile sig_atomic_t height;
+};
+
+/** The global status bar instance. */
+static struct bfs_bar the_bar = {
+	.fd = -1,
+};
+
+/** Get the terminal size, if possible. */
+static int bfs_bar_getsize(struct bfs_bar *bar) {
+#ifdef TIOCGWINSZ
+	struct winsize ws;
+	if (ioctl(bar->fd, TIOCGWINSZ, &ws) != 0) {
+		return -1;
+	}
+
+	bar->width = ws.ws_col;
+	bar->height = ws.ws_row;
+	return 0;
+#else
+	errno = ENOTSUP;
+	return -1;
+#endif
+}
+
+/** Async Signal Safe puts(). */
+static int ass_puts(int fd, const char *str) {
+	size_t len = strlen(str);
+	return xwrite(fd, str, len) == len ? 0 : -1;
+}
+
+/** Number of decimal digits needed for terminal sizes. */
+#define ITOA_DIGITS ((sizeof(unsigned short) * CHAR_BIT + 2) / 3)
+
+/** Async Signal Safe itoa(). */
+static char *ass_itoa(char *str, unsigned int n) {
+	char *end = str + ITOA_DIGITS;
+	*end = '\0';
+
+	char *c = end;
+	do {
+		*--c = '0' + (n % 10);
+		n /= 10;
+	} while (n);
+
+	size_t len = end - c;
+	memmove(str, c, len + 1);
+	return str + len;
+}
+
+/** Update the size of the scrollable region. */
+static int bfs_bar_resize(struct bfs_bar *bar) {
+	char esc_seq[12 + ITOA_DIGITS] =
+		"\0337"   // DECSC: Save cursor
+		"\033[;"; // DECSTBM: Set scrollable region
+
+	// DECSTBM takes the height as the second argument
+	char *ptr = esc_seq + strlen(esc_seq);
+	ptr = ass_itoa(ptr, bar->height - 1);
+
+	strcpy(ptr,
+		"r"      // DECSTBM
+		"\0338"  // DECRC: Restore the cursor
+		"\033[J" // ED: Erase display from cursor to end
+	);
+
+	return ass_puts(bar->fd, esc_seq);
+}
+
+#ifdef SIGWINCH
+/** SIGWINCH handler. */
+static void sighand_winch(int sig) {
+	int error = errno;
+
+	bfs_bar_getsize(&the_bar);
+	bfs_bar_resize(&the_bar);
+
+	errno = error;
+}
+#endif
+
+/** Reset the scrollable region and hide the bar. */
+static int bfs_bar_reset(struct bfs_bar *bar) {
+	return ass_puts(bar->fd,
+		"\0337"  // DECSC: Save cursor
+		"\033[r" // DECSTBM: Reset scrollable region
+		"\0338"  // DECRC: Restore cursor
+		"\033[J" // ED: Erase display from cursor to end
+	);
+}
+
+/** Signal handler for process-terminating signals. */
+static void sighand_reset(int sig) {
+	bfs_bar_reset(&the_bar);
+	raise(sig);
+}
+
+/** Register sighand_reset() for a signal. */
+static void reset_before_death_by(int sig) {
+	struct sigaction sa = {
+		.sa_handler = sighand_reset,
+		.sa_flags = SA_RESETHAND,
+	};
+	sigemptyset(&sa.sa_mask);
+	sigaction(sig, &sa, NULL);
+}
+
+/** printf() to the status bar with a single write(). */
+BFS_FORMATTER(2, 3)
+static int bfs_bar_printf(struct bfs_bar *bar, const char *format, ...) {
+	va_list args;
+	va_start(args, format);
+	char *str = dstrvprintf(format, args);
+	va_end(args);
+
+	if (!str) {
+		return -1;
+	}
+
+	int ret = ass_puts(bar->fd, str);
+	dstrfree(str);
+	return ret;
+}
+
+struct bfs_bar *bfs_bar_show(void) {
+	if (the_bar.fd >= 0) {
+		errno = EBUSY;
+		goto fail;
+	}
+
+	char term[L_ctermid];
+	ctermid(term);
+	if (strlen(term) == 0) {
+		errno = ENOTTY;
+		goto fail;
+	}
+
+	the_bar.fd = open(term, O_RDWR | O_CLOEXEC);
+	if (the_bar.fd < 0) {
+		goto fail;
+	}
+
+	if (bfs_bar_getsize(&the_bar) != 0) {
+		goto fail_close;
+	}
+
+	reset_before_death_by(SIGABRT);
+	reset_before_death_by(SIGINT);
+	reset_before_death_by(SIGPIPE);
+	reset_before_death_by(SIGQUIT);
+	reset_before_death_by(SIGTERM);
+
+#ifdef SIGWINCH
+	struct sigaction sa = {
+		.sa_handler = sighand_winch,
+		.sa_flags = SA_RESTART,
+	};
+	sigemptyset(&sa.sa_mask);
+	sigaction(SIGWINCH, &sa, NULL);
+#endif
+
+	bfs_bar_printf(&the_bar,
+		"\n"        // Make space for the bar
+		"\0337"     // DECSC: Save cursor
+		"\033[;%ur" // DECSTBM: Set scrollable region
+		"\0338"     // DECRC: Restore cursor
+		"\033[1A",  // CUU: Move cursor up 1 row
+		(unsigned int)(the_bar.height - 1)
+	);
+
+	return &the_bar;
+
+fail_close:
+	close_quietly(the_bar.fd);
+	the_bar.fd = -1;
+fail:
+	return NULL;
+}
+
+unsigned int bfs_bar_width(const struct bfs_bar *bar) {
+	return bar->width;
+}
+
+int bfs_bar_update(struct bfs_bar *bar, const char *str) {
+	return bfs_bar_printf(bar,
+		"\0337"      // DECSC: Save cursor
+		"\033[%u;0f" // HVP: Move cursor to row, column
+		"\033[K"     // EL: Erase line
+		"\033[7m"    // SGR reverse video
+		"%s"
+		"\033[27m"   // SGR reverse video off
+		"\0338",     // DECRC: Restore cursor
+		(unsigned int)bar->height,
+		str
+	);
+}
+
+void bfs_bar_hide(struct bfs_bar *bar) {
+	if (!bar) {
+		return;
+	}
+
+	signal(SIGABRT, SIG_DFL);
+	signal(SIGINT, SIG_DFL);
+	signal(SIGPIPE, SIG_DFL);
+	signal(SIGQUIT, SIG_DFL);
+	signal(SIGTERM, SIG_DFL);
+#ifdef SIGWINCH
+	signal(SIGWINCH, SIG_DFL);
+#endif
+
+	bfs_bar_reset(bar);
+
+	xclose(bar->fd);
+	bar->fd = -1;
+}
diff --git a/src/bar.h b/src/bar.h
new file mode 100644
index 0000000..3e509d6
--- /dev/null
+++ b/src/bar.h
@@ -0,0 +1,57 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2020 Tavian Barnes <tavianator@tavianator.com>             *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * A terminal status bar.
+ */
+
+#ifndef BFS_BAR_H
+#define BFS_BAR_H
+
+/** A terminal status bar. */
+struct bfs_bar;
+
+/**
+ * Create a terminal status bar.  Only one status bar is supported at a time.
+ *
+ * @return
+ *         A pointer to the new status bar, or NULL on failure.
+ */
+struct bfs_bar *bfs_bar_show(void);
+
+/**
+ * Get the width of the status bar.
+ */
+unsigned int bfs_bar_width(const struct bfs_bar *bar);
+
+/**
+ * Update the status bar message.
+ *
+ * @param bar
+ *         The status bar to update.
+ * @param str
+ *         The string to display.
+ * @return
+ *         0 on success, -1 on failure.
+ */
+int bfs_bar_update(struct bfs_bar *bar, const char *str);
+
+/**
+ * Hide the status bar.
+ */
+void bfs_bar_hide(struct bfs_bar *status);
+
+#endif // BFS_BAR_H
diff --git a/src/bfs.h b/src/bfs.h
new file mode 100644
index 0000000..93d8d79
--- /dev/null
+++ b/src/bfs.h
@@ -0,0 +1,32 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2015-2021 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * Constants about the bfs program itself.
+ */
+
+#ifndef BFS_H
+#define BFS_H
+
+#ifndef BFS_VERSION
+#	define BFS_VERSION "2.5"
+#endif
+
+#ifndef BFS_HOMEPAGE
+#	define BFS_HOMEPAGE "https://tavianator.com/projects/bfs.html"
+#endif
+
+#endif // BFS_H
diff --git a/src/bftw.c b/src/bftw.c
new file mode 100644
index 0000000..6f97bf6
--- /dev/null
+++ b/src/bftw.c
@@ -0,0 +1,1494 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2015-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * The bftw() implementation consists of the following components:
+ *
+ * - struct bftw_file: A file that has been encountered during the traversal.
+ *   They have reference-counted links to their parents in the directory tree.
+ *
+ * - struct bftw_cache: An LRU list of bftw_file's with open file descriptors,
+ *   used for openat() to minimize the amount of path re-traversals.
+ *
+ * - struct bftw_queue: The queue of bftw_file's left to explore.  Implemented
+ *   as a simple circular buffer.
+ *
+ * - struct bftw_state: Represents the current state of the traversal, allowing
+ *   various helper functions to take fewer parameters.
+ */
+
+#include "bftw.h"
+#include "dir.h"
+#include "darray.h"
+#include "dstring.h"
+#include "mtab.h"
+#include "stat.h"
+#include "trie.h"
+#include "util.h"
+#include <assert.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <stdbool.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+/**
+ * A file.
+ */
+struct bftw_file {
+	/** The parent directory, if any. */
+	struct bftw_file *parent;
+	/** The root under which this file was found. */
+	struct bftw_file *root;
+	/** The next file in the queue, if any. */
+	struct bftw_file *next;
+
+	/** The previous file in the LRU list. */
+	struct bftw_file *lru_prev;
+	/** The next file in the LRU list. */
+	struct bftw_file *lru_next;
+
+	/** This file's depth in the walk. */
+	size_t depth;
+	/** Reference count. */
+	size_t refcount;
+
+	/** An open descriptor to this file, or -1. */
+	int fd;
+
+	/** This file's type, if known. */
+	enum bfs_type type;
+	/** The device number, for cycle detection. */
+	dev_t dev;
+	/** The inode number, for cycle detection. */
+	ino_t ino;
+
+	/** The offset of this file in the full path. */
+	size_t nameoff;
+	/** The length of the file's name. */
+	size_t namelen;
+	/** The file's name. */
+	char name[];
+};
+
+/**
+ * A cache of open directories.
+ */
+struct bftw_cache {
+	/** The head of the LRU list. */
+	struct bftw_file *head;
+	/** The insertion target for the LRU list. */
+	struct bftw_file *target;
+	/** The tail of the LRU list. */
+	struct bftw_file *tail;
+	/** The remaining capacity of the LRU list. */
+	size_t capacity;
+};
+
+/** Initialize a cache. */
+static void bftw_cache_init(struct bftw_cache *cache, size_t capacity) {
+	cache->head = NULL;
+	cache->target = NULL;
+	cache->tail = NULL;
+	cache->capacity = capacity;
+}
+
+/** Destroy a cache. */
+static void bftw_cache_destroy(struct bftw_cache *cache) {
+	assert(!cache->tail);
+	assert(!cache->target);
+	assert(!cache->head);
+}
+
+/** Add a bftw_file to the cache. */
+static void bftw_cache_add(struct bftw_cache *cache, struct bftw_file *file) {
+	assert(cache->capacity > 0);
+	assert(file->fd >= 0);
+	assert(!file->lru_prev);
+	assert(!file->lru_next);
+
+	if (cache->target) {
+		file->lru_prev = cache->target;
+		file->lru_next = cache->target->lru_next;
+	} else {
+		file->lru_next = cache->head;
+	}
+
+	if (file->lru_prev) {
+		file->lru_prev->lru_next = file;
+	} else {
+		cache->head = file;
+	}
+
+	if (file->lru_next) {
+		file->lru_next->lru_prev = file;
+	} else {
+		cache->tail = file;
+	}
+
+	// Prefer to keep the root paths open by keeping them at the head of the list
+	if (file->depth == 0) {
+		cache->target = file;
+	}
+
+	--cache->capacity;
+}
+
+/** Remove a bftw_file from the cache. */
+static void bftw_cache_remove(struct bftw_cache *cache, struct bftw_file *file) {
+	if (cache->target == file) {
+		cache->target = file->lru_prev;
+	}
+
+	if (file->lru_prev) {
+		assert(cache->head != file);
+		file->lru_prev->lru_next = file->lru_next;
+	} else {
+		assert(cache->head == file);
+		cache->head = file->lru_next;
+	}
+
+	if (file->lru_next) {
+		assert(cache->tail != file);
+		file->lru_next->lru_prev = file->lru_prev;
+	} else {
+		assert(cache->tail == file);
+		cache->tail = file->lru_prev;
+	}
+
+	file->lru_prev = NULL;
+	file->lru_next = NULL;
+	++cache->capacity;
+}
+
+/** Mark a cache entry as recently used. */
+static void bftw_cache_use(struct bftw_cache *cache, struct bftw_file *file) {
+	bftw_cache_remove(cache, file);
+	bftw_cache_add(cache, file);
+}
+
+/** Close a bftw_file. */
+static void bftw_file_close(struct bftw_cache *cache, struct bftw_file *file) {
+	assert(file->fd >= 0);
+
+	bftw_cache_remove(cache, file);
+
+	xclose(file->fd);
+	file->fd = -1;
+}
+
+/** Pop a directory from the cache. */
+static void bftw_cache_pop(struct bftw_cache *cache) {
+	assert(cache->tail);
+	bftw_file_close(cache, cache->tail);
+}
+
+/**
+ * Shrink the cache, to recover from EMFILE.
+ *
+ * @param cache
+ *         The cache in question.
+ * @param saved
+ *         A bftw_file that must be preserved.
+ * @return
+ *         0 if successfully shrunk, otherwise -1.
+ */
+static int bftw_cache_shrink(struct bftw_cache *cache, const struct bftw_file *saved) {
+	struct bftw_file *file = cache->tail;
+	if (!file) {
+		return -1;
+	}
+
+	if (file == saved) {
+		file = file->lru_prev;
+		if (!file) {
+			return -1;
+		}
+	}
+
+	bftw_file_close(cache, file);
+	cache->capacity = 0;
+	return 0;
+}
+
+/** Compute the name offset of a child path. */
+static size_t bftw_child_nameoff(const struct bftw_file *parent) {
+	size_t ret = parent->nameoff + parent->namelen;
+	if (parent->name[parent->namelen - 1] != '/') {
+		++ret;
+	}
+	return ret;
+}
+
+/** Create a new bftw_file. */
+static struct bftw_file *bftw_file_new(struct bftw_file *parent, const char *name) {
+	size_t namelen = strlen(name);
+	size_t size = BFS_FLEX_SIZEOF(struct bftw_file, name, namelen + 1);
+
+	struct bftw_file *file = malloc(size);
+	if (!file) {
+		return NULL;
+	}
+
+	file->parent = parent;
+
+	if (parent) {
+		file->root = parent->root;
+		file->depth = parent->depth + 1;
+		file->nameoff = bftw_child_nameoff(parent);
+		++parent->refcount;
+	} else {
+		file->root = file;
+		file->depth = 0;
+		file->nameoff = 0;
+	}
+
+	file->next = NULL;
+
+	file->lru_prev = NULL;
+	file->lru_next = NULL;
+
+	file->refcount = 1;
+	file->fd = -1;
+
+	file->type = BFS_UNKNOWN;
+	file->dev = -1;
+	file->ino = -1;
+
+	file->namelen = namelen;
+	memcpy(file->name, name, namelen + 1);
+
+	return file;
+}
+
+/**
+ * Open a bftw_file relative to another one.
+ *
+ * @param cache
+ *         The cache to hold the file.
+ * @param file
+ *         The file to open.
+ * @param base
+ *         The base directory for the relative path (may be NULL).
+ * @param at_fd
+ *         The base file descriptor, AT_FDCWD if base == NULL.
+ * @param at_path
+ *         The relative path to the file.
+ * @return
+ *         The opened file descriptor, or negative on error.
+ */
+static int bftw_file_openat(struct bftw_cache *cache, struct bftw_file *file, struct bftw_file *base, const char *at_path) {
+	assert(file->fd < 0);
+
+	int at_fd = AT_FDCWD;
+	if (base) {
+		bftw_cache_use(cache, base);
+		at_fd = base->fd;
+	}
+
+	int flags = O_RDONLY | O_CLOEXEC | O_DIRECTORY;
+	int fd = openat(at_fd, at_path, flags);
+
+	if (fd < 0 && errno == EMFILE) {
+		if (bftw_cache_shrink(cache, base) == 0) {
+			fd = openat(at_fd, at_path, flags);
+		}
+	}
+
+	if (fd >= 0) {
+		if (cache->capacity == 0) {
+			bftw_cache_pop(cache);
+		}
+
+		file->fd = fd;
+		bftw_cache_add(cache, file);
+	}
+
+	return fd;
+}
+
+/**
+ * Open a bftw_file.
+ *
+ * @param cache
+ *         The cache to hold the file.
+ * @param file
+ *         The file to open.
+ * @param path
+ *         The full path to the file.
+ * @return
+ *         The opened file descriptor, or negative on error.
+ */
+static int bftw_file_open(struct bftw_cache *cache, struct bftw_file *file, const char *path) {
+	// Find the nearest open ancestor
+	struct bftw_file *base = file;
+	do {
+		base = base->parent;
+	} while (base && base->fd < 0);
+
+	const char *at_path = path;
+	if (base) {
+		at_path += bftw_child_nameoff(base);
+	}
+
+	int fd = bftw_file_openat(cache, file, base, at_path);
+	if (fd >= 0 || errno != ENAMETOOLONG) {
+		return fd;
+	}
+
+	// Handle ENAMETOOLONG by manually traversing the path component-by-component
+
+	// Use the ->next linked list to temporarily hold the reversed parent
+	// chain between base and file
+	struct bftw_file *cur;
+	for (cur = file; cur->parent != base; cur = cur->parent) {
+		cur->parent->next = cur;
+	}
+
+	// Open the files in the chain one by one
+	for (base = cur; base; base = base->next) {
+		fd = bftw_file_openat(cache, base, base->parent, base->name);
+		if (fd < 0 || base == file) {
+			break;
+		}
+	}
+
+	// Clear out the linked list
+	for (struct bftw_file *next = cur->next; cur != file; cur = next, next = next->next) {
+		cur->next = NULL;
+	}
+
+	return fd;
+}
+
+/**
+ * Open a bftw_file as a directory.
+ *
+ * @param cache
+ *         The cache to hold the file.
+ * @param file
+ *         The directory to open.
+ * @param path
+ *         The full path to the directory.
+ * @return
+ *         The opened directory, or NULL on error.
+ */
+static struct bfs_dir *bftw_file_opendir(struct bftw_cache *cache, struct bftw_file *file, const char *path) {
+	int fd = bftw_file_open(cache, file, path);
+	if (fd < 0) {
+		return NULL;
+	}
+
+	return bfs_opendir(fd, NULL);
+}
+
+/** Free a bftw_file. */
+static void bftw_file_free(struct bftw_cache *cache, struct bftw_file *file) {
+	assert(file->refcount == 0);
+
+	if (file->fd >= 0) {
+		bftw_file_close(cache, file);
+	}
+
+	free(file);
+}
+
+/**
+ * A queue of bftw_file's to examine.
+ */
+struct bftw_queue {
+	/** The head of the queue. */
+	struct bftw_file *head;
+	/** The insertion target. */
+	struct bftw_file **target;
+};
+
+/** Initialize a bftw_queue. */
+static void bftw_queue_init(struct bftw_queue *queue) {
+	queue->head = NULL;
+	queue->target = &queue->head;
+}
+
+/** Add a file to a bftw_queue. */
+static void bftw_queue_push(struct bftw_queue *queue, struct bftw_file *file) {
+	assert(file->next == NULL);
+
+	file->next = *queue->target;
+	*queue->target = file;
+	queue->target = &file->next;
+}
+
+/** Pop the next file from the head of the queue. */
+static struct bftw_file *bftw_queue_pop(struct bftw_queue *queue) {
+	struct bftw_file *file = queue->head;
+	queue->head = file->next;
+	file->next = NULL;
+	if (queue->target == &file->next) {
+		queue->target = &queue->head;
+	}
+	return file;
+}
+
+/** The split phase of mergesort. */
+static struct bftw_file **bftw_sort_split(struct bftw_file **head, struct bftw_file **tail) {
+	struct bftw_file **tortoise = head, **hare = head;
+
+	while (*hare != *tail) {
+		tortoise = &(*tortoise)->next;
+		hare = &(*hare)->next;
+		if (*hare != *tail) {
+			hare = &(*hare)->next;
+		}
+	}
+
+	return tortoise;
+}
+
+/** The merge phase of mergesort. */
+static struct bftw_file **bftw_sort_merge(struct bftw_file **head, struct bftw_file **mid, struct bftw_file **tail) {
+	struct bftw_file *left = *head, *right = *mid, *end = *tail;
+	*mid = NULL;
+	*tail = NULL;
+
+	while (left || right) {
+		struct bftw_file *next;
+		if (left && (!right || strcoll(left->name, right->name) <= 0)) {
+			next = left;
+			left = left->next;
+		} else {
+			next = right;
+			right = right->next;
+		}
+
+		*head = next;
+		head = &next->next;
+	}
+
+	*head = end;
+	return head;
+}
+
+/**
+ * Sort a (sub-)list of files.
+ *
+ * @param head
+ *         The head of the (sub-)list to sort.
+ * @param tail
+ *         The tail of the (sub-)list to sort.
+ * @return
+ *         The new tail of the (sub-)list.
+ */
+static struct bftw_file **bftw_sort_files(struct bftw_file **head, struct bftw_file **tail) {
+	struct bftw_file **mid = bftw_sort_split(head, tail);
+	if (*mid == *head || *mid == *tail) {
+		return tail;
+	}
+
+	mid = bftw_sort_files(head, mid);
+	tail = bftw_sort_files(mid, tail);
+
+	return bftw_sort_merge(head, mid, tail);
+}
+
+/**
+ * Holds the current state of the bftw() traversal.
+ */
+struct bftw_state {
+	/** bftw() callback. */
+	bftw_callback *callback;
+	/** bftw() callback data. */
+	void *ptr;
+	/** bftw() flags. */
+	enum bftw_flags flags;
+	/** Search strategy. */
+	enum bftw_strategy strategy;
+	/** The mount table. */
+	const struct bfs_mtab *mtab;
+
+	/** The appropriate errno value, if any. */
+	int error;
+
+	/** The cache of open directories. */
+	struct bftw_cache cache;
+	/** The queue of directories left to explore. */
+	struct bftw_queue queue;
+	/** The start of the current batch of files. */
+	struct bftw_file **batch;
+
+	/** The current path. */
+	char *path;
+	/** The current file. */
+	struct bftw_file *file;
+	/** The previous file. */
+	struct bftw_file *previous;
+
+	/** The currently open directory. */
+	struct bfs_dir *dir;
+	/** The current directory entry. */
+	struct bfs_dirent *de;
+	/** Storage for the directory entry. */
+	struct bfs_dirent de_storage;
+	/** Any error encountered while reading the directory. */
+	int direrror;
+
+	/** Extra data about the current file. */
+	struct BFTW ftwbuf;
+};
+
+/**
+ * Initialize the bftw() state.
+ */
+static int bftw_state_init(struct bftw_state *state, const struct bftw_args *args) {
+	state->callback = args->callback;
+	state->ptr = args->ptr;
+	state->flags = args->flags;
+	state->strategy = args->strategy;
+	state->mtab = args->mtab;
+
+	state->error = 0;
+
+	if (args->nopenfd < 1) {
+		errno = EMFILE;
+		return -1;
+	}
+
+	state->path = dstralloc(0);
+	if (!state->path) {
+		return -1;
+	}
+
+	bftw_cache_init(&state->cache, args->nopenfd);
+	bftw_queue_init(&state->queue);
+	state->batch = NULL;
+
+	state->file = NULL;
+	state->previous = NULL;
+
+	state->dir = NULL;
+	state->de = NULL;
+	state->direrror = 0;
+
+	return 0;
+}
+
+/** Cached bfs_stat(). */
+static const struct bfs_stat *bftw_stat_impl(struct BFTW *ftwbuf, struct bftw_stat *cache, enum bfs_stat_flags flags) {
+	if (!cache->buf) {
+		if (cache->error) {
+			errno = cache->error;
+		} else if (bfs_stat(ftwbuf->at_fd, ftwbuf->at_path, flags, &cache->storage) == 0) {
+			cache->buf = &cache->storage;
+		} else {
+			cache->error = errno;
+		}
+	}
+
+	return cache->buf;
+}
+
+const struct bfs_stat *bftw_stat(const struct BFTW *ftwbuf, enum bfs_stat_flags flags) {
+	struct BFTW *mutbuf = (struct BFTW *)ftwbuf;
+	const struct bfs_stat *ret;
+
+	if (flags & BFS_STAT_NOFOLLOW) {
+		ret = bftw_stat_impl(mutbuf, &mutbuf->lstat_cache, BFS_STAT_NOFOLLOW);
+		if (ret && !S_ISLNK(ret->mode) && !mutbuf->stat_cache.buf) {
+			// Non-link, so share stat info
+			mutbuf->stat_cache.buf = ret;
+		}
+	} else {
+		ret = bftw_stat_impl(mutbuf, &mutbuf->stat_cache, BFS_STAT_FOLLOW);
+		if (!ret && (flags & BFS_STAT_TRYFOLLOW) && is_nonexistence_error(errno)) {
+			ret = bftw_stat_impl(mutbuf, &mutbuf->lstat_cache, BFS_STAT_NOFOLLOW);
+		}
+	}
+
+	return ret;
+}
+
+const struct bfs_stat *bftw_cached_stat(const struct BFTW *ftwbuf, enum bfs_stat_flags flags) {
+	if (flags & BFS_STAT_NOFOLLOW) {
+		return ftwbuf->lstat_cache.buf;
+	} else if (ftwbuf->stat_cache.buf) {
+		return ftwbuf->stat_cache.buf;
+	} else if ((flags & BFS_STAT_TRYFOLLOW) && is_nonexistence_error(ftwbuf->stat_cache.error)) {
+		return ftwbuf->lstat_cache.buf;
+	} else {
+		return NULL;
+	}
+}
+
+enum bfs_type bftw_type(const struct BFTW *ftwbuf, enum bfs_stat_flags flags) {
+	if (flags & BFS_STAT_NOFOLLOW) {
+		if (ftwbuf->type == BFS_LNK || (ftwbuf->stat_flags & BFS_STAT_NOFOLLOW)) {
+			return ftwbuf->type;
+		}
+	} else if (flags & BFS_STAT_TRYFOLLOW) {
+		if (ftwbuf->type != BFS_LNK || (ftwbuf->stat_flags & BFS_STAT_TRYFOLLOW)) {
+			return ftwbuf->type;
+		}
+	} else {
+		if (ftwbuf->type != BFS_LNK) {
+			return ftwbuf->type;
+		} else if (ftwbuf->stat_flags & BFS_STAT_TRYFOLLOW) {
+			return BFS_ERROR;
+		}
+	}
+
+	const struct bfs_stat *statbuf = bftw_stat(ftwbuf, flags);
+	if (statbuf) {
+		return bfs_mode_to_type(statbuf->mode);
+	} else {
+		return BFS_ERROR;
+	}
+}
+
+/**
+ * Update the path for the current file.
+ */
+static int bftw_update_path(struct bftw_state *state, const char *name) {
+	const struct bftw_file *file = state->file;
+	size_t length = file ? file->nameoff + file->namelen : 0;
+
+	assert(dstrlen(state->path) >= length);
+	dstresize(&state->path, length);
+
+	if (name) {
+		if (length > 0 && state->path[length - 1] != '/') {
+			if (dstrapp(&state->path, '/') != 0) {
+				return -1;
+			}
+		}
+		if (dstrcat(&state->path, name) != 0) {
+			return -1;
+		}
+	}
+
+	return 0;
+}
+
+/** Check if a stat() call is needed for this visit. */
+static bool bftw_need_stat(const struct bftw_state *state) {
+	if (state->flags & BFTW_STAT) {
+		return true;
+	}
+
+	const struct BFTW *ftwbuf = &state->ftwbuf;
+	if (ftwbuf->type == BFS_UNKNOWN) {
+		return true;
+	}
+
+	if (ftwbuf->type == BFS_LNK && !(ftwbuf->stat_flags & BFS_STAT_NOFOLLOW)) {
+		return true;
+	}
+
+	if (ftwbuf->type == BFS_DIR) {
+		if (state->flags & (BFTW_DETECT_CYCLES | BFTW_SKIP_MOUNTS | BFTW_PRUNE_MOUNTS)) {
+			return true;
+		}
+#if __linux__
+	} else if (state->mtab) {
+		// Linux fills in d_type from the underlying inode, even when
+		// the directory entry is a bind mount point.  In that case, we
+		// need to stat() to get the correct type.  We don't need to
+		// check for directories because they can only be mounted over
+		// by other directories.
+		if (bfs_might_be_mount(state->mtab, ftwbuf->path)) {
+			return true;
+		}
+#endif
+	}
+
+	return false;
+}
+
+/** Initialize bftw_stat cache. */
+static void bftw_stat_init(struct bftw_stat *cache) {
+	cache->buf = NULL;
+	cache->error = 0;
+}
+
+/**
+ * Open a file if necessary.
+ *
+ * @param file
+ *         The file to open.
+ * @param path
+ *         The path to that file or one of its descendants.
+ * @return
+ *         The opened file descriptor, or -1 on error.
+ */
+static int bftw_ensure_open(struct bftw_cache *cache, struct bftw_file *file, const char *path) {
+	int ret = file->fd;
+
+	if (ret < 0) {
+		char *copy = strndup(path, file->nameoff + file->namelen);
+		if (!copy) {
+			return -1;
+		}
+
+		ret = bftw_file_open(cache, file, copy);
+		free(copy);
+	}
+
+	return ret;
+}
+
+/**
+ * Initialize the buffers with data about the current path.
+ */
+static void bftw_init_ftwbuf(struct bftw_state *state, enum bftw_visit visit) {
+	struct bftw_file *file = state->file;
+	const struct bfs_dirent *de = state->de;
+
+	struct BFTW *ftwbuf = &state->ftwbuf;
+	ftwbuf->path = state->path;
+	ftwbuf->root = file ? file->root->name : ftwbuf->path;
+	ftwbuf->depth = 0;
+	ftwbuf->visit = visit;
+	ftwbuf->type = BFS_UNKNOWN;
+	ftwbuf->error = state->direrror;
+	ftwbuf->at_fd = AT_FDCWD;
+	ftwbuf->at_path = ftwbuf->path;
+	ftwbuf->stat_flags = BFS_STAT_NOFOLLOW;
+	bftw_stat_init(&ftwbuf->lstat_cache);
+	bftw_stat_init(&ftwbuf->stat_cache);
+
+	struct bftw_file *parent = NULL;
+	if (de) {
+		parent = file;
+		ftwbuf->depth = file->depth + 1;
+		ftwbuf->type = de->type;
+		ftwbuf->nameoff = bftw_child_nameoff(file);
+	} else if (file) {
+		parent = file->parent;
+		ftwbuf->depth = file->depth;
+		ftwbuf->type = file->type;
+		ftwbuf->nameoff = file->nameoff;
+	}
+
+	if (parent) {
+		// Try to ensure the immediate parent is open, to avoid ENAMETOOLONG
+		if (bftw_ensure_open(&state->cache, parent, state->path) >= 0) {
+			ftwbuf->at_fd = parent->fd;
+			ftwbuf->at_path += ftwbuf->nameoff;
+		} else {
+			ftwbuf->error = errno;
+		}
+	}
+
+	if (ftwbuf->depth == 0) {
+		// Compute the name offset for root paths like "foo/bar"
+		ftwbuf->nameoff = xbasename(ftwbuf->path) - ftwbuf->path;
+	}
+
+	if (ftwbuf->error != 0) {
+		ftwbuf->type = BFS_ERROR;
+		return;
+	}
+
+	int follow_flags = BFTW_FOLLOW_ALL;
+	if (ftwbuf->depth == 0) {
+		follow_flags |= BFTW_FOLLOW_ROOTS;
+	}
+	bool follow = state->flags & follow_flags;
+	if (follow) {
+		ftwbuf->stat_flags = BFS_STAT_TRYFOLLOW;
+	}
+
+	const struct bfs_stat *statbuf = NULL;
+	if (bftw_need_stat(state)) {
+		statbuf = bftw_stat(ftwbuf, ftwbuf->stat_flags);
+		if (statbuf) {
+			ftwbuf->type = bfs_mode_to_type(statbuf->mode);
+		} else {
+			ftwbuf->type = BFS_ERROR;
+			ftwbuf->error = errno;
+			return;
+		}
+	}
+
+	if (ftwbuf->type == BFS_DIR && (state->flags & BFTW_DETECT_CYCLES)) {
+		for (const struct bftw_file *ancestor = parent; ancestor; ancestor = ancestor->parent) {
+			if (ancestor->dev == statbuf->dev && ancestor->ino == statbuf->ino) {
+				ftwbuf->type = BFS_ERROR;
+				ftwbuf->error = ELOOP;
+				return;
+			}
+		}
+	}
+}
+
+/** Check if the current file is a mount point. */
+static bool bftw_is_mount(struct bftw_state *state, const char *name) {
+	const struct bftw_file *file = state->file;
+	if (!file) {
+		return false;
+	}
+
+	const struct bftw_file *parent = name ? file : file->parent;
+	if (!parent) {
+		return false;
+	}
+
+	const struct BFTW *ftwbuf = &state->ftwbuf;
+	const struct bfs_stat *statbuf = bftw_stat(ftwbuf, ftwbuf->stat_flags);
+	return statbuf && statbuf->dev != parent->dev;
+}
+
+/** Fill file identity information from an ftwbuf. */
+static void bftw_fill_id(struct bftw_file *file, const struct BFTW *ftwbuf) {
+	const struct bfs_stat *statbuf = ftwbuf->stat_cache.buf;
+	if (!statbuf || (ftwbuf->stat_flags & BFS_STAT_NOFOLLOW)) {
+		statbuf = ftwbuf->lstat_cache.buf;
+	}
+	if (statbuf) {
+		file->dev = statbuf->dev;
+		file->ino = statbuf->ino;
+	}
+}
+
+/**
+ * Visit a path, invoking the callback.
+ */
+static enum bftw_action bftw_visit(struct bftw_state *state, const char *name, enum bftw_visit visit) {
+	if (bftw_update_path(state, name) != 0) {
+		state->error = errno;
+		return BFTW_STOP;
+	}
+
+	const struct BFTW *ftwbuf = &state->ftwbuf;
+	bftw_init_ftwbuf(state, visit);
+
+	// Never give the callback BFS_ERROR unless BFTW_RECOVER is specified
+	if (ftwbuf->type == BFS_ERROR && !(state->flags & BFTW_RECOVER)) {
+		state->error = ftwbuf->error;
+		return BFTW_STOP;
+	}
+
+	if ((state->flags & BFTW_SKIP_MOUNTS) && bftw_is_mount(state, name)) {
+		return BFTW_PRUNE;
+	}
+
+	enum bftw_action ret = state->callback(ftwbuf, state->ptr);
+	switch (ret) {
+	case BFTW_CONTINUE:
+		break;
+	case BFTW_PRUNE:
+	case BFTW_STOP:
+		goto done;
+	default:
+		state->error = EINVAL;
+		return BFTW_STOP;
+	}
+
+	if (visit != BFTW_PRE || ftwbuf->type != BFS_DIR) {
+		ret = BFTW_PRUNE;
+		goto done;
+	}
+
+	if ((state->flags & BFTW_PRUNE_MOUNTS) && bftw_is_mount(state, name)) {
+		ret = BFTW_PRUNE;
+		goto done;
+	}
+
+done:
+	if (state->file && !name) {
+		bftw_fill_id(state->file, ftwbuf);
+	}
+
+	return ret;
+}
+
+/**
+ * Push a new file onto the queue.
+ */
+static int bftw_push(struct bftw_state *state, const char *name, bool fill_id) {
+	struct bftw_file *parent = state->file;
+	struct bftw_file *file = bftw_file_new(parent, name);
+	if (!file) {
+		state->error = errno;
+		return -1;
+	}
+
+	if (state->de) {
+		file->type = state->de->type;
+	}
+
+	if (fill_id) {
+		bftw_fill_id(file, &state->ftwbuf);
+	}
+
+	bftw_queue_push(&state->queue, file);
+
+	return 0;
+}
+
+/**
+ * Build the path to the current file.
+ */
+static int bftw_build_path(struct bftw_state *state) {
+	const struct bftw_file *file = state->file;
+
+	size_t pathlen = file->nameoff + file->namelen;
+	if (dstresize(&state->path, pathlen) != 0) {
+		state->error = errno;
+		return -1;
+	}
+
+	// Try to find a common ancestor with the existing path
+	const struct bftw_file *ancestor = state->previous;
+	while (ancestor && ancestor->depth > file->depth) {
+		ancestor = ancestor->parent;
+	}
+
+	// Build the path backwards
+	while (file && file != ancestor) {
+		if (file->nameoff > 0) {
+			state->path[file->nameoff - 1] = '/';
+		}
+		memcpy(state->path + file->nameoff, file->name, file->namelen);
+
+		if (ancestor && ancestor->depth == file->depth) {
+			ancestor = ancestor->parent;
+		}
+		file = file->parent;
+	}
+
+	state->previous = state->file;
+	return 0;
+}
+
+/**
+ * Pop the next file from the queue.
+ */
+static int bftw_pop(struct bftw_state *state) {
+	if (!state->queue.head) {
+		return 0;
+	}
+
+	state->file = bftw_queue_pop(&state->queue);
+
+	if (bftw_build_path(state) != 0) {
+		return -1;
+	}
+
+	return 1;
+}
+
+/**
+ * Open the current directory.
+ */
+static void bftw_opendir(struct bftw_state *state) {
+	assert(!state->dir);
+	assert(!state->de);
+
+	state->direrror = 0;
+
+	state->dir = bftw_file_opendir(&state->cache, state->file, state->path);
+	if (!state->dir) {
+		state->direrror = errno;
+	}
+}
+
+/**
+ * Read an entry from the current directory.
+ */
+static int bftw_readdir(struct bftw_state *state) {
+	if (!state->dir) {
+		return -1;
+	}
+
+	int ret = bfs_readdir(state->dir, &state->de_storage);
+	if (ret > 0) {
+		state->de = &state->de_storage;
+	} else if (ret == 0) {
+		state->de = NULL;
+	} else {
+		state->de = NULL;
+		state->direrror = errno;
+	}
+
+	return ret;
+}
+
+/**
+ * Flags controlling which files get visited when done with a directory.
+ */
+enum bftw_gc_flags {
+	/** Don't visit anything. */
+	BFTW_VISIT_NONE = 0,
+	/** Visit the file itself. */
+	BFTW_VISIT_FILE = 1 << 0,
+	/** Visit the file's ancestors. */
+	BFTW_VISIT_PARENTS = 1 << 1,
+	/** Visit both the file and its ancestors. */
+	BFTW_VISIT_ALL = BFTW_VISIT_FILE | BFTW_VISIT_PARENTS,
+};
+
+/**
+ * Close the current directory.
+ */
+static enum bftw_action bftw_closedir(struct bftw_state *state, enum bftw_gc_flags flags) {
+	struct bftw_file *file = state->file;
+	enum bftw_action ret = BFTW_CONTINUE;
+
+	if (state->dir) {
+		assert(file->fd >= 0);
+
+		if (file->refcount > 1) {
+			// Keep the fd around if any subdirectories exist
+			file->fd = bfs_freedir(state->dir);
+		} else {
+			bfs_closedir(state->dir);
+			file->fd = -1;
+		}
+
+		if (file->fd < 0) {
+			bftw_cache_remove(&state->cache, file);
+		}
+	}
+
+	state->de = NULL;
+	state->dir = NULL;
+
+	if (state->direrror != 0) {
+		if (flags & BFTW_VISIT_FILE) {
+			ret = bftw_visit(state, NULL, BFTW_PRE);
+		} else {
+			state->error = state->direrror;
+		}
+		state->direrror = 0;
+	}
+
+	return ret;
+}
+
+/**
+ * Finalize and free a file we're done with.
+ */
+static enum bftw_action bftw_gc_file(struct bftw_state *state, enum bftw_gc_flags flags) {
+	enum bftw_action ret = BFTW_CONTINUE;
+
+	if (!(state->flags & BFTW_POST_ORDER)) {
+		flags = 0;
+	}
+	bool visit = flags & BFTW_VISIT_FILE;
+
+	while (state->file) {
+		struct bftw_file *file = state->file;
+		if (--file->refcount > 0) {
+			state->file = NULL;
+			break;
+		}
+
+		if (visit && bftw_visit(state, NULL, BFTW_POST) == BFTW_STOP) {
+			ret = BFTW_STOP;
+			flags &= ~BFTW_VISIT_PARENTS;
+		}
+		visit = flags & BFTW_VISIT_PARENTS;
+
+		struct bftw_file *parent = file->parent;
+		if (state->previous == file) {
+			state->previous = parent;
+		}
+		bftw_file_free(&state->cache, file);
+		state->file = parent;
+	}
+
+	return ret;
+}
+
+/**
+ * Drain all the entries from a bftw_queue.
+ */
+static void bftw_drain_queue(struct bftw_state *state, struct bftw_queue *queue) {
+	while (queue->head) {
+		state->file = bftw_queue_pop(queue);
+		bftw_gc_file(state, BFTW_VISIT_NONE);
+	}
+}
+
+/**
+ * Dispose of the bftw() state.
+ *
+ * @return
+ *         The bftw() return value.
+ */
+static int bftw_state_destroy(struct bftw_state *state) {
+	dstrfree(state->path);
+
+	bftw_closedir(state, BFTW_VISIT_NONE);
+
+	bftw_gc_file(state, BFTW_VISIT_NONE);
+	bftw_drain_queue(state, &state->queue);
+
+	bftw_cache_destroy(&state->cache);
+
+	errno = state->error;
+	return state->error ? -1 : 0;
+}
+
+/** Start a batch of files. */
+static void bftw_batch_start(struct bftw_state *state) {
+	if (state->strategy == BFTW_DFS) {
+		state->queue.target = &state->queue.head;
+	}
+	state->batch = state->queue.target;
+}
+
+/** Finish adding a batch of files. */
+static void bftw_batch_finish(struct bftw_state *state) {
+	if (state->flags & BFTW_SORT) {
+		state->queue.target = bftw_sort_files(state->batch, state->queue.target);
+	}
+}
+
+/**
+ * Streaming mode: visit files as they are encountered.
+ */
+static int bftw_stream(const struct bftw_args *args) {
+	struct bftw_state state;
+	if (bftw_state_init(&state, args) != 0) {
+		return -1;
+	}
+
+	assert(!(state.flags & (BFTW_SORT | BFTW_BUFFER)));
+
+	bftw_batch_start(&state);
+	for (size_t i = 0; i < args->npaths; ++i) {
+		const char *path = args->paths[i];
+
+		switch (bftw_visit(&state, path, BFTW_PRE)) {
+		case BFTW_CONTINUE:
+			break;
+		case BFTW_PRUNE:
+			continue;
+		case BFTW_STOP:
+			goto done;
+		}
+
+		if (bftw_push(&state, path, true) != 0) {
+			goto done;
+		}
+	}
+	bftw_batch_finish(&state);
+
+	while (bftw_pop(&state) > 0) {
+		bftw_opendir(&state);
+
+		bftw_batch_start(&state);
+		while (bftw_readdir(&state) > 0) {
+			const char *name = state.de->name;
+
+			switch (bftw_visit(&state, name, BFTW_PRE)) {
+			case BFTW_CONTINUE:
+				break;
+			case BFTW_PRUNE:
+				continue;
+			case BFTW_STOP:
+				goto done;
+			}
+
+			if (bftw_push(&state, name, true) != 0) {
+				goto done;
+			}
+		}
+		bftw_batch_finish(&state);
+
+		if (bftw_closedir(&state, BFTW_VISIT_ALL) == BFTW_STOP) {
+			goto done;
+		}
+		if (bftw_gc_file(&state, BFTW_VISIT_ALL) == BFTW_STOP) {
+			goto done;
+		}
+	}
+
+done:
+	return bftw_state_destroy(&state);
+}
+
+/**
+ * Batching mode: queue up all children before visiting them.
+ */
+static int bftw_batch(const struct bftw_args *args) {
+	struct bftw_state state;
+	if (bftw_state_init(&state, args) != 0) {
+		return -1;
+	}
+
+	bftw_batch_start(&state);
+	for (size_t i = 0; i < args->npaths; ++i) {
+		if (bftw_push(&state, args->paths[i], false) != 0) {
+			goto done;
+		}
+	}
+	bftw_batch_finish(&state);
+
+	while (bftw_pop(&state) > 0) {
+		enum bftw_gc_flags gcflags = BFTW_VISIT_ALL;
+
+		switch (bftw_visit(&state, NULL, BFTW_PRE)) {
+		case BFTW_CONTINUE:
+			break;
+		case BFTW_PRUNE:
+			gcflags &= ~BFTW_VISIT_FILE;
+			goto next;
+		case BFTW_STOP:
+			goto done;
+		}
+
+		bftw_opendir(&state);
+
+		bftw_batch_start(&state);
+		while (bftw_readdir(&state) > 0) {
+			if (bftw_push(&state, state.de->name, false) != 0) {
+				goto done;
+			}
+		}
+		bftw_batch_finish(&state);
+
+		if (bftw_closedir(&state, gcflags) == BFTW_STOP) {
+			goto done;
+		}
+
+	next:
+		if (bftw_gc_file(&state, gcflags) == BFTW_STOP) {
+			goto done;
+		}
+	}
+
+done:
+	return bftw_state_destroy(&state);
+}
+
+/** Select bftw_stream() or bftw_batch() appropriately. */
+static int bftw_auto(const struct bftw_args *args) {
+	if (args->flags & (BFTW_SORT | BFTW_BUFFER)) {
+		return bftw_batch(args);
+	} else {
+		return bftw_stream(args);
+	}
+}
+
+/**
+ * Iterative deepening search state.
+ */
+struct bftw_ids_state {
+	/** The wrapped callback. */
+	bftw_callback *delegate;
+	/** The wrapped callback arguments. */
+	void *ptr;
+	/** Which visit this search corresponds to. */
+	enum bftw_visit visit;
+	/** Whether to override the bftw_visit. */
+	bool force_visit;
+	/** The current minimum depth (inclusive). */
+	size_t min_depth;
+	/** The current maximum depth (exclusive). */
+	size_t max_depth;
+	/** The set of pruned paths. */
+	struct trie pruned;
+	/** An error code to report. */
+	int error;
+	/** Whether the bottom has been found. */
+	bool bottom;
+	/** Whether to quit the search. */
+	bool quit;
+};
+
+/** Iterative deepening callback function. */
+static enum bftw_action bftw_ids_callback(const struct BFTW *ftwbuf, void *ptr) {
+	struct bftw_ids_state *state = ptr;
+
+	if (state->force_visit) {
+		struct BFTW *mutbuf = (struct BFTW *)ftwbuf;
+		mutbuf->visit = state->visit;
+	}
+
+	if (ftwbuf->type == BFS_ERROR) {
+		if (ftwbuf->depth + 1 >= state->min_depth) {
+			return state->delegate(ftwbuf, state->ptr);
+		} else {
+			return BFTW_PRUNE;
+		}
+	}
+
+	if (ftwbuf->depth < state->min_depth) {
+		if (trie_find_str(&state->pruned, ftwbuf->path)) {
+			return BFTW_PRUNE;
+		} else {
+			return BFTW_CONTINUE;
+		}
+	} else if (state->visit == BFTW_POST) {
+		if (trie_find_str(&state->pruned, ftwbuf->path)) {
+			return BFTW_PRUNE;
+		}
+	}
+
+	enum bftw_action ret = BFTW_CONTINUE;
+	if (ftwbuf->visit == state->visit) {
+		ret = state->delegate(ftwbuf, state->ptr);
+	}
+
+	switch (ret) {
+	case BFTW_CONTINUE:
+		if (ftwbuf->type == BFS_DIR && ftwbuf->depth + 1 >= state->max_depth) {
+			state->bottom = false;
+			ret = BFTW_PRUNE;
+		}
+		break;
+	case BFTW_PRUNE:
+		if (ftwbuf->type == BFS_DIR) {
+			if (!trie_insert_str(&state->pruned, ftwbuf->path)) {
+				state->error = errno;
+				state->quit = true;
+				ret = BFTW_STOP;
+			}
+		}
+		break;
+	case BFTW_STOP:
+		state->quit = true;
+		break;
+	}
+
+	return ret;
+}
+
+/** Initialize iterative deepening state. */
+static void bftw_ids_init(const struct bftw_args *args, struct bftw_ids_state *state, struct bftw_args *ids_args) {
+	state->delegate = args->callback;
+	state->ptr = args->ptr;
+	state->visit = BFTW_PRE;
+	state->force_visit = false;
+	state->min_depth = 0;
+	state->max_depth = 1;
+	trie_init(&state->pruned);
+	state->error = 0;
+	state->bottom = false;
+	state->quit = false;
+
+	*ids_args = *args;
+	ids_args->callback = bftw_ids_callback;
+	ids_args->ptr = state;
+	ids_args->flags &= ~BFTW_POST_ORDER;
+	ids_args->strategy = BFTW_DFS;
+}
+
+/** Finish an iterative deepening search. */
+static int bftw_ids_finish(struct bftw_ids_state *state) {
+	int ret = 0;
+
+	if (state->error) {
+		ret = -1;
+	} else {
+		state->error = errno;
+	}
+
+	trie_destroy(&state->pruned);
+
+	errno = state->error;
+	return ret;
+}
+
+/**
+ * Iterative deepening bftw() wrapper.
+ */
+static int bftw_ids(const struct bftw_args *args) {
+	struct bftw_ids_state state;
+	struct bftw_args ids_args;
+	bftw_ids_init(args, &state, &ids_args);
+
+	while (!state.quit && !state.bottom) {
+		state.bottom = true;
+
+		if (bftw_auto(&ids_args) != 0) {
+			state.error = errno;
+			state.quit = true;
+		}
+
+		++state.min_depth;
+		++state.max_depth;
+	}
+
+	if (args->flags & BFTW_POST_ORDER) {
+		state.visit = BFTW_POST;
+		state.force_visit = true;
+
+		while (!state.quit && state.min_depth > 0) {
+			--state.max_depth;
+			--state.min_depth;
+
+			if (bftw_auto(&ids_args) != 0) {
+				state.error = errno;
+				state.quit = true;
+			}
+		}
+	}
+
+	return bftw_ids_finish(&state);
+}
+
+/**
+ * Exponential deepening bftw() wrapper.
+ */
+static int bftw_eds(const struct bftw_args *args) {
+	struct bftw_ids_state state;
+	struct bftw_args ids_args;
+	bftw_ids_init(args, &state, &ids_args);
+
+	while (!state.quit && !state.bottom) {
+		state.bottom = true;
+
+		if (bftw_auto(&ids_args) != 0) {
+			state.error = errno;
+			state.quit = true;
+		}
+
+		state.min_depth = state.max_depth;
+		state.max_depth *= 2;
+	}
+
+	if (!state.quit && (args->flags & BFTW_POST_ORDER)) {
+		state.visit = BFTW_POST;
+		state.min_depth = 0;
+		ids_args.flags |= BFTW_POST_ORDER;
+
+		if (bftw_auto(&ids_args) != 0) {
+			state.error = errno;
+		}
+	}
+
+	return bftw_ids_finish(&state);
+}
+
+int bftw(const struct bftw_args *args) {
+	switch (args->strategy) {
+	case BFTW_BFS:
+		return bftw_auto(args);
+	case BFTW_DFS:
+		return bftw_batch(args);
+	case BFTW_IDS:
+		return bftw_ids(args);
+	case BFTW_EDS:
+		return bftw_eds(args);
+	}
+
+	errno = EINVAL;
+	return -1;
+}
diff --git a/src/bftw.h b/src/bftw.h
new file mode 100644
index 0000000..c458e1b
--- /dev/null
+++ b/src/bftw.h
@@ -0,0 +1,223 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2015-2021 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * A file-walking API based on nftw().
+ */
+
+#ifndef BFS_BFTW_H
+#define BFS_BFTW_H
+
+#include "dir.h"
+#include "stat.h"
+#include <stddef.h>
+
+/**
+ * Possible visit occurrences.
+ */
+enum bftw_visit {
+	/** Pre-order visit. */
+	BFTW_PRE,
+	/** Post-order visit. */
+	BFTW_POST,
+};
+
+/**
+ * Cached bfs_stat() info for a file.
+ */
+struct bftw_stat {
+	/** A pointer to the bfs_stat() buffer, if available. */
+	const struct bfs_stat *buf;
+	/** Storage for the bfs_stat() buffer, if needed. */
+	struct bfs_stat storage;
+	/** The cached error code, if any. */
+	int error;
+};
+
+/**
+ * Data about the current file for the bftw() callback.
+ */
+struct BFTW {
+	/** The path to the file. */
+	const char *path;
+	/** The string offset of the filename. */
+	size_t nameoff;
+
+	/** The root path passed to bftw(). */
+	const char *root;
+	/** The depth of this file in the traversal. */
+	size_t depth;
+	/** Which visit this is. */
+	enum bftw_visit visit;
+
+	/** The file type. */
+	enum bfs_type type;
+	/** The errno that occurred, if type == BFTW_ERROR. */
+	int error;
+
+	/** A parent file descriptor for the *at() family of calls. */
+	int at_fd;
+	/** The path relative to at_fd for the *at() family of calls. */
+	const char *at_path;
+
+	/** Flags for bfs_stat(). */
+	enum bfs_stat_flags stat_flags;
+	/** Cached bfs_stat() info for BFS_STAT_NOFOLLOW. */
+	struct bftw_stat lstat_cache;
+	/** Cached bfs_stat() info for BFS_STAT_FOLLOW. */
+	struct bftw_stat stat_cache;
+};
+
+/**
+ * Get bfs_stat() info for a file encountered during bftw(), caching the result
+ * whenever possible.
+ *
+ * @param ftwbuf
+ *         bftw() data for the file to stat.
+ * @param flags
+ *         flags for bfs_stat().  Pass ftwbuf->stat_flags for the default flags.
+ * @return
+ *         A pointer to a bfs_stat() buffer, or NULL if the call failed.
+ */
+const struct bfs_stat *bftw_stat(const struct BFTW *ftwbuf, enum bfs_stat_flags flags);
+
+/**
+ * Get bfs_stat() info for a file encountered during bftw(), if it has already
+ * been cached.
+ *
+ * @param ftwbuf
+ *         bftw() data for the file to stat.
+ * @param flags
+ *         flags for bfs_stat().  Pass ftwbuf->stat_flags for the default flags.
+ * @return
+ *         A pointer to a bfs_stat() buffer, or NULL if no stat info is cached.
+ */
+const struct bfs_stat *bftw_cached_stat(const struct BFTW *ftwbuf, enum bfs_stat_flags flags);
+
+/**
+ * Get the type of a file encountered during bftw(), with flags controlling
+ * whether to follow links.  This function will avoid calling bfs_stat() if
+ * possible.
+ *
+ * @param ftwbuf
+ *         bftw() data for the file to check.
+ * @param flags
+ *         flags for bfs_stat().  Pass ftwbuf->stat_flags for the default flags.
+ * @return
+ *         The type of the file, or BFTW_ERROR if an error occurred.
+ */
+enum bfs_type bftw_type(const struct BFTW *ftwbuf, enum bfs_stat_flags flags);
+
+/**
+ * Walk actions returned by the bftw() callback.
+ */
+enum bftw_action {
+	/** Keep walking. */
+	BFTW_CONTINUE,
+	/** Skip this path's children. */
+	BFTW_PRUNE,
+	/** Stop walking. */
+	BFTW_STOP,
+};
+
+/**
+ * Callback function type for bftw().
+ *
+ * @param ftwbuf
+ *         Data about the current file.
+ * @param ptr
+ *         The pointer passed to bftw().
+ * @return
+ *         An action value.
+ */
+typedef enum bftw_action bftw_callback(const struct BFTW *ftwbuf, void *ptr);
+
+/**
+ * Flags that control bftw() behavior.
+ */
+enum bftw_flags {
+	/** stat() each encountered file. */
+	BFTW_STAT          = 1 << 0,
+	/** Attempt to recover from encountered errors. */
+	BFTW_RECOVER       = 1 << 1,
+	/** Visit directories in post-order as well as pre-order. */
+	BFTW_POST_ORDER    = 1 << 2,
+	/** If the initial path is a symbolic link, follow it. */
+	BFTW_FOLLOW_ROOTS  = 1 << 3,
+	/** Follow all symbolic links. */
+	BFTW_FOLLOW_ALL    = 1 << 4,
+	/** Detect directory cycles. */
+	BFTW_DETECT_CYCLES = 1 << 5,
+	/** Skip mount points and their descendents. */
+	BFTW_SKIP_MOUNTS   = 1 << 6,
+	/** Skip the descendents of mount points. */
+	BFTW_PRUNE_MOUNTS  = 1 << 7,
+	/** Sort directory entries before processing them. */
+	BFTW_SORT          = 1 << 8,
+	/** Read each directory into memory before processing its children. */
+	BFTW_BUFFER        = 1 << 9,
+};
+
+/**
+ * Tree search strategies for bftw().
+ */
+enum bftw_strategy {
+	/** Breadth-first search. */
+	BFTW_BFS,
+	/** Depth-first search. */
+	BFTW_DFS,
+	/** Iterative deepening search. */
+	BFTW_IDS,
+	/** Exponential deepening search. */
+	BFTW_EDS,
+};
+
+/**
+ * Structure for holding the arguments passed to bftw().
+ */
+struct bftw_args {
+	/** The path(s) to start from. */
+	const char **paths;
+	/** The number of starting paths. */
+	size_t npaths;
+	/** The callback to invoke. */
+	bftw_callback *callback;
+	/** A pointer which is passed to the callback. */
+	void *ptr;
+	/** The maximum number of file descriptors to keep open. */
+	int nopenfd;
+	/** Flags that control bftw() behaviour. */
+	enum bftw_flags flags;
+	/** The search strategy to use. */
+	enum bftw_strategy strategy;
+	/** The parsed mount table, if available. */
+	const struct bfs_mtab *mtab;
+};
+
+/**
+ * Breadth First Tree Walk (or Better File Tree Walk).
+ *
+ * Like ftw(3) and nftw(3), this function walks a directory tree recursively,
+ * and invokes a callback for each path it encounters.
+ *
+ * @param args
+ *         The arguments that control the walk.
+ * @return
+ *         0 on success, or -1 on failure.
+ */
+int bftw(const struct bftw_args *args);
+
+#endif // BFS_BFTW_H
diff --git a/src/color.c b/src/color.c
new file mode 100644
index 0000000..9e267da
--- /dev/null
+++ b/src/color.c
@@ -0,0 +1,1125 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2015-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+#include "color.h"
+#include "bftw.h"
+#include "dir.h"
+#include "dstring.h"
+#include "expr.h"
+#include "fsade.h"
+#include "stat.h"
+#include "trie.h"
+#include "util.h"
+#include <assert.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+struct colors {
+	char *reset;
+	char *leftcode;
+	char *rightcode;
+	char *endcode;
+	char *clear_to_eol;
+
+	char *bold;
+	char *gray;
+	char *red;
+	char *green;
+	char *yellow;
+	char *blue;
+	char *magenta;
+	char *cyan;
+	char *white;
+
+	char *warning;
+	char *error;
+
+	char *normal;
+
+	char *file;
+	char *multi_hard;
+	char *executable;
+	char *capable;
+	char *setgid;
+	char *setuid;
+
+	char *directory;
+	char *sticky;
+	char *other_writable;
+	char *sticky_other_writable;
+
+	char *link;
+	char *orphan;
+	char *missing;
+	bool link_as_target;
+
+	char *blockdev;
+	char *chardev;
+	char *door;
+	char *pipe;
+	char *socket;
+
+	/** A mapping from color names (fi, di, ln, etc.) to struct fields. */
+	struct trie names;
+
+	/** A mapping from file extensions to colors. */
+	struct trie ext_colors;
+};
+
+/** Initialize a color in the table. */
+static int init_color(struct colors *colors, const char *name, const char *value, char **field) {
+	if (value) {
+		*field = dstrdup(value);
+		if (!*field) {
+			return -1;
+		}
+	} else {
+		*field = NULL;
+	}
+
+	struct trie_leaf *leaf = trie_insert_str(&colors->names, name);
+	if (leaf) {
+		leaf->value = field;
+		return 0;
+	} else {
+		return -1;
+	}
+}
+
+/** Get a color from the table. */
+static char **get_color(const struct colors *colors, const char *name) {
+	const struct trie_leaf *leaf = trie_find_str(&colors->names, name);
+	if (leaf) {
+		return (char **)leaf->value;
+	} else {
+		return NULL;
+	}
+}
+
+/** Set the value of a color. */
+static int set_color(struct colors *colors, const char *name, char *value) {
+	char **color = get_color(colors, name);
+	if (color) {
+		dstrfree(*color);
+		*color = value;
+		return 0;
+	} else {
+		return -1;
+	}
+}
+
+/**
+ * Transform a file extension for fast lookups, by reversing and lowercasing it.
+ */
+static void extxfrm(char *ext) {
+	size_t len = strlen(ext);
+	for (size_t i = 0; i < len - i; ++i) {
+		char a = ext[i];
+		char b = ext[len - i - 1];
+
+		// What's internationalization?  Doesn't matter, this is what
+		// GNU ls does.  Luckily, since there's no standard C way to
+		// casefold.  Not using tolower() here since it respects the
+		// current locale, which GNU ls doesn't do.
+		if (a >= 'A' && a <= 'Z') {
+			a += 'a' - 'A';
+		}
+		if (b >= 'A' && b <= 'Z') {
+			b += 'a' - 'A';
+		}
+
+		ext[i] = b;
+		ext[len - i - 1] = a;
+	}
+}
+
+/**
+ * Set the color for an extension.
+ */
+static int set_ext_color(struct colors *colors, char *key, const char *value) {
+	extxfrm(key);
+
+	// A later *.x should override any earlier *.x, *.y.x, etc.
+	struct trie_leaf *match;
+	while ((match = trie_find_postfix(&colors->ext_colors, key))) {
+		dstrfree(match->value);
+		trie_remove(&colors->ext_colors, match);
+	}
+
+	struct trie_leaf *leaf = trie_insert_str(&colors->ext_colors, key);
+	if (leaf) {
+		leaf->value = (char *)value;
+		return 0;
+	} else {
+		return -1;
+	}
+}
+
+/**
+ * Find a color by an extension.
+ */
+static const char *get_ext_color(const struct colors *colors, const char *filename) {
+	char *xfrm = strdup(filename);
+	if (!xfrm) {
+		return NULL;
+	}
+	extxfrm(xfrm);
+
+	const struct trie_leaf *leaf = trie_find_prefix(&colors->ext_colors, xfrm);
+	free(xfrm);
+	if (leaf) {
+		return leaf->value;
+	} else {
+		return NULL;
+	}
+}
+
+/**
+ * Parse a chunk of $LS_COLORS that may have escape sequences.  The supported
+ * escapes are:
+ *
+ * \a, \b, \f, \n, \r, \t, \v:
+ *     As in C
+ * \e:
+ *     ESC (\033)
+ * \?:
+ *     DEL (\177)
+ * \_:
+ *     ' ' (space)
+ * \NNN:
+ *     Octal
+ * \xNN:
+ *     Hex
+ * ^C:
+ *     Control character.
+ *
+ * See man dir_colors.
+ *
+ * @param value
+ *         The value to parse.
+ * @param end
+ *         The character that marks the end of the chunk.
+ * @param[out] next
+ *         Will be set to the next chunk.
+ * @return
+ *         The parsed chunk as a dstring.
+ */
+static char *unescape(const char *value, char end, const char **next) {
+	if (!value) {
+		goto fail;
+	}
+
+	char *str = dstralloc(0);
+	if (!str) {
+		goto fail_str;
+	}
+
+	const char *i;
+	for (i = value; *i && *i != end; ++i) {
+		unsigned char c = 0;
+
+		switch (*i) {
+		case '\\':
+			switch (*++i) {
+			case 'a':
+				c = '\a';
+				break;
+			case 'b':
+				c = '\b';
+				break;
+			case 'e':
+				c = '\033';
+				break;
+			case 'f':
+				c = '\f';
+				break;
+			case 'n':
+				c = '\n';
+				break;
+			case 'r':
+				c = '\r';
+				break;
+			case 't':
+				c = '\t';
+				break;
+			case 'v':
+				c = '\v';
+				break;
+			case '?':
+				c = '\177';
+				break;
+			case '_':
+				c = ' ';
+				break;
+
+			case '0':
+			case '1':
+			case '2':
+			case '3':
+			case '4':
+			case '5':
+			case '6':
+			case '7':
+				while (i[1] >= '0' && i[1] <= '7') {
+					c <<= 3;
+					c |= *i++ - '0';
+				}
+				c <<= 3;
+				c |= *i - '0';
+				break;
+
+			case 'X':
+			case 'x':
+				while (true) {
+					if (i[1] >= '0' && i[1] <= '9') {
+						c <<= 4;
+						c |= i[1] - '0';
+					} else if (i[1] >= 'A' && i[1] <= 'F') {
+						c <<= 4;
+						c |= i[1] - 'A' + 0xA;
+					} else if (i[1] >= 'a' && i[1] <= 'f') {
+						c <<= 4;
+						c |= i[1] - 'a' + 0xA;
+					} else {
+						break;
+					}
+					++i;
+				}
+				break;
+
+			case '\0':
+				goto fail_str;
+
+			default:
+				c = *i;
+				break;
+			}
+			break;
+
+		case '^':
+			switch (*++i) {
+			case '?':
+				c = '\177';
+				break;
+			case '\0':
+				goto fail_str;
+			default:
+				// CTRL masks bits 6 and 7
+				c = *i & 0x1F;
+				break;
+			}
+			break;
+
+		default:
+			c = *i;
+			break;
+		}
+
+		if (dstrapp(&str, c) != 0) {
+			goto fail_str;
+		}
+	}
+
+	if (*i) {
+		*next = i + 1;
+	} else {
+		*next = NULL;
+	}
+
+	return str;
+
+fail_str:
+	dstrfree(str);
+fail:
+	*next = NULL;
+	return NULL;
+}
+
+/** Parse the GNU $LS_COLORS format. */
+static void parse_gnu_ls_colors(struct colors *colors, const char *ls_colors) {
+	for (const char *chunk = ls_colors, *next; chunk; chunk = next) {
+		if (chunk[0] == '*') {
+			char *key = unescape(chunk + 1, '=', &next);
+			if (!key) {
+				continue;
+			}
+
+			char *value = unescape(next, ':', &next);
+			if (value) {
+				if (set_ext_color(colors, key, value) != 0) {
+					dstrfree(value);
+				}
+			}
+
+			dstrfree(key);
+		} else {
+			const char *equals = strchr(chunk, '=');
+			if (!equals) {
+				break;
+			}
+
+			char *value = unescape(equals + 1, ':', &next);
+			if (!value) {
+				continue;
+			}
+
+			char *key = strndup(chunk, equals - chunk);
+			if (!key) {
+				dstrfree(value);
+				continue;
+			}
+
+			// All-zero values should be treated like NULL, to fall
+			// back on any other relevant coloring for that file
+			if (strspn(value, "0") == strlen(value)
+			    && strcmp(key, "rs") != 0
+			    && strcmp(key, "lc") != 0
+			    && strcmp(key, "rc") != 0
+			    && strcmp(key, "ec") != 0) {
+				dstrfree(value);
+				value = NULL;
+			}
+
+			if (set_color(colors, key, value) != 0) {
+				dstrfree(value);
+			}
+			free(key);
+		}
+	}
+
+	if (colors->link && strcmp(colors->link, "target") == 0) {
+		colors->link_as_target = true;
+		dstrfree(colors->link);
+		colors->link = NULL;
+	}
+}
+
+struct colors *parse_colors() {
+	struct colors *colors = malloc(sizeof(struct colors));
+	if (!colors) {
+		return NULL;
+	}
+
+	trie_init(&colors->names);
+	trie_init(&colors->ext_colors);
+
+	int ret = 0;
+
+	// From man console_codes
+
+	ret |= init_color(colors, "rs", "0",      &colors->reset);
+	ret |= init_color(colors, "lc", "\033[",  &colors->leftcode);
+	ret |= init_color(colors, "rc", "m",      &colors->rightcode);
+	ret |= init_color(colors, "ec", NULL,     &colors->endcode);
+	ret |= init_color(colors, "cl", "\033[K", &colors->clear_to_eol);
+
+	ret |= init_color(colors, "bld", "01;39", &colors->bold);
+	ret |= init_color(colors, "gry", "01;30", &colors->gray);
+	ret |= init_color(colors, "red", "01;31", &colors->red);
+	ret |= init_color(colors, "grn", "01;32", &colors->green);
+	ret |= init_color(colors, "ylw", "01;33", &colors->yellow);
+	ret |= init_color(colors, "blu", "01;34", &colors->blue);
+	ret |= init_color(colors, "mag", "01;35", &colors->magenta);
+	ret |= init_color(colors, "cyn", "01;36", &colors->cyan);
+	ret |= init_color(colors, "wht", "01;37", &colors->white);
+
+	ret |= init_color(colors, "wrn", "01;33", &colors->warning);
+	ret |= init_color(colors, "err", "01;31", &colors->error);
+
+	// Defaults from man dir_colors
+
+	ret |= init_color(colors, "no", NULL, &colors->normal);
+
+	ret |= init_color(colors, "fi", NULL,    &colors->file);
+	ret |= init_color(colors, "mh", NULL,    &colors->multi_hard);
+	ret |= init_color(colors, "ex", "01;32", &colors->executable);
+	ret |= init_color(colors, "ca", "30;41", &colors->capable);
+	ret |= init_color(colors, "sg", "30;43", &colors->setgid);
+	ret |= init_color(colors, "su", "37;41", &colors->setuid);
+
+	ret |= init_color(colors, "di", "01;34", &colors->directory);
+	ret |= init_color(colors, "st", "37;44", &colors->sticky);
+	ret |= init_color(colors, "ow", "34;42", &colors->other_writable);
+	ret |= init_color(colors, "tw", "30;42", &colors->sticky_other_writable);
+
+	ret |= init_color(colors, "ln", "01;36", &colors->link);
+	ret |= init_color(colors, "or", NULL,    &colors->orphan);
+	ret |= init_color(colors, "mi", NULL,    &colors->missing);
+	colors->link_as_target = false;
+
+	ret |= init_color(colors, "bd", "01;33", &colors->blockdev);
+	ret |= init_color(colors, "cd", "01;33", &colors->chardev);
+	ret |= init_color(colors, "do", "01;35", &colors->door);
+	ret |= init_color(colors, "pi", "33",    &colors->pipe);
+	ret |= init_color(colors, "so", "01;35", &colors->socket);
+
+	if (ret) {
+		free_colors(colors);
+		return NULL;
+	}
+
+	parse_gnu_ls_colors(colors, getenv("LS_COLORS"));
+	parse_gnu_ls_colors(colors, getenv("BFS_COLORS"));
+
+	return colors;
+}
+
+void free_colors(struct colors *colors) {
+	if (colors) {
+		struct trie_leaf *leaf;
+		while ((leaf = trie_first_leaf(&colors->ext_colors))) {
+			dstrfree(leaf->value);
+			trie_remove(&colors->ext_colors, leaf);
+		}
+		trie_destroy(&colors->ext_colors);
+
+		while ((leaf = trie_first_leaf(&colors->names))) {
+			char **field = leaf->value;
+			dstrfree(*field);
+			trie_remove(&colors->names, leaf);
+		}
+		trie_destroy(&colors->names);
+
+		free(colors);
+	}
+}
+
+CFILE *cfwrap(FILE *file, const struct colors *colors, bool close) {
+	CFILE *cfile = malloc(sizeof(*cfile));
+	if (!cfile) {
+		return NULL;
+	}
+
+	cfile->buffer = dstralloc(128);
+	if (!cfile->buffer) {
+		free(cfile);
+		return NULL;
+	}
+
+	cfile->file = file;
+	cfile->close = close;
+
+	if (isatty(fileno(file))) {
+		cfile->colors = colors;
+	} else {
+		cfile->colors = NULL;
+	}
+
+	return cfile;
+}
+
+int cfclose(CFILE *cfile) {
+	int ret = 0;
+
+	if (cfile) {
+		dstrfree(cfile->buffer);
+
+		if (cfile->close) {
+			ret = fclose(cfile->file);
+		}
+
+		free(cfile);
+	}
+
+	return ret;
+}
+
+/** Check if a symlink is broken. */
+static bool is_link_broken(const struct BFTW *ftwbuf) {
+	if (ftwbuf->stat_flags & BFS_STAT_NOFOLLOW) {
+		return xfaccessat(ftwbuf->at_fd, ftwbuf->at_path, F_OK) != 0;
+	} else {
+		return true;
+	}
+}
+
+/** Get the color for a file. */
+static const char *file_color(const struct colors *colors, const char *filename, const struct BFTW *ftwbuf, enum bfs_stat_flags flags) {
+	enum bfs_type type = bftw_type(ftwbuf, flags);
+	if (type == BFS_ERROR) {
+		goto error;
+	}
+
+	const struct bfs_stat *statbuf = NULL;
+	const char *color = NULL;
+
+	switch (type) {
+	case BFS_REG:
+		if (colors->setuid || colors->setgid || colors->executable || colors->multi_hard) {
+			statbuf = bftw_stat(ftwbuf, flags);
+			if (!statbuf) {
+				goto error;
+			}
+		}
+
+		if (colors->setuid && (statbuf->mode & 04000)) {
+			color = colors->setuid;
+		} else if (colors->setgid && (statbuf->mode & 02000)) {
+			color = colors->setgid;
+		} else if (colors->capable && bfs_check_capabilities(ftwbuf) > 0) {
+			color = colors->capable;
+		} else if (colors->executable && (statbuf->mode & 00111)) {
+			color = colors->executable;
+		} else if (colors->multi_hard && statbuf->nlink > 1) {
+			color = colors->multi_hard;
+		}
+
+		if (!color) {
+			color = get_ext_color(colors, filename);
+		}
+
+		if (!color) {
+			color = colors->file;
+		}
+
+		break;
+
+	case BFS_DIR:
+		if (colors->sticky_other_writable || colors->other_writable || colors->sticky) {
+			statbuf = bftw_stat(ftwbuf, flags);
+			if (!statbuf) {
+				goto error;
+			}
+		}
+
+		if (colors->sticky_other_writable && (statbuf->mode & 01002) == 01002) {
+			color = colors->sticky_other_writable;
+		} else if (colors->other_writable && (statbuf->mode & 00002)) {
+			color = colors->other_writable;
+		} else if (colors->sticky && (statbuf->mode & 01000)) {
+			color = colors->sticky;
+		} else {
+			color = colors->directory;
+		}
+
+		break;
+
+	case BFS_LNK:
+		if (colors->orphan && is_link_broken(ftwbuf)) {
+			color = colors->orphan;
+		} else {
+			color = colors->link;
+		}
+		break;
+
+	case BFS_BLK:
+		color = colors->blockdev;
+		break;
+	case BFS_CHR:
+		color = colors->chardev;
+		break;
+	case BFS_FIFO:
+		color = colors->pipe;
+		break;
+	case BFS_SOCK:
+		color = colors->socket;
+		break;
+	case BFS_DOOR:
+		color = colors->door;
+		break;
+
+	default:
+		break;
+	}
+
+	if (!color) {
+		color = colors->normal;
+	}
+
+	return color;
+
+error:
+	if (colors->missing) {
+		return colors->missing;
+	} else {
+		return colors->orphan;
+	}
+}
+
+/** Print an ANSI escape sequence. */
+static int print_esc(CFILE *cfile, const char *esc) {
+	const struct colors *colors = cfile->colors;
+
+	if (dstrdcat(&cfile->buffer, colors->leftcode) != 0) {
+		return -1;
+	}
+	if (dstrdcat(&cfile->buffer, esc) != 0) {
+		return -1;
+	}
+	if (dstrdcat(&cfile->buffer, colors->rightcode) != 0) {
+		return -1;
+	}
+
+	return 0;
+}
+
+/** Reset after an ANSI escape sequence. */
+static int print_reset(CFILE *cfile) {
+	const struct colors *colors = cfile->colors;
+
+	if (colors->endcode) {
+		return dstrdcat(&cfile->buffer, colors->endcode);
+	} else {
+		return print_esc(cfile, colors->reset);
+	}
+}
+
+/** Print a string with an optional color. */
+static int print_colored(CFILE *cfile, const char *esc, const char *str, size_t len) {
+	if (esc) {
+		if (print_esc(cfile, esc) != 0) {
+			return -1;
+		}
+	}
+	if (dstrncat(&cfile->buffer, str, len) != 0) {
+		return -1;
+	}
+	if (esc) {
+		if (print_reset(cfile) != 0) {
+			return -1;
+		}
+	}
+
+	return 0;
+}
+
+/** Find the offset of the first broken path component. */
+static ssize_t first_broken_offset(const char *path, const struct BFTW *ftwbuf, enum bfs_stat_flags flags, size_t max) {
+	ssize_t ret = max;
+	assert(ret >= 0);
+
+	if (bftw_type(ftwbuf, flags) != BFS_ERROR) {
+		goto out;
+	}
+
+	char *at_path;
+	int at_fd;
+	if (path == ftwbuf->path) {
+		if (ftwbuf->depth == 0) {
+			at_fd = AT_FDCWD;
+			at_path = dstrndup(path, max);
+		} else {
+			// The parent must have existed to get here
+			goto out;
+		}
+	} else {
+		// We're in print_link_target(), so resolve relative to the link's parent directory
+		at_fd = ftwbuf->at_fd;
+		if (at_fd == AT_FDCWD && path[0] != '/') {
+			at_path = dstrndup(ftwbuf->path, ftwbuf->nameoff);
+			if (at_path && dstrncat(&at_path, path, max) != 0) {
+				ret = -1;
+				goto out_path;
+			}
+		} else {
+			at_path = dstrndup(path, max);
+		}
+	}
+
+	if (!at_path) {
+		ret = -1;
+		goto out;
+	}
+
+	while (ret > 0) {
+		if (xfaccessat(at_fd, at_path, F_OK) == 0) {
+			break;
+		}
+
+		size_t len = dstrlen(at_path);
+		while (ret && at_path[len - 1] == '/') {
+			--len, --ret;
+		}
+		while (ret && at_path[len - 1] != '/') {
+			--len, --ret;
+		}
+
+		dstresize(&at_path, len);
+	}
+
+out_path:
+	dstrfree(at_path);
+out:
+	return ret;
+}
+
+/** Print the directories leading up to a file. */
+static int print_dirs_colored(CFILE *cfile, const char *path, const struct BFTW *ftwbuf, enum bfs_stat_flags flags, size_t nameoff) {
+	const struct colors *colors = cfile->colors;
+
+	ssize_t broken = first_broken_offset(path, ftwbuf, flags, nameoff);
+	if (broken < 0) {
+		return -1;
+	}
+
+	if (broken > 0) {
+		if (print_colored(cfile, colors->directory, path, broken) != 0) {
+			return -1;
+		}
+	}
+
+	if ((size_t)broken < nameoff) {
+		const char *color = colors->missing;
+		if (!color) {
+			color = colors->orphan;
+		}
+		if (print_colored(cfile, color, path + broken, nameoff - broken) != 0) {
+			return -1;
+		}
+	}
+
+	return 0;
+}
+
+/** Print a file name with colors. */
+static int print_name_colored(CFILE *cfile, const char *name, const struct BFTW *ftwbuf, enum bfs_stat_flags flags) {
+	const char *color = file_color(cfile->colors, name, ftwbuf, flags);
+	return print_colored(cfile, color, name, strlen(name));
+}
+
+/** Print a path with colors. */
+static int print_path_colored(CFILE *cfile, const char *path, const struct BFTW *ftwbuf, enum bfs_stat_flags flags) {
+	size_t nameoff;
+	if (path == ftwbuf->path) {
+		nameoff = ftwbuf->nameoff;
+	} else {
+		nameoff = xbasename(path) - path;
+	}
+
+	if (print_dirs_colored(cfile, path, ftwbuf, flags, nameoff) != 0) {
+		return -1;
+	}
+
+	return print_name_colored(cfile, path + nameoff, ftwbuf, flags);
+}
+
+/** Print the name of a file with the appropriate colors. */
+static int print_name(CFILE *cfile, const struct BFTW *ftwbuf) {
+	const char *name = ftwbuf->path + ftwbuf->nameoff;
+
+	const struct colors *colors = cfile->colors;
+	if (!colors) {
+		return dstrcat(&cfile->buffer, name);
+	}
+
+	enum bfs_stat_flags flags = ftwbuf->stat_flags;
+	if (colors->link_as_target && ftwbuf->type == BFS_LNK) {
+		flags = BFS_STAT_TRYFOLLOW;
+	}
+
+	return print_name_colored(cfile, name, ftwbuf, flags);
+}
+
+/** Print the path to a file with the appropriate colors. */
+static int print_path(CFILE *cfile, const struct BFTW *ftwbuf) {
+	const struct colors *colors = cfile->colors;
+	if (!colors) {
+		return dstrcat(&cfile->buffer, ftwbuf->path);
+	}
+
+	enum bfs_stat_flags flags = ftwbuf->stat_flags;
+	if (colors->link_as_target && ftwbuf->type == BFS_LNK) {
+		flags = BFS_STAT_TRYFOLLOW;
+	}
+
+	return print_path_colored(cfile, ftwbuf->path, ftwbuf, flags);
+}
+
+/** Print a link target with the appropriate colors. */
+static int print_link_target(CFILE *cfile, const struct BFTW *ftwbuf) {
+	const struct bfs_stat *statbuf = bftw_cached_stat(ftwbuf, BFS_STAT_NOFOLLOW);
+	size_t len = statbuf ? statbuf->size : 0;
+
+	char *target = xreadlinkat(ftwbuf->at_fd, ftwbuf->at_path, len);
+	if (!target) {
+		return -1;
+	}
+
+	int ret;
+	if (cfile->colors) {
+		ret = print_path_colored(cfile, target, ftwbuf, BFS_STAT_FOLLOW);
+	} else {
+		ret = dstrcat(&cfile->buffer, target);
+	}
+
+	free(target);
+	return ret;
+}
+
+/** Format some colored output to the buffer. */
+BFS_FORMATTER(2, 3)
+static int cbuff(CFILE *cfile, const char *format, ...);
+
+/** Dump a parsed expression tree, for debugging. */
+static int print_expr(CFILE *cfile, const struct bfs_expr *expr, bool verbose) {
+	if (dstrcat(&cfile->buffer, "(") != 0) {
+		return -1;
+	}
+
+	const struct bfs_expr *lhs = NULL;
+	const struct bfs_expr *rhs = NULL;
+
+	if (bfs_expr_has_children(expr)) {
+		lhs = expr->lhs;
+		rhs = expr->rhs;
+
+		if (cbuff(cfile, "${red}%s${rs}", expr->argv[0]) < 0) {
+			return -1;
+		}
+	} else {
+		if (cbuff(cfile, "${blu}%s${rs}", expr->argv[0]) < 0) {
+			return -1;
+		}
+	}
+
+	for (size_t i = 1; i < expr->argc; ++i) {
+		if (cbuff(cfile, " ${bld}%s${rs}", expr->argv[i]) < 0) {
+			return -1;
+		}
+	}
+
+	if (verbose) {
+		double rate = 0.0, time = 0.0;
+		if (expr->evaluations) {
+			rate = 100.0*expr->successes/expr->evaluations;
+			time = (1.0e9*expr->elapsed.tv_sec + expr->elapsed.tv_nsec)/expr->evaluations;
+		}
+		if (cbuff(cfile, " [${ylw}%zu${rs}/${ylw}%zu${rs}=${ylw}%g%%${rs}; ${ylw}%gns${rs}]",
+		          expr->successes, expr->evaluations, rate, time)) {
+			return -1;
+		}
+	}
+
+	if (lhs) {
+		if (dstrcat(&cfile->buffer, " ") != 0) {
+			return -1;
+		}
+		if (print_expr(cfile, lhs, verbose) != 0) {
+			return -1;
+		}
+	}
+
+	if (rhs) {
+		if (dstrcat(&cfile->buffer, " ") != 0) {
+			return -1;
+		}
+		if (print_expr(cfile, rhs, verbose) != 0) {
+			return -1;
+		}
+	}
+
+	if (dstrcat(&cfile->buffer, ")") != 0) {
+		return -1;
+	}
+
+	return 0;
+}
+
+static int cvbuff(CFILE *cfile, const char *format, va_list args) {
+	const struct colors *colors = cfile->colors;
+	int error = errno;
+
+	for (const char *i = format; *i; ++i) {
+		size_t verbatim = strcspn(i, "%$");
+		if (dstrncat(&cfile->buffer, i, verbatim) != 0) {
+			return -1;
+		}
+
+		i += verbatim;
+		switch (*i) {
+		case '%':
+			switch (*++i) {
+			case '%':
+				if (dstrapp(&cfile->buffer, '%') != 0) {
+					return -1;
+				}
+				break;
+
+			case 'c':
+				if (dstrapp(&cfile->buffer, va_arg(args, int)) != 0) {
+					return -1;
+				}
+				break;
+
+			case 'd':
+				if (dstrcatf(&cfile->buffer, "%d", va_arg(args, int)) != 0) {
+					return -1;
+				}
+				break;
+
+			case 'g':
+				if (dstrcatf(&cfile->buffer, "%g", va_arg(args, double)) != 0) {
+					return -1;
+				}
+				break;
+
+			case 's':
+				if (dstrcat(&cfile->buffer, va_arg(args, const char *)) != 0) {
+					return -1;
+				}
+				break;
+
+			case 'z':
+				++i;
+				if (*i != 'u') {
+					goto invalid;
+				}
+				if (dstrcatf(&cfile->buffer, "%zu", va_arg(args, size_t)) != 0) {
+					return -1;
+				}
+				break;
+
+			case 'm':
+				if (dstrcat(&cfile->buffer, strerror(error)) != 0) {
+					return -1;
+				}
+				break;
+
+			case 'p':
+				switch (*++i) {
+				case 'F':
+					if (print_name(cfile, va_arg(args, const struct BFTW *)) != 0) {
+						return -1;
+					}
+					break;
+
+				case 'P':
+					if (print_path(cfile, va_arg(args, const struct BFTW *)) != 0) {
+						return -1;
+					}
+					break;
+
+				case 'L':
+					if (print_link_target(cfile, va_arg(args, const struct BFTW *)) != 0) {
+						return -1;
+					}
+					break;
+
+				case 'e':
+					if (print_expr(cfile, va_arg(args, const struct bfs_expr *), false) != 0) {
+						return -1;
+					}
+					break;
+				case 'E':
+					if (print_expr(cfile, va_arg(args, const struct bfs_expr *), true) != 0) {
+						return -1;
+					}
+					break;
+
+				default:
+					goto invalid;
+				}
+
+				break;
+
+			default:
+				goto invalid;
+			}
+			break;
+
+		case '$':
+			switch (*++i) {
+			case '$':
+				if (dstrapp(&cfile->buffer, '$') != 0) {
+					return -1;
+				}
+				break;
+
+			case '{': {
+				++i;
+				const char *end = strchr(i, '}');
+				if (!end) {
+					goto invalid;
+				}
+				if (!colors) {
+					i = end;
+					break;
+				}
+
+				size_t len = end - i;
+				char name[len + 1];
+				memcpy(name, i, len);
+				name[len] = '\0';
+
+				char **esc = get_color(colors, name);
+				if (!esc) {
+					goto invalid;
+				}
+				if (*esc) {
+					if (print_esc(cfile, *esc) != 0) {
+						return -1;
+					}
+				}
+
+				i = end;
+				break;
+			}
+
+			default:
+				goto invalid;
+			}
+			break;
+
+		default:
+			return 0;
+		}
+	}
+
+	return 0;
+
+invalid:
+	assert(!"Invalid format string");
+	errno = EINVAL;
+	return -1;
+}
+
+static int cbuff(CFILE *cfile, const char *format, ...) {
+	va_list args;
+	va_start(args, format);
+	int ret = cvbuff(cfile, format, args);
+	va_end(args);
+	return ret;
+}
+
+int cvfprintf(CFILE *cfile, const char *format, va_list args) {
+	assert(dstrlen(cfile->buffer) == 0);
+
+	int ret = -1;
+	if (cvbuff(cfile, format, args) == 0) {
+		size_t len = dstrlen(cfile->buffer);
+		if (fwrite(cfile->buffer, 1, len, cfile->file) == len) {
+			ret = 0;
+		}
+	}
+
+	dstresize(&cfile->buffer, 0);
+	return ret;
+}
+
+int cfprintf(CFILE *cfile, const char *format, ...) {
+	va_list args;
+	va_start(args, format);
+	int ret = cvfprintf(cfile, format, args);
+	va_end(args);
+	return ret;
+}
diff --git a/src/color.h b/src/color.h
new file mode 100644
index 0000000..edf1ef7
--- /dev/null
+++ b/src/color.h
@@ -0,0 +1,120 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2015-2021 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * Utilities for colored output on ANSI terminals.
+ */
+
+#ifndef BFS_COLOR_H
+#define BFS_COLOR_H
+
+#include "util.h"
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stdio.h>
+
+/**
+ * A color scheme.
+ */
+struct colors;
+
+/**
+ * Parse a color table.
+ *
+ * @return The parsed color table.
+ */
+struct colors *parse_colors(void);
+
+/**
+ * Free a color table.
+ *
+ * @param colors
+ *         The color table to free.
+ */
+void free_colors(struct colors *colors);
+
+/**
+ * A file/stream with associated colors.
+ */
+typedef struct CFILE {
+	/** The underlying file/stream. */
+	FILE *file;
+	/** The color table to use, if any. */
+	const struct colors *colors;
+	/** A buffer for colored formatting. */
+	char *buffer;
+	/** Whether to close the underlying stream. */
+	bool close;
+} CFILE;
+
+/**
+ * Wrap an existing file into a colored stream.
+ *
+ * @param file
+ *         The underlying file.
+ * @param colors
+ *         The color table to use if file is a TTY.
+ * @param close
+ *         Whether to close the underlying stream when this stream is closed.
+ * @return
+ *         A colored wrapper around file.
+ */
+CFILE *cfwrap(FILE *file, const struct colors *colors, bool close);
+
+/**
+ * Close a colored file.
+ *
+ * @param cfile
+ *         The colored file to close.
+ * @return
+ *         0 on success, -1 on failure.
+ */
+int cfclose(CFILE *cfile);
+
+/**
+ * Colored, formatted output.
+ *
+ * @param cfile
+ *         The colored stream to print to.
+ * @param format
+ *         A printf()-style format string, supporting these format specifiers:
+ *
+ *         %c: A single character
+ *         %d: An integer
+ *         %g: A double
+ *         %s: A string
+ *         %zu: A size_t
+ *         %m: strerror(errno)
+ *         %pF: A colored file name, from a const struct BFTW * argument
+ *         %pP: A colored file path, from a const struct BFTW * argument
+ *         %pL: A colored link target, from a const struct BFTW * argument
+ *         %pe: Dump a const struct bfs_expr *, for debugging.
+ *         %pE: Dump a const struct bfs_expr * in verbose form, for debugging.
+ *         %%: A literal '%'
+ *         ${cc}: Change the color to 'cc'
+ *         $$: A literal '$'
+ * @return
+ *         0 on success, -1 on failure.
+ */
+BFS_FORMATTER(2, 3)
+int cfprintf(CFILE *cfile, const char *format, ...);
+
+/**
+ * cfprintf() variant that takes a va_list.
+ */
+int cvfprintf(CFILE *cfile, const char *format, va_list args);
+
+#endif // BFS_COLOR_H
diff --git a/src/ctx.c b/src/ctx.c
new file mode 100644
index 0000000..8ba2f38
--- /dev/null
+++ b/src/ctx.c
@@ -0,0 +1,311 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2015-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+#include "ctx.h"
+#include "color.h"
+#include "darray.h"
+#include "diag.h"
+#include "expr.h"
+#include "mtab.h"
+#include "pwcache.h"
+#include "stat.h"
+#include "trie.h"
+#include <assert.h>
+#include <errno.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+
+const char *debug_flag_name(enum debug_flags flag) {
+	switch (flag) {
+	case DEBUG_COST:
+		return "cost";
+	case DEBUG_EXEC:
+		return "exec";
+	case DEBUG_OPT:
+		return "opt";
+	case DEBUG_RATES:
+		return "rates";
+	case DEBUG_SEARCH:
+		return "search";
+	case DEBUG_STAT:
+		return "stat";
+	case DEBUG_TREE:
+		return "tree";
+
+	case DEBUG_ALL:
+		break;
+	}
+
+	assert(!"Unrecognized debug flag");
+	return "???";
+}
+
+struct bfs_ctx *bfs_ctx_new(void) {
+	struct bfs_ctx *ctx = malloc(sizeof(*ctx));
+	if (!ctx) {
+		return NULL;
+	}
+
+	ctx->argv = NULL;
+	ctx->paths = NULL;
+	ctx->expr = NULL;
+	ctx->exclude = NULL;
+
+	ctx->mindepth = 0;
+	ctx->maxdepth = INT_MAX;
+	ctx->flags = BFTW_RECOVER;
+	ctx->strategy = BFTW_BFS;
+	ctx->optlevel = 3;
+	ctx->debug = 0;
+	ctx->ignore_races = false;
+	ctx->posixly_correct = false;
+	ctx->status = false;
+	ctx->unique = false;
+	ctx->warn = false;
+	ctx->xargs_safe = false;
+
+	ctx->colors = NULL;
+	ctx->colors_error = 0;
+	ctx->cout = NULL;
+	ctx->cerr = NULL;
+
+	ctx->users = NULL;
+	ctx->users_error = 0;
+	ctx->groups = NULL;
+	ctx->groups_error = 0;
+
+	ctx->mtab = NULL;
+	ctx->mtab_error = 0;
+
+	trie_init(&ctx->files);
+	ctx->nfiles = 0;
+
+	struct rlimit rl;
+	if (getrlimit(RLIMIT_NOFILE, &rl) == 0) {
+		ctx->nofile_soft = rl.rlim_cur;
+		ctx->nofile_hard = rl.rlim_max;
+	} else {
+		ctx->nofile_soft = 1024;
+		ctx->nofile_hard = RLIM_INFINITY;
+	}
+
+	return ctx;
+}
+
+const struct bfs_users *bfs_ctx_users(const struct bfs_ctx *ctx) {
+	struct bfs_ctx *mut = (struct bfs_ctx *)ctx;
+
+	if (mut->users_error) {
+		errno = mut->users_error;
+	} else if (!mut->users) {
+		mut->users = bfs_users_parse();
+		if (!mut->users) {
+			mut->users_error = errno;
+		}
+	}
+
+	return mut->users;
+}
+
+const struct bfs_groups *bfs_ctx_groups(const struct bfs_ctx *ctx) {
+	struct bfs_ctx *mut = (struct bfs_ctx *)ctx;
+
+	if (mut->groups_error) {
+		errno = mut->groups_error;
+	} else if (!mut->groups) {
+		mut->groups = bfs_groups_parse();
+		if (!mut->groups) {
+			mut->groups_error = errno;
+		}
+	}
+
+	return mut->groups;
+}
+
+const struct bfs_mtab *bfs_ctx_mtab(const struct bfs_ctx *ctx) {
+	struct bfs_ctx *mut = (struct bfs_ctx *)ctx;
+
+	if (mut->mtab_error) {
+		errno = mut->mtab_error;
+	} else if (!mut->mtab) {
+		mut->mtab = bfs_mtab_parse();
+		if (!mut->mtab) {
+			mut->mtab_error = errno;
+		}
+	}
+
+	return mut->mtab;
+}
+
+/**
+ * An open file tracked by the bfs context.
+ */
+struct bfs_ctx_file {
+	/** The file itself. */
+	CFILE *cfile;
+	/** The path to the file (for diagnostics). */
+	const char *path;
+};
+
+CFILE *bfs_ctx_dedup(struct bfs_ctx *ctx, CFILE *cfile, const char *path) {
+	struct bfs_stat sb;
+	if (bfs_stat(fileno(cfile->file), NULL, 0, &sb) != 0) {
+		return NULL;
+	}
+
+	bfs_file_id id;
+	bfs_stat_id(&sb, &id);
+
+	struct trie_leaf *leaf = trie_insert_mem(&ctx->files, id, sizeof(id));
+	if (!leaf) {
+		return NULL;
+	}
+
+	struct bfs_ctx_file *ctx_file = leaf->value;
+	if (ctx_file) {
+		ctx_file->path = path;
+		return ctx_file->cfile;
+	}
+
+	leaf->value = ctx_file = malloc(sizeof(*ctx_file));
+	if (!ctx_file) {
+		trie_remove(&ctx->files, leaf);
+		return NULL;
+	}
+
+	ctx_file->cfile = cfile;
+	ctx_file->path = path;
+
+	if (cfile != ctx->cout && cfile != ctx->cerr) {
+		++ctx->nfiles;
+	}
+
+	return cfile;
+}
+
+void bfs_ctx_flush(const struct bfs_ctx *ctx) {
+	// Before executing anything, flush all open streams.  This ensures that
+	// - the user sees everything relevant before an -ok[dir] prompt
+	// - output from commands is interleaved consistently with bfs
+	// - executed commands can rely on I/O from other bfs actions
+	//
+	// We do not check errors here, but they will be caught at cleanup time
+	// with ferror().
+	fflush(NULL);
+}
+
+/** Flush a file and report any errors. */
+static int bfs_ctx_fflush(CFILE *cfile) {
+	int ret = 0, error = 0;
+	if (ferror(cfile->file)) {
+		ret = -1;
+		error = EIO;
+	}
+	if (fflush(cfile->file) != 0) {
+		ret = -1;
+		error = errno;
+	}
+
+	errno = error;
+	return ret;
+}
+
+/** Close a file tracked by the bfs context. */
+static int bfs_ctx_fclose(struct bfs_ctx *ctx, struct bfs_ctx_file *ctx_file) {
+	CFILE *cfile = ctx_file->cfile;
+
+	if (cfile == ctx->cout) {
+		// Will be checked later
+		return 0;
+	} else if (cfile == ctx->cerr) {
+		// Writes to stderr are allowed to fail silently, unless the same file was used by
+		// -fprint, -fls, etc.
+		if (ctx_file->path) {
+			return bfs_ctx_fflush(cfile);
+		} else {
+			return 0;
+		}
+	}
+
+	int ret = 0, error = 0;
+	if (ferror(cfile->file)) {
+		ret = -1;
+		error = EIO;
+	}
+	if (cfclose(cfile) != 0) {
+		ret = -1;
+		error = errno;
+	}
+
+	errno = error;
+	return ret;
+}
+
+int bfs_ctx_free(struct bfs_ctx *ctx) {
+	int ret = 0;
+
+	if (ctx) {
+		CFILE *cout = ctx->cout;
+		CFILE *cerr = ctx->cerr;
+
+		bfs_expr_free(ctx->exclude);
+		bfs_expr_free(ctx->expr);
+
+		bfs_mtab_free(ctx->mtab);
+
+		bfs_groups_free(ctx->groups);
+		bfs_users_free(ctx->users);
+
+		struct trie_leaf *leaf;
+		while ((leaf = trie_first_leaf(&ctx->files))) {
+			struct bfs_ctx_file *ctx_file = leaf->value;
+
+			if (bfs_ctx_fclose(ctx, ctx_file) != 0) {
+				if (cerr) {
+					bfs_error(ctx, "'%s': %m.\n", ctx_file->path);
+				}
+				ret = -1;
+			}
+
+			free(ctx_file);
+			trie_remove(&ctx->files, leaf);
+		}
+		trie_destroy(&ctx->files);
+
+		if (cout && bfs_ctx_fflush(cout) != 0) {
+			if (cerr) {
+				bfs_error(ctx, "standard output: %m.\n");
+			}
+			ret = -1;
+		}
+
+		cfclose(cout);
+		cfclose(cerr);
+
+		free_colors(ctx->colors);
+
+		for (size_t i = 0; i < darray_length(ctx->paths); ++i) {
+			free((char *)ctx->paths[i]);
+		}
+		darray_free(ctx->paths);
+
+		free(ctx->argv);
+		free(ctx);
+	}
+
+	return ret;
+}
diff --git a/src/ctx.h b/src/ctx.h
new file mode 100644
index 0000000..3ad7f85
--- /dev/null
+++ b/src/ctx.h
@@ -0,0 +1,212 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2015-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * bfs execution context.
+ */
+
+#ifndef BFS_CTX_H
+#define BFS_CTX_H
+
+#include "bftw.h"
+#include "trie.h"
+#include <stdbool.h>
+#include <sys/resource.h>
+
+/**
+ * Various debugging flags.
+ */
+enum debug_flags {
+	/** Print cost estimates. */
+	DEBUG_COST   = 1 << 0,
+	/** Print executed command details. */
+	DEBUG_EXEC   = 1 << 1,
+	/** Print optimization details. */
+	DEBUG_OPT    = 1 << 2,
+	/** Print rate information. */
+	DEBUG_RATES  = 1 << 3,
+	/** Trace the filesystem traversal. */
+	DEBUG_SEARCH = 1 << 4,
+	/** Trace all stat() calls. */
+	DEBUG_STAT   = 1 << 5,
+	/** Print the parse tree. */
+	DEBUG_TREE   = 1 << 6,
+	/** All debug flags. */
+	DEBUG_ALL    = (1 << 7) - 1,
+};
+
+/**
+ * Convert a debug flag to a string.
+ */
+const char *debug_flag_name(enum debug_flags flag);
+
+/**
+ * The execution context for bfs.
+ */
+struct bfs_ctx {
+	/** The number of command line arguments. */
+	size_t argc;
+	/** The unparsed command line arguments. */
+	char **argv;
+
+	/** The root paths. */
+	const char **paths;
+	/** The main command line expression. */
+	struct bfs_expr *expr;
+	/** An expression for files to filter out. */
+	struct bfs_expr *exclude;
+
+	/** -mindepth option. */
+	int mindepth;
+	/** -maxdepth option. */
+	int maxdepth;
+
+	/** bftw() flags. */
+	enum bftw_flags flags;
+	/** bftw() search strategy. */
+	enum bftw_strategy strategy;
+
+	/** Optimization level (-O). */
+	int optlevel;
+	/** Debugging flags (-D). */
+	enum debug_flags debug;
+	/** Whether to ignore deletions that race with bfs (-ignore_readdir_race). */
+	bool ignore_races;
+	/** Whether to follow POSIXisms more closely ($POSIXLY_CORRECT). */
+	bool posixly_correct;
+	/** Whether to show a status bar (-status). */
+	bool status;
+	/** Whether to only return unique files (-unique). */
+	bool unique;
+	/** Whether to print warnings (-warn/-nowarn). */
+	bool warn;
+	/** Whether to only handle paths with xargs-safe characters (-X). */
+	bool xargs_safe;
+
+	/** Color data. */
+	struct colors *colors;
+	/** The error that occurred parsing the color table, if any. */
+	int colors_error;
+	/** Colored stdout. */
+	struct CFILE *cout;
+	/** Colored stderr. */
+	struct CFILE *cerr;
+
+	/** User table. */
+	struct bfs_users *users;
+	/** The error that occurred parsing the user table, if any. */
+	int users_error;
+	/** Group table. */
+	struct bfs_groups *groups;
+	/** The error that occurred parsing the group table, if any. */
+	int groups_error;
+
+	/** Table of mounted file systems. */
+	struct bfs_mtab *mtab;
+	/** The error that occurred parsing the mount table, if any. */
+	int mtab_error;
+
+	/** All the files owned by the context. */
+	struct trie files;
+	/** The number of files owned by the context. */
+	int nfiles;
+
+	/** The initial RLIMIT_NOFILE soft limit. */
+	rlim_t nofile_soft;
+	/** The initial RLIMIT_NOFILE hard limit. */
+	rlim_t nofile_hard;
+};
+
+/**
+ * @return
+ *         A new bfs context, or NULL on failure.
+ */
+struct bfs_ctx *bfs_ctx_new(void);
+
+/**
+ * Get the users table.
+ *
+ * @param ctx
+ *         The bfs context.
+ * @return
+ *         The cached users table, or NULL on failure.
+ */
+const struct bfs_users *bfs_ctx_users(const struct bfs_ctx *ctx);
+
+/**
+ * Get the groups table.
+ *
+ * @param ctx
+ *         The bfs context.
+ * @return
+ *         The cached groups table, or NULL on failure.
+ */
+const struct bfs_groups *bfs_ctx_groups(const struct bfs_ctx *ctx);
+
+/**
+ * Get the mount table.
+ *
+ * @param ctx
+ *         The bfs context.
+ * @return
+ *         The cached mount table, or NULL on failure.
+ */
+const struct bfs_mtab *bfs_ctx_mtab(const struct bfs_ctx *ctx);
+
+/**
+ * Deduplicate an opened file.
+ *
+ * @param ctx
+ *         The bfs context.
+ * @param cfile
+ *         The opened file.
+ * @param path
+ *         The path to the opened file (or NULL for standard streams).
+ * @return
+ *         If the same file was opened previously, that file is returned.  If cfile is a new file,
+ *         cfile itself is returned.  If an error occurs, NULL is returned.
+ */
+struct CFILE *bfs_ctx_dedup(struct bfs_ctx *ctx, struct CFILE *cfile, const char *path);
+
+/**
+ * Flush any caches for consistency with external processes.
+ *
+ * @param ctx
+ *         The bfs context.
+ */
+void bfs_ctx_flush(const struct bfs_ctx *ctx);
+
+/**
+ * Dump the parsed command line.
+ *
+ * @param ctx
+ *         The bfs context.
+ * @param flag
+ *         The -D flag that triggered the dump.
+ */
+void bfs_ctx_dump(const struct bfs_ctx *ctx, enum debug_flags flag);
+
+/**
+ * Free a bfs context.
+ *
+ * @param ctx
+ *         The context to free.
+ * @return
+ *         0 on success, -1 if any errors occurred.
+ */
+int bfs_ctx_free(struct bfs_ctx *ctx);
+
+#endif // BFS_CTX_H
diff --git a/src/darray.c b/src/darray.c
new file mode 100644
index 0000000..6585d30
--- /dev/null
+++ b/src/darray.c
@@ -0,0 +1,103 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2019-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+#include "darray.h"
+#include <stdlib.h>
+#include <string.h>
+
+/**
+ * The darray header.
+ */
+struct darray {
+	/** The current capacity of the array, as a count of elements. */
+	size_t capacity;
+	/** The current length of the array. */
+	size_t length;
+
+	// The array elements are stored after this header in memory.  Not using
+	// a flexible array member to avoid worrying about strict aliasing.  We
+	// assume that 2*sizeof(size_t) keeps any memory allocation suitably
+	// aligned for the element type.
+};
+
+/** Get the header for a darray. */
+static struct darray *darray_header(const void *da) {
+	return (struct darray *)da - 1;
+}
+
+/** Get the array from a darray header. */
+static char *darray_data(struct darray *header) {
+	return (char *)(header + 1);
+}
+
+size_t darray_length(const void *da) {
+	if (da) {
+		return darray_header(da)->length;
+	} else {
+		return 0;
+	}
+}
+
+void *darray_push(void *da, const void *item, size_t size) {
+	struct darray *header;
+	if (da) {
+		header = darray_header(da);
+	} else {
+		header = malloc(sizeof(*header) + size);
+		if (!header) {
+			return NULL;
+		}
+		header->capacity = 1;
+		header->length = 0;
+	}
+
+	size_t capacity = header->capacity;
+	size_t i = header->length++;
+	if (i >= capacity) {
+		capacity *= 2;
+		header = realloc(header, sizeof(*header) + capacity*size);
+		if (!header) {
+			// This failure will be detected by darray_check()
+			return da;
+		}
+		header->capacity = capacity;
+	}
+
+	char *data = darray_data(header);
+	memcpy(data + i*size, item, size);
+	return data;
+}
+
+int darray_check(void *da) {
+	if (!da) {
+		return -1;
+	}
+
+	struct darray *header = darray_header(da);
+	if (header->length <= header->capacity) {
+		return 0;
+	} else {
+		// realloc() failed in darray_push(), so reset the length and report the failure
+		header->length = header->capacity;
+		return -1;
+	}
+}
+
+void darray_free(void *da) {
+	if (da) {
+		free(darray_header(da));
+	}
+}
diff --git a/src/darray.h b/src/darray.h
new file mode 100644
index 0000000..4464381
--- /dev/null
+++ b/src/darray.h
@@ -0,0 +1,110 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2019-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * A dynamic array library.
+ *
+ * darrays are represented by a simple pointer to the array element type, like
+ * any other array.  Behind the scenes, the capacity and current length of the
+ * array are stored along with it.  NULL is a valid way to initialize an empty
+ * darray:
+ *
+ *     int *darray = NULL;
+ *
+ * To append an element to a darray, use the DARRAY_PUSH macro:
+ *
+ *     int e = 42;
+ *     if (DARRAY_PUSH(&darray, &e) != 0) {
+ *             // Report the error...
+ *     }
+ *
+ * The length can be retrieved by darray_length().  Iterating over the array
+ * works like normal arrays:
+ *
+ *     for (size_t i = 0; i < darray_length(darray); ++i) {
+ *             printf("%d\n", darray[i]);
+ *     }
+ *
+ * To free a darray, use darray_free():
+ *
+ *     darray_free(darray);
+ */
+
+#ifndef BFS_DARRAY_H
+#define BFS_DARRAY_H
+
+#include <stddef.h>
+
+/**
+ * Get the length of a darray.
+ *
+ * @param da
+ *         The array in question.
+ * @return
+ *         The length of the array.
+ */
+size_t darray_length(const void *da);
+
+/**
+ * @internal Use DARRAY_PUSH().
+ *
+ * Push an element into a darray.
+ *
+ * @param da
+ *         The array to append to.
+ * @param item
+ *         The item to append.
+ * @param size
+ *         The size of the item.
+ * @return
+ *         The (new) location of the array.
+ */
+void *darray_push(void *da, const void *item, size_t size);
+
+/**
+ * @internal Use DARRAY_PUSH().
+ *
+ * Check if the last darray_push() call failed.
+ *
+ * @param da
+ *         The darray to check.
+ * @return
+ *         0 on success, -1 on failure.
+ */
+int darray_check(void *da);
+
+/**
+ * Free a darray.
+ *
+ * @param da
+ *         The darray to free.
+ */
+void darray_free(void *da);
+
+/**
+ * Push an item into a darray.
+ *
+ * @param da
+ *         The array to append to.
+ * @param item
+ *         A pointer to the item to append.
+ * @return
+ *         0 on success, -1 on failure.
+ */
+#define DARRAY_PUSH(da, item) \
+	(darray_check(*(da) = darray_push(*(da), (item), sizeof(**(da) = *(item)))))
+
+#endif // BFS_DARRAY_H
diff --git a/src/diag.c b/src/diag.c
new file mode 100644
index 0000000..27848f1
--- /dev/null
+++ b/src/diag.c
@@ -0,0 +1,233 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2019-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+#include "diag.h"
+#include "ctx.h"
+#include "color.h"
+#include "expr.h"
+#include "util.h"
+#include <assert.h>
+#include <errno.h>
+#include <stdarg.h>
+
+void bfs_perror(const struct bfs_ctx *ctx, const char *str) {
+	bfs_error(ctx, "%s: %m.\n", str);
+}
+
+void bfs_error(const struct bfs_ctx *ctx, const char *format, ...)  {
+	va_list args;
+	va_start(args, format);
+	bfs_verror(ctx, format, args);
+	va_end(args);
+}
+
+bool bfs_warning(const struct bfs_ctx *ctx, const char *format, ...)  {
+	va_list args;
+	va_start(args, format);
+	bool ret = bfs_vwarning(ctx, format, args);
+	va_end(args);
+	return ret;
+}
+
+bool bfs_debug(const struct bfs_ctx *ctx, enum debug_flags flag, const char *format, ...)  {
+	va_list args;
+	va_start(args, format);
+	bool ret = bfs_vdebug(ctx, flag, format, args);
+	va_end(args);
+	return ret;
+}
+
+void bfs_verror(const struct bfs_ctx *ctx, const char *format, va_list args) {
+	int error = errno;
+
+	bfs_error_prefix(ctx);
+
+	errno = error;
+	cvfprintf(ctx->cerr, format, args);
+}
+
+bool bfs_vwarning(const struct bfs_ctx *ctx, const char *format, va_list args) {
+	int error = errno;
+
+	if (bfs_warning_prefix(ctx)) {
+		errno = error;
+		cvfprintf(ctx->cerr, format, args);
+		return true;
+	} else {
+		return false;
+	}
+}
+
+bool bfs_vdebug(const struct bfs_ctx *ctx, enum debug_flags flag, const char *format, va_list args) {
+	int error = errno;
+
+	if (bfs_debug_prefix(ctx, flag)) {
+		errno = error;
+		cvfprintf(ctx->cerr, format, args);
+		return true;
+	} else {
+		return false;
+	}
+}
+
+void bfs_error_prefix(const struct bfs_ctx *ctx) {
+	cfprintf(ctx->cerr, "${bld}%s:${rs} ${err}error:${rs} ", xbasename(ctx->argv[0]));
+}
+
+bool bfs_warning_prefix(const struct bfs_ctx *ctx) {
+	if (ctx->warn) {
+		cfprintf(ctx->cerr, "${bld}%s:${rs} ${wrn}warning:${rs} ", xbasename(ctx->argv[0]));
+		return true;
+	} else {
+		return false;
+	}
+}
+
+bool bfs_debug_prefix(const struct bfs_ctx *ctx, enum debug_flags flag) {
+	if (ctx->debug & flag) {
+		cfprintf(ctx->cerr, "${bld}%s:${rs} ${cyn}-D %s${rs}: ", xbasename(ctx->argv[0]), debug_flag_name(flag));
+		return true;
+	} else {
+		return false;
+	}
+}
+
+/** Recursive part of highlight_expr(). */
+static bool highlight_expr_recursive(const struct bfs_ctx *ctx, const struct bfs_expr *expr, bool *args) {
+	if (!expr) {
+		return false;
+	}
+
+	bool ret = false;
+
+	if (!expr->synthetic) {
+		size_t i = expr->argv - ctx->argv;
+		for (size_t j = 0; j < expr->argc; ++j) {
+			assert(i + j < ctx->argc);
+			args[i + j] = true;
+			ret = true;
+		}
+	}
+
+	if (bfs_expr_has_children(expr)) {
+		ret |= highlight_expr_recursive(ctx, expr->lhs, args);
+		ret |= highlight_expr_recursive(ctx, expr->rhs, args);
+	}
+
+	return ret;
+}
+
+/** Highlight an expression in the command line. */
+static bool highlight_expr(const struct bfs_ctx *ctx, const struct bfs_expr *expr, bool *args) {
+	for (size_t i = 0; i < ctx->argc; ++i) {
+		args[i] = false;
+	}
+
+	return highlight_expr_recursive(ctx, expr, args);
+}
+
+/** Print a highlighted portion of the command line. */
+static void bfs_argv_diag(const struct bfs_ctx *ctx, const bool *args, bool warning) {
+	if (warning) {
+		bfs_warning_prefix(ctx);
+	} else {
+		bfs_error_prefix(ctx);
+	}
+
+	size_t max_argc = 0;
+	for (size_t i = 0; i < ctx->argc; ++i) {
+		if (i > 0) {
+			cfprintf(ctx->cerr, " ");
+		}
+
+		if (args[i]) {
+			max_argc = i + 1;
+			cfprintf(ctx->cerr, "${bld}%s${rs}", ctx->argv[i]);
+		} else {
+			cfprintf(ctx->cerr, "%s", ctx->argv[i]);
+		}
+	}
+
+	cfprintf(ctx->cerr, "\n");
+
+	if (warning) {
+		bfs_warning_prefix(ctx);
+	} else {
+		bfs_error_prefix(ctx);
+	}
+
+	for (size_t i = 0; i < max_argc; ++i) {
+		if (i > 0) {
+			if (args[i - 1] && args[i]) {
+				cfprintf(ctx->cerr, "~");
+			} else {
+				cfprintf(ctx->cerr, " ");
+			}
+		}
+
+		if (args[i] && (i == 0 || !args[i - 1])) {
+			if (warning) {
+				cfprintf(ctx->cerr, "${wrn}");
+			} else {
+				cfprintf(ctx->cerr, "${err}");
+			}
+		}
+
+		size_t len = xstrwidth(ctx->argv[i]);
+		for (size_t j = 0; j < len; ++j) {
+			if (args[i]) {
+				cfprintf(ctx->cerr, "~");
+			} else {
+				cfprintf(ctx->cerr, " ");
+			}
+		}
+
+		if (args[i] && (i + 1 >= max_argc || !args[i + 1])) {
+			cfprintf(ctx->cerr, "${rs}");
+		}
+	}
+
+	cfprintf(ctx->cerr, "\n");
+}
+
+void bfs_argv_error(const struct bfs_ctx *ctx, const bool *args) {
+	bfs_argv_diag(ctx, args, false);
+}
+
+void bfs_expr_error(const struct bfs_ctx *ctx, const struct bfs_expr *expr) {
+	bool args[ctx->argc];
+	if (highlight_expr(ctx, expr, args)) {
+		bfs_argv_error(ctx, args);
+	}
+}
+
+bool bfs_argv_warning(const struct bfs_ctx *ctx, const bool *args) {
+	if (!ctx->warn) {
+		return false;
+	}
+
+	bfs_argv_diag(ctx, args, true);
+	return true;
+}
+
+bool bfs_expr_warning(const struct bfs_ctx *ctx, const struct bfs_expr *expr) {
+	bool args[ctx->argc];
+	if (highlight_expr(ctx, expr, args)) {
+		return bfs_argv_warning(ctx, args);
+	}
+
+	return false;
+}
diff --git a/src/diag.h b/src/diag.h
new file mode 100644
index 0000000..39129cc
--- /dev/null
+++ b/src/diag.h
@@ -0,0 +1,108 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2019-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * Formatters for diagnostic messages.
+ */
+
+#ifndef BFS_DIAG_H
+#define BFS_DIAG_H
+
+#include "ctx.h"
+#include "util.h"
+#include <stdarg.h>
+#include <stdbool.h>
+
+struct bfs_expr;
+
+/**
+ * Like perror(), but decorated like bfs_error().
+ */
+void bfs_perror(const struct bfs_ctx *ctx, const char *str);
+
+/**
+ * Shorthand for printing error messages.
+ */
+BFS_FORMATTER(2, 3)
+void bfs_error(const struct bfs_ctx *ctx, const char *format, ...);
+
+/**
+ * Shorthand for printing warning messages.
+ *
+ * @return Whether a warning was printed.
+ */
+BFS_FORMATTER(2, 3)
+bool bfs_warning(const struct bfs_ctx *ctx, const char *format, ...);
+
+/**
+ * Shorthand for printing debug messages.
+ *
+ * @return Whether a debug message was printed.
+ */
+BFS_FORMATTER(3, 4)
+bool bfs_debug(const struct bfs_ctx *ctx, enum debug_flags flag, const char *format, ...);
+
+/**
+ * bfs_error() variant that takes a va_list.
+ */
+void bfs_verror(const struct bfs_ctx *ctx, const char *format, va_list args);
+
+/**
+ * bfs_warning() variant that takes a va_list.
+ */
+bool bfs_vwarning(const struct bfs_ctx *ctx, const char *format, va_list args);
+
+/**
+ * bfs_debug() variant that takes a va_list.
+ */
+bool bfs_vdebug(const struct bfs_ctx *ctx, enum debug_flags flag, const char *format, va_list args);
+
+/**
+ * Print the error message prefix.
+ */
+void bfs_error_prefix(const struct bfs_ctx *ctx);
+
+/**
+ * Print the warning message prefix.
+ */
+bool bfs_warning_prefix(const struct bfs_ctx *ctx);
+
+/**
+ * Print the debug message prefix.
+ */
+bool bfs_debug_prefix(const struct bfs_ctx *ctx, enum debug_flags flag);
+
+/**
+ * Highlight parts of the command line in an error message.
+ */
+void bfs_argv_error(const struct bfs_ctx *ctx, const bool *args);
+
+/**
+ * Highlight parts of an expression in an error message.
+ */
+void bfs_expr_error(const struct bfs_ctx *ctx, const struct bfs_expr *expr);
+
+/**
+ * Highlight parts of the command line in a warning message.
+ */
+bool bfs_argv_warning(const struct bfs_ctx *ctx, const bool *args);
+
+/**
+ * Highlight parts of an expression in a warning message.
+ */
+bool bfs_expr_warning(const struct bfs_ctx *ctx, const struct bfs_expr *expr);
+
+#endif // BFS_DIAG_H
diff --git a/src/dir.c b/src/dir.c
new file mode 100644
index 0000000..024e767
--- /dev/null
+++ b/src/dir.c
@@ -0,0 +1,303 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2021-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+#include "dir.h"
+#include "util.h"
+#include <dirent.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <stdbool.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+#if __linux__
+#	include <sys/syscall.h>
+#endif // __linux__
+
+enum bfs_type bfs_mode_to_type(mode_t mode) {
+	switch (mode & S_IFMT) {
+#ifdef S_IFBLK
+	case S_IFBLK:
+		return BFS_BLK;
+#endif
+#ifdef S_IFCHR
+	case S_IFCHR:
+		return BFS_CHR;
+#endif
+#ifdef S_IFDIR
+	case S_IFDIR:
+		return BFS_DIR;
+#endif
+#ifdef S_IFDOOR
+	case S_IFDOOR:
+		return BFS_DOOR;
+#endif
+#ifdef S_IFIFO
+	case S_IFIFO:
+		return BFS_FIFO;
+#endif
+#ifdef S_IFLNK
+	case S_IFLNK:
+		return BFS_LNK;
+#endif
+#ifdef S_IFPORT
+	case S_IFPORT:
+		return BFS_PORT;
+#endif
+#ifdef S_IFREG
+	case S_IFREG:
+		return BFS_REG;
+#endif
+#ifdef S_IFSOCK
+	case S_IFSOCK:
+		return BFS_SOCK;
+#endif
+#ifdef S_IFWHT
+	case S_IFWHT:
+		return BFS_WHT;
+#endif
+
+	default:
+		return BFS_UNKNOWN;
+	}
+}
+
+#if __linux__
+/**
+ * This is not defined in the kernel headers for some reason, callers have to
+ * define it themselves.
+ */
+struct linux_dirent64 {
+	ino64_t d_ino;
+	off64_t d_off;
+	unsigned short d_reclen;
+	unsigned char d_type;
+	char d_name[];
+};
+
+// Make the whole allocation 64k
+#define BUF_SIZE ((64 << 10) - 8)
+#endif
+
+struct bfs_dir {
+#if __linux__
+	int fd;
+	unsigned short pos;
+	unsigned short size;
+#else
+	DIR *dir;
+	struct dirent *de;
+#endif
+};
+
+struct bfs_dir *bfs_opendir(int at_fd, const char *at_path) {
+#if __linux__
+	struct bfs_dir *dir = malloc(sizeof(*dir) + BUF_SIZE);
+#else
+	struct bfs_dir *dir = malloc(sizeof(*dir));
+#endif
+	if (!dir) {
+		return NULL;
+	}
+
+	int fd;
+	if (at_path) {
+		fd = openat(at_fd, at_path, O_RDONLY | O_CLOEXEC | O_DIRECTORY);
+	} else if (at_fd >= 0) {
+		fd = at_fd;
+	} else {
+		free(dir);
+		errno = EBADF;
+		return NULL;
+	}
+
+	if (fd < 0) {
+		free(dir);
+		return NULL;
+	}
+
+#if __linux__
+	dir->fd = fd;
+	dir->pos = 0;
+	dir->size = 0;
+#else
+	dir->dir = fdopendir(fd);
+	if (!dir->dir) {
+		if (at_path) {
+			close_quietly(fd);
+		}
+		free(dir);
+		return NULL;
+	}
+
+	dir->de = NULL;
+#endif // __linux__
+
+	return dir;
+}
+
+int bfs_dirfd(const struct bfs_dir *dir) {
+#if __linux__
+	return dir->fd;
+#else
+	return dirfd(dir->dir);
+#endif
+}
+
+/** Convert a dirent type to a bfs_type. */
+static enum bfs_type translate_type(int d_type) {
+	switch (d_type) {
+#ifdef DT_BLK
+	case DT_BLK:
+		return BFS_BLK;
+#endif
+#ifdef DT_CHR
+	case DT_CHR:
+		return BFS_CHR;
+#endif
+#ifdef DT_DIR
+	case DT_DIR:
+		return BFS_DIR;
+#endif
+#ifdef DT_DOOR
+	case DT_DOOR:
+		return BFS_DOOR;
+#endif
+#ifdef DT_FIFO
+	case DT_FIFO:
+		return BFS_FIFO;
+#endif
+#ifdef DT_LNK
+	case DT_LNK:
+		return BFS_LNK;
+#endif
+#ifdef DT_PORT
+	case DT_PORT:
+		return BFS_PORT;
+#endif
+#ifdef DT_REG
+	case DT_REG:
+		return BFS_REG;
+#endif
+#ifdef DT_SOCK
+	case DT_SOCK:
+		return BFS_SOCK;
+#endif
+#ifdef DT_WHT
+	case DT_WHT:
+		return BFS_WHT;
+#endif
+	}
+
+	return BFS_UNKNOWN;
+}
+
+#if !__linux__
+/** Get the type from a struct dirent if it exists, and convert it. */
+static enum bfs_type dirent_type(const struct dirent *de) {
+#if defined(_DIRENT_HAVE_D_TYPE) || defined(DT_UNKNOWN)
+	return translate_type(de->d_type);
+#else
+	return BFS_UNKNOWN;
+#endif
+}
+#endif
+
+/** Check if a name is . or .. */
+static bool is_dot(const char *name) {
+	return name[0] == '.' && (name[1] == '\0' || (name[1] == '.' && name[2] == '\0'));
+}
+
+int bfs_readdir(struct bfs_dir *dir, struct bfs_dirent *de) {
+	while (true) {
+#if __linux__
+		char *buf = (char *)(dir + 1);
+
+		if (dir->pos >= dir->size) {
+#if BFS_HAS_FEATURE(memory_sanitizer, false)
+			// Make sure msan knows the buffer is initialized
+			memset(buf, 0, BUF_SIZE);
+#endif
+
+			ssize_t size = syscall(__NR_getdents64, dir->fd, buf, BUF_SIZE);
+			if (size <= 0) {
+				return size;
+			}
+			dir->pos = 0;
+			dir->size = size;
+		}
+
+		const struct linux_dirent64 *lde = (void *)(buf + dir->pos);
+		dir->pos += lde->d_reclen;
+
+		if (is_dot(lde->d_name)) {
+			continue;
+		}
+
+		if (de) {
+			de->type = translate_type(lde->d_type);
+			de->name = lde->d_name;
+		}
+
+		return 1;
+#else // !__linux__
+		errno = 0;
+		dir->de = readdir(dir->dir);
+		if (dir->de) {
+			if (is_dot(dir->de->d_name)) {
+				continue;
+			}
+			if (de) {
+				de->type = dirent_type(dir->de);
+				de->name = dir->de->d_name;
+			}
+			return 1;
+		} else if (errno != 0) {
+			return -1;
+		} else {
+			return 0;
+		}
+#endif // !__linux__
+	}
+}
+
+int bfs_closedir(struct bfs_dir *dir) {
+#if __linux__
+	int ret = xclose(dir->fd);
+#else
+	int ret = closedir(dir->dir);
+#endif
+	free(dir);
+	return ret;
+}
+
+int bfs_freedir(struct bfs_dir *dir) {
+#if __linux__
+	int ret = dir->fd;
+	free(dir);
+	return ret;
+#elif __FreeBSD__
+	int ret = fdclosedir(dir->dir);
+	free(dir);
+	return ret;
+#else
+	int ret = dup_cloexec(dirfd(dir->dir));
+	bfs_closedir(dir);
+	return ret;
+#endif
+}
diff --git a/src/dir.h b/src/dir.h
new file mode 100644
index 0000000..69344c6
--- /dev/null
+++ b/src/dir.h
@@ -0,0 +1,124 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2021 Tavian Barnes <tavianator@tavianator.com>             *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * Directories and their contents.
+ */
+
+#ifndef BFS_DIR_H
+#define BFS_DIR_H
+
+#include <sys/types.h>
+
+/**
+ * A directory.
+ */
+struct bfs_dir;
+
+/**
+ * File types.
+ */
+enum bfs_type {
+	/** An error occurred for this file. */
+	BFS_ERROR = -1,
+	/** Unknown type. */
+	BFS_UNKNOWN,
+	/** Block device. */
+	BFS_BLK,
+	/** Character device. */
+	BFS_CHR,
+	/** Directory. */
+	BFS_DIR,
+	/** Solaris door. */
+	BFS_DOOR,
+	/** Pipe. */
+	BFS_FIFO,
+	/** Symbolic link. */
+	BFS_LNK,
+	/** Solaris event port. */
+	BFS_PORT,
+	/** Regular file. */
+	BFS_REG,
+	/** Socket. */
+	BFS_SOCK,
+	/** BSD whiteout. */
+	BFS_WHT,
+};
+
+/**
+ * Convert a bfs_stat() mode to a bfs_type.
+ */
+enum bfs_type bfs_mode_to_type(mode_t mode);
+
+/**
+ * A directory entry.
+ */
+struct bfs_dirent {
+	/** The type of this file (possibly unknown). */
+	enum bfs_type type;
+	/** The name of this file. */
+	const char *name;
+};
+
+/**
+ * Open a directory.
+ *
+ * @param at_fd
+ *         The base directory for path resolution.
+ * @param at_path
+ *         The path of the directory to open, relative to at_fd.  Pass NULL to
+ *         open at_fd itself.
+ * @return
+ *         The opened directory, or NULL on failure.
+ */
+struct bfs_dir *bfs_opendir(int at_fd, const char *at_path);
+
+/**
+ * Get the file descriptor for a directory.
+ */
+int bfs_dirfd(const struct bfs_dir *dir);
+
+/**
+ * Read a directory entry.
+ *
+ * @param dir
+ *         The directory to read.
+ * @param[out] dirent
+ *         The directory entry to populate.
+ * @return
+ *         1 on success, 0 on EOF, or -1 on failure.
+ */
+int bfs_readdir(struct bfs_dir *dir, struct bfs_dirent *de);
+
+/**
+ * Close a directory.
+ *
+ * @return
+ *         0 on success, -1 on failure.
+ */
+int bfs_closedir(struct bfs_dir *dir);
+
+/**
+ * Free a directory, keeping an open file descriptor to it.
+ *
+ * @param dir
+ *         The directory to free.
+ * @return
+ *         The file descriptor on success, or -1 on failure.
+ */
+int bfs_freedir(struct bfs_dir *dir);
+
+#endif // BFS_DIR_H
diff --git a/src/dstring.c b/src/dstring.c
new file mode 100644
index 0000000..f344d09
--- /dev/null
+++ b/src/dstring.c
@@ -0,0 +1,220 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2016-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+#include "dstring.h"
+#include <assert.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+/**
+ * The memory representation of a dynamic string.  Users get a pointer to data.
+ */
+struct dstring {
+	size_t capacity;
+	size_t length;
+	char data[];
+};
+
+/** Get the string header from the string data pointer. */
+static struct dstring *dstrheader(const char *dstr) {
+	return (struct dstring *)(dstr - offsetof(struct dstring, data));
+}
+
+/** Get the correct size for a dstring with the given capacity. */
+static size_t dstrsize(size_t capacity) {
+	return BFS_FLEX_SIZEOF(struct dstring, data, capacity + 1);
+}
+
+/** Allocate a dstring with the given contents. */
+static char *dstralloc_impl(size_t capacity, size_t length, const char *data) {
+	// Avoid reallocations for small strings
+	if (capacity < 7) {
+		capacity = 7;
+	}
+
+	struct dstring *header = malloc(dstrsize(capacity));
+	if (!header) {
+		return NULL;
+	}
+
+	header->capacity = capacity;
+	header->length = length;
+
+	memcpy(header->data, data, length);
+	header->data[length] = '\0';
+	return header->data;
+}
+
+char *dstralloc(size_t capacity) {
+	return dstralloc_impl(capacity, 0, "");
+}
+
+char *dstrdup(const char *str) {
+	size_t len = strlen(str);
+	return dstralloc_impl(len, len, str);
+}
+
+char *dstrndup(const char *str, size_t n) {
+	size_t len = strnlen(str, n);
+	return dstralloc_impl(len, len, str);
+}
+
+size_t dstrlen(const char *dstr) {
+	return dstrheader(dstr)->length;
+}
+
+int dstreserve(char **dstr, size_t capacity) {
+	struct dstring *header = dstrheader(*dstr);
+
+	if (capacity > header->capacity) {
+		capacity *= 2;
+
+		header = realloc(header, dstrsize(capacity));
+		if (!header) {
+			return -1;
+		}
+		header->capacity = capacity;
+
+		*dstr = header->data;
+	}
+
+	return 0;
+}
+
+int dstresize(char **dstr, size_t length) {
+	if (dstreserve(dstr, length) != 0) {
+		return -1;
+	}
+
+	struct dstring *header = dstrheader(*dstr);
+	header->length = length;
+	header->data[length] = '\0';
+
+	return 0;
+}
+
+/** Common implementation of dstr{cat,ncat,app}. */
+static int dstrcat_impl(char **dest, const char *src, size_t srclen) {
+	size_t oldlen = dstrlen(*dest);
+	size_t newlen = oldlen + srclen;
+
+	if (dstresize(dest, newlen) != 0) {
+		return -1;
+	}
+
+	memcpy(*dest + oldlen, src, srclen);
+	return 0;
+}
+
+int dstrcat(char **dest, const char *src) {
+	return dstrcat_impl(dest, src, strlen(src));
+}
+
+int dstrncat(char **dest, const char *src, size_t n) {
+	return dstrcat_impl(dest, src, strnlen(src, n));
+}
+
+int dstrdcat(char **dest, const char *src) {
+	return dstrcat_impl(dest, src, dstrlen(src));
+}
+
+int dstrapp(char **str, char c) {
+	return dstrcat_impl(str, &c, 1);
+}
+
+char *dstrprintf(const char *format, ...) {
+	va_list args;
+
+	va_start(args, format);
+	char *str = dstrvprintf(format, args);
+	va_end(args);
+
+	return str;
+}
+
+char *dstrvprintf(const char *format, va_list args) {
+	// Guess a capacity to try to avoid reallocating
+	char *str = dstralloc(2*strlen(format));
+	if (!str) {
+		return NULL;
+	}
+
+	if (dstrvcatf(&str, format, args) != 0) {
+		dstrfree(str);
+		return NULL;
+	}
+
+	return str;
+}
+
+int dstrcatf(char **str, const char *format, ...) {
+	va_list args;
+
+	va_start(args, format);
+	int ret = dstrvcatf(str, format, args);
+	va_end(args);
+
+	return ret;
+}
+
+int dstrvcatf(char **str, const char *format, va_list args) {
+	// Guess a capacity to try to avoid calling vsnprintf() twice
+	size_t len = dstrlen(*str);
+	dstreserve(str, len + 2*strlen(format));
+	size_t cap = dstrheader(*str)->capacity;
+
+	va_list copy;
+	va_copy(copy, args);
+
+	char *tail = *str + len;
+	int ret = vsnprintf(tail, cap - len + 1, format, args);
+	if (ret < 0) {
+		goto fail;
+	}
+
+	size_t tail_len = ret;
+	if (tail_len > cap - len) {
+		cap = len + tail_len;
+		if (dstreserve(str, cap) != 0) {
+			goto fail;
+		}
+
+		tail = *str + len;
+		ret = vsnprintf(tail, tail_len + 1, format, copy);
+		if (ret < 0 || (size_t)ret != tail_len) {
+			assert(!"Length of formatted string changed");
+			goto fail;
+		}
+	}
+
+	va_end(copy);
+
+	struct dstring *header = dstrheader(*str);
+	header->length += tail_len;
+	return 0;
+
+fail:
+	*tail = '\0';
+	return -1;
+}
+
+void dstrfree(char *dstr) {
+	if (dstr) {
+		free(dstrheader(dstr));
+	}
+}
diff --git a/src/dstring.h b/src/dstring.h
new file mode 100644
index 0000000..54106f3
--- /dev/null
+++ b/src/dstring.h
@@ -0,0 +1,194 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2016-2020 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * A dynamic string library.
+ */
+
+#ifndef BFS_DSTRING_H
+#define BFS_DSTRING_H
+
+#include "util.h"
+#include <stdarg.h>
+#include <stddef.h>
+
+/**
+ * Allocate a dynamic string.
+ *
+ * @param capacity
+ *         The initial capacity of the string.
+ */
+char *dstralloc(size_t capacity);
+
+/**
+ * Create a dynamic copy of a string.
+ *
+ * @param str
+ *         The NUL-terminated string to copy.
+ */
+char *dstrdup(const char *str);
+
+/**
+ * Create a length-limited dynamic copy of a string.
+ *
+ * @param str
+ *         The string to copy.
+ * @param n
+ *         The maximum number of characters to copy from str.
+ */
+char *dstrndup(const char *str, size_t n);
+
+/**
+ * Get a dynamic string's length.
+ *
+ * @param dstr
+ *         The string to measure.
+ * @return The length of dstr.
+ */
+size_t dstrlen(const char *dstr);
+
+/**
+ * Reserve some capacity in a dynamic string.
+ *
+ * @param dstr
+ *         The dynamic string to preallocate.
+ * @param capacity
+ *         The new capacity for the string.
+ * @return 0 on success, -1 on failure.
+ */
+int dstreserve(char **dstr, size_t capacity);
+
+/**
+ * Resize a dynamic string.
+ *
+ * @param dstr
+ *         The dynamic string to resize.
+ * @param length
+ *         The new length for the dynamic string.
+ * @return 0 on success, -1 on failure.
+ */
+int dstresize(char **dstr, size_t length);
+
+/**
+ * Append to a dynamic string.
+ *
+ * @param dest
+ *         The destination dynamic string.
+ * @param src
+ *         The string to append.
+ * @return 0 on success, -1 on failure.
+ */
+int dstrcat(char **dest, const char *src);
+
+/**
+ * Append to a dynamic string.
+ *
+ * @param dest
+ *         The destination dynamic string.
+ * @param src
+ *         The string to append.
+ * @param n
+ *         The maximum number of characters to take from src.
+ * @return 0 on success, -1 on failure.
+ */
+int dstrncat(char **dest, const char *src, size_t n);
+
+/**
+ * Append a dynamic string to another dynamic string.
+ *
+ * @param dest
+ *         The destination dynamic string.
+ * @param src
+ *         The dynamic string to append.
+ * @return
+ *         0 on success, -1 on failure.
+ */
+int dstrdcat(char **dest, const char *src);
+
+/**
+ * Append a single character to a dynamic string.
+ *
+ * @param str
+ *         The string to append to.
+ * @param c
+ *         The character to append.
+ * @return 0 on success, -1 on failure.
+ */
+int dstrapp(char **str, char c);
+
+/**
+ * Create a dynamic string from a format string.
+ *
+ * @param format
+ *         The format string to fill in.
+ * @param ...
+ *         Any arguments for the format string.
+ * @return
+ *         The created string, or NULL on failure.
+ */
+BFS_FORMATTER(1, 2)
+char *dstrprintf(const char *format, ...);
+
+/**
+ * Create a dynamic string from a format string and a va_list.
+ *
+ * @param format
+ *         The format string to fill in.
+ * @param args
+ *         The arguments for the format string.
+ * @return
+ *         The created string, or NULL on failure.
+ */
+char *dstrvprintf(const char *format, va_list args);
+
+/**
+ * Format some text onto the end of a dynamic string.
+ *
+ * @param str
+ *         The destination dynamic string.
+ * @param format
+ *         The format string to fill in.
+ * @param ...
+ *         Any arguments for the format string.
+ * @return
+ *         0 on success, -1 on failure.
+ */
+BFS_FORMATTER(2, 3)
+int dstrcatf(char **str, const char *format, ...);
+
+/**
+ * Format some text from a va_list onto the end of a dynamic string.
+ *
+ * @param str
+ *         The destination dynamic string.
+ * @param format
+ *         The format string to fill in.
+ * @param args
+ *         The arguments for the format string.
+ * @return
+ *         0 on success, -1 on failure.
+ */
+int dstrvcatf(char **str, const char *format, va_list args);
+
+/**
+ * Free a dynamic string.
+ *
+ * @param dstr
+ *         The string to free.
+ */
+void dstrfree(char *dstr);
+
+#endif // BFS_DSTRING_H
diff --git a/src/eval.c b/src/eval.c
new file mode 100644
index 0000000..1d0a6f2
--- /dev/null
+++ b/src/eval.c
@@ -0,0 +1,1644 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2015-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * Implementation of all the literal expressions.
+ */
+
+#include "eval.h"
+#include "bar.h"
+#include "bftw.h"
+#include "color.h"
+#include "ctx.h"
+#include "darray.h"
+#include "diag.h"
+#include "dir.h"
+#include "dstring.h"
+#include "exec.h"
+#include "expr.h"
+#include "fsade.h"
+#include "mtab.h"
+#include "printf.h"
+#include "pwcache.h"
+#include "stat.h"
+#include "trie.h"
+#include "util.h"
+#include "xregex.h"
+#include "xtime.h"
+#include <assert.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <fnmatch.h>
+#include <grp.h>
+#include <pwd.h>
+#include <stdarg.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/resource.h>
+#include <sys/stat.h>
+#include <time.h>
+#include <unistd.h>
+#include <wchar.h>
+
+struct bfs_eval {
+	/** Data about the current file. */
+	const struct BFTW *ftwbuf;
+	/** The bfs context. */
+	const struct bfs_ctx *ctx;
+	/** The bftw() callback return value. */
+	enum bftw_action action;
+	/** The bfs_eval() return value. */
+	int *ret;
+	/** Whether to quit immediately. */
+	bool quit;
+};
+
+/**
+ * Print an error message.
+ */
+BFS_FORMATTER(2, 3)
+static void eval_error(struct bfs_eval *state, const char *format, ...) {
+	// By POSIX, any errors should be accompanied by a non-zero exit status
+	*state->ret = EXIT_FAILURE;
+
+	int error = errno;
+	const struct bfs_ctx *ctx = state->ctx;
+	CFILE *cerr = ctx->cerr;
+
+	bfs_error(ctx, "%pP: ", state->ftwbuf);
+
+	va_list args;
+	va_start(args, format);
+	errno = error;
+	cvfprintf(cerr, format, args);
+	va_end(args);
+}
+
+/**
+ * Check if an error should be ignored.
+ */
+static bool eval_should_ignore(const struct bfs_eval *state, int error) {
+	return state->ctx->ignore_races
+		&& is_nonexistence_error(error)
+		&& state->ftwbuf->depth > 0;
+}
+
+/**
+ * Report an error that occurs during evaluation.
+ */
+static void eval_report_error(struct bfs_eval *state) {
+	if (!eval_should_ignore(state, errno)) {
+		eval_error(state, "%m.\n");
+	}
+}
+
+/**
+ * Perform a bfs_stat() call if necessary.
+ */
+static const struct bfs_stat *eval_stat(struct bfs_eval *state) {
+	const struct BFTW *ftwbuf = state->ftwbuf;
+	const struct bfs_stat *ret = bftw_stat(ftwbuf, ftwbuf->stat_flags);
+	if (!ret) {
+		eval_report_error(state);
+	}
+	return ret;
+}
+
+/**
+ * Get the difference (in seconds) between two struct timespecs.
+ */
+static time_t timespec_diff(const struct timespec *lhs, const struct timespec *rhs) {
+	time_t ret = lhs->tv_sec - rhs->tv_sec;
+	if (lhs->tv_nsec < rhs->tv_nsec) {
+		--ret;
+	}
+	return ret;
+}
+
+bool bfs_expr_cmp(const struct bfs_expr *expr, long long n) {
+	switch (expr->int_cmp) {
+	case BFS_INT_EQUAL:
+		return n == expr->num;
+	case BFS_INT_LESS:
+		return n < expr->num;
+	case BFS_INT_GREATER:
+		return n > expr->num;
+	}
+
+	assert(!"Invalid comparison mode");
+	return false;
+}
+
+/**
+ * -true test.
+ */
+bool eval_true(const struct bfs_expr *expr, struct bfs_eval *state) {
+	return true;
+}
+
+/**
+ * -false test.
+ */
+bool eval_false(const struct bfs_expr *expr, struct bfs_eval *state) {
+	return false;
+}
+
+/**
+ * -executable, -readable, -writable tests.
+ */
+bool eval_access(const struct bfs_expr *expr, struct bfs_eval *state) {
+	const struct BFTW *ftwbuf = state->ftwbuf;
+	return xfaccessat(ftwbuf->at_fd, ftwbuf->at_path, expr->num) == 0;
+}
+
+/**
+ * -acl test.
+ */
+bool eval_acl(const struct bfs_expr *expr, struct bfs_eval *state) {
+	int ret = bfs_check_acl(state->ftwbuf);
+	if (ret >= 0) {
+		return ret;
+	} else {
+		eval_report_error(state);
+		return false;
+	}
+}
+
+/**
+ * -capable test.
+ */
+bool eval_capable(const struct bfs_expr *expr, struct bfs_eval *state) {
+	int ret = bfs_check_capabilities(state->ftwbuf);
+	if (ret >= 0) {
+		return ret;
+	} else {
+		eval_report_error(state);
+		return false;
+	}
+}
+
+/**
+ * Get the given timespec field out of a stat buffer.
+ */
+static const struct timespec *eval_stat_time(const struct bfs_stat *statbuf, enum bfs_stat_field field, struct bfs_eval *state) {
+	const struct timespec *ret = bfs_stat_time(statbuf, field);
+	if (!ret) {
+		eval_error(state, "Couldn't get file %s: %m.\n", bfs_stat_field_name(field));
+	}
+	return ret;
+}
+
+/**
+ * -[aBcm]?newer tests.
+ */
+bool eval_newer(const struct bfs_expr *expr, struct bfs_eval *state) {
+	const struct bfs_stat *statbuf = eval_stat(state);
+	if (!statbuf) {
+		return false;
+	}
+
+	const struct timespec *time = eval_stat_time(statbuf, expr->stat_field, state);
+	if (!time) {
+		return false;
+	}
+
+	return time->tv_sec > expr->reftime.tv_sec
+		|| (time->tv_sec == expr->reftime.tv_sec && time->tv_nsec > expr->reftime.tv_nsec);
+}
+
+/**
+ * -[aBcm]{min,time} tests.
+ */
+bool eval_time(const struct bfs_expr *expr, struct bfs_eval *state) {
+	const struct bfs_stat *statbuf = eval_stat(state);
+	if (!statbuf) {
+		return false;
+	}
+
+	const struct timespec *time = eval_stat_time(statbuf, expr->stat_field, state);
+	if (!time) {
+		return false;
+	}
+
+	time_t diff = timespec_diff(&expr->reftime, time);
+	switch (expr->time_unit) {
+	case BFS_DAYS:
+		diff /= 60*24;
+		BFS_FALLTHROUGH;
+	case BFS_MINUTES:
+		diff /= 60;
+		BFS_FALLTHROUGH;
+	case BFS_SECONDS:
+		break;
+	}
+
+	return bfs_expr_cmp(expr, diff);
+}
+
+/**
+ * -used test.
+ */
+bool eval_used(const struct bfs_expr *expr, struct bfs_eval *state) {
+	const struct bfs_stat *statbuf = eval_stat(state);
+	if (!statbuf) {
+		return false;
+	}
+
+	const struct timespec *atime = eval_stat_time(statbuf, BFS_STAT_ATIME, state);
+	const struct timespec *ctime = eval_stat_time(statbuf, BFS_STAT_CTIME, state);
+	if (!atime || !ctime) {
+		return false;
+	}
+
+	long long diff = timespec_diff(atime, ctime);
+	if (diff < 0) {
+		return false;
+	}
+
+	long long day_seconds = 60*60*24;
+	diff = (diff + day_seconds - 1) / day_seconds;
+	return bfs_expr_cmp(expr, diff);
+}
+
+/**
+ * -gid test.
+ */
+bool eval_gid(const struct bfs_expr *expr, struct bfs_eval *state) {
+	const struct bfs_stat *statbuf = eval_stat(state);
+	if (!statbuf) {
+		return false;
+	}
+
+	return bfs_expr_cmp(expr, statbuf->gid);
+}
+
+/**
+ * -uid test.
+ */
+bool eval_uid(const struct bfs_expr *expr, struct bfs_eval *state) {
+	const struct bfs_stat *statbuf = eval_stat(state);
+	if (!statbuf) {
+		return false;
+	}
+
+	return bfs_expr_cmp(expr, statbuf->uid);
+}
+
+/**
+ * -nogroup test.
+ */
+bool eval_nogroup(const struct bfs_expr *expr, struct bfs_eval *state) {
+	const struct bfs_stat *statbuf = eval_stat(state);
+	if (!statbuf) {
+		return false;
+	}
+
+	const struct bfs_groups *groups = bfs_ctx_groups(state->ctx);
+	if (!groups) {
+		eval_report_error(state);
+		return false;
+	}
+
+	return bfs_getgrgid(groups, statbuf->gid) == NULL;
+}
+
+/**
+ * -nouser test.
+ */
+bool eval_nouser(const struct bfs_expr *expr, struct bfs_eval *state) {
+	const struct bfs_stat *statbuf = eval_stat(state);
+	if (!statbuf) {
+		return false;
+	}
+
+	const struct bfs_users *users = bfs_ctx_users(state->ctx);
+	if (!users) {
+		eval_report_error(state);
+		return false;
+	}
+
+	return bfs_getpwuid(users, statbuf->uid) == NULL;
+}
+
+/**
+ * -delete action.
+ */
+bool eval_delete(const struct bfs_expr *expr, struct bfs_eval *state) {
+	const struct BFTW *ftwbuf = state->ftwbuf;
+
+	// Don't try to delete the current directory
+	if (strcmp(ftwbuf->path, ".") == 0) {
+		return true;
+	}
+
+	int flag = 0;
+
+	// We need to know the actual type of the path, not what it points to
+	enum bfs_type type = bftw_type(ftwbuf, BFS_STAT_NOFOLLOW);
+	if (type == BFS_DIR) {
+		flag |= AT_REMOVEDIR;
+	} else if (type == BFS_ERROR) {
+		eval_report_error(state);
+		return false;
+	}
+
+	if (unlinkat(ftwbuf->at_fd, ftwbuf->at_path, flag) != 0) {
+		eval_report_error(state);
+		return false;
+	}
+
+	return true;
+}
+
+/** Finish any pending -exec ... + operations. */
+static int eval_exec_finish(const struct bfs_expr *expr, const struct bfs_ctx *ctx) {
+	int ret = 0;
+
+	if (expr->eval_fn == eval_exec) {
+		if (bfs_exec_finish(expr->exec) != 0) {
+			if (errno != 0) {
+				bfs_error(ctx, "%s %s: %m.\n", expr->argv[0], expr->argv[1]);
+			}
+			ret = -1;
+		}
+	} else if (bfs_expr_has_children(expr)) {
+		if (expr->lhs && eval_exec_finish(expr->lhs, ctx) != 0) {
+			ret = -1;
+		}
+		if (expr->rhs && eval_exec_finish(expr->rhs, ctx) != 0) {
+			ret = -1;
+		}
+	}
+
+	return ret;
+}
+
+/**
+ * -exec[dir]/-ok[dir] actions.
+ */
+bool eval_exec(const struct bfs_expr *expr, struct bfs_eval *state) {
+	bool ret = bfs_exec(expr->exec, state->ftwbuf) == 0;
+	if (errno != 0) {
+		eval_error(state, "%s %s: %m.\n", expr->argv[0], expr->argv[1]);
+	}
+	return ret;
+}
+
+/**
+ * -exit action.
+ */
+bool eval_exit(const struct bfs_expr *expr, struct bfs_eval *state) {
+	state->action = BFTW_STOP;
+	*state->ret = expr->num;
+	state->quit = true;
+	return true;
+}
+
+/**
+ * -depth N test.
+ */
+bool eval_depth(const struct bfs_expr *expr, struct bfs_eval *state) {
+	return bfs_expr_cmp(expr, state->ftwbuf->depth);
+}
+
+/**
+ * -empty test.
+ */
+bool eval_empty(const struct bfs_expr *expr, struct bfs_eval *state) {
+	bool ret = false;
+	const struct BFTW *ftwbuf = state->ftwbuf;
+
+	if (ftwbuf->type == BFS_DIR) {
+		struct bfs_dir *dir = bfs_opendir(ftwbuf->at_fd, ftwbuf->at_path);
+		if (!dir) {
+			eval_report_error(state);
+			goto done;
+		}
+
+		int did_read = bfs_readdir(dir, NULL);
+		if (did_read < 0) {
+			eval_report_error(state);
+		} else {
+			ret = !did_read;
+		}
+
+		bfs_closedir(dir);
+	} else if (ftwbuf->type == BFS_REG) {
+		const struct bfs_stat *statbuf = eval_stat(state);
+		if (statbuf) {
+			ret = statbuf->size == 0;
+		}
+	}
+
+done:
+	return ret;
+}
+
+/**
+ * -flags test.
+ */
+bool eval_flags(const struct bfs_expr *expr, struct bfs_eval *state) {
+	const struct bfs_stat *statbuf = eval_stat(state);
+	if (!statbuf) {
+		return false;
+	}
+
+	if (!(statbuf->mask & BFS_STAT_ATTRS)) {
+		eval_error(state, "Couldn't get file %s.\n", bfs_stat_field_name(BFS_STAT_ATTRS));
+		return false;
+	}
+
+	unsigned long flags = statbuf->attrs;
+	unsigned long set = expr->set_flags;
+	unsigned long clear = expr->clear_flags;
+
+	switch (expr->flags_cmp) {
+	case BFS_MODE_EQUAL:
+		return flags == set && !(flags & clear);
+
+	case BFS_MODE_ALL:
+		return (flags & set) == set && !(flags & clear);
+
+	case BFS_MODE_ANY:
+		return (flags & set) || (flags & clear) != clear;
+	}
+
+	assert(!"Invalid comparison mode");
+	return false;
+}
+
+/**
+ * -fstype test.
+ */
+bool eval_fstype(const struct bfs_expr *expr, struct bfs_eval *state) {
+	const struct bfs_stat *statbuf = eval_stat(state);
+	if (!statbuf) {
+		return false;
+	}
+
+	const struct bfs_mtab *mtab = bfs_ctx_mtab(state->ctx);
+	if (!mtab) {
+		eval_report_error(state);
+		return false;
+	}
+
+	const char *type = bfs_fstype(mtab, statbuf);
+	return strcmp(type, expr->argv[1]) == 0;
+}
+
+/**
+ * -hidden test.
+ */
+bool eval_hidden(const struct bfs_expr *expr, struct bfs_eval *state) {
+	const struct BFTW *ftwbuf = state->ftwbuf;
+	const char *name = ftwbuf->path + ftwbuf->nameoff;
+
+	// Don't treat "." or ".." as hidden directories.  Otherwise we'd filter
+	// out everything when given
+	//
+	//     $ bfs . -nohidden
+	//     $ bfs .. -nohidden
+	return name[0] == '.' && strcmp(name, ".") != 0 && strcmp(name, "..") != 0;
+}
+
+/**
+ * -inum test.
+ */
+bool eval_inum(const struct bfs_expr *expr, struct bfs_eval *state) {
+	const struct bfs_stat *statbuf = eval_stat(state);
+	if (!statbuf) {
+		return false;
+	}
+
+	return bfs_expr_cmp(expr, statbuf->ino);
+}
+
+/**
+ * -links test.
+ */
+bool eval_links(const struct bfs_expr *expr, struct bfs_eval *state) {
+	const struct bfs_stat *statbuf = eval_stat(state);
+	if (!statbuf) {
+		return false;
+	}
+
+	return bfs_expr_cmp(expr, statbuf->nlink);
+}
+
+/**
+ * -i?lname test.
+ */
+bool eval_lname(const struct bfs_expr *expr, struct bfs_eval *state) {
+	bool ret = false;
+	char *name = NULL;
+
+	const struct BFTW *ftwbuf = state->ftwbuf;
+	if (ftwbuf->type != BFS_LNK) {
+		goto done;
+	}
+
+	const struct bfs_stat *statbuf = bftw_cached_stat(ftwbuf, BFS_STAT_NOFOLLOW);
+	size_t len = statbuf ? statbuf->size : 0;
+
+	name = xreadlinkat(ftwbuf->at_fd, ftwbuf->at_path, len);
+	if (!name) {
+		eval_report_error(state);
+		goto done;
+	}
+
+	ret = fnmatch(expr->argv[1], name, expr->num) == 0;
+
+done:
+	free(name);
+	return ret;
+}
+
+/**
+ * -i?name test.
+ */
+bool eval_name(const struct bfs_expr *expr, struct bfs_eval *state) {
+	const struct BFTW *ftwbuf = state->ftwbuf;
+
+	const char *name = ftwbuf->path + ftwbuf->nameoff;
+	char *copy = NULL;
+	if (ftwbuf->depth == 0) {
+		// Any trailing slashes are not part of the name.  This can only
+		// happen for the root path.
+		const char *slash = strchr(name, '/');
+		if (slash && slash > name) {
+			copy = strndup(name, slash - name);
+			if (!copy) {
+				eval_report_error(state);
+				return false;
+			}
+			name = copy;
+		}
+	}
+
+	bool ret = fnmatch(expr->argv[1], name, expr->num) == 0;
+	free(copy);
+	return ret;
+}
+
+/**
+ * -i?path test.
+ */
+bool eval_path(const struct bfs_expr *expr, struct bfs_eval *state) {
+	const struct BFTW *ftwbuf = state->ftwbuf;
+	return fnmatch(expr->argv[1], ftwbuf->path, expr->num) == 0;
+}
+
+/**
+ * -perm test.
+ */
+bool eval_perm(const struct bfs_expr *expr, struct bfs_eval *state) {
+	const struct bfs_stat *statbuf = eval_stat(state);
+	if (!statbuf) {
+		return false;
+	}
+
+	mode_t mode = statbuf->mode;
+	mode_t target;
+	if (state->ftwbuf->type == BFS_DIR) {
+		target = expr->dir_mode;
+	} else {
+		target = expr->file_mode;
+	}
+
+	switch (expr->mode_cmp) {
+	case BFS_MODE_EQUAL:
+		return (mode & 07777) == target;
+
+	case BFS_MODE_ALL:
+		return (mode & target) == target;
+
+	case BFS_MODE_ANY:
+		return !(mode & target) == !target;
+	}
+
+	assert(!"Invalid comparison mode");
+	return false;
+}
+
+/**
+ * -f?ls action.
+ */
+bool eval_fls(const struct bfs_expr *expr, struct bfs_eval *state) {
+	CFILE *cfile = expr->cfile;
+	FILE *file = cfile->file;
+	const struct bfs_users *users = bfs_ctx_users(state->ctx);
+	const struct bfs_groups *groups = bfs_ctx_groups(state->ctx);
+	const struct BFTW *ftwbuf = state->ftwbuf;
+	const struct bfs_stat *statbuf = eval_stat(state);
+	if (!statbuf) {
+		goto done;
+	}
+
+	uintmax_t ino = statbuf->ino;
+	uintmax_t block_size = state->ctx->posixly_correct ? 512 : 1024;
+	uintmax_t blocks = ((uintmax_t)statbuf->blocks*BFS_STAT_BLKSIZE + block_size - 1)/block_size;
+	char mode[11];
+	xstrmode(statbuf->mode, mode);
+	char acl = bfs_check_acl(ftwbuf) > 0 ? '+' : ' ';
+	uintmax_t nlink = statbuf->nlink;
+	if (fprintf(file, "%9ju %6ju %s%c %2ju", ino, blocks, mode, acl, nlink) < 0) {
+		goto error;
+	}
+
+	uintmax_t uid = statbuf->uid;
+	const struct passwd *pwd = users ? bfs_getpwuid(users, uid) : NULL;
+	if (pwd) {
+		if (fprintf(file, " %-8s", pwd->pw_name) < 0) {
+			goto error;
+		}
+	} else {
+		if (fprintf(file, " %-8ju", uid) < 0) {
+			goto error;
+		}
+	}
+
+	uintmax_t gid = statbuf->gid;
+	const struct group *grp = groups ? bfs_getgrgid(groups, gid) : NULL;
+	if (grp) {
+		if (fprintf(file, " %-8s", grp->gr_name) < 0) {
+			goto error;
+		}
+	} else {
+		if (fprintf(file, " %-8ju", gid) < 0) {
+			goto error;
+		}
+	}
+
+	if (ftwbuf->type == BFS_BLK || ftwbuf->type == BFS_CHR) {
+		int ma = bfs_major(statbuf->rdev);
+		int mi = bfs_minor(statbuf->rdev);
+		if (fprintf(file, " %3d, %3d", ma, mi) < 0) {
+			goto error;
+		}
+	} else {
+		uintmax_t size = statbuf->size;
+		if (fprintf(file, " %8ju", size) < 0) {
+			goto error;
+		}
+	}
+
+	time_t time = statbuf->mtime.tv_sec;
+	time_t now = expr->reftime.tv_sec;
+	time_t six_months_ago = now - 6*30*24*60*60;
+	time_t tomorrow = now + 24*60*60;
+	struct tm tm;
+	if (xlocaltime(&time, &tm) != 0) {
+		goto error;
+	}
+	char time_str[256];
+	const char *time_format = "%b %e %H:%M";
+	if (time <= six_months_ago || time >= tomorrow) {
+		time_format = "%b %e  %Y";
+	}
+	if (!strftime(time_str, sizeof(time_str), time_format, &tm)) {
+		errno = EOVERFLOW;
+		goto error;
+	}
+	if (fprintf(file, " %s", time_str) < 0) {
+		goto error;
+	}
+
+	if (cfprintf(cfile, " %pP", ftwbuf) < 0) {
+		goto error;
+	}
+
+	if (ftwbuf->type == BFS_LNK) {
+		if (cfprintf(cfile, " -> %pL", ftwbuf) < 0) {
+			goto error;
+		}
+	}
+
+	if (fputc('\n', file) == EOF) {
+		goto error;
+	}
+
+done:
+	return true;
+
+error:
+	eval_report_error(state);
+	return true;
+}
+
+/**
+ * -f?print action.
+ */
+bool eval_fprint(const struct bfs_expr *expr, struct bfs_eval *state) {
+	if (cfprintf(expr->cfile, "%pP\n", state->ftwbuf) < 0) {
+		eval_report_error(state);
+	}
+	return true;
+}
+
+/**
+ * -f?print0 action.
+ */
+bool eval_fprint0(const struct bfs_expr *expr, struct bfs_eval *state) {
+	const char *path = state->ftwbuf->path;
+	size_t length = strlen(path) + 1;
+	if (fwrite(path, 1, length, expr->cfile->file) != length) {
+		eval_report_error(state);
+	}
+	return true;
+}
+
+/**
+ * -f?printf action.
+ */
+bool eval_fprintf(const struct bfs_expr *expr, struct bfs_eval *state) {
+	if (bfs_printf(expr->cfile, expr->printf, state->ftwbuf) != 0) {
+		eval_report_error(state);
+	}
+
+	return true;
+}
+
+/**
+ * -printx action.
+ */
+bool eval_fprintx(const struct bfs_expr *expr, struct bfs_eval *state) {
+	FILE *file = expr->cfile->file;
+	const char *path = state->ftwbuf->path;
+
+	while (true) {
+		size_t span = strcspn(path, " \t\n\\$'\"`");
+		if (fwrite(path, 1, span, file) != span) {
+			goto error;
+		}
+		path += span;
+
+		char c = path[0];
+		if (!c) {
+			break;
+		}
+
+		char escaped[] = {'\\', c};
+		if (fwrite(escaped, 1, sizeof(escaped), file) != sizeof(escaped)) {
+			goto error;
+		}
+		++path;
+	}
+
+
+	if (fputc('\n', file) == EOF) {
+		goto error;
+	}
+
+	return true;
+
+error:
+	eval_report_error(state);
+	return true;
+}
+
+/**
+ * -prune action.
+ */
+bool eval_prune(const struct bfs_expr *expr, struct bfs_eval *state) {
+	state->action = BFTW_PRUNE;
+	return true;
+}
+
+/**
+ * -quit action.
+ */
+bool eval_quit(const struct bfs_expr *expr, struct bfs_eval *state) {
+	state->action = BFTW_STOP;
+	state->quit = true;
+	return true;
+}
+
+/**
+ * -i?regex test.
+ */
+bool eval_regex(const struct bfs_expr *expr, struct bfs_eval *state) {
+	const char *path = state->ftwbuf->path;
+
+	int ret = bfs_regexec(expr->regex, path, BFS_REGEX_ANCHOR);
+	if (ret < 0) {
+		char *str = bfs_regerror(expr->regex);
+		if (str) {
+			eval_error(state, "%s.\n", str);
+			free(str);
+		} else {
+			eval_error(state, "bfs_regerror(): %m.\n");
+		}
+	}
+
+	return ret > 0;
+}
+
+/**
+ * -samefile test.
+ */
+bool eval_samefile(const struct bfs_expr *expr, struct bfs_eval *state) {
+	const struct bfs_stat *statbuf = eval_stat(state);
+	if (!statbuf) {
+		return false;
+	}
+
+	return statbuf->dev == expr->dev && statbuf->ino == expr->ino;
+}
+
+/**
+ * -size test.
+ */
+bool eval_size(const struct bfs_expr *expr, struct bfs_eval *state) {
+	const struct bfs_stat *statbuf = eval_stat(state);
+	if (!statbuf) {
+		return false;
+	}
+
+	static const off_t scales[] = {
+		[BFS_BLOCKS] = 512,
+		[BFS_BYTES] = 1,
+		[BFS_WORDS] = 2,
+		[BFS_KB] = 1LL << 10,
+		[BFS_MB] = 1LL << 20,
+		[BFS_GB] = 1LL << 30,
+		[BFS_TB] = 1LL << 40,
+		[BFS_PB] = 1LL << 50,
+	};
+
+	off_t scale = scales[expr->size_unit];
+	off_t size = (statbuf->size + scale - 1)/scale; // Round up
+	return bfs_expr_cmp(expr, size);
+}
+
+/**
+ * -sparse test.
+ */
+bool eval_sparse(const struct bfs_expr *expr, struct bfs_eval *state) {
+	const struct bfs_stat *statbuf = eval_stat(state);
+	if (!statbuf) {
+		return false;
+	}
+
+	blkcnt_t expected = (statbuf->size + BFS_STAT_BLKSIZE - 1)/BFS_STAT_BLKSIZE;
+	return statbuf->blocks < expected;
+}
+
+/**
+ * -type test.
+ */
+bool eval_type(const struct bfs_expr *expr, struct bfs_eval *state) {
+	return (1 << state->ftwbuf->type) & expr->num;
+}
+
+/**
+ * -xattr test.
+ */
+bool eval_xattr(const struct bfs_expr *expr, struct bfs_eval *state) {
+	int ret = bfs_check_xattrs(state->ftwbuf);
+	if (ret >= 0) {
+		return ret;
+	} else {
+		eval_report_error(state);
+		return false;
+	}
+}
+
+/**
+ * -xattrname test.
+ */
+bool eval_xattrname(const struct bfs_expr *expr, struct bfs_eval *state) {
+	int ret = bfs_check_xattr_named(state->ftwbuf, expr->argv[1]);
+	if (ret >= 0) {
+		return ret;
+	} else {
+		eval_report_error(state);
+		return false;
+	}
+}
+
+/**
+ * -xtype test.
+ */
+bool eval_xtype(const struct bfs_expr *expr, struct bfs_eval *state) {
+	const struct BFTW *ftwbuf = state->ftwbuf;
+	enum bfs_stat_flags flags = ftwbuf->stat_flags ^ (BFS_STAT_NOFOLLOW | BFS_STAT_TRYFOLLOW);
+	enum bfs_type type = bftw_type(ftwbuf, flags);
+	if (type == BFS_ERROR) {
+		eval_report_error(state);
+		return false;
+	} else {
+		return (1 << type) & expr->num;
+	}
+}
+
+#if _POSIX_MONOTONIC_CLOCK > 0
+#	define BFS_CLOCK CLOCK_MONOTONIC
+#elif _POSIX_TIMERS > 0
+#	define BFS_CLOCK CLOCK_REALTIME
+#endif
+
+/**
+ * Call clock_gettime(), if available.
+ */
+static int eval_gettime(struct bfs_eval *state, struct timespec *ts) {
+#ifdef BFS_CLOCK
+	int ret = clock_gettime(BFS_CLOCK, ts);
+	if (ret != 0) {
+		bfs_warning(state->ctx, "%pP: clock_gettime(): %m.\n", state->ftwbuf);
+	}
+	return ret;
+#else
+	return -1;
+#endif
+}
+
+/**
+ * Record an elapsed time.
+ */
+static void timespec_elapsed(struct timespec *elapsed, const struct timespec *start, const struct timespec *end) {
+	elapsed->tv_sec += end->tv_sec - start->tv_sec;
+	elapsed->tv_nsec += end->tv_nsec - start->tv_nsec;
+	if (elapsed->tv_nsec < 0) {
+		elapsed->tv_nsec += 1000000000L;
+		--elapsed->tv_sec;
+	} else if (elapsed->tv_nsec >= 1000000000L) {
+		elapsed->tv_nsec -= 1000000000L;
+		++elapsed->tv_sec;
+	}
+}
+
+/**
+ * Evaluate an expression.
+ */
+static bool eval_expr(struct bfs_expr *expr, struct bfs_eval *state) {
+	struct timespec start, end;
+	bool time = state->ctx->debug & DEBUG_RATES;
+	if (time) {
+		if (eval_gettime(state, &start) != 0) {
+			time = false;
+		}
+	}
+
+	assert(!state->quit);
+
+	bool ret = expr->eval_fn(expr, state);
+
+	if (time) {
+		if (eval_gettime(state, &end) == 0) {
+			timespec_elapsed(&expr->elapsed, &start, &end);
+		}
+	}
+
+	++expr->evaluations;
+	if (ret) {
+		++expr->successes;
+	}
+
+	if (bfs_expr_never_returns(expr)) {
+		assert(state->quit);
+	} else if (!state->quit) {
+		assert(!expr->always_true || ret);
+		assert(!expr->always_false || !ret);
+	}
+
+	return ret;
+}
+
+/**
+ * Evaluate a negation.
+ */
+bool eval_not(const struct bfs_expr *expr, struct bfs_eval *state) {
+	return !eval_expr(expr->rhs, state);
+}
+
+/**
+ * Evaluate a conjunction.
+ */
+bool eval_and(const struct bfs_expr *expr, struct bfs_eval *state) {
+	if (!eval_expr(expr->lhs, state)) {
+		return false;
+	}
+
+	if (state->quit) {
+		return false;
+	}
+
+	return eval_expr(expr->rhs, state);
+}
+
+/**
+ * Evaluate a disjunction.
+ */
+bool eval_or(const struct bfs_expr *expr, struct bfs_eval *state) {
+	if (eval_expr(expr->lhs, state)) {
+		return true;
+	}
+
+	if (state->quit) {
+		return false;
+	}
+
+	return eval_expr(expr->rhs, state);
+}
+
+/**
+ * Evaluate the comma operator.
+ */
+bool eval_comma(const struct bfs_expr *expr, struct bfs_eval *state) {
+	eval_expr(expr->lhs, state);
+
+	if (state->quit) {
+		return false;
+	}
+
+	return eval_expr(expr->rhs, state);
+}
+
+/** Update the status bar. */
+static void eval_status(struct bfs_eval *state, struct bfs_bar *bar, struct timespec *last_status, size_t count) {
+	struct timespec now;
+	if (eval_gettime(state, &now) == 0) {
+		struct timespec elapsed = {0};
+		timespec_elapsed(&elapsed, last_status, &now);
+
+		// Update every 0.1s
+		if (elapsed.tv_sec > 0 || elapsed.tv_nsec >= 100000000L) {
+			*last_status = now;
+		} else {
+			return;
+		}
+	}
+
+	size_t width = bfs_bar_width(bar);
+	if (width < 3) {
+		return;
+	}
+
+	const struct BFTW *ftwbuf = state->ftwbuf;
+
+	char *rhs = dstrprintf(" (visited: %zu, depth: %2zu)", count, ftwbuf->depth);
+	if (!rhs) {
+		return;
+	}
+
+	size_t rhslen = dstrlen(rhs);
+	if (3 + rhslen > width) {
+		dstresize(&rhs, 0);
+		rhslen = 0;
+	}
+
+	char *status = dstralloc(0);
+	if (!status) {
+		goto out_rhs;
+	}
+
+	const char *path = ftwbuf->path;
+	size_t pathlen = ftwbuf->nameoff;
+	if (ftwbuf->depth == 0) {
+		pathlen = strlen(path);
+	}
+
+	// Try to make sure even wide characters fit in the status bar
+	size_t pathmax = width - rhslen - 3;
+	size_t pathwidth = 0;
+	mbstate_t mb;
+	memset(&mb, 0, sizeof(mb));
+	while (pathlen > 0) {
+		wchar_t wc;
+		size_t len = mbrtowc(&wc, path, pathlen, &mb);
+		int cwidth;
+		if (len == (size_t)-1) {
+			// Invalid byte sequence, assume a single-width '?'
+			len = 1;
+			cwidth = 1;
+			memset(&mb, 0, sizeof(mb));
+		} else if (len == (size_t)-2) {
+			// Incomplete byte sequence, assume a single-width '?'
+			len = pathlen;
+			cwidth = 1;
+		} else {
+			cwidth = wcwidth(wc);
+			if (cwidth < 0) {
+				cwidth = 0;
+			}
+		}
+
+		if (pathwidth + cwidth > pathmax) {
+			break;
+		}
+
+		if (dstrncat(&status, path, len) != 0) {
+			goto out_rhs;
+		}
+
+		path += len;
+		pathlen -= len;
+		pathwidth += cwidth;
+	}
+
+	if (dstrcat(&status, "...") != 0) {
+		goto out_rhs;
+	}
+
+	while (pathwidth < pathmax) {
+		if (dstrapp(&status, ' ') != 0) {
+			goto out_rhs;
+		}
+		++pathwidth;
+	}
+
+	if (dstrcat(&status, rhs) != 0) {
+		goto out_rhs;
+	}
+
+	bfs_bar_update(bar, status);
+
+	dstrfree(status);
+out_rhs:
+	dstrfree(rhs);
+}
+
+/** Check if we've seen a file before. */
+static bool eval_file_unique(struct bfs_eval *state, struct trie *seen) {
+	const struct bfs_stat *statbuf = eval_stat(state);
+	if (!statbuf) {
+		return false;
+	}
+
+	bfs_file_id id;
+	bfs_stat_id(statbuf, &id);
+
+	struct trie_leaf *leaf = trie_insert_mem(seen, id, sizeof(id));
+	if (!leaf) {
+		eval_report_error(state);
+		return false;
+	}
+
+	if (leaf->value) {
+		state->action = BFTW_PRUNE;
+		return false;
+	} else {
+		leaf->value = leaf;
+		return true;
+	}
+}
+
+#define DEBUG_FLAG(flags, flag)				\
+	do {						\
+		if ((flags & flag) || flags == flag) {	\
+			fputs(#flag, stderr);		\
+			flags ^= flag;			\
+			if (flags) {			\
+				fputs(" | ", stderr);	\
+			}				\
+		}					\
+	} while (0)
+
+/**
+ * Log a stat() call.
+ */
+static void debug_stat(const struct bfs_ctx *ctx, const struct BFTW *ftwbuf, const struct bftw_stat *cache, enum bfs_stat_flags flags) {
+	bfs_debug_prefix(ctx, DEBUG_STAT);
+
+	fprintf(stderr, "bfs_stat(");
+	if (ftwbuf->at_fd == AT_FDCWD) {
+		fprintf(stderr, "AT_FDCWD");
+	} else {
+		size_t baselen = strlen(ftwbuf->path) - strlen(ftwbuf->at_path);
+		fprintf(stderr, "\"");
+		fwrite(ftwbuf->path, 1, baselen, stderr);
+		fprintf(stderr, "\"");
+	}
+
+	fprintf(stderr, ", \"%s\", ", ftwbuf->at_path);
+
+	DEBUG_FLAG(flags, BFS_STAT_FOLLOW);
+	DEBUG_FLAG(flags, BFS_STAT_NOFOLLOW);
+	DEBUG_FLAG(flags, BFS_STAT_TRYFOLLOW);
+
+	fprintf(stderr, ") == %d", cache->buf ? 0 : -1);
+
+	if (cache->error) {
+		fprintf(stderr, " [%d]", cache->error);
+	}
+
+	fprintf(stderr, "\n");
+}
+
+/**
+ * Log any stat() calls that happened.
+ */
+static void debug_stats(const struct bfs_ctx *ctx, const struct BFTW *ftwbuf) {
+	if (!(ctx->debug & DEBUG_STAT)) {
+		return;
+	}
+
+	const struct bfs_stat *statbuf = ftwbuf->stat_cache.buf;
+	if (statbuf || ftwbuf->stat_cache.error) {
+		debug_stat(ctx, ftwbuf, &ftwbuf->stat_cache, BFS_STAT_FOLLOW);
+	}
+
+	const struct bfs_stat *lstatbuf = ftwbuf->lstat_cache.buf;
+	if ((lstatbuf && lstatbuf != statbuf) || ftwbuf->lstat_cache.error) {
+		debug_stat(ctx, ftwbuf, &ftwbuf->lstat_cache, BFS_STAT_NOFOLLOW);
+	}
+}
+
+#define DUMP_MAP(value) [value] = #value
+
+/**
+ * Dump the bfs_type for -D search.
+ */
+static const char *dump_bfs_type(enum bfs_type type) {
+	static const char *types[] = {
+		DUMP_MAP(BFS_UNKNOWN),
+		DUMP_MAP(BFS_BLK),
+		DUMP_MAP(BFS_CHR),
+		DUMP_MAP(BFS_DIR),
+		DUMP_MAP(BFS_DOOR),
+		DUMP_MAP(BFS_FIFO),
+		DUMP_MAP(BFS_LNK),
+		DUMP_MAP(BFS_PORT),
+		DUMP_MAP(BFS_REG),
+		DUMP_MAP(BFS_SOCK),
+		DUMP_MAP(BFS_WHT),
+	};
+
+	if (type == BFS_ERROR) {
+		return "BFS_ERROR";
+	} else {
+		return types[type];
+	}
+}
+
+/**
+ * Dump the bftw_visit for -D search.
+ */
+static const char *dump_bftw_visit(enum bftw_visit visit) {
+	static const char *visits[] = {
+		DUMP_MAP(BFTW_PRE),
+		DUMP_MAP(BFTW_POST),
+	};
+	return visits[visit];
+}
+
+/**
+ * Dump the bftw_action for -D search.
+ */
+static const char *dump_bftw_action(enum bftw_action action) {
+	static const char *actions[] = {
+		DUMP_MAP(BFTW_CONTINUE),
+		DUMP_MAP(BFTW_PRUNE),
+		DUMP_MAP(BFTW_STOP),
+	};
+	return actions[action];
+}
+
+/**
+ * Type passed as the argument to the bftw() callback.
+ */
+struct callback_args {
+	/** The bfs context. */
+	const struct bfs_ctx *ctx;
+
+	/** The status bar. */
+	struct bfs_bar *bar;
+	/** The time of the last status update. */
+	struct timespec last_status;
+	/** The number of files visited so far. */
+	size_t count;
+
+	/** The set of seen files. */
+	struct trie *seen;
+
+	/** Eventual return value from bfs_eval(). */
+	int ret;
+};
+
+/**
+ * bftw() callback.
+ */
+static enum bftw_action eval_callback(const struct BFTW *ftwbuf, void *ptr) {
+	struct callback_args *args = ptr;
+	++args->count;
+
+	const struct bfs_ctx *ctx = args->ctx;
+
+	struct bfs_eval state;
+	state.ftwbuf = ftwbuf;
+	state.ctx = ctx;
+	state.action = BFTW_CONTINUE;
+	state.ret = &args->ret;
+	state.quit = false;
+
+	if (args->bar) {
+		eval_status(&state, args->bar, &args->last_status, args->count);
+	}
+
+	if (ftwbuf->type == BFS_ERROR) {
+		if (!eval_should_ignore(&state, ftwbuf->error)) {
+			eval_error(&state, "%s.\n", strerror(ftwbuf->error));
+		}
+		state.action = BFTW_PRUNE;
+		goto done;
+	}
+
+	if (ctx->unique && ftwbuf->visit == BFTW_PRE) {
+		if (!eval_file_unique(&state, args->seen)) {
+			goto done;
+		}
+	}
+
+	if (eval_expr(ctx->exclude, &state)) {
+		state.action = BFTW_PRUNE;
+		goto done;
+	}
+
+	if (ctx->xargs_safe && strpbrk(ftwbuf->path, " \t\n\'\"\\")) {
+		eval_error(&state, "Path is not safe for xargs.\n");
+		state.action = BFTW_PRUNE;
+		goto done;
+	}
+
+	if (ctx->maxdepth < 0 || ftwbuf->depth >= (size_t)ctx->maxdepth) {
+		state.action = BFTW_PRUNE;
+	}
+
+	// In -depth mode, only handle directories on the BFTW_POST visit
+	enum bftw_visit expected_visit = BFTW_PRE;
+	if ((ctx->flags & BFTW_POST_ORDER)
+	    && (ctx->strategy == BFTW_IDS || ftwbuf->type == BFS_DIR)
+	    && ftwbuf->depth < (size_t)ctx->maxdepth) {
+		expected_visit = BFTW_POST;
+	}
+
+	if (ftwbuf->visit == expected_visit
+	    && ftwbuf->depth >= (size_t)ctx->mindepth
+	    && ftwbuf->depth <= (size_t)ctx->maxdepth) {
+		eval_expr(ctx->expr, &state);
+	}
+
+done:
+	debug_stats(ctx, ftwbuf);
+
+	if (bfs_debug(ctx, DEBUG_SEARCH, "eval_callback({\n")) {
+		fprintf(stderr, "\t.path = \"%s\",\n", ftwbuf->path);
+		fprintf(stderr, "\t.root = \"%s\",\n", ftwbuf->root);
+		fprintf(stderr, "\t.depth = %zu,\n", ftwbuf->depth);
+		fprintf(stderr, "\t.visit = %s,\n", dump_bftw_visit(ftwbuf->visit));
+		fprintf(stderr, "\t.type = %s,\n", dump_bfs_type(ftwbuf->type));
+		fprintf(stderr, "\t.error = %d,\n", ftwbuf->error);
+		fprintf(stderr, "}) == %s\n", dump_bftw_action(state.action));
+	}
+
+	return state.action;
+}
+
+/** Check if an rlimit value is infinite. */
+static bool rlim_isinf(rlim_t r) {
+	// Consider RLIM_{INFINITY,SAVED_{CUR,MAX}} all equally infinite
+	if (r == RLIM_INFINITY) {
+		return true;
+	}
+
+#ifdef RLIM_SAVED_CUR
+	if (r == RLIM_SAVED_CUR) {
+		return true;
+	}
+#endif
+
+#ifdef RLIM_SAVED_MAX
+	if (r == RLIM_SAVED_MAX) {
+		return true;
+	}
+#endif
+
+	return false;
+}
+
+/** Compare two rlimit values, accounting for RLIM_INFINITY etc. */
+static int rlim_cmp(rlim_t a, rlim_t b) {
+	bool a_inf = rlim_isinf(a);
+	bool b_inf = rlim_isinf(b);
+	if (a_inf || b_inf) {
+		return a_inf - b_inf;
+	}
+
+	return (a > b) - (a < b);
+}
+
+/** Raise RLIMIT_NOFILE if possible, and return the new limit. */
+static int raise_fdlimit(const struct bfs_ctx *ctx) {
+	rlim_t target = 64 << 10;
+	if (rlim_cmp(target, ctx->nofile_hard) > 0) {
+		target = ctx->nofile_hard;
+	}
+
+	int ret = target;
+
+	if (rlim_cmp(target, ctx->nofile_soft) > 0) {
+		const struct rlimit rl = {
+			.rlim_cur = target,
+			.rlim_max = ctx->nofile_hard,
+		};
+		if (setrlimit(RLIMIT_NOFILE, &rl) != 0) {
+			ret = ctx->nofile_soft;
+		}
+	}
+
+	return ret;
+}
+
+/** Infer the number of file descriptors available to bftw(). */
+static int infer_fdlimit(const struct bfs_ctx *ctx, int limit) {
+	// 3 for std{in,out,err}
+	int nopen = 3 + ctx->nfiles;
+
+	// Check /proc/self/fd for the current number of open fds, if possible
+	// (we may have inherited more than just the standard ones)
+	struct bfs_dir *dir = bfs_opendir(AT_FDCWD, "/proc/self/fd");
+	if (!dir) {
+		dir = bfs_opendir(AT_FDCWD, "/dev/fd");
+	}
+	if (dir) {
+		// Account for 'dir' itself
+		nopen = -1;
+
+		while (bfs_readdir(dir, NULL) > 0) {
+			++nopen;
+		}
+
+		bfs_closedir(dir);
+	}
+
+	int ret = limit - nopen;
+	ret -= ctx->expr->persistent_fds;
+	ret -= ctx->expr->ephemeral_fds;
+
+	// bftw() needs at least 2 available fds
+	if (ret < 2) {
+		ret = 2;
+	}
+
+	return ret;
+}
+
+/**
+ * Dump the bftw() flags for -D search.
+ */
+static void dump_bftw_flags(enum bftw_flags flags) {
+	DEBUG_FLAG(flags, 0);
+	DEBUG_FLAG(flags, BFTW_STAT);
+	DEBUG_FLAG(flags, BFTW_RECOVER);
+	DEBUG_FLAG(flags, BFTW_POST_ORDER);
+	DEBUG_FLAG(flags, BFTW_FOLLOW_ROOTS);
+	DEBUG_FLAG(flags, BFTW_FOLLOW_ALL);
+	DEBUG_FLAG(flags, BFTW_DETECT_CYCLES);
+	DEBUG_FLAG(flags, BFTW_SKIP_MOUNTS);
+	DEBUG_FLAG(flags, BFTW_PRUNE_MOUNTS);
+	DEBUG_FLAG(flags, BFTW_SORT);
+	DEBUG_FLAG(flags, BFTW_BUFFER);
+
+	assert(!flags);
+}
+
+/**
+ * Dump the bftw_strategy for -D search.
+ */
+static const char *dump_bftw_strategy(enum bftw_strategy strategy) {
+	static const char *strategies[] = {
+		DUMP_MAP(BFTW_BFS),
+		DUMP_MAP(BFTW_DFS),
+		DUMP_MAP(BFTW_IDS),
+		DUMP_MAP(BFTW_EDS),
+	};
+	return strategies[strategy];
+}
+
+/** Check if we need to enable BFTW_BUFFER. */
+static bool eval_must_buffer(const struct bfs_expr *expr) {
+#if __FreeBSD__
+	// FreeBSD doesn't properly handle adding/removing directory entries
+	// during readdir() on NFS mounts.  Work around it by passing BFTW_BUFFER
+	// whenever we could be mutating the directory ourselves through -delete
+	// or -exec.  We don't attempt to handle concurrent modification by other
+	// processes, which are racey anyway.
+	//
+	// https://bugs.freebsd.org/bugzilla/show_bug.cgi?id=57696
+	// https://github.com/tavianator/bfs/issues/67
+
+	if (expr->eval_fn == eval_delete || expr->eval_fn == eval_exec) {
+		return true;
+	}
+
+	if (bfs_expr_has_children(expr)) {
+		if (expr->lhs && eval_must_buffer(expr->lhs)) {
+			return true;
+		}
+
+		if (expr->rhs && eval_must_buffer(expr->rhs)) {
+			return true;
+		}
+	}
+#endif // __FreeBSD__
+
+	return false;
+}
+
+int bfs_eval(const struct bfs_ctx *ctx) {
+	if (!ctx->expr) {
+		return EXIT_SUCCESS;
+	}
+
+	struct callback_args args = {
+		.ctx = ctx,
+		.ret = EXIT_SUCCESS,
+	};
+
+	if (ctx->status) {
+		args.bar = bfs_bar_show();
+		if (!args.bar) {
+			bfs_warning(ctx, "Couldn't show status bar: %m.\n\n");
+		}
+	}
+
+	struct trie seen;
+	if (ctx->unique) {
+		trie_init(&seen);
+		args.seen = &seen;
+	}
+
+	int fdlimit = raise_fdlimit(ctx);
+	fdlimit = infer_fdlimit(ctx, fdlimit);
+
+	struct bftw_args bftw_args = {
+		.paths = ctx->paths,
+		.npaths = darray_length(ctx->paths),
+		.callback = eval_callback,
+		.ptr = &args,
+		.nopenfd = fdlimit,
+		.flags = ctx->flags,
+		.strategy = ctx->strategy,
+		.mtab = bfs_ctx_mtab(ctx),
+	};
+
+	if (eval_must_buffer(ctx->expr)) {
+		bftw_args.flags |= BFTW_BUFFER;
+	}
+
+	if (bfs_debug(ctx, DEBUG_SEARCH, "bftw({\n")) {
+		fprintf(stderr, "\t.paths = {\n");
+		for (size_t i = 0; i < bftw_args.npaths; ++i) {
+			fprintf(stderr, "\t\t\"%s\",\n", bftw_args.paths[i]);
+		}
+		fprintf(stderr, "\t},\n");
+		fprintf(stderr, "\t.npaths = %zu,\n", bftw_args.npaths);
+		fprintf(stderr, "\t.callback = eval_callback,\n");
+		fprintf(stderr, "\t.ptr = &args,\n");
+		fprintf(stderr, "\t.nopenfd = %d,\n", bftw_args.nopenfd);
+		fprintf(stderr, "\t.flags = ");
+		dump_bftw_flags(bftw_args.flags);
+		fprintf(stderr, ",\n\t.strategy = %s,\n", dump_bftw_strategy(bftw_args.strategy));
+		fprintf(stderr, "\t.mtab = ");
+		if (bftw_args.mtab) {
+			fprintf(stderr, "ctx->mtab");
+		} else {
+			fprintf(stderr, "NULL");
+		}
+		fprintf(stderr, ",\n})\n");
+	}
+
+	if (bftw(&bftw_args) != 0) {
+		args.ret = EXIT_FAILURE;
+		bfs_perror(ctx, "bftw()");
+	}
+
+	if (eval_exec_finish(ctx->expr, ctx) != 0) {
+		args.ret = EXIT_FAILURE;
+	}
+
+	bfs_ctx_dump(ctx, DEBUG_RATES);
+
+	if (ctx->unique) {
+		trie_destroy(&seen);
+	}
+
+	bfs_bar_hide(args.bar);
+
+	return args.ret;
+}
diff --git a/src/eval.h b/src/eval.h
new file mode 100644
index 0000000..a1bbd2f
--- /dev/null
+++ b/src/eval.h
@@ -0,0 +1,113 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2015-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * The evaluation functions that implement literal expressions like -name,
+ * -print, etc.
+ */
+
+#ifndef BFS_EVAL_H
+#define BFS_EVAL_H
+
+#include <stdbool.h>
+
+struct bfs_ctx;
+struct bfs_expr;
+
+/**
+ * Ephemeral state for evaluating an expression.
+ */
+struct bfs_eval;
+
+/**
+ * Expression evaluation function.
+ *
+ * @param expr
+ *         The current expression.
+ * @param state
+ *         The current evaluation state.
+ * @return
+ *         The result of the test.
+ */
+typedef bool bfs_eval_fn(const struct bfs_expr *expr, struct bfs_eval *state);
+
+/**
+ * Evaluate the command line.
+ *
+ * @param ctx
+ *         The bfs context to evaluate.
+ * @return
+ *         EXIT_SUCCESS on success, otherwise on failure.
+ */
+int bfs_eval(const struct bfs_ctx *ctx);
+
+// Predicate evaluation functions
+
+bool eval_true(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_false(const struct bfs_expr *expr, struct bfs_eval *state);
+
+bool eval_access(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_acl(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_capable(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_perm(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_xattr(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_xattrname(const struct bfs_expr *expr, struct bfs_eval *state);
+
+bool eval_newer(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_time(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_used(const struct bfs_expr *expr, struct bfs_eval *state);
+
+bool eval_gid(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_uid(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_nogroup(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_nouser(const struct bfs_expr *expr, struct bfs_eval *state);
+
+bool eval_depth(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_empty(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_flags(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_fstype(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_hidden(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_inum(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_links(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_samefile(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_size(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_sparse(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_type(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_xtype(const struct bfs_expr *expr, struct bfs_eval *state);
+
+bool eval_lname(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_name(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_path(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_regex(const struct bfs_expr *expr, struct bfs_eval *state);
+
+bool eval_delete(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_exec(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_exit(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_fls(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_fprint(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_fprint0(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_fprintf(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_fprintx(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_prune(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_quit(const struct bfs_expr *expr, struct bfs_eval *state);
+
+// Operator evaluation functions
+bool eval_not(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_and(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_or(const struct bfs_expr *expr, struct bfs_eval *state);
+bool eval_comma(const struct bfs_expr *expr, struct bfs_eval *state);
+
+#endif // BFS_EVAL_H
diff --git a/src/exec.c b/src/exec.c
new file mode 100644
index 0000000..0130317
--- /dev/null
+++ b/src/exec.c
@@ -0,0 +1,715 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2017-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+#include "exec.h"
+#include "bftw.h"
+#include "ctx.h"
+#include "color.h"
+#include "diag.h"
+#include "dstring.h"
+#include "util.h"
+#include "xspawn.h"
+#include <assert.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/resource.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+/** Print some debugging info. */
+BFS_FORMATTER(2, 3)
+static void bfs_exec_debug(const struct bfs_exec *execbuf, const char *format, ...) {
+	const struct bfs_ctx *ctx = execbuf->ctx;
+
+	if (!bfs_debug(ctx, DEBUG_EXEC, "${blu}")) {
+		return;
+	}
+
+	if (execbuf->flags & BFS_EXEC_CONFIRM) {
+		fputs("-ok", stderr);
+	} else {
+		fputs("-exec", stderr);
+	}
+	if (execbuf->flags & BFS_EXEC_CHDIR) {
+		fputs("dir", stderr);
+	}
+	cfprintf(ctx->cerr, "${rs}: ");
+
+	va_list args;
+	va_start(args, format);
+	vfprintf(stderr, format, args);
+	va_end(args);
+}
+
+/** Determine the size of a single argument, for comparison to arg_max. */
+static size_t bfs_exec_arg_size(const char *arg) {
+	return sizeof(arg) + strlen(arg) + 1;
+}
+
+/** Even if we can pass a bigger argument list, cap it here. */
+#define BFS_EXEC_ARG_MAX (16 << 20)
+
+/** Determine the maximum argv size. */
+static size_t bfs_exec_arg_max(const struct bfs_exec *execbuf) {
+	long arg_max = sysconf(_SC_ARG_MAX);
+	bfs_exec_debug(execbuf, "ARG_MAX: %ld according to sysconf()\n", arg_max);
+	if (arg_max < 0) {
+		arg_max = BFS_EXEC_ARG_MAX;
+		bfs_exec_debug(execbuf, "ARG_MAX: %ld assumed\n", arg_max);
+	}
+
+	// We have to share space with the environment variables
+	extern char **environ;
+	for (char **envp = environ; *envp; ++envp) {
+		arg_max -= bfs_exec_arg_size(*envp);
+	}
+	// Account for the terminating NULL entry
+	arg_max -= sizeof(char *);
+	bfs_exec_debug(execbuf, "ARG_MAX: %ld remaining after environment variables\n", arg_max);
+
+	// Account for the fixed arguments
+	for (size_t i = 0; i < execbuf->tmpl_argc - 1; ++i) {
+		arg_max -= bfs_exec_arg_size(execbuf->tmpl_argv[i]);
+	}
+	// Account for the terminating NULL entry
+	arg_max -= sizeof(char *);
+	bfs_exec_debug(execbuf, "ARG_MAX: %ld remaining after fixed arguments\n", arg_max);
+
+	// Assume arguments are counted with the granularity of a single page,
+	// so allow a one page cushion to account for rounding up
+	long page_size = sysconf(_SC_PAGESIZE);
+	if (page_size < 4096) {
+		page_size = 4096;
+	}
+	arg_max -= page_size;
+	bfs_exec_debug(execbuf, "ARG_MAX: %ld remaining after page cushion\n", arg_max);
+
+	// POSIX recommends an additional 2048 bytes of headroom
+	arg_max -= 2048;
+	bfs_exec_debug(execbuf, "ARG_MAX: %ld remaining after headroom\n", arg_max);
+
+	if (arg_max < 0) {
+		arg_max = 0;
+	} else if (arg_max > BFS_EXEC_ARG_MAX) {
+		arg_max = BFS_EXEC_ARG_MAX;
+	}
+
+	bfs_exec_debug(execbuf, "ARG_MAX: %ld final value\n", arg_max);
+	return arg_max;
+}
+
+/** Highlight part of the command line as an error. */
+static void bfs_exec_parse_error(const struct bfs_ctx *ctx, const struct bfs_exec *execbuf) {
+	char **argv = execbuf->tmpl_argv - 1;
+	size_t argc = execbuf->tmpl_argc + 1;
+	if (argv[argc]) {
+		++argc;
+	}
+
+	bool args[ctx->argc];
+	for (size_t i = 0; i < ctx->argc; ++i) {
+		args[i] = false;
+	}
+
+	size_t i = argv - ctx->argv;
+	for (size_t j = 0; j < argc; ++j) {
+		args[i + j] = true;
+	}
+
+	bfs_argv_error(ctx, args);
+}
+
+struct bfs_exec *bfs_exec_parse(const struct bfs_ctx *ctx, char **argv, enum bfs_exec_flags flags) {
+	struct bfs_exec *execbuf = malloc(sizeof(*execbuf));
+	if (!execbuf) {
+		bfs_perror(ctx, "malloc()");
+		goto fail;
+	}
+
+	execbuf->flags = flags;
+	execbuf->ctx = ctx;
+	execbuf->tmpl_argv = argv + 1;
+	execbuf->tmpl_argc = 0;
+	execbuf->argv = NULL;
+	execbuf->argc = 0;
+	execbuf->argv_cap = 0;
+	execbuf->arg_size = 0;
+	execbuf->arg_max = 0;
+	execbuf->arg_min = 0;
+	execbuf->wd_fd = -1;
+	execbuf->wd_path = NULL;
+	execbuf->wd_len = 0;
+	execbuf->ret = 0;
+
+	while (true) {
+		const char *arg = execbuf->tmpl_argv[execbuf->tmpl_argc];
+		if (!arg) {
+			if (execbuf->flags & BFS_EXEC_CONFIRM) {
+				bfs_exec_parse_error(ctx, execbuf);
+				bfs_error(ctx, "Expected '... ;'.\n");
+			} else {
+				bfs_exec_parse_error(ctx, execbuf);
+				bfs_error(ctx, "Expected '... ;' or '... {} +'.\n");
+			}
+			goto fail;
+		} else if (strcmp(arg, ";") == 0) {
+			break;
+		} else if (strcmp(arg, "+") == 0) {
+			const char *prev = execbuf->tmpl_argv[execbuf->tmpl_argc - 1];
+			if (!(execbuf->flags & BFS_EXEC_CONFIRM) && strcmp(prev, "{}") == 0) {
+				execbuf->flags |= BFS_EXEC_MULTI;
+				break;
+			}
+		}
+
+		++execbuf->tmpl_argc;
+	}
+
+	if (execbuf->tmpl_argc == 0) {
+		bfs_exec_parse_error(ctx, execbuf);
+		bfs_error(ctx, "Missing command.\n");
+		goto fail;
+	}
+
+	execbuf->argv_cap = execbuf->tmpl_argc + 1;
+	execbuf->argv = malloc(execbuf->argv_cap*sizeof(*execbuf->argv));
+	if (!execbuf->argv) {
+		bfs_perror(ctx, "malloc()");
+		goto fail;
+	}
+
+	if (execbuf->flags & BFS_EXEC_MULTI) {
+		for (size_t i = 0; i < execbuf->tmpl_argc - 1; ++i) {
+			char *arg = execbuf->tmpl_argv[i];
+			if (strstr(arg, "{}")) {
+				bfs_exec_parse_error(ctx, execbuf);
+				bfs_error(ctx, "Only one '{}' is supported.\n");
+				goto fail;
+			}
+			execbuf->argv[i] = arg;
+		}
+		execbuf->argc = execbuf->tmpl_argc - 1;
+
+		execbuf->arg_max = bfs_exec_arg_max(execbuf);
+		execbuf->arg_min = execbuf->arg_max;
+	}
+
+	return execbuf;
+
+fail:
+	bfs_exec_free(execbuf);
+	return NULL;
+}
+
+/** Format the current path for use as a command line argument. */
+static char *bfs_exec_format_path(const struct bfs_exec *execbuf, const struct BFTW *ftwbuf) {
+	if (!(execbuf->flags & BFS_EXEC_CHDIR)) {
+		return strdup(ftwbuf->path);
+	}
+
+	const char *name = ftwbuf->path + ftwbuf->nameoff;
+
+	if (name[0] == '/') {
+		// Must be a root path ("/", "//", etc.)
+		return strdup(name);
+	}
+
+	// For compatibility with GNU find, use './name' instead of just 'name'
+	char *path = malloc(2 + strlen(name) + 1);
+	if (!path) {
+		return NULL;
+	}
+
+	strcpy(path, "./");
+	strcpy(path + 2, name);
+
+	return path;
+}
+
+/** Format an argument, expanding "{}" to the current path. */
+static char *bfs_exec_format_arg(char *arg, const char *path) {
+	char *match = strstr(arg, "{}");
+	if (!match) {
+		return arg;
+	}
+
+	char *ret = dstralloc(0);
+	if (!ret) {
+		return NULL;
+	}
+
+	char *last = arg;
+	do {
+		if (dstrncat(&ret, last, match - last) != 0) {
+			goto err;
+		}
+		if (dstrcat(&ret, path) != 0) {
+			goto err;
+		}
+
+		last = match + 2;
+		match = strstr(last, "{}");
+	} while (match);
+
+	if (dstrcat(&ret, last) != 0) {
+		goto err;
+	}
+
+	return ret;
+
+err:
+	dstrfree(ret);
+	return NULL;
+}
+
+/** Free a formatted argument. */
+static void bfs_exec_free_arg(char *arg, const char *tmpl) {
+	if (arg != tmpl) {
+		dstrfree(arg);
+	}
+}
+
+/** Open a file to use as the working directory. */
+static int bfs_exec_openwd(struct bfs_exec *execbuf, const struct BFTW *ftwbuf) {
+	assert(execbuf->wd_fd < 0);
+	assert(!execbuf->wd_path);
+
+	if (ftwbuf->at_fd != AT_FDCWD) {
+		// Rely on at_fd being the immediate parent
+		assert(ftwbuf->at_path == xbasename(ftwbuf->at_path));
+
+		execbuf->wd_fd = ftwbuf->at_fd;
+		if (!(execbuf->flags & BFS_EXEC_MULTI)) {
+			return 0;
+		}
+
+		execbuf->wd_fd = dup_cloexec(execbuf->wd_fd);
+		if (execbuf->wd_fd < 0) {
+			return -1;
+		}
+	}
+
+	execbuf->wd_len = ftwbuf->nameoff;
+	if (execbuf->wd_len == 0) {
+		if (ftwbuf->path[0] == '/') {
+			++execbuf->wd_len;
+		} else {
+			// The path is something like "foo", so we're already in the right directory
+			return 0;
+		}
+	}
+
+	execbuf->wd_path = strndup(ftwbuf->path, execbuf->wd_len);
+	if (!execbuf->wd_path) {
+		return -1;
+	}
+
+	if (execbuf->wd_fd < 0) {
+		execbuf->wd_fd = open(execbuf->wd_path, O_RDONLY | O_CLOEXEC | O_DIRECTORY);
+	}
+
+	if (execbuf->wd_fd < 0) {
+		return -1;
+	}
+
+	return 0;
+}
+
+/** Close the working directory. */
+static void bfs_exec_closewd(struct bfs_exec *execbuf, const struct BFTW *ftwbuf) {
+	if (execbuf->wd_fd >= 0) {
+		if (!ftwbuf || execbuf->wd_fd != ftwbuf->at_fd) {
+			xclose(execbuf->wd_fd);
+		}
+		execbuf->wd_fd = -1;
+	}
+
+	if (execbuf->wd_path) {
+		free(execbuf->wd_path);
+		execbuf->wd_path = NULL;
+		execbuf->wd_len = 0;
+	}
+}
+
+/** Actually spawn the process. */
+static int bfs_exec_spawn(const struct bfs_exec *execbuf) {
+	if (execbuf->flags & BFS_EXEC_CONFIRM) {
+		for (size_t i = 0; i < execbuf->argc; ++i) {
+			if (fprintf(stderr, "%s ", execbuf->argv[i]) < 0) {
+				return -1;
+			}
+		}
+		if (fprintf(stderr, "? ") < 0) {
+			return -1;
+		}
+
+		if (ynprompt() <= 0) {
+			errno = 0;
+			return -1;
+		}
+	}
+
+	// Flush cached state for consistency with the external process
+	bfs_ctx_flush(execbuf->ctx);
+
+	if (execbuf->flags & BFS_EXEC_MULTI) {
+		bfs_exec_debug(execbuf, "Executing '%s' ... [%zu arguments] (size %zu)\n",
+		               execbuf->argv[0], execbuf->argc - 1, execbuf->arg_size);
+	} else {
+		bfs_exec_debug(execbuf, "Executing '%s' ... [%zu arguments]\n", execbuf->argv[0], execbuf->argc - 1);
+	}
+
+	pid_t pid = -1;
+	int error;
+
+	struct bfs_spawn ctx;
+	if (bfs_spawn_init(&ctx) != 0) {
+		return -1;
+	}
+
+	if (bfs_spawn_setflags(&ctx, BFS_SPAWN_USEPATH) != 0) {
+		goto fail;
+	}
+
+	// Reset RLIMIT_NOFILE, to avoid breaking applications that use select()
+	struct rlimit rl = {
+		.rlim_cur = execbuf->ctx->nofile_soft,
+		.rlim_max = execbuf->ctx->nofile_hard,
+	};
+	if (bfs_spawn_addsetrlimit(&ctx, RLIMIT_NOFILE, &rl) != 0) {
+		goto fail;
+	}
+
+	if (execbuf->wd_fd >= 0) {
+		if (bfs_spawn_addfchdir(&ctx, execbuf->wd_fd) != 0) {
+			goto fail;
+		}
+	}
+
+	pid = bfs_spawn(execbuf->argv[0], &ctx, execbuf->argv, NULL);
+fail:
+	error = errno;
+	bfs_spawn_destroy(&ctx);
+	if (pid < 0) {
+		errno = error;
+		return -1;
+	}
+
+	int wstatus;
+	if (waitpid(pid, &wstatus, 0) < 0) {
+		return -1;
+	}
+
+	int ret = -1;
+
+	if (WIFEXITED(wstatus)) {
+		int status = WEXITSTATUS(wstatus);
+		if (status == EXIT_SUCCESS) {
+			ret = 0;
+		} else {
+			bfs_exec_debug(execbuf, "Command '%s' failed with status %d\n", execbuf->argv[0], status);
+		}
+	} else if (WIFSIGNALED(wstatus)) {
+		int sig = WTERMSIG(wstatus);
+		const char *str = strsignal(sig);
+		if (!str) {
+			str = "unknown";
+		}
+		bfs_warning(execbuf->ctx, "Command '${ex}%s${rs}' terminated by signal %d (%s)\n", execbuf->argv[0], sig, str);
+	} else {
+		bfs_warning(execbuf->ctx, "Command '${ex}%s${rs}' terminated abnormally\n", execbuf->argv[0]);
+	}
+
+	errno = 0;
+	return ret;
+}
+
+/** exec() a command for a single file. */
+static int bfs_exec_single(struct bfs_exec *execbuf, const struct BFTW *ftwbuf) {
+	int ret = -1, error = 0;
+
+	char *path = bfs_exec_format_path(execbuf, ftwbuf);
+	if (!path) {
+		goto out;
+	}
+
+	size_t i;
+	for (i = 0; i < execbuf->tmpl_argc; ++i) {
+		execbuf->argv[i] = bfs_exec_format_arg(execbuf->tmpl_argv[i], path);
+		if (!execbuf->argv[i]) {
+			goto out_free;
+		}
+	}
+	execbuf->argv[i] = NULL;
+	execbuf->argc = i;
+
+	if (execbuf->flags & BFS_EXEC_CHDIR) {
+		if (bfs_exec_openwd(execbuf, ftwbuf) != 0) {
+			goto out_free;
+		}
+	}
+
+	ret = bfs_exec_spawn(execbuf);
+
+out_free:
+	error = errno;
+
+	bfs_exec_closewd(execbuf, ftwbuf);
+
+	for (size_t j = 0; j < i; ++j) {
+		bfs_exec_free_arg(execbuf->argv[j], execbuf->tmpl_argv[j]);
+	}
+
+	free(path);
+
+	errno = error;
+
+out:
+	return ret;
+}
+
+/** Check if any arguments remain in the buffer. */
+static bool bfs_exec_args_remain(const struct bfs_exec *execbuf) {
+	return execbuf->argc >= execbuf->tmpl_argc;
+}
+
+/** Compute the current ARG_MAX estimate for binary search. */
+static size_t bfs_exec_estimate_max(const struct bfs_exec *execbuf) {
+	size_t min = execbuf->arg_min;
+	size_t max = execbuf->arg_max;
+	return min + (max - min)/2;
+}
+
+/** Update the ARG_MAX lower bound from a successful execution. */
+static void bfs_exec_update_min(struct bfs_exec *execbuf) {
+	if (execbuf->arg_size > execbuf->arg_min) {
+		execbuf->arg_min = execbuf->arg_size;
+
+		// Don't let min exceed max
+		if (execbuf->arg_min > execbuf->arg_max) {
+			execbuf->arg_min = execbuf->arg_max;
+		}
+
+		size_t estimate = bfs_exec_estimate_max(execbuf);
+		bfs_exec_debug(execbuf, "ARG_MAX between [%zu, %zu], trying %zu\n",
+		               execbuf->arg_min, execbuf->arg_max, estimate);
+	}
+}
+
+/** Update the ARG_MAX upper bound from a failed execution. */
+static size_t bfs_exec_update_max(struct bfs_exec *execbuf) {
+	bfs_exec_debug(execbuf, "Got E2BIG, shrinking argument list...\n");
+
+	size_t size = execbuf->arg_size;
+	if (size <= execbuf->arg_min) {
+		// Lower bound was wrong, restart binary search.
+		execbuf->arg_min = 0;
+	}
+
+	// Trim a fraction off the max size to avoid repeated failures near the
+	// top end of the working range
+	size -= size/16;
+	if (size < execbuf->arg_max) {
+		execbuf->arg_max = size;
+
+		// Don't let min exceed max
+		if (execbuf->arg_min > execbuf->arg_max) {
+			execbuf->arg_min = execbuf->arg_max;
+		}
+	}
+
+	// Binary search for a more precise bound
+	size_t estimate = bfs_exec_estimate_max(execbuf);
+	bfs_exec_debug(execbuf, "ARG_MAX between [%zu, %zu], trying %zu\n",
+	               execbuf->arg_min, execbuf->arg_max, estimate);
+	return estimate;
+}
+
+/** Execute the pending command from a BFS_EXEC_MULTI execbuf. */
+static int bfs_exec_flush(struct bfs_exec *execbuf) {
+	int ret = 0, error = 0;
+
+	size_t orig_argc = execbuf->argc;
+	while (bfs_exec_args_remain(execbuf)) {
+		execbuf->argv[execbuf->argc] = NULL;
+		ret = bfs_exec_spawn(execbuf);
+		error = errno;
+		if (ret == 0) {
+			bfs_exec_update_min(execbuf);
+			break;
+		} else if (error != E2BIG) {
+			break;
+		}
+
+		// Try to recover from E2BIG by trying fewer and fewer arguments
+		// until they fit
+		size_t new_max = bfs_exec_update_max(execbuf);
+		while (execbuf->arg_size > new_max) {
+			execbuf->argv[execbuf->argc] = execbuf->argv[execbuf->argc - 1];
+			execbuf->arg_size -= bfs_exec_arg_size(execbuf->argv[execbuf->argc]);
+			--execbuf->argc;
+		}
+	}
+
+	size_t new_argc = execbuf->argc;
+	for (size_t i = execbuf->tmpl_argc - 1; i < new_argc; ++i) {
+		free(execbuf->argv[i]);
+	}
+	execbuf->argc = execbuf->tmpl_argc - 1;
+	execbuf->arg_size = 0;
+
+	if (new_argc < orig_argc) {
+		// If we recovered from E2BIG, there are unused arguments at the
+		// end of the list
+		for (size_t i = new_argc + 1; i <= orig_argc; ++i) {
+			if (error == 0) {
+				execbuf->argv[execbuf->argc] = execbuf->argv[i];
+				execbuf->arg_size += bfs_exec_arg_size(execbuf->argv[execbuf->argc]);
+				++execbuf->argc;
+			} else {
+				free(execbuf->argv[i]);
+			}
+		}
+	}
+
+	errno = error;
+	return ret;
+}
+
+/** Check if we need to flush the execbuf because we're changing directories. */
+static bool bfs_exec_changed_dirs(const struct bfs_exec *execbuf, const struct BFTW *ftwbuf) {
+	if (execbuf->flags & BFS_EXEC_CHDIR) {
+		if (ftwbuf->nameoff > execbuf->wd_len
+		    || (execbuf->wd_path && strncmp(ftwbuf->path, execbuf->wd_path, execbuf->wd_len) != 0)) {
+			bfs_exec_debug(execbuf, "Changed directories, executing buffered command\n");
+			return true;
+		}
+	}
+
+	return false;
+}
+
+/** Check if we need to flush the execbuf because we're too big. */
+static bool bfs_exec_would_overflow(const struct bfs_exec *execbuf, const char *arg) {
+	size_t arg_max = bfs_exec_estimate_max(execbuf);
+	size_t next_size = execbuf->arg_size + bfs_exec_arg_size(arg);
+	if (next_size > arg_max) {
+		bfs_exec_debug(execbuf, "Command size (%zu) would exceed maximum (%zu), executing buffered command\n",
+		               next_size, arg_max);
+		return true;
+	}
+
+	return false;
+}
+
+/** Push a new argument to a BFS_EXEC_MULTI execbuf. */
+static int bfs_exec_push(struct bfs_exec *execbuf, char *arg) {
+	execbuf->argv[execbuf->argc] = arg;
+
+	if (execbuf->argc + 1 >= execbuf->argv_cap) {
+		size_t cap = 2*execbuf->argv_cap;
+		char **argv = realloc(execbuf->argv, cap*sizeof(*argv));
+		if (!argv) {
+			return -1;
+		}
+		execbuf->argv = argv;
+		execbuf->argv_cap = cap;
+	}
+
+	++execbuf->argc;
+	execbuf->arg_size += bfs_exec_arg_size(arg);
+	return 0;
+}
+
+/** Handle a new path for a BFS_EXEC_MULTI execbuf. */
+static int bfs_exec_multi(struct bfs_exec *execbuf, const struct BFTW *ftwbuf) {
+	int ret = 0;
+
+	char *arg = bfs_exec_format_path(execbuf, ftwbuf);
+	if (!arg) {
+		ret = -1;
+		goto out;
+	}
+
+	if (bfs_exec_changed_dirs(execbuf, ftwbuf)) {
+		while (bfs_exec_args_remain(execbuf)) {
+			ret |= bfs_exec_flush(execbuf);
+		}
+		bfs_exec_closewd(execbuf, ftwbuf);
+	} else if (bfs_exec_would_overflow(execbuf, arg)) {
+		ret |= bfs_exec_flush(execbuf);
+	}
+
+	if ((execbuf->flags & BFS_EXEC_CHDIR) && execbuf->wd_fd < 0) {
+		if (bfs_exec_openwd(execbuf, ftwbuf) != 0) {
+			ret = -1;
+			goto out_arg;
+		}
+	}
+
+	if (bfs_exec_push(execbuf, arg) != 0) {
+		ret = -1;
+		goto out_arg;
+	}
+
+	// arg will get cleaned up later by bfs_exec_flush()
+	goto out;
+
+out_arg:
+	free(arg);
+out:
+	return ret;
+}
+
+int bfs_exec(struct bfs_exec *execbuf, const struct BFTW *ftwbuf) {
+	if (execbuf->flags & BFS_EXEC_MULTI) {
+		if (bfs_exec_multi(execbuf, ftwbuf) == 0) {
+			errno = 0;
+		} else {
+			execbuf->ret = -1;
+		}
+		// -exec ... + never returns false
+		return 0;
+	} else {
+		return bfs_exec_single(execbuf, ftwbuf);
+	}
+}
+
+int bfs_exec_finish(struct bfs_exec *execbuf) {
+	if (execbuf->flags & BFS_EXEC_MULTI) {
+		bfs_exec_debug(execbuf, "Finishing execution, executing buffered command\n");
+		while (bfs_exec_args_remain(execbuf)) {
+			execbuf->ret |= bfs_exec_flush(execbuf);
+		}
+		if (execbuf->ret != 0) {
+			bfs_exec_debug(execbuf, "One or more executions of '%s' failed\n", execbuf->argv[0]);
+		}
+	}
+	return execbuf->ret;
+}
+
+void bfs_exec_free(struct bfs_exec *execbuf) {
+	if (execbuf) {
+		bfs_exec_closewd(execbuf, NULL);
+		free(execbuf->argv);
+		free(execbuf);
+	}
+}
diff --git a/src/exec.h b/src/exec.h
new file mode 100644
index 0000000..a3e3c71
--- /dev/null
+++ b/src/exec.h
@@ -0,0 +1,121 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2017-2020 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * Implementation of -exec/-execdir/-ok/-okdir.
+ */
+
+#ifndef BFS_EXEC_H
+#define BFS_EXEC_H
+
+#include <stddef.h>
+
+struct BFTW;
+struct bfs_ctx;
+
+/**
+ * Flags for the -exec actions.
+ */
+enum bfs_exec_flags {
+	/** Prompt the user before executing (-ok, -okdir). */
+	BFS_EXEC_CONFIRM = 1 << 0,
+	/** Run the command in the file's parent directory (-execdir, -okdir). */
+	BFS_EXEC_CHDIR   = 1 << 1,
+	/** Pass multiple files at once to the command (-exec ... {} +). */
+	BFS_EXEC_MULTI   = 1 << 2,
+};
+
+/**
+ * Buffer for a command line to be executed.
+ */
+struct bfs_exec {
+	/** Flags for this exec buffer. */
+	enum bfs_exec_flags flags;
+
+	/** The bfs context. */
+	const struct bfs_ctx *ctx;
+	/** Command line template. */
+	char **tmpl_argv;
+	/** Command line template size. */
+	size_t tmpl_argc;
+
+	/** The built command line. */
+	char **argv;
+	/** Number of command line arguments. */
+	size_t argc;
+	/** Capacity of argv. */
+	size_t argv_cap;
+
+	/** Current size of all arguments. */
+	size_t arg_size;
+	/** Maximum arg_size before E2BIG. */
+	size_t arg_max;
+	/** Lower bound for arg_max. */
+	size_t arg_min;
+
+	/** A file descriptor for the working directory, for BFS_EXEC_CHDIR. */
+	int wd_fd;
+	/** The path to the working directory, for BFS_EXEC_CHDIR. */
+	char *wd_path;
+	/** Length of the working directory path. */
+	size_t wd_len;
+
+	/** The ultimate return value for bfs_exec_finish(). */
+	int ret;
+};
+
+/**
+ * Parse an exec action.
+ *
+ * @param argv
+ *         The (bfs) command line argument to parse.
+ * @param flags
+ *         Any flags for this exec action.
+ * @param ctx
+ *         The bfs context.
+ * @return
+ *         The parsed exec action, or NULL on failure.
+ */
+struct bfs_exec *bfs_exec_parse(const struct bfs_ctx *ctx, char **argv, enum bfs_exec_flags flags);
+
+/**
+ * Execute the command for a file.
+ *
+ * @param execbuf
+ *         The parsed exec action.
+ * @param ftwbuf
+ *         The bftw() data for the current file.
+ * @return 0 if the command succeeded, -1 if it failed.  If the command could
+ *         be executed, -1 is returned, and errno will be non-zero.  For
+ *         BFS_EXEC_MULTI, errors will not be reported until bfs_exec_finish().
+ */
+int bfs_exec(struct bfs_exec *execbuf, const struct BFTW *ftwbuf);
+
+/**
+ * Finish executing any commands.
+ *
+ * @param execbuf
+ *         The parsed exec action.
+ * @return 0 on success, -1 if any errors were encountered.
+ */
+int bfs_exec_finish(struct bfs_exec *execbuf);
+
+/**
+ * Free a parsed exec action.
+ */
+void bfs_exec_free(struct bfs_exec *execbuf);
+
+#endif // BFS_EXEC_H
diff --git a/src/expr.h b/src/expr.h
new file mode 100644
index 0000000..1f1ece6
--- /dev/null
+++ b/src/expr.h
@@ -0,0 +1,235 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2015-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * The expression tree representation.
+ */
+
+#ifndef BFS_EXPR_H
+#define BFS_EXPR_H
+
+#include "color.h"
+#include "eval.h"
+#include "stat.h"
+#include <stdbool.h>
+#include <stddef.h>
+#include <sys/types.h>
+#include <time.h>
+
+/**
+ * Integer comparison modes.
+ */
+enum bfs_int_cmp {
+	/** Exactly N. */
+	BFS_INT_EQUAL,
+	/** Less than N (-N). */
+	BFS_INT_LESS,
+	/** Greater than N (+N). */
+	BFS_INT_GREATER,
+};
+
+/**
+ * Permission comparison modes.
+ */
+enum bfs_mode_cmp {
+	/** Mode is an exact match (MODE). */
+	BFS_MODE_EQUAL,
+	/** Mode has all these bits (-MODE). */
+	BFS_MODE_ALL,
+	/** Mode has any of these bits (/MODE). */
+	BFS_MODE_ANY,
+};
+
+/**
+ * Possible time units.
+ */
+enum bfs_time_unit {
+	/** Seconds. */
+	BFS_SECONDS,
+	/** Minutes. */
+	BFS_MINUTES,
+	/** Days. */
+	BFS_DAYS,
+};
+
+/**
+ * Possible file size units.
+ */
+enum bfs_size_unit {
+	/** 512-byte blocks. */
+	BFS_BLOCKS,
+	/** Single bytes. */
+	BFS_BYTES,
+	/** Two-byte words. */
+	BFS_WORDS,
+	/** Kibibytes. */
+	BFS_KB,
+	/** Mebibytes. */
+	BFS_MB,
+	/** Gibibytes. */
+	BFS_GB,
+	/** Tebibytes. */
+	BFS_TB,
+	/** Pebibytes. */
+	BFS_PB,
+};
+
+/**
+ * A command line expression.
+ */
+struct bfs_expr {
+	/** The function that evaluates this expression. */
+	bfs_eval_fn *eval_fn;
+
+	/** The number of command line arguments for this expression. */
+	size_t argc;
+	/** The command line arguments comprising this expression. */
+	char **argv;
+
+	/** The number of files this expression keeps open between evaluations. */
+	int persistent_fds;
+	/** The number of files this expression opens during evaluation. */
+	int ephemeral_fds;
+
+	/** Whether this expression has no side effects. */
+	bool pure;
+	/** Whether this expression always evaluates to true. */
+	bool always_true;
+	/** Whether this expression always evaluates to false. */
+	bool always_false;
+	/** Whether this expression doesn't appear on the command line. */
+	bool synthetic;
+
+	/** Estimated cost. */
+	float cost;
+	/** Estimated probability of success. */
+	float probability;
+	/** Number of times this predicate was evaluated. */
+	size_t evaluations;
+	/** Number of times this predicate succeeded. */
+	size_t successes;
+	/** Total time spent running this predicate. */
+	struct timespec elapsed;
+
+	/** Auxilliary data for the evaluation function. */
+	union {
+		/** Child expressions. */
+		struct {
+			/** The left hand side of the expression. */
+			struct bfs_expr *lhs;
+			/** The right hand side of the expression. */
+			struct bfs_expr *rhs;
+		};
+
+		/** Integer comparisons. */
+		struct {
+			/** Integer for this comparison. */
+			long long num;
+			/** The comparison mode. */
+			enum bfs_int_cmp int_cmp;
+
+			/** Optional extra data. */
+			union {
+				/** -size data. */
+				enum bfs_size_unit size_unit;
+
+				/** Timestamp comparison data. */
+				struct {
+					/** The stat field to look at. */
+					enum bfs_stat_field stat_field;
+					/** The reference time. */
+					struct timespec reftime;
+					/** The time unit. */
+					enum bfs_time_unit time_unit;
+				};
+			};
+		};
+
+		/** Printing actions. */
+		struct {
+			/** The output stream. */
+			CFILE *cfile;
+			/** Optional -printf format. */
+			struct bfs_printf *printf;
+		};
+
+		/** -exec data. */
+		struct bfs_exec *exec;
+
+		/** -flags data. */
+		struct {
+			/** The comparison mode. */
+			enum bfs_mode_cmp flags_cmp;
+			/** Flags that should be set. */
+			unsigned long long set_flags;
+			/** Flags that should be cleared. */
+			unsigned long long clear_flags;
+		};
+
+		/** -perm data. */
+		struct {
+			/** The comparison mode. */
+			enum bfs_mode_cmp mode_cmp;
+			/** Mode to use for files. */
+			mode_t file_mode;
+			/** Mode to use for directories (different due to X). */
+			mode_t dir_mode;
+		};
+
+		/** -regex data. */
+		struct bfs_regex *regex;
+
+		/** -samefile data. */
+		struct {
+			/** Device number of the target file. */
+			dev_t dev;
+			/** Inode number of the target file. */
+			ino_t ino;
+		};
+	};
+};
+
+/** Singleton true expression instance. */
+extern struct bfs_expr bfs_true;
+/** Singleton false expression instance. */
+extern struct bfs_expr bfs_false;
+
+/**
+ * Create a new expression.
+ */
+struct bfs_expr *bfs_expr_new(bfs_eval_fn *eval, size_t argc, char **argv);
+
+/**
+ * @return Whether the expression has child expressions.
+ */
+bool bfs_expr_has_children(const struct bfs_expr *expr);
+
+/**
+ * @return Whether expr is known to always quit.
+ */
+bool bfs_expr_never_returns(const struct bfs_expr *expr);
+
+/**
+ * @return The result of the integer comparison for this expression.
+ */
+bool bfs_expr_cmp(const struct bfs_expr *expr, long long n);
+
+/**
+ * Free an expression tree.
+ */
+void bfs_expr_free(struct bfs_expr *expr);
+
+#endif // BFS_EXPR_H
diff --git a/src/fsade.c b/src/fsade.c
new file mode 100644
index 0000000..1444cf4
--- /dev/null
+++ b/src/fsade.c
@@ -0,0 +1,392 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2019-2021 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+#include "fsade.h"
+#include "bftw.h"
+#include "dir.h"
+#include "dstring.h"
+#include "util.h"
+#include <errno.h>
+#include <fcntl.h>
+#include <stddef.h>
+#include <unistd.h>
+
+#if BFS_CAN_CHECK_ACL
+#	include <sys/acl.h>
+#endif
+
+#if BFS_CAN_CHECK_CAPABILITIES
+#	include <sys/capability.h>
+#endif
+
+#if BFS_HAS_SYS_EXTATTR
+#	include <sys/extattr.h>
+#elif BFS_HAS_SYS_XATTR
+#	include <sys/xattr.h>
+#endif
+
+#if BFS_CAN_CHECK_ACL || BFS_CAN_CHECK_CAPABILITIES || BFS_CAN_CHECK_XATTRS
+
+/**
+ * Many of the APIs used here don't have *at() variants, but we can try to
+ * emulate something similar if /proc/self/fd is available.
+ */
+static const char *fake_at(const struct BFTW *ftwbuf) {
+	static bool proc_works = true;
+	static bool proc_checked = false;
+
+	char *path = NULL;
+	if (!proc_works || ftwbuf->at_fd == AT_FDCWD) {
+		goto fail;
+	}
+
+	path = dstrprintf("/proc/self/fd/%d/", ftwbuf->at_fd);
+	if (!path) {
+		goto fail;
+	}
+
+	if (!proc_checked) {
+		proc_checked = true;
+		if (xfaccessat(AT_FDCWD, path, F_OK) != 0) {
+			proc_works = false;
+			goto fail;
+		}
+	}
+
+	if (dstrcat(&path, ftwbuf->at_path) != 0) {
+		goto fail;
+	}
+
+	return path;
+
+fail:
+	dstrfree(path);
+	return ftwbuf->path;
+}
+
+static void free_fake_at(const struct BFTW *ftwbuf, const char *path) {
+	if (path != ftwbuf->path) {
+		dstrfree((char *)path);
+	}
+}
+
+/**
+ * Check if an error was caused by the absence of support or data for a feature.
+ */
+static bool is_absence_error(int error) {
+	// If the OS doesn't support the feature, it's obviously not enabled for
+	// any files
+	if (error == ENOTSUP) {
+		return true;
+	}
+
+	// On Linux, ACLs and capabilities are implemented in terms of extended
+	// attributes, which report ENODATA/ENOATTR when missing
+
+#ifdef ENODATA
+	if (error == ENODATA) {
+		return true;
+	}
+#endif
+
+#if defined(ENOATTR) && ENOATTR != ENODATA
+	if (error == ENOATTR) {
+		return true;
+	}
+#endif
+
+	// On at least FreeBSD and macOS, EINVAL is returned when the requested
+	// ACL type is not supported for that file
+	if (error == EINVAL) {
+		return true;
+	}
+
+#if __APPLE__
+	// On macOS, ENOENT can also signal that a file has no ACLs
+	if (error == ENOENT) {
+		return true;
+	}
+#endif
+
+	return false;
+}
+
+#endif // BFS_CAN_CHECK_ACL || BFS_CAN_CHECK_CAPABILITIES || BFS_CAN_CHECK_XATTRS
+
+#if BFS_CAN_CHECK_ACL
+
+/** Check if a POSIX.1e ACL is non-trivial. */
+static int bfs_check_posix1e_acl(acl_t acl, bool ignore_required) {
+	int ret = 0;
+
+	acl_entry_t entry;
+	for (int status = acl_get_entry(acl, ACL_FIRST_ENTRY, &entry);
+#if __APPLE__
+	     // POSIX.1e specifies a return value of 1 for success, but macOS
+	     // returns 0 instead
+	     status == 0;
+#else
+	     status > 0;
+#endif
+	     status = acl_get_entry(acl, ACL_NEXT_ENTRY, &entry)) {
+#if defined(ACL_USER_OBJ) && defined(ACL_GROUP_OBJ) && defined(ACL_OTHER)
+		if (ignore_required) {
+			acl_tag_t tag;
+			if (acl_get_tag_type(entry, &tag) != 0) {
+				ret = -1;
+				continue;
+			}
+			if (tag == ACL_USER_OBJ || tag == ACL_GROUP_OBJ || tag == ACL_OTHER) {
+				continue;
+			}
+		}
+#endif
+
+		ret = 1;
+		break;
+	}
+
+	return ret;
+}
+
+/** Check if an ACL of the given type is non-trivial. */
+static int bfs_check_acl_type(acl_t acl, acl_type_t type) {
+	if (type == ACL_TYPE_DEFAULT) {
+		// For directory default ACLs, any entries make them non-trivial
+		return bfs_check_posix1e_acl(acl, false);
+	}
+
+#if __FreeBSD__
+	int trivial;
+
+#if BFS_HAS_FEATURE(memory_sanitizer, false)
+        // msan seems to be missing an interceptor for acl_is_trivial_np()
+        trivial = 0;
+#endif
+
+	if (acl_is_trivial_np(acl, &trivial) < 0) {
+		return -1;
+	} else if (trivial) {
+		return 0;
+	} else {
+		return 1;
+	}
+#else // !__FreeBSD__
+	return bfs_check_posix1e_acl(acl, true);
+#endif
+}
+
+int bfs_check_acl(const struct BFTW *ftwbuf) {
+	static const acl_type_t acl_types[] = {
+#if __APPLE__
+		// macOS gives EINVAL for either of the two standard ACL types,
+		// supporting only ACL_TYPE_EXTENDED
+		ACL_TYPE_EXTENDED,
+#else
+		// The two standard POSIX.1e ACL types
+		ACL_TYPE_ACCESS,
+		ACL_TYPE_DEFAULT,
+#endif
+
+#ifdef ACL_TYPE_NFS4
+		ACL_TYPE_NFS4,
+#endif
+	};
+	static const size_t n_acl_types = sizeof(acl_types)/sizeof(acl_types[0]);
+
+	if (ftwbuf->type == BFS_LNK) {
+		return 0;
+	}
+
+	const char *path = fake_at(ftwbuf);
+
+	int ret = -1, error = 0;
+	for (size_t i = 0; i < n_acl_types && ret <= 0; ++i) {
+		acl_type_t type = acl_types[i];
+
+		if (type == ACL_TYPE_DEFAULT && ftwbuf->type != BFS_DIR) {
+			// ACL_TYPE_DEFAULT is supported only for directories,
+			// otherwise acl_get_file() gives EACCESS
+			continue;
+		}
+
+		acl_t acl = acl_get_file(path, type);
+		if (!acl) {
+			error = errno;
+			if (is_absence_error(error)) {
+				ret = 0;
+			}
+			continue;
+		}
+
+		ret = bfs_check_acl_type(acl, type);
+		error = errno;
+		acl_free(acl);
+	}
+
+	free_fake_at(ftwbuf, path);
+	errno = error;
+	return ret;
+}
+
+#else // !BFS_CAN_CHECK_ACL
+
+int bfs_check_acl(const struct BFTW *ftwbuf) {
+	errno = ENOTSUP;
+	return -1;
+}
+
+#endif
+
+#if BFS_CAN_CHECK_CAPABILITIES
+
+int bfs_check_capabilities(const struct BFTW *ftwbuf) {
+	if (ftwbuf->type == BFS_LNK) {
+		return 0;
+	}
+
+	int ret = -1, error;
+	const char *path = fake_at(ftwbuf);
+
+	cap_t caps = cap_get_file(path);
+	if (!caps) {
+		error = errno;
+		if (is_absence_error(error)) {
+			ret = 0;
+		}
+		goto out_path;
+	}
+
+	// TODO: Any better way to check for a non-empty capability set?
+	char *text = cap_to_text(caps, NULL);
+	if (!text) {
+		error = errno;
+		goto out_caps;
+	}
+	ret = text[0] ? 1 : 0;
+
+	error = errno;
+	cap_free(text);
+out_caps:
+	cap_free(caps);
+out_path:
+	free_fake_at(ftwbuf, path);
+	errno = error;
+	return ret;
+}
+
+#else // !BFS_CAN_CHECK_CAPABILITIES
+
+int bfs_check_capabilities(const struct BFTW *ftwbuf) {
+	errno = ENOTSUP;
+	return -1;
+}
+
+#endif
+
+#if BFS_CAN_CHECK_XATTRS
+
+int bfs_check_xattrs(const struct BFTW *ftwbuf) {
+	const char *path = fake_at(ftwbuf);
+	ssize_t len;
+
+#if BFS_HAS_SYS_EXTATTR
+	ssize_t (*extattr_list)(const char *, int, void*, size_t) =
+		ftwbuf->type == BFS_LNK ? extattr_list_link : extattr_list_file;
+
+	len = extattr_list(path, EXTATTR_NAMESPACE_SYSTEM, NULL, 0);
+	if (len <= 0) {
+		len = extattr_list(path, EXTATTR_NAMESPACE_USER, NULL, 0);
+	}
+#elif __APPLE__
+	int options = ftwbuf->type == BFS_LNK ? XATTR_NOFOLLOW : 0;
+	len = listxattr(path, NULL, 0, options);
+#else
+	if (ftwbuf->type == BFS_LNK) {
+		len = llistxattr(path, NULL, 0);
+	} else {
+		len = listxattr(path, NULL, 0);
+	}
+#endif
+
+	int error = errno;
+
+	free_fake_at(ftwbuf, path);
+
+	if (len > 0) {
+		return 1;
+	} else if (len == 0 || is_absence_error(error)) {
+		return 0;
+	} else if (error == E2BIG) {
+		return 1;
+	} else {
+		errno = error;
+		return -1;
+	}
+}
+
+int bfs_check_xattr_named(const struct BFTW *ftwbuf, const char *name) {
+	const char *path = fake_at(ftwbuf);
+	ssize_t len;
+
+#if BFS_HAS_SYS_EXTATTR
+	ssize_t (*extattr_get)(const char *, int, const char *, void*, size_t) =
+		ftwbuf->type == BFS_LNK ? extattr_get_link : extattr_get_file;
+
+	len = extattr_get(path, EXTATTR_NAMESPACE_SYSTEM, name, NULL, 0);
+	if (len < 0) {
+		len = extattr_get(path, EXTATTR_NAMESPACE_USER, name, NULL, 0);
+	}
+#elif __APPLE__
+	int options = ftwbuf->type == BFS_LNK ? XATTR_NOFOLLOW : 0;
+	len = getxattr(path, name, NULL, 0, 0, options);
+#else
+	if (ftwbuf->type == BFS_LNK) {
+		len = lgetxattr(path, name, NULL, 0);
+	} else {
+		len = getxattr(path, name, NULL, 0);
+	}
+#endif
+
+	int error = errno;
+
+	free_fake_at(ftwbuf, path);
+
+	if (len >= 0) {
+		return 1;
+	} else if (is_absence_error(error)) {
+		return 0;
+	} else if (error == E2BIG) {
+		return 1;
+	} else {
+		errno = error;
+		return -1;
+	}
+}
+
+#else // !BFS_CAN_CHECK_XATTRS
+
+int bfs_check_xattrs(const struct BFTW *ftwbuf) {
+	errno = ENOTSUP;
+	return -1;
+}
+
+int bfs_check_xattr_named(const struct BFTW *ftwbuf, const char *name) {
+	errno = ENOTSUP;
+	return -1;
+}
+
+#endif
diff --git a/src/fsade.h b/src/fsade.h
new file mode 100644
index 0000000..e964112
--- /dev/null
+++ b/src/fsade.h
@@ -0,0 +1,83 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2019-2020 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * A facade over (file)system features that are (un)implemented differently
+ * between platforms.
+ */
+
+#ifndef BFS_FSADE_H
+#define BFS_FSADE_H
+
+#include "util.h"
+#include <stdbool.h>
+
+#define BFS_CAN_CHECK_ACL BFS_HAS_SYS_ACL
+
+#if !defined(BFS_CAN_CHECK_CAPABILITIES) && BFS_HAS_SYS_CAPABILITY && !__FreeBSD__
+#	include <sys/capability.h>
+#	ifdef CAP_CHOWN
+#		define BFS_CAN_CHECK_CAPABILITIES true
+#	endif
+#endif
+
+#define BFS_CAN_CHECK_XATTRS (BFS_HAS_SYS_EXTATTR || BFS_HAS_SYS_XATTR)
+
+struct BFTW;
+
+/**
+ * Check if a file has a non-trivial Access Control List.
+ *
+ * @param ftwbuf
+ *         The file to check.
+ * @return
+ *         1 if it does, 0 if it doesn't, or -1 if an error occurred.
+ */
+int bfs_check_acl(const struct BFTW *ftwbuf);
+
+/**
+ * Check if a file has a non-trivial capability set.
+ *
+ * @param ftwbuf
+ *         The file to check.
+ * @return
+ *         1 if it does, 0 if it doesn't, or -1 if an error occurred.
+ */
+int bfs_check_capabilities(const struct BFTW *ftwbuf);
+
+/**
+ * Check if a file has any extended attributes set.
+ *
+ * @param ftwbuf
+ *         The file to check.
+ * @return
+ *         1 if it does, 0 if it doesn't, or -1 if an error occurred.
+ */
+int bfs_check_xattrs(const struct BFTW *ftwbuf);
+
+/**
+ * Check if a file has an extended attribute with the given name.
+ *
+ * @param ftwbuf
+ *         The file to check.
+ * @param name
+ *         The name of the xattr to check.
+ * @return
+ *         1 if it does, 0 if it doesn't, or -1 if an error occurred.
+ */
+int bfs_check_xattr_named(const struct BFTW *ftwbuf, const char *name);
+
+#endif // BFS_FSADE_H
diff --git a/src/main.c b/src/main.c
new file mode 100644
index 0000000..9dc96e4
--- /dev/null
+++ b/src/main.c
@@ -0,0 +1,141 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2015-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * - main(): the entry point for bfs(1), a breadth-first version of find(1)
+ *     - main.c        (this file)
+ *
+ * - bfs_parse_cmdline(): parses the command line into an expression tree
+ *     - ctx.[ch]      (struct bfs_ctx, the overall bfs context)
+ *     - expr.h        (declares the expression tree nodes)
+ *     - parse.[ch]    (the parser itself)
+ *     - opt.[ch]      (the optimizer)
+ *
+ * - bfs_eval(): runs the expression on every file it sees
+ *     - eval.[ch]     (the main evaluation functions)
+ *     - exec.[ch]     (implements -exec[dir]/-ok[dir])
+ *     - printf.[ch]   (implements -[f]printf)
+ *
+ * - bftw(): used by bfs_eval() to walk the directory tree(s)
+ *     - bftw.[ch]     (an extended version of nftw(3))
+ *
+ * - Utilities:
+ *     - bfs.h         (constants about bfs itself)
+ *     - bar.[ch]      (a terminal status bar)
+ *     - color.[ch]    (for pretty terminal colors)
+ *     - darray.[ch]   (a dynamic array library)
+ *     - diag.[ch]     (formats diagnostic messages)
+ *     - dir.[ch]      (a directory API facade)
+ *     - dstring.[ch]  (a dynamic string library)
+ *     - fsade.[ch]    (a facade over non-standard filesystem features)
+ *     - mtab.[ch]     (parses the system's mount table)
+ *     - pwcache.[ch]  (a cache for the user/group tables)
+ *     - stat.[ch]     (wraps stat(), or statx() on Linux)
+ *     - trie.[ch]     (a trie set/map implementation)
+ *     - typo.[ch]     (fuzzy matching for typos)
+ *     - util.[ch]     (everything else)
+ *     - xregex.[ch]   (regular expression support)
+ *     - xspawn.[ch]   (spawns processes)
+ *     - xtime.[ch]    (date/time handling utilities)
+ */
+
+#include "ctx.h"
+#include "eval.h"
+#include "parse.h"
+#include "util.h"
+#include <errno.h>
+#include <fcntl.h>
+#include <locale.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <unistd.h>
+
+/**
+ * Check if a file descriptor is open.
+ */
+static bool isopen(int fd) {
+	return fcntl(fd, F_GETFD) >= 0 || errno != EBADF;
+}
+
+/**
+ * Open a file and redirect it to a particular descriptor.
+ */
+static int redirect(int fd, const char *path, int flags) {
+	int newfd = open(path, flags);
+	if (newfd < 0 || newfd == fd) {
+		return newfd;
+	}
+
+	int ret = dup2(newfd, fd);
+	close_quietly(newfd);
+	return ret;
+}
+
+/**
+ * Make sure the standard streams std{in,out,err} are open.  If they are not,
+ * future open() calls may use those file descriptors, and std{in,out,err} will
+ * use them unintentionally.
+ */
+static int open_std_streams(void) {
+#ifdef O_PATH
+	const int inflags = O_PATH, outflags = O_PATH;
+#else
+	// These are intentionally backwards so that bfs >&- still fails with EBADF
+	const int inflags = O_WRONLY, outflags = O_RDONLY;
+#endif
+
+	if (!isopen(STDERR_FILENO) && redirect(STDERR_FILENO, "/dev/null", outflags) < 0) {
+		return -1;
+	}
+	if (!isopen(STDOUT_FILENO) && redirect(STDOUT_FILENO, "/dev/null", outflags) < 0) {
+		perror("redirect()");
+		return -1;
+	}
+	if (!isopen(STDIN_FILENO) && redirect(STDIN_FILENO, "/dev/null", inflags) < 0) {
+		perror("redirect()");
+		return -1;
+	}
+
+	return 0;
+}
+
+/**
+ * bfs entry point.
+ */
+int main(int argc, char *argv[]) {
+	int ret = EXIT_FAILURE;
+
+	// Make sure the standard streams are open
+	if (open_std_streams() != 0) {
+		goto done;
+	}
+
+	// Use the system locale instead of "C"
+	setlocale(LC_ALL, "");
+
+	struct bfs_ctx *ctx = bfs_parse_cmdline(argc, argv);
+	if (ctx) {
+		ret = bfs_eval(ctx);
+	}
+
+	if (bfs_ctx_free(ctx) != 0 && ret == EXIT_SUCCESS) {
+		ret = EXIT_FAILURE;
+	}
+
+done:
+	return ret;
+}
diff --git a/src/mtab.c b/src/mtab.c
new file mode 100644
index 0000000..adc3f58
--- /dev/null
+++ b/src/mtab.c
@@ -0,0 +1,246 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2017-2020 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+#include "mtab.h"
+#include "darray.h"
+#include "stat.h"
+#include "trie.h"
+#include "util.h"
+#include <errno.h>
+#include <fcntl.h>
+#include <stdbool.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/types.h>
+
+#if BFS_HAS_SYS_PARAM
+#	include <sys/param.h>
+#endif
+
+#if BFS_HAS_MNTENT
+#	define BFS_MNTENT 1
+#elif BSD
+#	define BFS_MNTINFO 1
+#elif __SVR4
+#	define BFS_MNTTAB 1
+#endif
+
+#if BFS_MNTENT
+#	include <mntent.h>
+#	include <paths.h>
+#	include <stdio.h>
+#elif BFS_MNTINFO
+#	include <sys/mount.h>
+#	include <sys/ucred.h>
+#elif BFS_MNTTAB
+#	include <stdio.h>
+#	include <sys/mnttab.h>
+#endif
+
+/**
+ * A mount point in the table.
+ */
+struct bfs_mtab_entry {
+	/** The path to the mount point. */
+	char *path;
+	/** The filesystem type. */
+	char *type;
+};
+
+struct bfs_mtab {
+	/** The list of mount points. */
+	struct bfs_mtab_entry *entries;
+	/** The basenames of every mount point. */
+	struct trie names;
+
+	/** A map from device ID to fstype (populated lazily). */
+	struct trie types;
+	/** Whether the types map has been populated. */
+	bool types_filled;
+};
+
+/**
+ * Add an entry to the mount table.
+ */
+static int bfs_mtab_add(struct bfs_mtab *mtab, const char *path, const char *type) {
+	struct bfs_mtab_entry entry = {
+		.path = strdup(path),
+		.type = strdup(type),
+	};
+
+	if (!entry.path || !entry.type) {
+		goto fail_entry;
+	}
+
+	if (DARRAY_PUSH(&mtab->entries, &entry) != 0) {
+		goto fail_entry;
+	}
+
+	if (!trie_insert_str(&mtab->names, xbasename(path))) {
+		goto fail;
+	}
+
+	return 0;
+
+fail_entry:
+	free(entry.type);
+	free(entry.path);
+fail:
+	return -1;
+}
+
+struct bfs_mtab *bfs_mtab_parse(void) {
+	struct bfs_mtab *mtab = malloc(sizeof(*mtab));
+	if (!mtab) {
+		return NULL;
+	}
+
+	mtab->entries = NULL;
+	trie_init(&mtab->names);
+	trie_init(&mtab->types);
+	mtab->types_filled = false;
+
+	int error = 0;
+
+#if BFS_MNTENT
+
+	FILE *file = setmntent(_PATH_MOUNTED, "r");
+	if (!file) {
+		// In case we're in a chroot or something with /proc but no /etc/mtab
+		error = errno;
+		file = setmntent("/proc/mounts", "r");
+	}
+	if (!file) {
+		goto fail;
+	}
+
+	struct mntent *mnt;
+	while ((mnt = getmntent(file))) {
+		if (bfs_mtab_add(mtab, mnt->mnt_dir, mnt->mnt_type) != 0) {
+			error = errno;
+			endmntent(file);
+			goto fail;
+		}
+	}
+
+	endmntent(file);
+
+#elif BFS_MNTINFO
+
+#if __NetBSD__
+	typedef struct statvfs bfs_statfs;
+#else
+	typedef struct statfs bfs_statfs;
+#endif
+
+	bfs_statfs *mntbuf;
+	int size = getmntinfo(&mntbuf, MNT_WAIT);
+	if (size < 0) {
+		error = errno;
+		goto fail;
+	}
+
+	for (bfs_statfs *mnt = mntbuf; mnt < mntbuf + size; ++mnt) {
+		if (bfs_mtab_add(mtab, mnt->f_mntonname, mnt->f_fstypename) != 0) {
+			error = errno;
+			goto fail;
+		}
+	}
+
+#elif BFS_MNTTAB
+
+	FILE *file = xfopen(MNTTAB, O_RDONLY | O_CLOEXEC);
+	if (!file) {
+		error = errno;
+		goto fail;
+	}
+
+	struct mnttab mnt;
+	while (getmntent(file, &mnt) == 0) {
+		if (bfs_mtab_add(mtab, mnt.mnt_mountp, mnt.mnt_fstype) != 0) {
+			error = errno;
+			fclose(file);
+			goto fail;
+		}
+	}
+
+	fclose(file);
+
+#else
+
+	error = ENOTSUP;
+	goto fail;
+
+#endif
+
+	return mtab;
+
+fail:
+	bfs_mtab_free(mtab);
+	errno = error;
+	return NULL;
+}
+
+static void bfs_mtab_fill_types(struct bfs_mtab *mtab) {
+	for (size_t i = 0; i < darray_length(mtab->entries); ++i) {
+		struct bfs_mtab_entry *entry = &mtab->entries[i];
+
+		struct bfs_stat sb;
+		if (bfs_stat(AT_FDCWD, entry->path, BFS_STAT_NOFOLLOW | BFS_STAT_NOSYNC, &sb) != 0) {
+			continue;
+		}
+
+		struct trie_leaf *leaf = trie_insert_mem(&mtab->types, &sb.dev, sizeof(sb.dev));
+		if (leaf) {
+			leaf->value = entry->type;
+		}
+	}
+
+	mtab->types_filled = true;
+}
+
+const char *bfs_fstype(const struct bfs_mtab *mtab, const struct bfs_stat *statbuf) {
+	if (!mtab->types_filled) {
+		bfs_mtab_fill_types((struct bfs_mtab *)mtab);
+	}
+
+	const struct trie_leaf *leaf = trie_find_mem(&mtab->types, &statbuf->dev, sizeof(statbuf->dev));
+	if (leaf) {
+		return leaf->value;
+	} else {
+		return "unknown";
+	}
+}
+
+bool bfs_might_be_mount(const struct bfs_mtab *mtab, const char *path) {
+	const char *name = xbasename(path);
+	return trie_find_str(&mtab->names, name);
+}
+
+void bfs_mtab_free(struct bfs_mtab *mtab) {
+	if (mtab) {
+		trie_destroy(&mtab->types);
+		trie_destroy(&mtab->names);
+
+		for (size_t i = 0; i < darray_length(mtab->entries); ++i) {
+			free(mtab->entries[i].type);
+			free(mtab->entries[i].path);
+		}
+		darray_free(mtab->entries);
+
+		free(mtab);
+	}
+}
diff --git a/src/mtab.h b/src/mtab.h
new file mode 100644
index 0000000..807539d
--- /dev/null
+++ b/src/mtab.h
@@ -0,0 +1,71 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2017-2020 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * A facade over platform-specific APIs for enumerating mounted filesystems.
+ */
+
+#ifndef BFS_MTAB_H
+#define BFS_MTAB_H
+
+#include <stdbool.h>
+
+struct bfs_stat;
+
+/**
+ * A file system mount table.
+ */
+struct bfs_mtab;
+
+/**
+ * Parse the mount table.
+ *
+ * @return
+ *         The parsed mount table, or NULL on error.
+ */
+struct bfs_mtab *bfs_mtab_parse(void);
+
+/**
+ * Determine the file system type that a file is on.
+ *
+ * @param mtab
+ *         The current mount table.
+ * @param statbuf
+ *         The bfs_stat() buffer for the file in question.
+ * @return
+ *         The type of file system containing this file, "unknown" if not known,
+ *         or NULL on error.
+ */
+const char *bfs_fstype(const struct bfs_mtab *mtab, const struct bfs_stat *statbuf);
+
+/**
+ * Check if a file could be a mount point.
+ *
+ * @param mtab
+ *         The current mount table.
+ * @param path
+ *         The path to check.
+ * @return
+ *         Whether the named file could be a mount point.
+ */
+bool bfs_might_be_mount(const struct bfs_mtab *mtab, const char *path);
+
+/**
+ * Free a mount table.
+ */
+void bfs_mtab_free(struct bfs_mtab *mtab);
+
+#endif // BFS_MTAB_H
diff --git a/src/opt.c b/src/opt.c
new file mode 100644
index 0000000..f8c0ba3
--- /dev/null
+++ b/src/opt.c
@@ -0,0 +1,1088 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2017-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * The expression optimizer.  Different optimization levels are supported:
+ *
+ * -O1: basic logical simplifications, like folding (-true -and -foo) to -foo.
+ *
+ * -O2: dead code elimination and data flow analysis.  struct opt_facts is used
+ * to record data flow facts that are true at various points of evaluation.
+ * Specifically, struct opt_facts records the facts that must be true before an
+ * expression is evaluated (state->facts), and those that must be true after the
+ * expression is evaluated, given that it returns true (state->facts_when_true)
+ * or false (state->facts_when_true).  Additionally, state->facts_when_impure
+ * records the possible data flow facts before any expressions with side effects
+ * are evaluated.
+ *
+ * -O3: expression re-ordering to reduce expected cost.  In an expression like
+ * (-foo -and -bar), if both -foo and -bar are pure (no side effects), they can
+ * be re-ordered to (-bar -and -foo).  This is profitable if the expected cost
+ * is lower for the re-ordered expression, for example if -foo is very slow or
+ * -bar is likely to return false.
+ *
+ * -O4/-Ofast: aggressive optimizations that may affect correctness in corner
+ * cases.  The main effect is to use facts_when_impure to determine if any side-
+ * effects are reachable at all, and skipping the traversal if not.
+ */
+
+#include "opt.h"
+#include "color.h"
+#include "ctx.h"
+#include "diag.h"
+#include "eval.h"
+#include "expr.h"
+#include "pwcache.h"
+#include "util.h"
+#include <assert.h>
+#include <limits.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <string.h>
+#include <unistd.h>
+
+static char *fake_and_arg = "-a";
+static char *fake_or_arg = "-o";
+static char *fake_not_arg = "!";
+
+/**
+ * A contrained integer range.
+ */
+struct range {
+	/** The (inclusive) minimum value. */
+	long long min;
+	/** The (inclusive) maximum value. */
+	long long max;
+};
+
+/** Compute the minimum of two values. */
+static long long min_value(long long a, long long b) {
+	if (a < b) {
+		return a;
+	} else {
+		return b;
+	}
+}
+
+/** Compute the maximum of two values. */
+static long long max_value(long long a, long long b) {
+	if (a > b) {
+		return a;
+	} else {
+		return b;
+	}
+}
+
+/** Constrain the minimum of a range. */
+static void constrain_min(struct range *range, long long value) {
+	range->min = max_value(range->min, value);
+}
+
+/** Contrain the maximum of a range. */
+static void constrain_max(struct range *range, long long value) {
+	range->max = min_value(range->max, value);
+}
+
+/** Remove a single value from a range. */
+static void range_remove(struct range *range, long long value) {
+	if (range->min == value) {
+		if (range->min == LLONG_MAX) {
+			range->max = LLONG_MIN;
+		} else {
+			++range->min;
+		}
+	}
+
+	if (range->max == value) {
+		if (range->max == LLONG_MIN) {
+			range->min = LLONG_MAX;
+		} else {
+			--range->max;
+		}
+	}
+}
+
+/** Compute the union of two ranges. */
+static void range_union(struct range *result, const struct range *lhs, const struct range *rhs) {
+	result->min = min_value(lhs->min, rhs->min);
+	result->max = max_value(lhs->max, rhs->max);
+}
+
+/** Check if a range contains no values. */
+static bool range_is_impossible(const struct range *range) {
+	return range->min > range->max;
+}
+
+/** Set a range to contain no values. */
+static void set_range_impossible(struct range *range) {
+	range->min = LLONG_MAX;
+	range->max = LLONG_MIN;
+}
+
+/**
+ * Types of ranges we track.
+ */
+enum range_type {
+	/** Search tree depth. */
+	DEPTH_RANGE,
+	/** Group ID. */
+	GID_RANGE,
+	/** Inode number.  */
+	INUM_RANGE,
+	/** Hard link count. */
+	LINKS_RANGE,
+	/** File size. */
+	SIZE_RANGE,
+	/** User ID. */
+	UID_RANGE,
+	/** The number of range_types. */
+	RANGE_TYPES,
+};
+
+/**
+ * A possibly-known value of a predicate.
+ */
+enum known_pred {
+	/** The state is impossible to reach. */
+	PRED_IMPOSSIBLE = -2,
+	/** The value of the predicate is not known. */
+	PRED_UNKNOWN = -1,
+	/** The predicate is known to be false. */
+	PRED_FALSE = false,
+	/** The predicate is known to be true. */
+	PRED_TRUE = true,
+};
+
+/** Make a predicate known. */
+static void constrain_pred(enum known_pred *pred, bool value) {
+	if (*pred == PRED_UNKNOWN) {
+		*pred = value;
+	} else if (*pred == !value) {
+		*pred = PRED_IMPOSSIBLE;
+	}
+}
+
+/** Compute the union of two known predicates. */
+static enum known_pred pred_union(enum known_pred lhs, enum known_pred rhs) {
+	if (lhs == PRED_IMPOSSIBLE) {
+		return rhs;
+	} else if (rhs == PRED_IMPOSSIBLE) {
+		return lhs;
+	} else if (lhs == rhs) {
+		return lhs;
+	} else {
+		return PRED_UNKNOWN;
+	}
+}
+
+/**
+ * Types of predicates we track.
+ */
+enum pred_type {
+	/** -readable */
+	READABLE_PRED,
+	/** -writable */
+	WRITABLE_PRED,
+	/** -executable */
+	EXECUTABLE_PRED,
+	/** -acl */
+	ACL_PRED,
+	/** -capable */
+	CAPABLE_PRED,
+	/** -empty */
+	EMPTY_PRED,
+	/** -hidden */
+	HIDDEN_PRED,
+	/** -nogroup */
+	NOGROUP_PRED,
+	/** -nouser */
+	NOUSER_PRED,
+	/** -sparse */
+	SPARSE_PRED,
+	/** -xattr */
+	XATTR_PRED,
+	/** The number of pred_types. */
+	PRED_TYPES,
+};
+
+/**
+ * Data flow facts about an evaluation point.
+ */
+struct opt_facts {
+	/** The value ranges we track. */
+	struct range ranges[RANGE_TYPES];
+
+	/** The predicates we track. */
+	enum known_pred preds[PRED_TYPES];
+
+	/** Bitmask of possible file types. */
+	unsigned int types;
+	/** Bitmask of possible link target types. */
+	unsigned int xtypes;
+};
+
+/** Initialize some data flow facts. */
+static void facts_init(struct opt_facts *facts) {
+	for (int i = 0; i < RANGE_TYPES; ++i) {
+		struct range *range = &facts->ranges[i];
+		range->min = 0; // All ranges we currently track are non-negative
+		range->max = LLONG_MAX;
+	}
+
+	for (int i = 0; i < PRED_TYPES; ++i) {
+		facts->preds[i] = PRED_UNKNOWN;
+	}
+
+	facts->types = ~0;
+	facts->xtypes = ~0;
+}
+
+/** Compute the union of two fact sets. */
+static void facts_union(struct opt_facts *result, const struct opt_facts *lhs, const struct opt_facts *rhs) {
+	for (int i = 0; i < RANGE_TYPES; ++i) {
+		range_union(&result->ranges[i], &lhs->ranges[i], &rhs->ranges[i]);
+	}
+
+	for (int i = 0; i < PRED_TYPES; ++i) {
+		result->preds[i] = pred_union(lhs->preds[i], rhs->preds[i]);
+	}
+
+	result->types = lhs->types | rhs->types;
+	result->xtypes = lhs->xtypes | rhs->xtypes;
+}
+
+/** Determine whether a fact set is impossible. */
+static bool facts_are_impossible(const struct opt_facts *facts) {
+	for (int i = 0; i < RANGE_TYPES; ++i) {
+		if (range_is_impossible(&facts->ranges[i])) {
+			return true;
+		}
+	}
+
+	for (int i = 0; i < PRED_TYPES; ++i) {
+		if (facts->preds[i] == PRED_IMPOSSIBLE) {
+			return true;
+		}
+	}
+
+	if (!facts->types || !facts->xtypes) {
+		return true;
+	}
+
+	return false;
+}
+
+/** Set some facts to be impossible. */
+static void set_facts_impossible(struct opt_facts *facts) {
+	for (int i = 0; i < RANGE_TYPES; ++i) {
+		set_range_impossible(&facts->ranges[i]);
+	}
+
+	for (int i = 0; i < PRED_TYPES; ++i) {
+		facts->preds[i] = PRED_IMPOSSIBLE;
+	}
+
+	facts->types = 0;
+	facts->xtypes = 0;
+}
+
+/**
+ * Optimizer state.
+ */
+struct opt_state {
+	/** The context we're optimizing. */
+	const struct bfs_ctx *ctx;
+
+	/** Data flow facts before this expression is evaluated. */
+	struct opt_facts facts;
+	/** Data flow facts after this expression returns true. */
+	struct opt_facts facts_when_true;
+	/** Data flow facts after this expression returns false. */
+	struct opt_facts facts_when_false;
+	/** Data flow facts before any side-effecting expressions are evaluated. */
+	struct opt_facts *facts_when_impure;
+};
+
+/** Log an optimization. */
+BFS_FORMATTER(3, 4)
+static bool opt_debug(const struct opt_state *state, int level, const char *format, ...) {
+	assert(state->ctx->optlevel >= level);
+
+	if (bfs_debug(state->ctx, DEBUG_OPT, "${cyn}-O%d${rs}: ", level)) {
+		va_list args;
+		va_start(args, format);
+		cvfprintf(state->ctx->cerr, format, args);
+		va_end(args);
+		return true;
+	} else {
+		return false;
+	}
+}
+
+/** Warn about an expression. */
+BFS_FORMATTER(3, 4)
+static void opt_warning(const struct opt_state *state, const struct bfs_expr *expr, const char *format, ...) {
+	if (bfs_expr_warning(state->ctx, expr)) {
+		va_list args;
+		va_start(args, format);
+		bfs_warning(state->ctx, format, args);
+		va_end(args);
+	}
+}
+
+/** Extract a child expression, freeing the outer expression. */
+static struct bfs_expr *extract_child_expr(struct bfs_expr *expr, struct bfs_expr **child) {
+	struct bfs_expr *ret = *child;
+	*child = NULL;
+	bfs_expr_free(expr);
+	return ret;
+}
+
+/**
+ * Negate an expression.
+ */
+static struct bfs_expr *negate_expr(struct bfs_expr *rhs, char **argv) {
+	if (rhs->eval_fn == eval_not) {
+		return extract_child_expr(rhs, &rhs->rhs);
+	}
+
+	struct bfs_expr *expr = bfs_expr_new(eval_not, 1, argv);
+	if (!expr) {
+		bfs_expr_free(rhs);
+		return NULL;
+	}
+
+	if (argv == &fake_not_arg) {
+		expr->synthetic = true;
+	}
+
+	expr->lhs = NULL;
+	expr->rhs = rhs;
+	return expr;
+}
+
+static struct bfs_expr *optimize_not_expr(const struct opt_state *state, struct bfs_expr *expr);
+static struct bfs_expr *optimize_and_expr(const struct opt_state *state, struct bfs_expr *expr);
+static struct bfs_expr *optimize_or_expr(const struct opt_state *state, struct bfs_expr *expr);
+
+/**
+ * Apply De Morgan's laws.
+ */
+static struct bfs_expr *de_morgan(const struct opt_state *state, struct bfs_expr *expr, char **argv) {
+	bool debug = opt_debug(state, 1, "De Morgan's laws: %pe ", expr);
+
+	struct bfs_expr *parent = negate_expr(expr, argv);
+	if (!parent) {
+		return NULL;
+	}
+
+	bool has_parent = true;
+	if (parent->eval_fn != eval_not) {
+		expr = parent;
+		has_parent = false;
+	}
+
+	assert(expr->eval_fn == eval_and || expr->eval_fn == eval_or);
+	if (expr->eval_fn == eval_and) {
+		expr->eval_fn = eval_or;
+		expr->argv = &fake_or_arg;
+	} else {
+		expr->eval_fn = eval_and;
+		expr->argv = &fake_and_arg;
+	}
+	expr->synthetic = true;
+
+	expr->lhs = negate_expr(expr->lhs, argv);
+	expr->rhs = negate_expr(expr->rhs, argv);
+	if (!expr->lhs || !expr->rhs) {
+		bfs_expr_free(parent);
+		return NULL;
+	}
+
+	if (debug) {
+		cfprintf(state->ctx->cerr, "<==> %pe\n", parent);
+	}
+
+	if (expr->lhs->eval_fn == eval_not) {
+		expr->lhs = optimize_not_expr(state, expr->lhs);
+	}
+	if (expr->rhs->eval_fn == eval_not) {
+		expr->rhs = optimize_not_expr(state, expr->rhs);
+	}
+	if (!expr->lhs || !expr->rhs) {
+		bfs_expr_free(parent);
+		return NULL;
+	}
+
+	if (expr->eval_fn == eval_and) {
+		expr = optimize_and_expr(state, expr);
+	} else {
+		expr = optimize_or_expr(state, expr);
+	}
+	if (has_parent) {
+		parent->rhs = expr;
+	} else {
+		parent = expr;
+	}
+	if (!expr) {
+		bfs_expr_free(parent);
+		return NULL;
+	}
+
+	if (has_parent) {
+		parent = optimize_not_expr(state, parent);
+	}
+	return parent;
+}
+
+/** Optimize an expression recursively. */
+static struct bfs_expr *optimize_expr_recursive(struct opt_state *state, struct bfs_expr *expr);
+
+/**
+ * Optimize a negation.
+ */
+static struct bfs_expr *optimize_not_expr(const struct opt_state *state, struct bfs_expr *expr) {
+	assert(expr->eval_fn == eval_not);
+
+	struct bfs_expr *rhs = expr->rhs;
+
+	int optlevel = state->ctx->optlevel;
+	if (optlevel >= 1) {
+		if (rhs == &bfs_true) {
+			opt_debug(state, 1, "constant propagation: %pe <==> %pe\n", expr, &bfs_false);
+			bfs_expr_free(expr);
+			return &bfs_false;
+		} else if (rhs == &bfs_false) {
+			opt_debug(state, 1, "constant propagation: %pe <==> %pe\n", expr, &bfs_true);
+			bfs_expr_free(expr);
+			return &bfs_true;
+		} else if (rhs->eval_fn == eval_not) {
+			opt_debug(state, 1, "double negation: %pe <==> %pe\n", expr, rhs->rhs);
+			return extract_child_expr(expr, &rhs->rhs);
+		} else if (bfs_expr_never_returns(rhs)) {
+			opt_debug(state, 1, "reachability: %pe <==> %pe\n", expr, rhs);
+			return extract_child_expr(expr, &expr->rhs);
+		} else if ((rhs->eval_fn == eval_and || rhs->eval_fn == eval_or)
+			   && (rhs->lhs->eval_fn == eval_not || rhs->rhs->eval_fn == eval_not)) {
+			return de_morgan(state, expr, expr->argv);
+		}
+	}
+
+	expr->pure = rhs->pure;
+	expr->always_true = rhs->always_false;
+	expr->always_false = rhs->always_true;
+	expr->cost = rhs->cost;
+	expr->probability = 1.0 - rhs->probability;
+
+	return expr;
+}
+
+/** Optimize a negation recursively. */
+static struct bfs_expr *optimize_not_expr_recursive(struct opt_state *state, struct bfs_expr *expr) {
+	struct opt_state rhs_state = *state;
+	expr->rhs = optimize_expr_recursive(&rhs_state, expr->rhs);
+	if (!expr->rhs) {
+		goto fail;
+	}
+
+	state->facts_when_true = rhs_state.facts_when_false;
+	state->facts_when_false = rhs_state.facts_when_true;
+
+	return optimize_not_expr(state, expr);
+
+fail:
+	bfs_expr_free(expr);
+	return NULL;
+}
+
+/** Optimize a conjunction. */
+static struct bfs_expr *optimize_and_expr(const struct opt_state *state, struct bfs_expr *expr) {
+	assert(expr->eval_fn == eval_and);
+
+	struct bfs_expr *lhs = expr->lhs;
+	struct bfs_expr *rhs = expr->rhs;
+
+	const struct bfs_ctx *ctx = state->ctx;
+	int optlevel = ctx->optlevel;
+	if (optlevel >= 1) {
+		if (lhs == &bfs_true) {
+			opt_debug(state, 1, "conjunction elimination: %pe <==> %pe\n", expr, rhs);
+			return extract_child_expr(expr, &expr->rhs);
+		} else if (rhs == &bfs_true) {
+			opt_debug(state, 1, "conjunction elimination: %pe <==> %pe\n", expr, lhs);
+			return extract_child_expr(expr, &expr->lhs);
+		} else if (lhs->always_false) {
+			opt_debug(state, 1, "short-circuit: %pe <==> %pe\n", expr, lhs);
+			opt_warning(state, expr->rhs, "This expression is unreachable.\n\n");
+			return extract_child_expr(expr, &expr->lhs);
+		} else if (lhs->always_true && rhs == &bfs_false) {
+			bool debug = opt_debug(state, 1, "strength reduction: %pe <==> ", expr);
+			struct bfs_expr *ret = extract_child_expr(expr, &expr->lhs);
+			ret = negate_expr(ret, &fake_not_arg);
+			if (debug && ret) {
+				cfprintf(ctx->cerr, "%pe\n", ret);
+			}
+			return ret;
+		} else if (optlevel >= 2 && lhs->pure && rhs == &bfs_false) {
+			opt_debug(state, 2, "purity: %pe <==> %pe\n", expr, rhs);
+			opt_warning(state, expr->lhs, "The result of this expression is ignored.\n\n");
+			return extract_child_expr(expr, &expr->rhs);
+		} else if (lhs->eval_fn == eval_not && rhs->eval_fn == eval_not) {
+			return de_morgan(state, expr, expr->lhs->argv);
+		}
+	}
+
+	expr->pure = lhs->pure && rhs->pure;
+	expr->always_true = lhs->always_true && rhs->always_true;
+	expr->always_false = lhs->always_false || rhs->always_false;
+	expr->cost = lhs->cost + lhs->probability*rhs->cost;
+	expr->probability = lhs->probability*rhs->probability;
+
+	return expr;
+}
+
+/** Optimize a conjunction recursively. */
+static struct bfs_expr *optimize_and_expr_recursive(struct opt_state *state, struct bfs_expr *expr) {
+	struct opt_state lhs_state = *state;
+	expr->lhs = optimize_expr_recursive(&lhs_state, expr->lhs);
+	if (!expr->lhs) {
+		goto fail;
+	}
+
+	struct opt_state rhs_state = *state;
+	rhs_state.facts = lhs_state.facts_when_true;
+	expr->rhs = optimize_expr_recursive(&rhs_state, expr->rhs);
+	if (!expr->rhs) {
+		goto fail;
+	}
+
+	state->facts_when_true = rhs_state.facts_when_true;
+	facts_union(&state->facts_when_false, &lhs_state.facts_when_false, &rhs_state.facts_when_false);
+
+	return optimize_and_expr(state, expr);
+
+fail:
+	bfs_expr_free(expr);
+	return NULL;
+}
+
+/** Optimize a disjunction. */
+static struct bfs_expr *optimize_or_expr(const struct opt_state *state, struct bfs_expr *expr) {
+	assert(expr->eval_fn == eval_or);
+
+	struct bfs_expr *lhs = expr->lhs;
+	struct bfs_expr *rhs = expr->rhs;
+
+	const struct bfs_ctx *ctx = state->ctx;
+	int optlevel = ctx->optlevel;
+	if (optlevel >= 1) {
+		if (lhs->always_true) {
+			opt_debug(state, 1, "short-circuit: %pe <==> %pe\n", expr, lhs);
+			opt_warning(state, expr->rhs, "This expression is unreachable.\n\n");
+			return extract_child_expr(expr, &expr->lhs);
+		} else if (lhs == &bfs_false) {
+			opt_debug(state, 1, "disjunctive syllogism: %pe <==> %pe\n", expr, rhs);
+			return extract_child_expr(expr, &expr->rhs);
+		} else if (rhs == &bfs_false) {
+			opt_debug(state, 1, "disjunctive syllogism: %pe <==> %pe\n", expr, lhs);
+			return extract_child_expr(expr, &expr->lhs);
+		} else if (lhs->always_false && rhs == &bfs_true) {
+			bool debug = opt_debug(state, 1, "strength reduction: %pe <==> ", expr);
+			struct bfs_expr *ret = extract_child_expr(expr, &expr->lhs);
+			ret = negate_expr(ret, &fake_not_arg);
+			if (debug && ret) {
+				cfprintf(ctx->cerr, "%pe\n", ret);
+			}
+			return ret;
+		} else if (optlevel >= 2 && lhs->pure && rhs == &bfs_true) {
+			opt_debug(state, 2, "purity: %pe <==> %pe\n", expr, rhs);
+			opt_warning(state, expr->lhs, "The result of this expression is ignored.\n\n");
+			return extract_child_expr(expr, &expr->rhs);
+		} else if (lhs->eval_fn == eval_not && rhs->eval_fn == eval_not) {
+			return de_morgan(state, expr, expr->lhs->argv);
+		}
+	}
+
+	expr->pure = lhs->pure && rhs->pure;
+	expr->always_true = lhs->always_true || rhs->always_true;
+	expr->always_false = lhs->always_false && rhs->always_false;
+	expr->cost = lhs->cost + (1 - lhs->probability)*rhs->cost;
+	expr->probability = lhs->probability + rhs->probability - lhs->probability*rhs->probability;
+
+	return expr;
+}
+
+/** Optimize a disjunction recursively. */
+static struct bfs_expr *optimize_or_expr_recursive(struct opt_state *state, struct bfs_expr *expr) {
+	struct opt_state lhs_state = *state;
+	expr->lhs = optimize_expr_recursive(&lhs_state, expr->lhs);
+	if (!expr->lhs) {
+		goto fail;
+	}
+
+	struct opt_state rhs_state = *state;
+	rhs_state.facts = lhs_state.facts_when_false;
+	expr->rhs = optimize_expr_recursive(&rhs_state, expr->rhs);
+	if (!expr->rhs) {
+		goto fail;
+	}
+
+	facts_union(&state->facts_when_true, &lhs_state.facts_when_true, &rhs_state.facts_when_true);
+	state->facts_when_false = rhs_state.facts_when_false;
+
+	return optimize_or_expr(state, expr);
+
+fail:
+	bfs_expr_free(expr);
+	return NULL;
+}
+
+/** Optimize an expression in an ignored-result context. */
+static struct bfs_expr *ignore_result(const struct opt_state *state, struct bfs_expr *expr) {
+	int optlevel = state->ctx->optlevel;
+
+	if (optlevel >= 1) {
+		while (true) {
+			if (expr->eval_fn == eval_not) {
+				opt_debug(state, 1, "ignored result: %pe --> %pe\n", expr, expr->rhs);
+				opt_warning(state, expr, "The result of this expression is ignored.\n\n");
+				expr = extract_child_expr(expr, &expr->rhs);
+			} else if (optlevel >= 2
+			           && (expr->eval_fn == eval_and || expr->eval_fn == eval_or || expr->eval_fn == eval_comma)
+			           && expr->rhs->pure) {
+				opt_debug(state, 2, "ignored result: %pe --> %pe\n", expr, expr->lhs);
+				opt_warning(state, expr->rhs, "The result of this expression is ignored.\n\n");
+				expr = extract_child_expr(expr, &expr->lhs);
+			} else {
+				break;
+			}
+		}
+
+		if (optlevel >= 2 && expr->pure && expr != &bfs_false) {
+			opt_debug(state, 2, "ignored result: %pe --> %pe\n", expr, &bfs_false);
+			opt_warning(state, expr, "The result of this expression is ignored.\n\n");
+			bfs_expr_free(expr);
+			expr = &bfs_false;
+		}
+	}
+
+	return expr;
+}
+
+/** Optimize a comma expression. */
+static struct bfs_expr *optimize_comma_expr(const struct opt_state *state, struct bfs_expr *expr) {
+	assert(expr->eval_fn == eval_comma);
+
+	struct bfs_expr *lhs = expr->lhs;
+	struct bfs_expr *rhs = expr->rhs;
+
+	int optlevel = state->ctx->optlevel;
+	if (optlevel >= 1) {
+		lhs = expr->lhs = ignore_result(state, lhs);
+
+		if (bfs_expr_never_returns(lhs)) {
+			opt_debug(state, 1, "reachability: %pe <==> %pe\n", expr, lhs);
+			opt_warning(state, expr->rhs, "This expression is unreachable.\n\n");
+			return extract_child_expr(expr, &expr->lhs);
+		} else if ((lhs->always_true && rhs == &bfs_true)
+			   || (lhs->always_false && rhs == &bfs_false)) {
+			opt_debug(state, 1, "redundancy elimination: %pe <==> %pe\n", expr, lhs);
+			return extract_child_expr(expr, &expr->lhs);
+		} else if (optlevel >= 2 && lhs->pure) {
+			opt_debug(state, 2, "purity: %pe <==> %pe\n", expr, rhs);
+			opt_warning(state, expr->lhs, "The result of this expression is ignored.\n\n");
+			return extract_child_expr(expr, &expr->rhs);
+		}
+	}
+
+	expr->pure = lhs->pure && rhs->pure;
+	expr->always_true = bfs_expr_never_returns(lhs) || rhs->always_true;
+	expr->always_false = bfs_expr_never_returns(lhs) || rhs->always_false;
+	expr->cost = lhs->cost + rhs->cost;
+	expr->probability = rhs->probability;
+
+	return expr;
+}
+
+/** Optimize a comma expression recursively. */
+static struct bfs_expr *optimize_comma_expr_recursive(struct opt_state *state, struct bfs_expr *expr) {
+	struct opt_state lhs_state = *state;
+	expr->lhs = optimize_expr_recursive(&lhs_state, expr->lhs);
+	if (!expr->lhs) {
+		goto fail;
+	}
+
+	struct opt_state rhs_state = *state;
+	facts_union(&rhs_state.facts, &lhs_state.facts_when_true, &lhs_state.facts_when_false);
+	expr->rhs = optimize_expr_recursive(&rhs_state, expr->rhs);
+	if (!expr->rhs) {
+		goto fail;
+	}
+
+	return optimize_comma_expr(state, expr);
+
+fail:
+	bfs_expr_free(expr);
+	return NULL;
+}
+
+/** Infer data flow facts about a predicate. */
+static void infer_pred_facts(struct opt_state *state, enum pred_type pred) {
+	constrain_pred(&state->facts_when_true.preds[pred], true);
+	constrain_pred(&state->facts_when_false.preds[pred], false);
+}
+
+/** Infer data flow facts about an -{execut,read,writ}able expression. */
+static void infer_access_facts(struct opt_state *state, const struct bfs_expr *expr) {
+	if (expr->num & R_OK) {
+		infer_pred_facts(state, READABLE_PRED);
+	}
+	if (expr->num & W_OK) {
+		infer_pred_facts(state, WRITABLE_PRED);
+	}
+	if (expr->num & X_OK) {
+		infer_pred_facts(state, EXECUTABLE_PRED);
+	}
+}
+
+/** Infer data flow facts about an icmp-style ([+-]N) expression. */
+static void infer_icmp_facts(struct opt_state *state, const struct bfs_expr *expr, enum range_type type) {
+	struct range *range_when_true = &state->facts_when_true.ranges[type];
+	struct range *range_when_false = &state->facts_when_false.ranges[type];
+	long long value = expr->num;
+
+	switch (expr->int_cmp) {
+	case BFS_INT_EQUAL:
+		constrain_min(range_when_true, value);
+		constrain_max(range_when_true, value);
+		range_remove(range_when_false, value);
+		break;
+
+	case BFS_INT_LESS:
+		constrain_min(range_when_false, value);
+		constrain_max(range_when_true, value);
+		range_remove(range_when_true, value);
+		break;
+
+	case BFS_INT_GREATER:
+		constrain_max(range_when_false, value);
+		constrain_min(range_when_true, value);
+		range_remove(range_when_true, value);
+		break;
+	}
+}
+
+/** Infer data flow facts about a -gid expression. */
+static void infer_gid_facts(struct opt_state *state, const struct bfs_expr *expr) {
+	infer_icmp_facts(state, expr, GID_RANGE);
+
+	const struct bfs_groups *groups = bfs_ctx_groups(state->ctx);
+	struct range *range = &state->facts_when_true.ranges[GID_RANGE];
+	if (groups && range->min == range->max) {
+		gid_t gid = range->min;
+		bool nogroup = !bfs_getgrgid(groups, gid);
+		constrain_pred(&state->facts_when_true.preds[NOGROUP_PRED], nogroup);
+	}
+}
+
+/** Infer data flow facts about a -uid expression. */
+static void infer_uid_facts(struct opt_state *state, const struct bfs_expr *expr) {
+	infer_icmp_facts(state, expr, UID_RANGE);
+
+	const struct bfs_users *users = bfs_ctx_users(state->ctx);
+	struct range *range = &state->facts_when_true.ranges[UID_RANGE];
+	if (users && range->min == range->max) {
+		uid_t uid = range->min;
+		bool nouser = !bfs_getpwuid(users, uid);
+		constrain_pred(&state->facts_when_true.preds[NOUSER_PRED], nouser);
+	}
+}
+
+/** Infer data flow facts about a -samefile expression. */
+static void infer_samefile_facts(struct opt_state *state, const struct bfs_expr *expr) {
+	struct range *range_when_true = &state->facts_when_true.ranges[INUM_RANGE];
+	constrain_min(range_when_true, expr->ino);
+	constrain_max(range_when_true, expr->ino);
+}
+
+/** Infer data flow facts about a -type expression. */
+static void infer_type_facts(struct opt_state *state, const struct bfs_expr *expr) {
+	state->facts_when_true.types &= expr->num;
+	state->facts_when_false.types &= ~expr->num;
+}
+
+/** Infer data flow facts about an -xtype expression. */
+static void infer_xtype_facts(struct opt_state *state, const struct bfs_expr *expr) {
+	state->facts_when_true.xtypes &= expr->num;
+	state->facts_when_false.xtypes &= ~expr->num;
+}
+
+static struct bfs_expr *optimize_expr_recursive(struct opt_state *state, struct bfs_expr *expr) {
+	int optlevel = state->ctx->optlevel;
+
+	state->facts_when_true = state->facts;
+	state->facts_when_false = state->facts;
+
+	if (optlevel >= 2 && facts_are_impossible(&state->facts)) {
+		opt_debug(state, 2, "reachability: %pe --> %pe\n", expr, &bfs_false);
+		opt_warning(state, expr, "This expression is unreachable.\n\n");
+		bfs_expr_free(expr);
+		expr = &bfs_false;
+		goto done;
+	}
+
+	if (!bfs_expr_has_children(expr) && !expr->pure) {
+		facts_union(state->facts_when_impure, state->facts_when_impure, &state->facts);
+	}
+
+	if (expr->eval_fn == eval_access) {
+		infer_access_facts(state, expr);
+	} else if (expr->eval_fn == eval_acl) {
+		infer_pred_facts(state, ACL_PRED);
+	} else if (expr->eval_fn == eval_capable) {
+		infer_pred_facts(state, CAPABLE_PRED);
+	} else if (expr->eval_fn == eval_depth) {
+		infer_icmp_facts(state, expr, DEPTH_RANGE);
+	} else if (expr->eval_fn == eval_empty) {
+		infer_pred_facts(state, EMPTY_PRED);
+	} else if (expr->eval_fn == eval_gid) {
+		infer_gid_facts(state, expr);
+	} else if (expr->eval_fn == eval_hidden) {
+		infer_pred_facts(state, HIDDEN_PRED);
+	} else if (expr->eval_fn == eval_inum) {
+		infer_icmp_facts(state, expr, INUM_RANGE);
+	} else if (expr->eval_fn == eval_links) {
+		infer_icmp_facts(state, expr, LINKS_RANGE);
+	} else if (expr->eval_fn == eval_nogroup) {
+		infer_pred_facts(state, NOGROUP_PRED);
+	} else if (expr->eval_fn == eval_nouser) {
+		infer_pred_facts(state, NOUSER_PRED);
+	} else if (expr->eval_fn == eval_samefile) {
+		infer_samefile_facts(state, expr);
+	} else if (expr->eval_fn == eval_size) {
+		infer_icmp_facts(state, expr, SIZE_RANGE);
+	} else if (expr->eval_fn == eval_sparse) {
+		infer_pred_facts(state, SPARSE_PRED);
+	} else if (expr->eval_fn == eval_type) {
+		infer_type_facts(state, expr);
+	} else if (expr->eval_fn == eval_uid) {
+		infer_uid_facts(state, expr);
+	} else if (expr->eval_fn == eval_xattr) {
+		infer_pred_facts(state, XATTR_PRED);
+	} else if (expr->eval_fn == eval_xtype) {
+		infer_xtype_facts(state, expr);
+	} else if (expr->eval_fn == eval_not) {
+		expr = optimize_not_expr_recursive(state, expr);
+	} else if (expr->eval_fn == eval_and) {
+		expr = optimize_and_expr_recursive(state, expr);
+	} else if (expr->eval_fn == eval_or) {
+		expr = optimize_or_expr_recursive(state, expr);
+	} else if (expr->eval_fn == eval_comma) {
+		expr = optimize_comma_expr_recursive(state, expr);
+	}
+
+	if (!expr) {
+		goto done;
+	}
+
+	if (bfs_expr_has_children(expr)) {
+		struct bfs_expr *lhs = expr->lhs;
+		struct bfs_expr *rhs = expr->rhs;
+		if (rhs) {
+			expr->persistent_fds = rhs->persistent_fds;
+			expr->ephemeral_fds = rhs->ephemeral_fds;
+		}
+		if (lhs) {
+			expr->persistent_fds += lhs->persistent_fds;
+			if (lhs->ephemeral_fds > expr->ephemeral_fds) {
+				expr->ephemeral_fds = lhs->ephemeral_fds;
+			}
+		}
+	}
+
+	if (expr->always_true) {
+		set_facts_impossible(&state->facts_when_false);
+	}
+	if (expr->always_false) {
+		set_facts_impossible(&state->facts_when_true);
+	}
+
+	if (optlevel < 2 || expr == &bfs_true || expr == &bfs_false) {
+		goto done;
+	}
+
+	if (facts_are_impossible(&state->facts_when_true)) {
+		if (expr->pure) {
+			opt_debug(state, 2, "data flow: %pe --> %pe\n", expr, &bfs_false);
+			opt_warning(state, expr, "This expression is always false.\n\n");
+			bfs_expr_free(expr);
+			expr = &bfs_false;
+		} else {
+			expr->always_false = true;
+			expr->probability = 0.0;
+		}
+	} else if (facts_are_impossible(&state->facts_when_false)) {
+		if (expr->pure) {
+			opt_debug(state, 2, "data flow: %pe --> %pe\n", expr, &bfs_true);
+			opt_warning(state, expr, "This expression is always true.\n\n");
+			bfs_expr_free(expr);
+			expr = &bfs_true;
+		} else {
+			expr->always_true = true;
+			expr->probability = 1.0;
+		}
+	}
+
+done:
+	return expr;
+}
+
+/** Swap the children of a binary expression if it would reduce the cost. */
+static bool reorder_expr(const struct opt_state *state, struct bfs_expr *expr, float swapped_cost) {
+	if (swapped_cost < expr->cost) {
+		bool debug = opt_debug(state, 3, "cost: %pe <==> ", expr);
+		struct bfs_expr *lhs = expr->lhs;
+		expr->lhs = expr->rhs;
+		expr->rhs = lhs;
+		if (debug) {
+			cfprintf(state->ctx->cerr, "%pe (~${ylw}%g${rs} --> ~${ylw}%g${rs})\n", expr, expr->cost, swapped_cost);
+		}
+		expr->cost = swapped_cost;
+		return true;
+	} else {
+		return false;
+	}
+}
+
+/**
+ * Recursively reorder sub-expressions to reduce the overall cost.
+ *
+ * @param expr
+ *         The expression to optimize.
+ * @return
+ *         Whether any subexpression was reordered.
+ */
+static bool reorder_expr_recursive(const struct opt_state *state, struct bfs_expr *expr) {
+	if (!bfs_expr_has_children(expr)) {
+		return false;
+	}
+
+	struct bfs_expr *lhs = expr->lhs;
+	struct bfs_expr *rhs = expr->rhs;
+
+	bool ret = false;
+	if (lhs) {
+		ret |= reorder_expr_recursive(state, lhs);
+	}
+	if (rhs) {
+		ret |= reorder_expr_recursive(state, rhs);
+	}
+
+	if (expr->eval_fn == eval_and || expr->eval_fn == eval_or) {
+		if (lhs->pure && rhs->pure) {
+			float rhs_prob = expr->eval_fn == eval_and ? rhs->probability : 1.0 - rhs->probability;
+			float swapped_cost = rhs->cost + rhs_prob*lhs->cost;
+			ret |= reorder_expr(state, expr, swapped_cost);
+		}
+	}
+
+	return ret;
+}
+
+/**
+ * Optimize a top-level expression.
+ */
+static struct bfs_expr *optimize_expr(struct opt_state *state, struct bfs_expr *expr) {
+	struct opt_facts saved_impure = *state->facts_when_impure;
+
+	expr = optimize_expr_recursive(state, expr);
+	if (!expr) {
+		return NULL;
+	}
+
+	if (state->ctx->optlevel >= 3 && reorder_expr_recursive(state, expr)) {
+		// Re-do optimizations to account for the new ordering
+		*state->facts_when_impure = saved_impure;
+		expr = optimize_expr_recursive(state, expr);
+		if (!expr) {
+			return NULL;
+		}
+	}
+
+	return expr;
+}
+
+int bfs_optimize(struct bfs_ctx *ctx) {
+	bfs_ctx_dump(ctx, DEBUG_OPT);
+
+	struct opt_facts facts_when_impure;
+	set_facts_impossible(&facts_when_impure);
+
+	struct opt_state state = {
+		.ctx = ctx,
+		.facts_when_impure = &facts_when_impure,
+	};
+	facts_init(&state.facts);
+
+	ctx->exclude = optimize_expr(&state, ctx->exclude);
+	if (!ctx->exclude) {
+		return -1;
+	}
+
+	// Only non-excluded files are evaluated
+	state.facts = state.facts_when_false;
+
+	struct range *depth = &state.facts.ranges[DEPTH_RANGE];
+	constrain_min(depth, ctx->mindepth);
+	constrain_max(depth, ctx->maxdepth);
+
+	ctx->expr = optimize_expr(&state, ctx->expr);
+	if (!ctx->expr) {
+		return -1;
+	}
+
+	ctx->expr = ignore_result(&state, ctx->expr);
+
+	if (facts_are_impossible(&facts_when_impure)) {
+		bfs_warning(ctx, "This command won't do anything.\n\n");
+	}
+
+	const struct range *depth_when_impure = &facts_when_impure.ranges[DEPTH_RANGE];
+	long long mindepth = depth_when_impure->min;
+	long long maxdepth = depth_when_impure->max;
+
+	int optlevel = ctx->optlevel;
+
+	if (optlevel >= 2 && mindepth > ctx->mindepth) {
+		if (mindepth > INT_MAX) {
+			mindepth = INT_MAX;
+		}
+		ctx->mindepth = mindepth;
+		opt_debug(&state, 2, "data flow: mindepth --> %d\n", ctx->mindepth);
+	}
+
+	if (optlevel >= 4 && maxdepth < ctx->maxdepth) {
+		if (maxdepth < INT_MIN) {
+			maxdepth = INT_MIN;
+		}
+		ctx->maxdepth = maxdepth;
+		opt_debug(&state, 4, "data flow: maxdepth --> %d\n", ctx->maxdepth);
+	}
+
+	return 0;
+}
diff --git a/src/opt.h b/src/opt.h
new file mode 100644
index 0000000..5f8180d
--- /dev/null
+++ b/src/opt.h
@@ -0,0 +1,37 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2020 Tavian Barnes <tavianator@tavianator.com>             *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * Optimization.
+ */
+
+#ifndef BFS_OPT_H
+#define BFS_OPT_H
+
+struct bfs_ctx;
+
+/**
+ * Apply optimizations to the command line.
+ *
+ * @param ctx
+ *         The bfs context to optimize.
+ * @return
+ *         0 if successful, -1 on error.
+ */
+int bfs_optimize(struct bfs_ctx *ctx);
+
+#endif // BFS_OPT_H
+
diff --git a/src/parse.c b/src/parse.c
new file mode 100644
index 0000000..65087a0
--- /dev/null
+++ b/src/parse.c
@@ -0,0 +1,3959 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2015-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * The command line parser.  Expressions are parsed by recursive descent, with a
+ * grammar described in the comments of the parse_*() functions.  The parser
+ * also accepts flags and paths at any point in the expression, by treating
+ * flags like always-true options, and skipping over paths wherever they appear.
+ */
+
+#include "parse.h"
+#include "bfs.h"
+#include "bftw.h"
+#include "color.h"
+#include "ctx.h"
+#include "darray.h"
+#include "diag.h"
+#include "dir.h"
+#include "eval.h"
+#include "exec.h"
+#include "expr.h"
+#include "fsade.h"
+#include "opt.h"
+#include "printf.h"
+#include "pwcache.h"
+#include "stat.h"
+#include "typo.h"
+#include "util.h"
+#include "xregex.h"
+#include "xspawn.h"
+#include "xtime.h"
+#include <assert.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <fnmatch.h>
+#include <grp.h>
+#include <limits.h>
+#include <pwd.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/time.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+
+// Strings printed by -D tree for "fake" expressions
+static char *fake_and_arg = "-a";
+static char *fake_false_arg = "-false";
+static char *fake_hidden_arg = "-hidden";
+static char *fake_or_arg = "-o";
+static char *fake_print_arg = "-print";
+static char *fake_true_arg = "-true";
+
+// Cost estimation constants
+#define FAST_COST     40.0
+#define STAT_COST   1000.0
+#define PRINT_COST 20000.0
+
+struct bfs_expr bfs_true = {
+	.eval_fn = eval_true,
+	.argc = 1,
+	.argv = &fake_true_arg,
+	.pure = true,
+	.always_true = true,
+	.synthetic = true,
+	.cost = FAST_COST,
+	.probability = 1.0,
+};
+
+struct bfs_expr bfs_false = {
+	.eval_fn = eval_false,
+	.argc = 1,
+	.argv = &fake_false_arg,
+	.pure = true,
+	.always_false = true,
+	.synthetic = true,
+	.cost = FAST_COST,
+	.probability = 0.0,
+};
+
+void bfs_expr_free(struct bfs_expr *expr) {
+	if (!expr || expr == &bfs_true || expr == &bfs_false) {
+		return;
+	}
+
+	if (bfs_expr_has_children(expr)) {
+		bfs_expr_free(expr->rhs);
+		bfs_expr_free(expr->lhs);
+	} else if (expr->eval_fn == eval_exec) {
+		bfs_exec_free(expr->exec);
+	} else if (expr->eval_fn == eval_fprintf) {
+		bfs_printf_free(expr->printf);
+	} else if (expr->eval_fn == eval_regex) {
+		bfs_regfree(expr->regex);
+	}
+
+	free(expr);
+}
+
+struct bfs_expr *bfs_expr_new(bfs_eval_fn *eval_fn, size_t argc, char **argv) {
+	struct bfs_expr *expr = malloc(sizeof(*expr));
+	if (!expr) {
+		perror("malloc()");
+		return NULL;
+	}
+
+	expr->eval_fn = eval_fn;
+	expr->argc = argc;
+	expr->argv = argv;
+	expr->persistent_fds = 0;
+	expr->ephemeral_fds = 0;
+	expr->pure = false;
+	expr->always_true = false;
+	expr->always_false = false;
+	expr->synthetic = false;
+	expr->cost = FAST_COST;
+	expr->probability = 0.5;
+	expr->evaluations = 0;
+	expr->successes = 0;
+	expr->elapsed.tv_sec = 0;
+	expr->elapsed.tv_nsec = 0;
+	return expr;
+}
+
+/**
+ * Create a new unary expression.
+ */
+static struct bfs_expr *new_unary_expr(bfs_eval_fn *eval_fn, struct bfs_expr *rhs, char **argv) {
+	struct bfs_expr *expr = bfs_expr_new(eval_fn, 1, argv);
+	if (!expr) {
+		bfs_expr_free(rhs);
+		return NULL;
+	}
+
+	expr->lhs = NULL;
+	expr->rhs = rhs;
+	assert(bfs_expr_has_children(expr));
+
+	expr->persistent_fds = rhs->persistent_fds;
+	expr->ephemeral_fds = rhs->ephemeral_fds;
+	return expr;
+}
+
+/**
+ * Create a new binary expression.
+ */
+static struct bfs_expr *new_binary_expr(bfs_eval_fn *eval_fn, struct bfs_expr *lhs, struct bfs_expr *rhs, char **argv) {
+	struct bfs_expr *expr = bfs_expr_new(eval_fn, 1, argv);
+	if (!expr) {
+		bfs_expr_free(rhs);
+		bfs_expr_free(lhs);
+		return NULL;
+	}
+
+	expr->lhs = lhs;
+	expr->rhs = rhs;
+	assert(bfs_expr_has_children(expr));
+
+	if (argv == &fake_and_arg || argv == &fake_or_arg) {
+		expr->synthetic = true;
+	}
+
+	expr->persistent_fds = lhs->persistent_fds + rhs->persistent_fds;
+	if (lhs->ephemeral_fds > rhs->ephemeral_fds) {
+		expr->ephemeral_fds = lhs->ephemeral_fds;
+	} else {
+		expr->ephemeral_fds = rhs->ephemeral_fds;
+	}
+
+	return expr;
+}
+
+bool bfs_expr_has_children(const struct bfs_expr *expr) {
+	return expr->eval_fn == eval_and
+		|| expr->eval_fn == eval_or
+		|| expr->eval_fn == eval_not
+		|| expr->eval_fn == eval_comma;
+}
+
+bool bfs_expr_never_returns(const struct bfs_expr *expr) {
+	// Expressions that never return are vacuously both always true and always false
+	return expr->always_true && expr->always_false;
+}
+
+/**
+ * Set an expression to always return true.
+ */
+static void expr_set_always_true(struct bfs_expr *expr) {
+	expr->always_true = true;
+	expr->probability = 1.0;
+}
+
+/**
+ * Set an expression to never return.
+ */
+static void expr_set_never_returns(struct bfs_expr *expr) {
+	expr->always_true = expr->always_false = true;
+}
+
+/**
+ * Color use flags.
+ */
+enum use_color {
+	COLOR_NEVER,
+	COLOR_AUTO,
+	COLOR_ALWAYS,
+};
+
+/**
+ * Ephemeral state for parsing the command line.
+ */
+struct parser_state {
+	/** The command line being constructed. */
+	struct bfs_ctx *ctx;
+	/** The command line arguments being parsed. */
+	char **argv;
+	/** The name of this program. */
+	const char *command;
+
+	/** The current regex flags to use. */
+	enum bfs_regex_type regex_type;
+
+	/** Whether stdout is a terminal. */
+	bool stdout_tty;
+	/** Whether this session is interactive (stdin and stderr are each a terminal). */
+	bool interactive;
+	/** Whether -color or -nocolor has been passed. */
+	enum use_color use_color;
+	/** Whether a -print action is implied. */
+	bool implicit_print;
+	/** Whether the default root "." should be used. */
+	bool implicit_root;
+	/** Whether the expression has started. */
+	bool expr_started;
+	/** Whether an information option like -help or -version was passed. */
+	bool just_info;
+	/** Whether we are currently parsing an -exclude expression. */
+	bool excluding;
+
+	/** The last non-path argument. */
+	char **last_arg;
+	/** A "-depth"-type argument, if any. */
+	char **depth_arg;
+	/** A "-prune" argument, if any. */
+	char **prune_arg;
+	/** A "-mount" argument, if any. */
+	char **mount_arg;
+	/** An "-xdev" argument, if any. */
+	char **xdev_arg;
+	/** A "-files0-from" argument, if any. */
+	char **files0_arg;
+	/** A "-files0-from -" argument, if any. */
+	char **files0_stdin_arg;
+	/** An "-ok"-type expression, if any. */
+	const struct bfs_expr *ok_expr;
+
+	/** The current time. */
+	struct timespec now;
+};
+
+/**
+ * Possible token types.
+ */
+enum token_type {
+	/** A flag. */
+	T_FLAG,
+	/** A root path. */
+	T_PATH,
+	/** An option. */
+	T_OPTION,
+	/** A test. */
+	T_TEST,
+	/** An action. */
+	T_ACTION,
+	/** An operator. */
+	T_OPERATOR,
+};
+
+/**
+ * Print a low-level error message during parsing.
+ */
+static void parse_perror(const struct parser_state *state, const char *str) {
+	bfs_perror(state->ctx, str);
+}
+
+/** Initialize an empty highlighted range. */
+static void init_highlight(const struct bfs_ctx *ctx, bool *args) {
+	for (size_t i = 0; i < ctx->argc; ++i) {
+		args[i] = false;
+	}
+}
+
+/** Highlight a range of command line arguments. */
+static void highlight_args(const struct bfs_ctx *ctx, char **argv, size_t argc, bool *args) {
+	size_t i = argv - ctx->argv;
+	for (size_t j = 0; j < argc; ++j) {
+		assert(i + j < ctx->argc);
+		args[i + j] = true;
+	}
+}
+
+/**
+ * Print an error message during parsing.
+ */
+BFS_FORMATTER(2, 3)
+static void parse_error(const struct parser_state *state, const char *format, ...) {
+	int error = errno;
+	const struct bfs_ctx *ctx = state->ctx;
+
+	bool highlight[ctx->argc];
+	init_highlight(ctx, highlight);
+	highlight_args(ctx, state->argv, 1, highlight);
+	bfs_argv_error(ctx, highlight);
+
+	va_list args;
+	va_start(args, format);
+	errno = error;
+	bfs_verror(state->ctx, format, args);
+	va_end(args);
+}
+
+/**
+ * Print an error about some command line arguments.
+ */
+BFS_FORMATTER(4, 5)
+static void parse_argv_error(const struct parser_state *state, char **argv, size_t argc, const char *format, ...) {
+	int error = errno;
+	const struct bfs_ctx *ctx = state->ctx;
+
+	bool highlight[ctx->argc];
+	init_highlight(ctx, highlight);
+	highlight_args(ctx, argv, argc, highlight);
+	bfs_argv_error(ctx, highlight);
+
+	va_list args;
+	va_start(args, format);
+	errno = error;
+	bfs_verror(ctx, format, args);
+	va_end(args);
+}
+
+/**
+ * Print an error about conflicting command line arguments.
+ */
+BFS_FORMATTER(6, 7)
+static void parse_conflict_error(const struct parser_state *state, char **argv1, size_t argc1, char **argv2, size_t argc2, const char *format, ...) {
+	int error = errno;
+	const struct bfs_ctx *ctx = state->ctx;
+
+	bool highlight[ctx->argc];
+	init_highlight(ctx, highlight);
+	highlight_args(ctx, argv1, argc1, highlight);
+	highlight_args(ctx, argv2, argc2, highlight);
+	bfs_argv_error(ctx, highlight);
+
+	va_list args;
+	va_start(args, format);
+	errno = error;
+	bfs_verror(ctx, format, args);
+	va_end(args);
+}
+
+/**
+ * Print an error about an expression.
+ */
+BFS_FORMATTER(3, 4)
+static void parse_expr_error(const struct parser_state *state, const struct bfs_expr *expr, const char *format, ...) {
+	int error = errno;
+	const struct bfs_ctx *ctx = state->ctx;
+
+	bfs_expr_error(ctx, expr);
+
+	va_list args;
+	va_start(args, format);
+	errno = error;
+	bfs_verror(ctx, format, args);
+	va_end(args);
+}
+
+/**
+ * Print a warning message during parsing.
+ */
+BFS_FORMATTER(2, 3)
+static bool parse_warning(const struct parser_state *state, const char *format, ...) {
+	int error = errno;
+	const struct bfs_ctx *ctx = state->ctx;
+
+	bool highlight[ctx->argc];
+	init_highlight(ctx, highlight);
+	highlight_args(ctx, state->argv, 1, highlight);
+	if (!bfs_argv_warning(ctx, highlight)) {
+		return false;
+	}
+
+	va_list args;
+	va_start(args, format);
+	errno = error;
+	bool ret = bfs_vwarning(state->ctx, format, args);
+	va_end(args);
+	return ret;
+}
+
+/**
+ * Print a warning about conflicting command line arguments.
+ */
+BFS_FORMATTER(6, 7)
+static bool parse_conflict_warning(const struct parser_state *state, char **argv1, size_t argc1, char **argv2, size_t argc2, const char *format, ...) {
+	int error = errno;
+	const struct bfs_ctx *ctx = state->ctx;
+
+	bool highlight[ctx->argc];
+	init_highlight(ctx, highlight);
+	highlight_args(ctx, argv1, argc1, highlight);
+	highlight_args(ctx, argv2, argc2, highlight);
+	if (!bfs_argv_warning(ctx, highlight)) {
+		return false;
+	}
+
+	va_list args;
+	va_start(args, format);
+	errno = error;
+	bool ret = bfs_vwarning(ctx, format, args);
+	va_end(args);
+	return ret;
+}
+
+/**
+ * Print a warning about an expression.
+ */
+BFS_FORMATTER(3, 4)
+static bool parse_expr_warning(const struct parser_state *state, const struct bfs_expr *expr, const char *format, ...) {
+	int error = errno;
+	const struct bfs_ctx *ctx = state->ctx;
+
+	if (!bfs_expr_warning(ctx, expr)) {
+		return false;
+	}
+
+	va_list args;
+	va_start(args, format);
+	errno = error;
+	bool ret = bfs_vwarning(ctx, format, args);
+	va_end(args);
+	return ret;
+}
+
+/**
+ * Fill in a "-print"-type expression.
+ */
+static void init_print_expr(struct parser_state *state, struct bfs_expr *expr) {
+	expr_set_always_true(expr);
+	expr->cost = PRINT_COST;
+	expr->cfile = state->ctx->cout;
+}
+
+/**
+ * Open a file for an expression.
+ */
+static int expr_open(struct parser_state *state, struct bfs_expr *expr, const char *path) {
+	struct bfs_ctx *ctx = state->ctx;
+
+	FILE *file = NULL;
+	CFILE *cfile = NULL;
+
+	file = xfopen(path, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC);
+	if (!file) {
+		goto fail;
+	}
+
+	cfile = cfwrap(file, state->use_color ? ctx->colors : NULL, true);
+	if (!cfile) {
+		goto fail;
+	}
+
+	CFILE *dedup = bfs_ctx_dedup(ctx, cfile, path);
+	if (!dedup) {
+		goto fail;
+	}
+
+	if (dedup != cfile) {
+		cfclose(cfile);
+	}
+
+	expr->cfile = dedup;
+	return 0;
+
+fail:
+	parse_expr_error(state, expr, "%m.\n");
+	if (cfile) {
+		cfclose(cfile);
+	} else if (file) {
+		fclose(file);
+	}
+	return -1;
+}
+
+/**
+ * Invoke bfs_stat() on an argument.
+ */
+static int stat_arg(const struct parser_state *state, char **arg, struct bfs_stat *sb) {
+	const struct bfs_ctx *ctx = state->ctx;
+
+	bool follow = ctx->flags & (BFTW_FOLLOW_ROOTS | BFTW_FOLLOW_ALL);
+	enum bfs_stat_flags flags = follow ? BFS_STAT_TRYFOLLOW : BFS_STAT_NOFOLLOW;
+
+	int ret = bfs_stat(AT_FDCWD, *arg, flags, sb);
+	if (ret != 0) {
+		parse_argv_error(state, arg, 1, "%m.\n");
+	}
+	return ret;
+}
+
+/**
+ * Parse the expression specified on the command line.
+ */
+static struct bfs_expr *parse_expr(struct parser_state *state);
+
+/**
+ * Advance by a single token.
+ */
+static char **parser_advance(struct parser_state *state, enum token_type type, size_t argc) {
+	if (type != T_FLAG && type != T_PATH) {
+		state->expr_started = true;
+	}
+
+	if (type != T_PATH) {
+		state->last_arg = state->argv;
+	}
+
+	char **argv = state->argv;
+	state->argv += argc;
+	return argv;
+}
+
+/**
+ * Parse a root path.
+ */
+static int parse_root(struct parser_state *state, const char *path) {
+	char *copy = strdup(path);
+	if (!copy) {
+		parse_perror(state, "strdup()");
+		return -1;
+	}
+
+	struct bfs_ctx *ctx = state->ctx;
+	if (DARRAY_PUSH(&ctx->paths, &copy) != 0) {
+		parse_perror(state, "DARRAY_PUSH()");
+		free(copy);
+		return -1;
+	}
+
+	state->implicit_root = false;
+	return 0;
+}
+
+/**
+ * While parsing an expression, skip any paths and add them to ctx->paths.
+ */
+static int skip_paths(struct parser_state *state) {
+	while (true) {
+		const char *arg = state->argv[0];
+		if (!arg) {
+			return 0;
+		}
+
+		if (arg[0] == '-') {
+			if (strcmp(arg, "--") == 0) {
+				// find uses -- to separate flags from the rest
+				// of the command line.  We allow mixing flags
+				// and paths/predicates, so we just ignore --.
+				parser_advance(state, T_FLAG, 1);
+				continue;
+			}
+			if (strcmp(arg, "-") != 0) {
+				// - by itself is a file name.  Anything else
+				// starting with - is a flag/predicate.
+				return 0;
+			}
+		}
+
+		// By POSIX, these are always options
+		if (strcmp(arg, "(") == 0 || strcmp(arg, "!") == 0) {
+			return 0;
+		}
+
+		if (state->expr_started) {
+			// By POSIX, these can be paths.  We only treat them as
+			// such at the beginning of the command line.
+			if (strcmp(arg, ")") == 0 || strcmp(arg, ",") == 0) {
+				return 0;
+			}
+		}
+
+		if (parse_root(state, arg) != 0) {
+			return -1;
+		}
+
+		parser_advance(state, T_PATH, 1);
+	}
+}
+
+/** Integer parsing flags. */
+enum int_flags {
+	IF_BASE_MASK   = 0x03F,
+	IF_INT         = 0x040,
+	IF_LONG        = 0x080,
+	IF_LONG_LONG   = 0x0C0,
+	IF_SIZE_MASK   = 0x0C0,
+	IF_UNSIGNED    = 0x100,
+	IF_PARTIAL_OK  = 0x200,
+	IF_QUIET       = 0x400,
+};
+
+/**
+ * Parse an integer.
+ */
+static const char *parse_int(const struct parser_state *state, char **arg, const char *str, void *result, enum int_flags flags) {
+	char *endptr;
+
+	int base = flags & IF_BASE_MASK;
+	if (base == 0) {
+		base = 10;
+	}
+
+	errno = 0;
+	long long value = strtoll(str, &endptr, base);
+	if (errno != 0) {
+		if (errno == ERANGE) {
+			goto range;
+		} else {
+			goto bad;
+		}
+	}
+
+	if (endptr == str) {
+		goto bad;
+	}
+
+	if (!(flags & IF_PARTIAL_OK) && *endptr != '\0') {
+		goto bad;
+	}
+
+	if ((flags & IF_UNSIGNED) && value < 0) {
+		goto negative;
+	}
+
+	switch (flags & IF_SIZE_MASK) {
+	case IF_INT:
+		if (value < INT_MIN || value > INT_MAX) {
+			goto range;
+		}
+		*(int *)result = value;
+		break;
+
+	case IF_LONG:
+		if (value < LONG_MIN || value > LONG_MAX) {
+			goto range;
+		}
+		*(long *)result = value;
+		break;
+
+	case IF_LONG_LONG:
+		*(long long *)result = value;
+		break;
+
+	default:
+		assert(!"Invalid int size");
+		goto bad;
+	}
+
+	return endptr;
+
+bad:
+	if (!(flags & IF_QUIET)) {
+		parse_argv_error(state, arg, 1, "${bld}%s${rs} is not a valid integer.\n", str);
+	}
+	return NULL;
+
+negative:
+	if (!(flags & IF_QUIET)) {
+		parse_argv_error(state, arg, 1, "Negative integer ${bld}%s${rs} is not allowed here.\n", str);
+	}
+	return NULL;
+
+range:
+	if (!(flags & IF_QUIET)) {
+		parse_argv_error(state, arg, 1, "${bld}%s${rs} is too large an integer.\n", str);
+	}
+	return NULL;
+}
+
+/**
+ * Parse an integer and a comparison flag.
+ */
+static const char *parse_icmp(const struct parser_state *state, struct bfs_expr *expr, enum int_flags flags) {
+	char **arg = &expr->argv[1];
+	const char *str = *arg;
+	switch (str[0]) {
+	case '-':
+		expr->int_cmp = BFS_INT_LESS;
+		++str;
+		break;
+	case '+':
+		expr->int_cmp = BFS_INT_GREATER;
+		++str;
+		break;
+	default:
+		expr->int_cmp = BFS_INT_EQUAL;
+		break;
+	}
+
+	return parse_int(state, arg, str, &expr->num, flags | IF_LONG_LONG | IF_UNSIGNED);
+}
+
+/**
+ * Check if a string could be an integer comparison.
+ */
+static bool looks_like_icmp(const char *str) {
+	int i;
+
+	// One +/- for the comparison flag, one for the sign
+	for (i = 0; i < 2; ++i) {
+		if (str[i] != '-' && str[i] != '+') {
+			break;
+		}
+	}
+
+	return str[i] >= '0' && str[i] <= '9';
+}
+
+/**
+ * Parse a single flag.
+ */
+static struct bfs_expr *parse_flag(struct parser_state *state, size_t argc) {
+	parser_advance(state, T_FLAG, argc);
+	return &bfs_true;
+}
+
+/**
+ * Parse a flag that doesn't take a value.
+ */
+static struct bfs_expr *parse_nullary_flag(struct parser_state *state) {
+	return parse_flag(state, 1);
+}
+
+/**
+ * Parse a single option.
+ */
+static struct bfs_expr *parse_option(struct parser_state *state, size_t argc) {
+	parser_advance(state, T_OPTION, argc);
+	return &bfs_true;
+}
+
+/**
+ * Parse an option that doesn't take a value.
+ */
+static struct bfs_expr *parse_nullary_option(struct parser_state *state) {
+	return parse_option(state, 1);
+}
+
+/**
+ * Parse an option that takes a value.
+ */
+static struct bfs_expr *parse_unary_option(struct parser_state *state) {
+	return parse_option(state, 2);
+}
+
+/**
+ * Parse a single test.
+ */
+static struct bfs_expr *parse_test(struct parser_state *state, bfs_eval_fn *eval_fn, size_t argc) {
+	char **argv = parser_advance(state, T_TEST, argc);
+	struct bfs_expr *expr = bfs_expr_new(eval_fn, argc, argv);
+	if (expr) {
+		expr->pure = true;
+	}
+	return expr;
+}
+
+/**
+ * Parse a test that doesn't take a value.
+ */
+static struct bfs_expr *parse_nullary_test(struct parser_state *state, bfs_eval_fn *eval_fn) {
+	return parse_test(state, eval_fn, 1);
+}
+
+/**
+ * Parse a test that takes a value.
+ */
+static struct bfs_expr *parse_unary_test(struct parser_state *state, bfs_eval_fn *eval_fn) {
+	const char *arg = state->argv[0];
+	const char *value = state->argv[1];
+	if (!value) {
+		parse_error(state, "${blu}%s${rs} needs a value.\n", arg);
+		return NULL;
+	}
+
+	return parse_test(state, eval_fn, 2);
+}
+
+/**
+ * Parse a single action.
+ */
+static struct bfs_expr *parse_action(struct parser_state *state, bfs_eval_fn *eval_fn, size_t argc) {
+	char **argv = parser_advance(state, T_ACTION, argc);
+
+	if (state->excluding) {
+		parse_argv_error(state, argv, argc, "This action is not supported within ${red}-exclude${rs}.\n");
+		return NULL;
+	}
+
+	if (eval_fn != eval_prune && eval_fn != eval_quit) {
+		state->implicit_print = false;
+	}
+
+	return bfs_expr_new(eval_fn, argc, argv);
+}
+
+/**
+ * Parse an action that takes no arguments.
+ */
+static struct bfs_expr *parse_nullary_action(struct parser_state *state, bfs_eval_fn *eval_fn) {
+	return parse_action(state, eval_fn, 1);
+}
+
+/**
+ * Parse an action that takes one argument.
+ */
+static struct bfs_expr *parse_unary_action(struct parser_state *state, bfs_eval_fn *eval_fn) {
+	const char *arg = state->argv[0];
+	const char *value = state->argv[1];
+	if (!value) {
+		parse_error(state, "${blu}%s${rs} needs a value.\n", arg);
+		return NULL;
+	}
+
+	return parse_action(state, eval_fn, 2);
+}
+
+/**
+ * Add an expression to the exclusions.
+ */
+static int parse_exclude(struct parser_state *state, struct bfs_expr *expr) {
+	struct bfs_ctx *ctx = state->ctx;
+	ctx->exclude = new_binary_expr(eval_or, ctx->exclude, expr, &fake_or_arg);
+	if (ctx->exclude) {
+		return 0;
+	} else {
+		return -1;
+	}
+}
+
+/**
+ * Parse a test expression with integer data and a comparison flag.
+ */
+static struct bfs_expr *parse_test_icmp(struct parser_state *state, bfs_eval_fn *eval_fn) {
+	struct bfs_expr *expr = parse_unary_test(state, eval_fn);
+	if (!expr) {
+		return NULL;
+	}
+
+	if (!parse_icmp(state, expr, 0)) {
+		bfs_expr_free(expr);
+		return NULL;
+	}
+
+	return expr;
+}
+
+/**
+ * Print usage information for -D.
+ */
+static void debug_help(CFILE *cfile) {
+	cfprintf(cfile, "Supported debug flags:\n\n");
+
+	cfprintf(cfile, "  ${bld}help${rs}:   This message.\n");
+	cfprintf(cfile, "  ${bld}cost${rs}:   Show cost estimates.\n");
+	cfprintf(cfile, "  ${bld}exec${rs}:   Print executed command details.\n");
+	cfprintf(cfile, "  ${bld}opt${rs}:    Print optimization details.\n");
+	cfprintf(cfile, "  ${bld}rates${rs}:  Print predicate success rates.\n");
+	cfprintf(cfile, "  ${bld}search${rs}: Trace the filesystem traversal.\n");
+	cfprintf(cfile, "  ${bld}stat${rs}:   Trace all stat() calls.\n");
+	cfprintf(cfile, "  ${bld}tree${rs}:   Print the parse tree.\n");
+	cfprintf(cfile, "  ${bld}all${rs}:    All debug flags at once.\n");
+}
+
+/** Check if a substring matches a debug flag. */
+static bool parse_debug_flag(const char *flag, size_t len, const char *expected) {
+	if (len == strlen(expected)) {
+		return strncmp(flag, expected, len) == 0;
+	} else {
+		return false;
+	}
+}
+
+/**
+ * Parse -D FLAG.
+ */
+static struct bfs_expr *parse_debug(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_ctx *ctx = state->ctx;
+
+	const char *arg = state->argv[0];
+	const char *flags = state->argv[1];
+	if (!flags) {
+		parse_error(state, "${cyn}%s${rs} needs a flag.\n\n", arg);
+		debug_help(ctx->cerr);
+		return NULL;
+	}
+
+	parser_advance(state, T_FLAG, 1);
+
+	bool unrecognized = false;
+
+	for (const char *flag = flags, *next; flag; flag = next) {
+		size_t len = strcspn(flag, ",");
+		if (flag[len]) {
+			next = flag + len + 1;
+		} else {
+			next = NULL;
+		}
+
+		if (parse_debug_flag(flag, len, "help")) {
+			debug_help(ctx->cout);
+			state->just_info = true;
+			return NULL;
+		} else if (parse_debug_flag(flag, len, "all")) {
+			ctx->debug = DEBUG_ALL;
+			continue;
+		}
+
+		enum debug_flags i;
+		for (i = 1; DEBUG_ALL & i; i <<= 1) {
+			const char *name = debug_flag_name(i);
+			if (parse_debug_flag(flag, len, name)) {
+				break;
+			}
+		}
+
+		if (DEBUG_ALL & i) {
+			ctx->debug |= i;
+		} else {
+			if (parse_warning(state, "Unrecognized debug flag ${bld}")) {
+				fwrite(flag, 1, len, stderr);
+				cfprintf(ctx->cerr, "${rs}.\n\n");
+				unrecognized = true;
+			}
+		}
+	}
+
+	if (unrecognized) {
+		debug_help(ctx->cerr);
+		cfprintf(ctx->cerr, "\n");
+	}
+
+	parser_advance(state, T_FLAG, 1);
+	return &bfs_true;
+}
+
+/**
+ * Parse -On.
+ */
+static struct bfs_expr *parse_optlevel(struct parser_state *state, int arg1, int arg2) {
+	int *optlevel = &state->ctx->optlevel;
+
+	if (strcmp(state->argv[0], "-Ofast") == 0) {
+		*optlevel = 4;
+	} else if (!parse_int(state, state->argv, state->argv[0] + 2, optlevel, IF_INT | IF_UNSIGNED)) {
+		return NULL;
+	}
+
+	if (*optlevel > 4) {
+		parse_warning(state, "${cyn}-O${bld}%s${rs} is the same as ${cyn}-O${bld}4${rs}.\n\n", state->argv[0] + 2);
+	}
+
+	return parse_nullary_flag(state);
+}
+
+/**
+ * Parse -[PHL], -follow.
+ */
+static struct bfs_expr *parse_follow(struct parser_state *state, int flags, int option) {
+	struct bfs_ctx *ctx = state->ctx;
+	ctx->flags &= ~(BFTW_FOLLOW_ROOTS | BFTW_FOLLOW_ALL);
+	ctx->flags |= flags;
+	if (option) {
+		return parse_nullary_option(state);
+	} else {
+		return parse_nullary_flag(state);
+	}
+}
+
+/**
+ * Parse -X.
+ */
+static struct bfs_expr *parse_xargs_safe(struct parser_state *state, int arg1, int arg2) {
+	state->ctx->xargs_safe = true;
+	return parse_nullary_flag(state);
+}
+
+/**
+ * Parse -executable, -readable, -writable
+ */
+static struct bfs_expr *parse_access(struct parser_state *state, int flag, int arg2) {
+	struct bfs_expr *expr = parse_nullary_test(state, eval_access);
+	if (!expr) {
+		return NULL;
+	}
+
+	expr->num = flag;
+	expr->cost = STAT_COST;
+
+	switch (flag) {
+	case R_OK:
+		expr->probability = 0.99;
+		break;
+	case W_OK:
+		expr->probability = 0.8;
+		break;
+	case X_OK:
+		expr->probability = 0.2;
+		break;
+	}
+
+	return expr;
+}
+
+/**
+ * Parse -acl.
+ */
+static struct bfs_expr *parse_acl(struct parser_state *state, int flag, int arg2) {
+#if BFS_CAN_CHECK_ACL
+	struct bfs_expr *expr = parse_nullary_test(state, eval_acl);
+	if (expr) {
+		expr->cost = STAT_COST;
+		expr->probability = 0.00002;
+	}
+	return expr;
+#else
+	parse_error(state, "Missing platform support.\n");
+	return NULL;
+#endif
+}
+
+/**
+ * Parse -[aBcm]?newer.
+ */
+static struct bfs_expr *parse_newer(struct parser_state *state, int field, int arg2) {
+	struct bfs_expr *expr = parse_unary_test(state, eval_newer);
+	if (!expr) {
+		return NULL;
+	}
+
+	struct bfs_stat sb;
+	if (stat_arg(state, &expr->argv[1], &sb) != 0) {
+		goto fail;
+	}
+
+	expr->cost = STAT_COST;
+	expr->reftime = sb.mtime;
+	expr->stat_field = field;
+	return expr;
+
+fail:
+	bfs_expr_free(expr);
+	return NULL;
+}
+
+/**
+ * Parse -[aBcm]min.
+ */
+static struct bfs_expr *parse_min(struct parser_state *state, int field, int arg2) {
+	struct bfs_expr *expr = parse_test_icmp(state, eval_time);
+	if (!expr) {
+		return NULL;
+	}
+
+	expr->cost = STAT_COST;
+	expr->reftime = state->now;
+	expr->stat_field = field;
+	expr->time_unit = BFS_MINUTES;
+	return expr;
+}
+
+/**
+ * Parse -[aBcm]time.
+ */
+static struct bfs_expr *parse_time(struct parser_state *state, int field, int arg2) {
+	struct bfs_expr *expr = parse_unary_test(state, eval_time);
+	if (!expr) {
+		return NULL;
+	}
+
+	expr->cost = STAT_COST;
+	expr->reftime = state->now;
+	expr->stat_field = field;
+
+	const char *tail = parse_icmp(state, expr, IF_PARTIAL_OK);
+	if (!tail) {
+		goto fail;
+	}
+
+	if (!*tail) {
+		expr->time_unit = BFS_DAYS;
+		return expr;
+	}
+
+	unsigned long long time = expr->num;
+	expr->num = 0;
+
+	while (true) {
+		switch (*tail) {
+		case 'w':
+			time *= 7;
+			BFS_FALLTHROUGH;
+		case 'd':
+			time *= 24;
+			BFS_FALLTHROUGH;
+		case 'h':
+			time *= 60;
+			BFS_FALLTHROUGH;
+		case 'm':
+			time *= 60;
+			BFS_FALLTHROUGH;
+		case 's':
+			break;
+		default:
+			parse_expr_error(state, expr, "Unknown time unit ${bld}%c${rs}.\n", *tail);
+			goto fail;
+		}
+
+		expr->num += time;
+
+		if (!*++tail) {
+			break;
+		}
+
+		tail = parse_int(state, &expr->argv[1], tail, &time, IF_PARTIAL_OK | IF_LONG_LONG | IF_UNSIGNED);
+		if (!tail) {
+			goto fail;
+		}
+		if (!*tail) {
+			parse_expr_error(state, expr, "Missing time unit.\n");
+			goto fail;
+		}
+	}
+
+	expr->time_unit = BFS_SECONDS;
+	return expr;
+
+fail:
+	bfs_expr_free(expr);
+	return NULL;
+}
+
+/**
+ * Parse -capable.
+ */
+static struct bfs_expr *parse_capable(struct parser_state *state, int flag, int arg2) {
+#if BFS_CAN_CHECK_CAPABILITIES
+	struct bfs_expr *expr = parse_nullary_test(state, eval_capable);
+	if (expr) {
+		expr->cost = STAT_COST;
+		expr->probability = 0.000002;
+	}
+	return expr;
+#else
+	parse_error(state, "Missing platform support.\n");
+	return NULL;
+#endif
+}
+
+/**
+ * Parse -(no)?color.
+ */
+static struct bfs_expr *parse_color(struct parser_state *state, int color, int arg2) {
+	struct bfs_ctx *ctx = state->ctx;
+	struct colors *colors = ctx->colors;
+
+	if (color) {
+		if (!colors) {
+			parse_error(state, "%s.\n", strerror(ctx->colors_error));
+			return NULL;
+		}
+
+		state->use_color = COLOR_ALWAYS;
+		ctx->cout->colors = colors;
+		ctx->cerr->colors = colors;
+	} else {
+		state->use_color = COLOR_NEVER;
+		ctx->cout->colors = NULL;
+		ctx->cerr->colors = NULL;
+	}
+
+	return parse_nullary_option(state);
+}
+
+/**
+ * Parse -{false,true}.
+ */
+static struct bfs_expr *parse_const(struct parser_state *state, int value, int arg2) {
+	parser_advance(state, T_TEST, 1);
+	return value ? &bfs_true : &bfs_false;
+}
+
+/**
+ * Parse -daystart.
+ */
+static struct bfs_expr *parse_daystart(struct parser_state *state, int arg1, int arg2) {
+	struct tm tm;
+	if (xlocaltime(&state->now.tv_sec, &tm) != 0) {
+		parse_perror(state, "xlocaltime()");
+		return NULL;
+	}
+
+	if (tm.tm_hour || tm.tm_min || tm.tm_sec || state->now.tv_nsec) {
+		++tm.tm_mday;
+	}
+	tm.tm_hour = 0;
+	tm.tm_min = 0;
+	tm.tm_sec = 0;
+
+	time_t time;
+	if (xmktime(&tm, &time) != 0) {
+		parse_perror(state, "xmktime()");
+		return NULL;
+	}
+
+	state->now.tv_sec = time;
+	state->now.tv_nsec = 0;
+
+	return parse_nullary_option(state);
+}
+
+/**
+ * Parse -delete.
+ */
+static struct bfs_expr *parse_delete(struct parser_state *state, int arg1, int arg2) {
+	state->ctx->flags |= BFTW_POST_ORDER;
+	state->depth_arg = state->argv;
+	return parse_nullary_action(state, eval_delete);
+}
+
+/**
+ * Parse -d.
+ */
+static struct bfs_expr *parse_depth(struct parser_state *state, int arg1, int arg2) {
+	state->ctx->flags |= BFTW_POST_ORDER;
+	state->depth_arg = state->argv;
+	return parse_nullary_flag(state);
+}
+
+/**
+ * Parse -depth [N].
+ */
+static struct bfs_expr *parse_depth_n(struct parser_state *state, int arg1, int arg2) {
+	const char *arg = state->argv[1];
+	if (arg && looks_like_icmp(arg)) {
+		return parse_test_icmp(state, eval_depth);
+	} else {
+		return parse_depth(state, arg1, arg2);
+	}
+}
+
+/**
+ * Parse -{min,max}depth N.
+ */
+static struct bfs_expr *parse_depth_limit(struct parser_state *state, int is_min, int arg2) {
+	struct bfs_ctx *ctx = state->ctx;
+	const char *arg = state->argv[0];
+	const char *value = state->argv[1];
+	if (!value) {
+		parse_error(state, "${blu}%s${rs} needs a value.\n", arg);
+		return NULL;
+	}
+
+	int *depth = is_min ? &ctx->mindepth : &ctx->maxdepth;
+	if (!parse_int(state, &state->argv[1], value, depth, IF_INT | IF_UNSIGNED)) {
+		return NULL;
+	}
+
+	return parse_unary_option(state);
+}
+
+/**
+ * Parse -empty.
+ */
+static struct bfs_expr *parse_empty(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_expr *expr = parse_nullary_test(state, eval_empty);
+	if (!expr) {
+		return NULL;
+	}
+
+	expr->cost = 2000.0;
+	expr->probability = 0.01;
+
+	if (state->ctx->optlevel < 4) {
+		// Since -empty attempts to open and read directories, it may
+		// have side effects such as reporting permission errors, and
+		// thus shouldn't be re-ordered without aggressive optimizations
+		expr->pure = false;
+	}
+
+	expr->ephemeral_fds = 1;
+
+	return expr;
+}
+
+/**
+ * Parse -exec(dir)?/-ok(dir)?.
+ */
+static struct bfs_expr *parse_exec(struct parser_state *state, int flags, int arg2) {
+	struct bfs_exec *execbuf = bfs_exec_parse(state->ctx, state->argv, flags);
+	if (!execbuf) {
+		return NULL;
+	}
+
+	struct bfs_expr *expr = parse_action(state, eval_exec, execbuf->tmpl_argc + 2);
+	if (!expr) {
+		bfs_exec_free(execbuf);
+		return NULL;
+	}
+
+	expr->exec = execbuf;
+
+	if (execbuf->flags & BFS_EXEC_MULTI) {
+		expr_set_always_true(expr);
+	} else {
+		expr->cost = 1000000.0;
+	}
+
+	expr->ephemeral_fds = 2;
+	if (execbuf->flags & BFS_EXEC_CHDIR) {
+		if (execbuf->flags & BFS_EXEC_MULTI) {
+			expr->persistent_fds = 1;
+		} else {
+			++expr->ephemeral_fds;
+		}
+	}
+
+	if (execbuf->flags & BFS_EXEC_CONFIRM) {
+		state->ok_expr = expr;
+	}
+
+	return expr;
+}
+
+/**
+ * Parse -exit [STATUS].
+ */
+static struct bfs_expr *parse_exit(struct parser_state *state, int arg1, int arg2) {
+	size_t argc = 1;
+	const char *value = state->argv[1];
+
+	int status = EXIT_SUCCESS;
+	if (value && parse_int(state, NULL, value, &status, IF_INT | IF_UNSIGNED | IF_QUIET)) {
+		argc = 2;
+	}
+
+	struct bfs_expr *expr = parse_action(state, eval_exit, argc);
+	if (expr) {
+		expr_set_never_returns(expr);
+		expr->num = status;
+	}
+	return expr;
+}
+
+/**
+ * Parse -f PATH.
+ */
+static struct bfs_expr *parse_f(struct parser_state *state, int arg1, int arg2) {
+	const char *path = state->argv[1];
+	if (!path) {
+		parse_error(state, "${cyn}-f${rs} requires a path.\n");
+		return NULL;
+	}
+
+	if (parse_root(state, path) != 0) {
+		return NULL;
+	}
+
+	parser_advance(state, T_FLAG, 1);
+	parser_advance(state, T_PATH, 1);
+	return &bfs_true;
+}
+
+/**
+ * Parse -files0-from PATH.
+ */
+static struct bfs_expr *parse_files0_from(struct parser_state *state, int arg1, int arg2) {
+	const char *arg = state->argv[0];
+	const char *from = state->argv[1];
+	if (!from) {
+		parse_error(state, "${blu}%s${rs} requires a path.\n", arg);
+		return NULL;
+	}
+
+	state->files0_arg = parser_advance(state, T_OPTION, 1);
+
+	FILE *file;
+	if (strcmp(from, "-") == 0) {
+		file = stdin;
+	} else {
+		file = xfopen(from, O_RDONLY | O_CLOEXEC);
+	}
+	if (!file) {
+		parse_error(state, "%m.\n");
+		return NULL;
+	}
+
+	struct bfs_expr *expr = &bfs_true;
+
+	while (true) {
+		char *path = xgetdelim(file, '\0');
+		if (!path) {
+			if (errno) {
+				parse_error(state, "%m.\n");
+				expr = NULL;
+			}
+			break;
+		}
+
+		int ret = parse_root(state, path);
+		free(path);
+		if (ret != 0) {
+			expr = NULL;
+			break;
+		}
+	}
+
+	if (file == stdin) {
+		state->files0_stdin_arg = state->files0_arg;
+	} else {
+		fclose(file);
+	}
+
+	state->implicit_root = false;
+	parser_advance(state, T_OPTION, 1);
+	return expr;
+}
+
+/**
+ * Parse -flags FLAGS.
+ */
+static struct bfs_expr *parse_flags(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_expr *expr = parse_unary_test(state, eval_flags);
+	if (!expr) {
+		return NULL;
+	}
+
+	const char *flags = expr->argv[1];
+	switch (flags[0]) {
+	case '-':
+		expr->flags_cmp = BFS_MODE_ALL;
+		++flags;
+		break;
+	case '+':
+		expr->flags_cmp = BFS_MODE_ANY;
+		++flags;
+		break;
+	default:
+		expr->flags_cmp = BFS_MODE_EQUAL;
+		break;
+	}
+
+	if (xstrtofflags(&flags, &expr->set_flags, &expr->clear_flags) != 0) {
+		if (errno == ENOTSUP) {
+			parse_expr_error(state, expr, "Missing platform support.\n");
+		} else {
+			parse_expr_error(state, expr, "Invalid flags.\n");
+		}
+		bfs_expr_free(expr);
+		return NULL;
+	}
+
+	return expr;
+}
+
+/**
+ * Parse -fls FILE.
+ */
+static struct bfs_expr *parse_fls(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_expr *expr = parse_unary_action(state, eval_fls);
+	if (!expr) {
+		goto fail;
+	}
+
+	if (expr_open(state, expr, expr->argv[1]) != 0) {
+		goto fail;
+	}
+
+	expr_set_always_true(expr);
+	expr->cost = PRINT_COST;
+	expr->reftime = state->now;
+
+	// We'll need these for user/group names, so initialize them now to
+	// avoid EMFILE later
+	bfs_ctx_users(state->ctx);
+	bfs_ctx_groups(state->ctx);
+
+	return expr;
+
+fail:
+	bfs_expr_free(expr);
+	return NULL;
+}
+
+/**
+ * Parse -fprint FILE.
+ */
+static struct bfs_expr *parse_fprint(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_expr *expr = parse_unary_action(state, eval_fprint);
+	if (expr) {
+		expr_set_always_true(expr);
+		expr->cost = PRINT_COST;
+		if (expr_open(state, expr, expr->argv[1]) != 0) {
+			goto fail;
+		}
+	}
+	return expr;
+
+fail:
+	bfs_expr_free(expr);
+	return NULL;
+}
+
+/**
+ * Parse -fprint0 FILE.
+ */
+static struct bfs_expr *parse_fprint0(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_expr *expr = parse_unary_action(state, eval_fprint0);
+	if (expr) {
+		expr_set_always_true(expr);
+		expr->cost = PRINT_COST;
+		if (expr_open(state, expr, expr->argv[1]) != 0) {
+			goto fail;
+		}
+	}
+	return expr;
+
+fail:
+	bfs_expr_free(expr);
+	return NULL;
+}
+
+/**
+ * Parse -fprintf FILE FORMAT.
+ */
+static struct bfs_expr *parse_fprintf(struct parser_state *state, int arg1, int arg2) {
+	const char *arg = state->argv[0];
+
+	const char *file = state->argv[1];
+	if (!file) {
+		parse_error(state, "${blu}%s${rs} needs a file.\n", arg);
+		return NULL;
+	}
+
+	const char *format = state->argv[2];
+	if (!format) {
+		parse_error(state, "${blu}%s${rs} needs a format string.\n", arg);
+		return NULL;
+	}
+
+	struct bfs_expr *expr = parse_action(state, eval_fprintf, 3);
+	if (!expr) {
+		return NULL;
+	}
+
+	expr_set_always_true(expr);
+
+	expr->cost = PRINT_COST;
+
+	if (expr_open(state, expr, file) != 0) {
+		goto fail;
+	}
+
+	if (bfs_printf_parse(state->ctx, expr, format) != 0) {
+		goto fail;
+	}
+
+	return expr;
+
+fail:
+	bfs_expr_free(expr);
+	return NULL;
+}
+
+/**
+ * Parse -fstype TYPE.
+ */
+static struct bfs_expr *parse_fstype(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_expr *expr = parse_unary_test(state, eval_fstype);
+	if (!expr) {
+		return NULL;
+	}
+
+	if (!bfs_ctx_mtab(state->ctx)) {
+		parse_expr_error(state, expr, "Couldn't parse the mount table: %m.\n");
+		bfs_expr_free(expr);
+		return NULL;
+	}
+
+	expr->cost = STAT_COST;
+	return expr;
+}
+
+/**
+ * Parse -gid/-group.
+ */
+static struct bfs_expr *parse_group(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_expr *expr = parse_unary_test(state, eval_gid);
+	if (!expr) {
+		return NULL;
+	}
+
+	const struct bfs_groups *groups = bfs_ctx_groups(state->ctx);
+	if (!groups) {
+		parse_expr_error(state, expr, "Couldn't parse the group table: %m.\n");
+		goto fail;
+	}
+
+	const struct group *grp = bfs_getgrnam(groups, expr->argv[1]);
+	if (grp) {
+		expr->num = grp->gr_gid;
+		expr->int_cmp = BFS_INT_EQUAL;
+	} else if (looks_like_icmp(expr->argv[1])) {
+		if (!parse_icmp(state, expr, 0)) {
+			goto fail;
+		}
+	} else {
+		parse_expr_error(state, expr, "No such group.\n");
+		goto fail;
+	}
+
+	expr->cost = STAT_COST;
+
+	return expr;
+
+fail:
+	bfs_expr_free(expr);
+	return NULL;
+}
+
+/**
+ * Parse -unique.
+ */
+static struct bfs_expr *parse_unique(struct parser_state *state, int arg1, int arg2) {
+	state->ctx->unique = true;
+	return parse_nullary_option(state);
+}
+
+/**
+ * Parse -used N.
+ */
+static struct bfs_expr *parse_used(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_expr *expr = parse_test_icmp(state, eval_used);
+	if (expr) {
+		expr->cost = STAT_COST;
+	}
+	return expr;
+}
+
+/**
+ * Parse -uid/-user.
+ */
+static struct bfs_expr *parse_user(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_expr *expr = parse_unary_test(state, eval_uid);
+	if (!expr) {
+		return NULL;
+	}
+
+	const struct bfs_users *users = bfs_ctx_users(state->ctx);
+	if (!users) {
+		parse_expr_error(state, expr, "Couldn't parse the user table: %m.\n");
+		goto fail;
+	}
+
+	const struct passwd *pwd = bfs_getpwnam(users, expr->argv[1]);
+	if (pwd) {
+		expr->num = pwd->pw_uid;
+		expr->int_cmp = BFS_INT_EQUAL;
+	} else if (looks_like_icmp(expr->argv[1])) {
+		if (!parse_icmp(state, expr, 0)) {
+			goto fail;
+		}
+	} else {
+		parse_expr_error(state, expr, "No such user.\n");
+		goto fail;
+	}
+
+	expr->cost = STAT_COST;
+
+	return expr;
+
+fail:
+	bfs_expr_free(expr);
+	return NULL;
+}
+
+/**
+ * Parse -hidden.
+ */
+static struct bfs_expr *parse_hidden(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_expr *expr = parse_nullary_test(state, eval_hidden);
+	if (expr) {
+		expr->probability = 0.01;
+	}
+	return expr;
+}
+
+/**
+ * Parse -(no)?ignore_readdir_race.
+ */
+static struct bfs_expr *parse_ignore_races(struct parser_state *state, int ignore, int arg2) {
+	state->ctx->ignore_races = ignore;
+	return parse_nullary_option(state);
+}
+
+/**
+ * Parse -inum N.
+ */
+static struct bfs_expr *parse_inum(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_expr *expr = parse_test_icmp(state, eval_inum);
+	if (expr) {
+		expr->cost = STAT_COST;
+		expr->probability = expr->int_cmp == BFS_INT_EQUAL ? 0.01 : 0.50;
+	}
+	return expr;
+}
+
+/**
+ * Parse -links N.
+ */
+static struct bfs_expr *parse_links(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_expr *expr = parse_test_icmp(state, eval_links);
+	if (expr) {
+		expr->cost = STAT_COST;
+		expr->probability = bfs_expr_cmp(expr, 1) ? 0.99 : 0.01;
+	}
+	return expr;
+}
+
+/**
+ * Parse -ls.
+ */
+static struct bfs_expr *parse_ls(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_expr *expr = parse_nullary_action(state, eval_fls);
+	if (!expr) {
+		return NULL;
+	}
+
+	init_print_expr(state, expr);
+	expr->reftime = state->now;
+
+	// We'll need these for user/group names, so initialize them now to
+	// avoid EMFILE later
+	bfs_ctx_users(state->ctx);
+	bfs_ctx_groups(state->ctx);
+
+	return expr;
+}
+
+/**
+ * Parse -mount.
+ */
+static struct bfs_expr *parse_mount(struct parser_state *state, int arg1, int arg2) {
+	parse_warning(state, "In the future, ${blu}%s${rs} will skip mount points entirely, unlike\n", state->argv[0]);
+	bfs_warning(state->ctx, "${blu}-xdev${rs}, due to http://austingroupbugs.net/view.php?id=1133.\n\n");
+
+	state->ctx->flags |= BFTW_PRUNE_MOUNTS;
+	state->mount_arg = state->argv;
+	return parse_nullary_option(state);
+}
+
+/**
+ * Common code for fnmatch() tests.
+ */
+static struct bfs_expr *parse_fnmatch(const struct parser_state *state, struct bfs_expr *expr, bool casefold) {
+	if (!expr) {
+		return NULL;
+	}
+
+	if (casefold) {
+#ifdef FNM_CASEFOLD
+		expr->num = FNM_CASEFOLD;
+#else
+		parse_expr_error(state, expr, "Missing platform support.\n");
+		bfs_expr_free(expr);
+		return NULL;
+#endif
+	} else {
+		expr->num = 0;
+	}
+
+	// POSIX says, about fnmatch():
+	//
+	//     If pattern ends with an unescaped <backslash>, fnmatch() shall
+	//     return a non-zero value (indicating either no match or an error).
+	//
+	// But not all implementations obey this, so check for it ourselves.
+	const char *pattern = expr->argv[1];
+	size_t i, len = strlen(pattern);
+	for (i = 0; i < len; ++i) {
+		if (pattern[len - i - 1] != '\\') {
+			break;
+		}
+	}
+	if (i % 2 != 0) {
+		parse_expr_warning(state, expr, "Unescaped trailing backslash.\n\n");
+		bfs_expr_free(expr);
+		return &bfs_false;
+	}
+
+	expr->cost = 400.0;
+
+	if (strchr(pattern, '*')) {
+		expr->probability = 0.5;
+	} else {
+		expr->probability = 0.1;
+	}
+
+	return expr;
+}
+
+/**
+ * Parse -i?name.
+ */
+static struct bfs_expr *parse_name(struct parser_state *state, int casefold, int arg2) {
+	struct bfs_expr *expr = parse_unary_test(state, eval_name);
+	return parse_fnmatch(state, expr, casefold);
+}
+
+/**
+ * Parse -i?path, -i?wholename.
+ */
+static struct bfs_expr *parse_path(struct parser_state *state, int casefold, int arg2) {
+	struct bfs_expr *expr = parse_unary_test(state, eval_path);
+	return parse_fnmatch(state, expr, casefold);
+}
+
+/**
+ * Parse -i?lname.
+ */
+static struct bfs_expr *parse_lname(struct parser_state *state, int casefold, int arg2) {
+	struct bfs_expr *expr = parse_unary_test(state, eval_lname);
+	return parse_fnmatch(state, expr, casefold);
+}
+
+/** Get the bfs_stat_field for X/Y in -newerXY. */
+static enum bfs_stat_field parse_newerxy_field(char c) {
+	switch (c) {
+	case 'a':
+		return BFS_STAT_ATIME;
+	case 'B':
+		return BFS_STAT_BTIME;
+	case 'c':
+		return BFS_STAT_CTIME;
+	case 'm':
+		return BFS_STAT_MTIME;
+	default:
+		return 0;
+	}
+}
+
+/** Parse an explicit reference timestamp for -newerXt and -*since. */
+static int parse_reftime(const struct parser_state *state, struct bfs_expr *expr) {
+	if (parse_timestamp(expr->argv[1], &expr->reftime) == 0) {
+		return 0;
+	} else if (errno != EINVAL) {
+		parse_expr_error(state, expr, "%m.\n");
+		return -1;
+	}
+
+	parse_expr_error(state, expr, "Invalid timestamp.\n\n");
+	fprintf(stderr, "Supported timestamp formats are ISO 8601-like, e.g.\n\n");
+
+	struct tm tm;
+	if (xlocaltime(&state->now.tv_sec, &tm) != 0) {
+		parse_perror(state, "xlocaltime()");
+		return -1;
+	}
+
+	int year = tm.tm_year + 1900;
+	int month = tm.tm_mon + 1;
+	fprintf(stderr, "  - %04d-%02d-%02d\n", year, month, tm.tm_mday);
+	fprintf(stderr, "  - %04d-%02d-%02dT%02d:%02d:%02d\n", year, month, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec);
+
+#if __FreeBSD__
+	int gmtoff = tm.tm_gmtoff;
+#else
+	int gmtoff = -timezone;
+#endif
+	int tz_hour = gmtoff/3600;
+	int tz_min = (labs(gmtoff)/60)%60;
+	fprintf(stderr, "  - %04d-%02d-%02dT%02d:%02d:%02d%+03d:%02d\n",
+	        year, month, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec, tz_hour, tz_min);
+
+	if (xgmtime(&state->now.tv_sec, &tm) != 0) {
+		parse_perror(state, "xgmtime()");
+		return -1;
+	}
+
+	year = tm.tm_year + 1900;
+	month = tm.tm_mon + 1;
+	fprintf(stderr, "  - %04d-%02d-%02dT%02d:%02d:%02dZ\n", year, month, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec);
+
+	return -1;
+}
+
+/**
+ * Parse -newerXY.
+ */
+static struct bfs_expr *parse_newerxy(struct parser_state *state, int arg1, int arg2) {
+	const char *arg = state->argv[0];
+	if (strlen(arg) != 8) {
+		parse_error(state, "Expected ${blu}-newer${bld}XY${rs}; found ${blu}-newer${bld}%s${rs}.\n", arg + 6);
+		return NULL;
+	}
+
+	struct bfs_expr *expr = parse_unary_test(state, eval_newer);
+	if (!expr) {
+		goto fail;
+	}
+
+	expr->stat_field = parse_newerxy_field(arg[6]);
+	if (!expr->stat_field) {
+		parse_expr_error(state, expr,
+		                 "For ${blu}-newer${bld}XY${rs}, ${bld}X${rs} should be ${bld}a${rs}, ${bld}c${rs}, ${bld}m${rs}, or ${bld}B${rs}, not ${err}%c${rs}.\n",
+		                 arg[6]);
+		goto fail;
+	}
+
+	if (arg[7] == 't') {
+		if (parse_reftime(state, expr) != 0) {
+			goto fail;
+		}
+	} else {
+		enum bfs_stat_field field = parse_newerxy_field(arg[7]);
+		if (!field) {
+			parse_expr_error(state, expr,
+			                 "For ${blu}-newer${bld}XY${rs}, ${bld}Y${rs} should be ${bld}a${rs}, ${bld}c${rs}, ${bld}m${rs}, ${bld}B${rs}, or ${bld}t${rs}, not ${err}%c${rs}.\n",
+			                 arg[7]);
+			goto fail;
+		}
+
+		struct bfs_stat sb;
+		if (stat_arg(state, &expr->argv[1], &sb) != 0) {
+			goto fail;
+		}
+
+
+		const struct timespec *reftime = bfs_stat_time(&sb, field);
+		if (!reftime) {
+			parse_expr_error(state, expr, "Couldn't get file %s.\n", bfs_stat_field_name(field));
+			goto fail;
+		}
+
+		expr->reftime = *reftime;
+	}
+
+	expr->cost = STAT_COST;
+
+	return expr;
+
+fail:
+	bfs_expr_free(expr);
+	return NULL;
+}
+
+/**
+ * Parse -nogroup.
+ */
+static struct bfs_expr *parse_nogroup(struct parser_state *state, int arg1, int arg2) {
+	if (!bfs_ctx_groups(state->ctx)) {
+		parse_error(state, "Couldn't parse the group table: %m.\n");
+		return NULL;
+	}
+
+	struct bfs_expr *expr = parse_nullary_test(state, eval_nogroup);
+	if (expr) {
+		expr->cost = STAT_COST;
+		expr->probability = 0.01;
+	}
+	return expr;
+}
+
+/**
+ * Parse -nohidden.
+ */
+static struct bfs_expr *parse_nohidden(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_expr *hidden = bfs_expr_new(eval_hidden, 1, &fake_hidden_arg);
+	if (!hidden) {
+		return NULL;
+	}
+
+	hidden->probability = 0.01;
+	hidden->pure = true;
+	hidden->synthetic = true;
+
+	if (parse_exclude(state, hidden) != 0) {
+		return NULL;
+	}
+
+	parser_advance(state, T_OPTION, 1);
+	return &bfs_true;
+}
+
+/**
+ * Parse -noleaf.
+ */
+static struct bfs_expr *parse_noleaf(struct parser_state *state, int arg1, int arg2) {
+	parse_warning(state, "${ex}bfs${rs} does not apply the optimization that ${blu}%s${rs} inhibits.\n\n", state->argv[0]);
+	return parse_nullary_option(state);
+}
+
+/**
+ * Parse -nouser.
+ */
+static struct bfs_expr *parse_nouser(struct parser_state *state, int arg1, int arg2) {
+	if (!bfs_ctx_users(state->ctx)) {
+		parse_error(state, "Couldn't parse the user table: %m.\n");
+		return NULL;
+	}
+
+	struct bfs_expr *expr = parse_nullary_test(state, eval_nouser);
+	if (expr) {
+		expr->cost = STAT_COST;
+		expr->probability = 0.01;
+	}
+	return expr;
+}
+
+/**
+ * Parse a permission mode like chmod(1).
+ */
+static int parse_mode(const struct parser_state *state, const char *mode, struct bfs_expr *expr) {
+	if (mode[0] >= '0' && mode[0] <= '9') {
+		unsigned int parsed;
+		if (!parse_int(state, NULL, mode, &parsed, 8 | IF_INT | IF_UNSIGNED | IF_QUIET)) {
+			goto fail;
+		}
+		if (parsed > 07777) {
+			goto fail;
+		}
+
+		expr->file_mode = parsed;
+		expr->dir_mode = parsed;
+		return 0;
+	}
+
+	expr->file_mode = 0;
+	expr->dir_mode = 0;
+
+	// Parse the same grammar as chmod(1), which looks like this:
+	//
+	// MODE : CLAUSE ["," CLAUSE]*
+	//
+	// CLAUSE : WHO* ACTION+
+	//
+	// WHO : "u" | "g" | "o" | "a"
+	//
+	// ACTION : OP PERM*
+	//        | OP PERMCOPY
+	//
+	// OP : "+" | "-" | "="
+	//
+	// PERM : "r" | "w" | "x" | "X" | "s" | "t"
+	//
+	// PERMCOPY : "u" | "g" | "o"
+
+	// State machine state
+	enum {
+		MODE_CLAUSE,
+		MODE_WHO,
+		MODE_ACTION,
+		MODE_ACTION_APPLY,
+		MODE_OP,
+		MODE_PERM,
+	} mstate = MODE_CLAUSE;
+
+	enum {
+		MODE_PLUS,
+		MODE_MINUS,
+		MODE_EQUALS,
+	} op;
+
+	mode_t who;
+	mode_t file_change;
+	mode_t dir_change;
+
+	const char *i = mode;
+	while (true) {
+		switch (mstate) {
+		case MODE_CLAUSE:
+			who = 0;
+			mstate = MODE_WHO;
+			BFS_FALLTHROUGH;
+
+		case MODE_WHO:
+			switch (*i) {
+			case 'u':
+				who |= 0700;
+				break;
+			case 'g':
+				who |= 0070;
+				break;
+			case 'o':
+				who |= 0007;
+				break;
+			case 'a':
+				who |= 0777;
+				break;
+			default:
+				mstate = MODE_ACTION;
+				continue;
+			}
+			break;
+
+		case MODE_ACTION_APPLY:
+			switch (op) {
+			case MODE_EQUALS:
+				expr->file_mode &= ~who;
+				expr->dir_mode &= ~who;
+				BFS_FALLTHROUGH;
+			case MODE_PLUS:
+				expr->file_mode |= file_change;
+				expr->dir_mode |= dir_change;
+				break;
+			case MODE_MINUS:
+				expr->file_mode &= ~file_change;
+				expr->dir_mode &= ~dir_change;
+				break;
+			}
+			BFS_FALLTHROUGH;
+
+		case MODE_ACTION:
+			if (who == 0) {
+				who = 0777;
+			}
+
+			switch (*i) {
+			case '+':
+				op = MODE_PLUS;
+				mstate = MODE_OP;
+				break;
+			case '-':
+				op = MODE_MINUS;
+				mstate = MODE_OP;
+				break;
+			case '=':
+				op = MODE_EQUALS;
+				mstate = MODE_OP;
+				break;
+
+			case ',':
+				if (mstate == MODE_ACTION_APPLY) {
+					mstate = MODE_CLAUSE;
+				} else {
+					goto fail;
+				}
+				break;
+
+			case '\0':
+				if (mstate == MODE_ACTION_APPLY) {
+					goto done;
+				} else {
+					goto fail;
+				}
+
+			default:
+				goto fail;
+			}
+			break;
+
+		case MODE_OP:
+			switch (*i) {
+			case 'u':
+				file_change = (expr->file_mode >> 6) & 07;
+				dir_change = (expr->dir_mode >> 6) & 07;
+				break;
+			case 'g':
+				file_change = (expr->file_mode >> 3) & 07;
+				dir_change = (expr->dir_mode >> 3) & 07;
+				break;
+			case 'o':
+				file_change = expr->file_mode & 07;
+				dir_change = expr->dir_mode & 07;
+				break;
+
+			default:
+				file_change = 0;
+				dir_change = 0;
+				mstate = MODE_PERM;
+				continue;
+			}
+
+			file_change |= (file_change << 6) | (file_change << 3);
+			file_change &= who;
+			dir_change |= (dir_change << 6) | (dir_change << 3);
+			dir_change &= who;
+			mstate = MODE_ACTION_APPLY;
+			break;
+
+		case MODE_PERM:
+			switch (*i) {
+			case 'r':
+				file_change |= who & 0444;
+				dir_change |= who & 0444;
+				break;
+			case 'w':
+				file_change |= who & 0222;
+				dir_change |= who & 0222;
+				break;
+			case 'x':
+				file_change |= who & 0111;
+				BFS_FALLTHROUGH;
+			case 'X':
+				dir_change |= who & 0111;
+				break;
+			case 's':
+				if (who & 0700) {
+					file_change |= S_ISUID;
+					dir_change |= S_ISUID;
+				}
+				if (who & 0070) {
+					file_change |= S_ISGID;
+					dir_change |= S_ISGID;
+				}
+				break;
+			case 't':
+				if (who & 0007) {
+					file_change |= S_ISVTX;
+					dir_change |= S_ISVTX;
+				}
+				break;
+			default:
+				mstate = MODE_ACTION_APPLY;
+				continue;
+			}
+			break;
+		}
+
+		++i;
+	}
+
+done:
+	return 0;
+
+fail:
+	parse_expr_error(state, expr, "Invalid mode.\n");
+	return -1;
+}
+
+/**
+ * Parse -perm MODE.
+ */
+static struct bfs_expr *parse_perm(struct parser_state *state, int field, int arg2) {
+	struct bfs_expr *expr = parse_unary_test(state, eval_perm);
+	if (!expr) {
+		return NULL;
+	}
+
+	const char *mode = expr->argv[1];
+	switch (mode[0]) {
+	case '-':
+		expr->mode_cmp = BFS_MODE_ALL;
+		++mode;
+		break;
+	case '/':
+		expr->mode_cmp = BFS_MODE_ANY;
+		++mode;
+		break;
+	case '+':
+		if (mode[1] >= '0' && mode[1] <= '9') {
+			expr->mode_cmp = BFS_MODE_ANY;
+			++mode;
+			break;
+		}
+		BFS_FALLTHROUGH;
+	default:
+		expr->mode_cmp = BFS_MODE_EQUAL;
+		break;
+	}
+
+	if (parse_mode(state, mode, expr) != 0) {
+		goto fail;
+	}
+
+	expr->cost = STAT_COST;
+
+	return expr;
+
+fail:
+	bfs_expr_free(expr);
+	return NULL;
+}
+
+/**
+ * Parse -print.
+ */
+static struct bfs_expr *parse_print(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_expr *expr = parse_nullary_action(state, eval_fprint);
+	if (expr) {
+		init_print_expr(state, expr);
+	}
+	return expr;
+}
+
+/**
+ * Parse -print0.
+ */
+static struct bfs_expr *parse_print0(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_expr *expr = parse_nullary_action(state, eval_fprint0);
+	if (expr) {
+		init_print_expr(state, expr);
+	}
+	return expr;
+}
+
+/**
+ * Parse -printf FORMAT.
+ */
+static struct bfs_expr *parse_printf(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_expr *expr = parse_unary_action(state, eval_fprintf);
+	if (!expr) {
+		return NULL;
+	}
+
+	init_print_expr(state, expr);
+
+	if (bfs_printf_parse(state->ctx, expr, expr->argv[1]) != 0) {
+		bfs_expr_free(expr);
+		return NULL;
+	}
+
+	return expr;
+}
+
+/**
+ * Parse -printx.
+ */
+static struct bfs_expr *parse_printx(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_expr *expr = parse_nullary_action(state, eval_fprintx);
+	if (expr) {
+		init_print_expr(state, expr);
+	}
+	return expr;
+}
+
+/**
+ * Parse -prune.
+ */
+static struct bfs_expr *parse_prune(struct parser_state *state, int arg1, int arg2) {
+	state->prune_arg = state->argv;
+
+	struct bfs_expr *expr = parse_nullary_action(state, eval_prune);
+	if (expr) {
+		expr_set_always_true(expr);
+	}
+	return expr;
+}
+
+/**
+ * Parse -quit.
+ */
+static struct bfs_expr *parse_quit(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_expr *expr = parse_nullary_action(state, eval_quit);
+	if (expr) {
+		expr_set_never_returns(expr);
+	}
+	return expr;
+}
+
+/**
+ * Parse -i?regex.
+ */
+static struct bfs_expr *parse_regex(struct parser_state *state, int flags, int arg2) {
+	struct bfs_expr *expr = parse_unary_test(state, eval_regex);
+	if (!expr) {
+		goto fail;
+	}
+
+	if (bfs_regcomp(&expr->regex, expr->argv[1], state->regex_type, flags) != 0) {
+		if (!expr->regex) {
+			parse_perror(state, "bfs_regcomp()");
+			goto fail;
+		}
+
+		char *str = bfs_regerror(expr->regex);
+		if (!str) {
+			parse_perror(state, "bfs_regerror()");
+			goto fail;
+		}
+
+		parse_expr_error(state, expr, "%s.\n", str);
+		free(str);
+		goto fail;
+	}
+
+	return expr;
+
+fail:
+	bfs_expr_free(expr);
+	return NULL;
+}
+
+/**
+ * Parse -E.
+ */
+static struct bfs_expr *parse_regex_extended(struct parser_state *state, int arg1, int arg2) {
+	state->regex_type = BFS_REGEX_POSIX_EXTENDED;
+	return parse_nullary_flag(state);
+}
+
+/**
+ * Parse -regextype TYPE.
+ */
+static struct bfs_expr *parse_regextype(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_ctx *ctx = state->ctx;
+	CFILE *cfile = ctx->cerr;
+
+	const char *arg = state->argv[0];
+	const char *type = state->argv[1];
+	if (!type) {
+		parse_error(state, "${blu}%s${rs} needs a value.\n\n", arg);
+		goto list_types;
+	}
+
+	parser_advance(state, T_OPTION, 1);
+
+	// See https://www.gnu.org/software/gnulib/manual/html_node/Predefined-Syntaxes.html
+	if (strcmp(type, "posix-basic") == 0
+	    || strcmp(type, "ed") == 0
+	    || strcmp(type, "sed") == 0) {
+		state->regex_type = BFS_REGEX_POSIX_BASIC;
+	} else if (strcmp(type, "posix-extended") == 0) {
+		state->regex_type = BFS_REGEX_POSIX_EXTENDED;
+#if BFS_WITH_ONIGURUMA
+	} else if (strcmp(type, "emacs") == 0) {
+		state->regex_type = BFS_REGEX_EMACS;
+	} else if (strcmp(type, "grep") == 0) {
+		state->regex_type = BFS_REGEX_GREP;
+#endif
+	} else if (strcmp(type, "help") == 0) {
+		state->just_info = true;
+		cfile = ctx->cout;
+		goto list_types;
+	} else {
+		parse_error(state, "Unsupported regex type.\n\n");
+		goto list_types;
+	}
+
+	parser_advance(state, T_OPTION, 1);
+	return &bfs_true;
+
+list_types:
+	cfprintf(cfile, "Supported types are:\n\n");
+	cfprintf(cfile, "  ${bld}posix-basic${rs}:    POSIX basic regular expressions (BRE)\n");
+	cfprintf(cfile, "  ${bld}posix-extended${rs}: POSIX extended regular expressions (ERE)\n");
+	cfprintf(cfile, "  ${bld}ed${rs}:             Like ${grn}ed${rs} (same as ${bld}posix-basic${rs})\n");
+#if BFS_WITH_ONIGURUMA
+	cfprintf(cfile, "  ${bld}emacs${rs}:          Like ${grn}emacs${rs}\n");
+	cfprintf(cfile, "  ${bld}grep${rs}:           Like ${grn}grep${rs}\n");
+#endif
+	cfprintf(cfile, "  ${bld}sed${rs}:            Like ${grn}sed${rs} (same as ${bld}posix-basic${rs})\n");
+	return NULL;
+}
+
+/**
+ * Parse -s.
+ */
+static struct bfs_expr *parse_s(struct parser_state *state, int arg1, int arg2) {
+	state->ctx->flags |= BFTW_SORT;
+	return parse_nullary_flag(state);
+}
+
+/**
+ * Parse -samefile FILE.
+ */
+static struct bfs_expr *parse_samefile(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_expr *expr = parse_unary_test(state, eval_samefile);
+	if (!expr) {
+		return NULL;
+	}
+
+	struct bfs_stat sb;
+	if (stat_arg(state, &expr->argv[1], &sb) != 0) {
+		bfs_expr_free(expr);
+		return NULL;
+	}
+
+	expr->dev = sb.dev;
+	expr->ino = sb.ino;
+
+	expr->cost = STAT_COST;
+	expr->probability = 0.01;
+
+	return expr;
+}
+
+/**
+ * Parse -S STRATEGY.
+ */
+static struct bfs_expr *parse_search_strategy(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_ctx *ctx = state->ctx;
+	CFILE *cfile = ctx->cerr;
+
+	const char *flag = state->argv[0];
+	const char *arg = state->argv[1];
+	if (!arg) {
+		parse_error(state, "${cyn}%s${rs} needs an argument.\n\n", flag);
+		goto list_strategies;
+	}
+
+	parser_advance(state, T_FLAG, 1);
+
+	if (strcmp(arg, "bfs") == 0) {
+		ctx->strategy = BFTW_BFS;
+	} else if (strcmp(arg, "dfs") == 0) {
+		ctx->strategy = BFTW_DFS;
+	} else if (strcmp(arg, "ids") == 0) {
+		ctx->strategy = BFTW_IDS;
+	} else if (strcmp(arg, "eds") == 0) {
+		ctx->strategy = BFTW_EDS;
+	} else if (strcmp(arg, "help") == 0) {
+		state->just_info = true;
+		cfile = ctx->cout;
+		goto list_strategies;
+	} else {
+		parse_error(state, "Unrecognized search strategy.\n\n");
+		goto list_strategies;
+	}
+
+	parser_advance(state, T_FLAG, 1);
+	return &bfs_true;
+
+list_strategies:
+	cfprintf(cfile, "Supported search strategies:\n\n");
+	cfprintf(cfile, "  ${bld}bfs${rs}: breadth-first search\n");
+	cfprintf(cfile, "  ${bld}dfs${rs}: depth-first search\n");
+	cfprintf(cfile, "  ${bld}ids${rs}: iterative deepening search\n");
+	cfprintf(cfile, "  ${bld}eds${rs}: exponential deepening search\n");
+	return NULL;
+}
+
+/**
+ * Parse -[aBcm]?since.
+ */
+static struct bfs_expr *parse_since(struct parser_state *state, int field, int arg2) {
+	struct bfs_expr *expr = parse_unary_test(state, eval_newer);
+	if (!expr) {
+		return NULL;
+	}
+
+	if (parse_reftime(state, expr) != 0) {
+		goto fail;
+	}
+
+	expr->cost = STAT_COST;
+	expr->stat_field = field;
+	return expr;
+
+fail:
+	bfs_expr_free(expr);
+	return NULL;
+}
+
+/**
+ * Parse -size N[cwbkMGTP]?.
+ */
+static struct bfs_expr *parse_size(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_expr *expr = parse_unary_test(state, eval_size);
+	if (!expr) {
+		return NULL;
+	}
+
+	const char *unit = parse_icmp(state, expr, IF_PARTIAL_OK);
+	if (!unit) {
+		goto fail;
+	}
+
+	if (strlen(unit) > 1) {
+		goto bad_unit;
+	}
+
+	switch (*unit) {
+	case '\0':
+	case 'b':
+		expr->size_unit = BFS_BLOCKS;
+		break;
+	case 'c':
+		expr->size_unit = BFS_BYTES;
+		break;
+	case 'w':
+		expr->size_unit = BFS_WORDS;
+		break;
+	case 'k':
+		expr->size_unit = BFS_KB;
+		break;
+	case 'M':
+		expr->size_unit = BFS_MB;
+		break;
+	case 'G':
+		expr->size_unit = BFS_GB;
+		break;
+	case 'T':
+		expr->size_unit = BFS_TB;
+		break;
+	case 'P':
+		expr->size_unit = BFS_PB;
+		break;
+
+	default:
+		goto bad_unit;
+	}
+
+	expr->cost = STAT_COST;
+	expr->probability = expr->int_cmp == BFS_INT_EQUAL ? 0.01 : 0.50;
+
+	return expr;
+
+bad_unit:
+	parse_expr_error(state, expr, "Expected a size unit (one of ${bld}cwbkMGTP${rs}); found ${err}%s${rs}.\n", unit);
+fail:
+	bfs_expr_free(expr);
+	return NULL;
+}
+
+/**
+ * Parse -sparse.
+ */
+static struct bfs_expr *parse_sparse(struct parser_state *state, int arg1, int arg2) {
+	struct bfs_expr *expr = parse_nullary_test(state, eval_sparse);
+	if (expr) {
+		expr->cost = STAT_COST;
+	}
+	return expr;
+}
+
+/**
+ * Parse -status.
+ */
+static struct bfs_expr *parse_status(struct parser_state *state, int arg1, int arg2) {
+	state->ctx->status = true;
+	return parse_nullary_option(state);
+}
+
+/**
+ * Parse -x?type [bcdpflsD].
+ */
+static struct bfs_expr *parse_type(struct parser_state *state, int x, int arg2) {
+	bfs_eval_fn *eval = x ? eval_xtype : eval_type;
+	struct bfs_expr *expr = parse_unary_test(state, eval);
+	if (!expr) {
+		return NULL;
+	}
+
+	unsigned int types = 0;
+	float probability = 0.0;
+
+	const char *c = expr->argv[1];
+	while (true) {
+		enum bfs_type type;
+		float type_prob;
+
+		switch (*c) {
+		case 'b':
+			type = BFS_BLK;
+			type_prob = 0.00000721183;
+			break;
+		case 'c':
+			type = BFS_CHR;
+			type_prob = 0.0000499855;
+			break;
+		case 'd':
+			type = BFS_DIR;
+			type_prob = 0.114475;
+			break;
+		case 'D':
+			type = BFS_DOOR;
+			type_prob = 0.000001;
+			break;
+		case 'p':
+			type = BFS_FIFO;
+			type_prob = 0.00000248684;
+			break;
+		case 'f':
+			type = BFS_REG;
+			type_prob = 0.859772;
+			break;
+		case 'l':
+			type = BFS_LNK;
+			type_prob = 0.0256816;
+			break;
+		case 's':
+			type = BFS_SOCK;
+			type_prob = 0.0000116881;
+			break;
+		case 'w':
+			type = BFS_WHT;
+			type_prob = 0.000001;
+			break;
+
+		case '\0':
+			parse_expr_error(state, expr, "Expected a type flag.\n");
+			goto fail;
+
+		default:
+			parse_expr_error(state, expr, "Unknown type flag ${err}%c${rs}; expected one of [${bld}bcdpflsD${rs}].\n", *c);
+			goto fail;
+		}
+
+		unsigned int flag = 1 << type;
+		if (!(types & flag)) {
+			types |= flag;
+			probability += type_prob;
+		}
+
+		++c;
+		if (*c == '\0') {
+			break;
+		} else if (*c == ',') {
+			++c;
+			continue;
+		} else {
+			parse_expr_error(state, expr, "Types must be comma-separated.\n");
+			goto fail;
+		}
+	}
+
+	expr->num = types;
+	expr->probability = probability;
+
+	if (x && state->ctx->optlevel < 4) {
+		// Since -xtype dereferences symbolic links, it may have side
+		// effects such as reporting permission errors, and thus
+		// shouldn't be re-ordered without aggressive optimizations
+		expr->pure = false;
+	}
+
+	return expr;
+
+fail:
+	bfs_expr_free(expr);
+	return NULL;
+}
+
+/**
+ * Parse -(no)?warn.
+ */
+static struct bfs_expr *parse_warn(struct parser_state *state, int warn, int arg2) {
+	state->ctx->warn = warn;
+	return parse_nullary_option(state);
+}
+
+/**
+ * Parse -xattr.
+ */
+static struct bfs_expr *parse_xattr(struct parser_state *state, int arg1, int arg2) {
+#if BFS_CAN_CHECK_XATTRS
+	struct bfs_expr *expr = parse_nullary_test(state, eval_xattr);
+	if (expr) {
+		expr->cost = STAT_COST;
+		expr->probability = 0.01;
+	}
+	return expr;
+#else
+	parse_error(state, "Missing platform support.\n");
+	return NULL;
+#endif
+}
+
+/**
+ * Parse -xattrname.
+ */
+static struct bfs_expr *parse_xattrname(struct parser_state *state, int arg1, int arg2) {
+#if BFS_CAN_CHECK_XATTRS
+	struct bfs_expr *expr = parse_unary_test(state, eval_xattrname);
+	if (expr) {
+		expr->cost = STAT_COST;
+		expr->probability = 0.01;
+	}
+	return expr;
+#else
+	parse_error(state, "Missing platform support.\n");
+	return NULL;
+#endif
+}
+
+/**
+ * Parse -xdev.
+ */
+static struct bfs_expr *parse_xdev(struct parser_state *state, int arg1, int arg2) {
+	state->ctx->flags |= BFTW_PRUNE_MOUNTS;
+	state->xdev_arg = state->argv;
+	return parse_nullary_option(state);
+}
+
+/**
+ * Launch a pager for the help output.
+ */
+static CFILE *launch_pager(pid_t *pid, CFILE *cout) {
+	char *pager = getenv("PAGER");
+
+	char *exe;
+	if (pager && pager[0]) {
+		exe = bfs_spawn_resolve(pager);
+	} else {
+		exe = bfs_spawn_resolve("less");
+		if (!exe) {
+			exe = bfs_spawn_resolve("more");
+		}
+	}
+	if (!exe) {
+		goto fail;
+	}
+
+	int pipefd[2];
+	if (pipe(pipefd) != 0) {
+		goto fail_exe;
+	}
+
+	FILE *file = fdopen(pipefd[1], "w");
+	if (!file) {
+		goto fail_pipe;
+	}
+	pipefd[1] = -1;
+
+	CFILE *ret = cfwrap(file, NULL, true);
+	if (!ret) {
+		goto fail_file;
+	}
+	file = NULL;
+
+	struct bfs_spawn ctx;
+	if (bfs_spawn_init(&ctx) != 0) {
+		goto fail_ret;
+	}
+
+	if (bfs_spawn_addclose(&ctx, fileno(ret->file)) != 0) {
+		goto fail_ctx;
+	}
+	if (bfs_spawn_adddup2(&ctx, pipefd[0], STDIN_FILENO) != 0) {
+		goto fail_ctx;
+	}
+	if (bfs_spawn_addclose(&ctx, pipefd[0]) != 0) {
+		goto fail_ctx;
+	}
+
+	char *argv[] = {
+		exe,
+		NULL,
+		NULL,
+	};
+
+	if (strcmp(xbasename(exe), "less") == 0) {
+		// We know less supports colors, other pagers may not
+		ret->colors = cout->colors;
+		argv[1] = "-FKRX";
+	}
+
+	*pid = bfs_spawn(exe, &ctx, argv, NULL);
+	if (*pid < 0) {
+		goto fail_ctx;
+	}
+
+	xclose(pipefd[0]);
+	bfs_spawn_destroy(&ctx);
+	free(exe);
+	return ret;
+
+fail_ctx:
+	bfs_spawn_destroy(&ctx);
+fail_ret:
+	cfclose(ret);
+fail_file:
+	if (file) {
+		fclose(file);
+	}
+fail_pipe:
+	if (pipefd[1] >= 0) {
+		xclose(pipefd[1]);
+	}
+	if (pipefd[0] >= 0) {
+		xclose(pipefd[0]);
+	}
+fail_exe:
+	free(exe);
+fail:
+	return cout;
+}
+
+/**
+ * "Parse" -help.
+ */
+static struct bfs_expr *parse_help(struct parser_state *state, int arg1, int arg2) {
+	CFILE *cout = state->ctx->cout;
+
+	pid_t pager = -1;
+	if (state->stdout_tty) {
+		cout = launch_pager(&pager, cout);
+	}
+
+	cfprintf(cout, "Usage: ${ex}%s${rs} [${cyn}flags${rs}...] [${mag}paths${rs}...] [${blu}expression${rs}...]\n\n",
+		 state->command);
+
+	cfprintf(cout, "${ex}bfs${rs} is compatible with ${ex}find${rs}, with some extensions. "
+		       "${cyn}Flags${rs} (${cyn}-H${rs}/${cyn}-L${rs}/${cyn}-P${rs} etc.), ${mag}paths${rs},\n"
+		       "and ${blu}expressions${rs} may be freely mixed in any order.\n\n");
+
+	cfprintf(cout, "${bld}Flags:${rs}\n\n");
+
+	cfprintf(cout, "  ${cyn}-H${rs}\n");
+	cfprintf(cout, "      Follow symbolic links on the command line, but not while searching\n");
+	cfprintf(cout, "  ${cyn}-L${rs}\n");
+	cfprintf(cout, "      Follow all symbolic links\n");
+	cfprintf(cout, "  ${cyn}-P${rs}\n");
+	cfprintf(cout, "      Never follow symbolic links (the default)\n");
+
+	cfprintf(cout, "  ${cyn}-E${rs}\n");
+	cfprintf(cout, "      Use extended regular expressions (same as ${blu}-regextype${rs} ${bld}posix-extended${rs})\n");
+	cfprintf(cout, "  ${cyn}-X${rs}\n");
+	cfprintf(cout, "      Filter out files with non-${ex}xargs${rs}-safe names\n");
+	cfprintf(cout, "  ${cyn}-d${rs}\n");
+	cfprintf(cout, "      Search in post-order (same as ${blu}-depth${rs})\n");
+	cfprintf(cout, "  ${cyn}-s${rs}\n");
+	cfprintf(cout, "      Visit directory entries in sorted order\n");
+	cfprintf(cout, "  ${cyn}-x${rs}\n");
+	cfprintf(cout, "      Don't descend into other mount points (same as ${blu}-xdev${rs})\n");
+
+	cfprintf(cout, "  ${cyn}-f${rs} ${mag}PATH${rs}\n");
+	cfprintf(cout, "      Treat ${mag}PATH${rs} as a path to search (useful if begins with a dash)\n");
+	cfprintf(cout, "  ${cyn}-D${rs} ${bld}FLAG${rs}\n");
+	cfprintf(cout, "      Turn on a debugging flag (see ${cyn}-D${rs} ${bld}help${rs})\n");
+	cfprintf(cout, "  ${cyn}-O${bld}N${rs}\n");
+	cfprintf(cout, "      Enable optimization level ${bld}N${rs} (default: ${bld}3${rs})\n");
+	cfprintf(cout, "  ${cyn}-S${rs} ${bld}bfs${rs}|${bld}dfs${rs}|${bld}ids${rs}|${bld}eds${rs}\n");
+	cfprintf(cout, "      Use ${bld}b${rs}readth-${bld}f${rs}irst/${bld}d${rs}epth-${bld}f${rs}irst/${bld}i${rs}terative/${bld}e${rs}xponential ${bld}d${rs}eepening ${bld}s${rs}earch\n");
+	cfprintf(cout, "      (default: ${cyn}-S${rs} ${bld}bfs${rs})\n\n");
+
+	cfprintf(cout, "${bld}Operators:${rs}\n\n");
+
+	cfprintf(cout, "  ${red}(${rs} ${blu}expression${rs} ${red})${rs}\n\n");
+
+	cfprintf(cout, "  ${red}!${rs} ${blu}expression${rs}\n");
+	cfprintf(cout, "  ${red}-not${rs} ${blu}expression${rs}\n\n");
+
+	cfprintf(cout, "  ${blu}expression${rs} ${blu}expression${rs}\n");
+	cfprintf(cout, "  ${blu}expression${rs} ${red}-a${rs} ${blu}expression${rs}\n");
+	cfprintf(cout, "  ${blu}expression${rs} ${red}-and${rs} ${blu}expression${rs}\n\n");
+
+	cfprintf(cout, "  ${blu}expression${rs} ${red}-o${rs} ${blu}expression${rs}\n");
+	cfprintf(cout, "  ${blu}expression${rs} ${red}-or${rs} ${blu}expression${rs}\n\n");
+
+	cfprintf(cout, "  ${blu}expression${rs} ${red},${rs} ${blu}expression${rs}\n\n");
+
+	cfprintf(cout, "${bld}Special forms:${rs}\n\n");
+
+	cfprintf(cout, "  ${red}-exclude${rs} ${blu}expression${rs}\n");
+	cfprintf(cout, "      Exclude all paths matching the ${blu}expression${rs} from the search.\n\n");
+
+	cfprintf(cout, "${bld}Options:${rs}\n\n");
+
+	cfprintf(cout, "  ${blu}-color${rs}\n");
+	cfprintf(cout, "  ${blu}-nocolor${rs}\n");
+	cfprintf(cout, "      Turn colors on or off (default: ${blu}-color${rs} if outputting to a terminal,\n");
+	cfprintf(cout, "      ${blu}-nocolor${rs} otherwise)\n");
+	cfprintf(cout, "  ${blu}-daystart${rs}\n");
+	cfprintf(cout, "      Measure times relative to the start of today\n");
+	cfprintf(cout, "  ${blu}-depth${rs}\n");
+	cfprintf(cout, "      Search in post-order (descendents first)\n");
+	cfprintf(cout, "  ${blu}-files0-from${rs} ${bld}FILE${rs}\n");
+	cfprintf(cout, "      Search the NUL ('\\0')-separated paths from ${bld}FILE${rs} (${bld}-${rs} for standard input).\n");
+	cfprintf(cout, "  ${blu}-follow${rs}\n");
+	cfprintf(cout, "      Follow all symbolic links (same as ${cyn}-L${rs})\n");
+	cfprintf(cout, "  ${blu}-ignore_readdir_race${rs}\n");
+	cfprintf(cout, "  ${blu}-noignore_readdir_race${rs}\n");
+	cfprintf(cout, "      Whether to report an error if ${ex}bfs${rs} detects that the file tree is modified\n");
+	cfprintf(cout, "      during the search (default: ${blu}-noignore_readdir_race${rs})\n");
+	cfprintf(cout, "  ${blu}-maxdepth${rs} ${bld}N${rs}\n");
+	cfprintf(cout, "  ${blu}-mindepth${rs} ${bld}N${rs}\n");
+	cfprintf(cout, "      Ignore files deeper/shallower than ${bld}N${rs}\n");
+	cfprintf(cout, "  ${blu}-mount${rs}\n");
+	cfprintf(cout, "      Don't descend into other mount points (same as ${blu}-xdev${rs} for now, but will\n");
+	cfprintf(cout, "      skip mount points entirely in the future)\n");
+	cfprintf(cout, "  ${blu}-nohidden${rs}\n");
+	cfprintf(cout, "      Exclude hidden files\n");
+	cfprintf(cout, "  ${blu}-noleaf${rs}\n");
+	cfprintf(cout, "      Ignored; for compatibility with GNU find\n");
+	cfprintf(cout, "  ${blu}-regextype${rs} ${bld}TYPE${rs}\n");
+	cfprintf(cout, "      Use ${bld}TYPE${rs}-flavored regexes (default: ${bld}posix-basic${rs}; see ${blu}-regextype${rs} ${bld}help${rs})\n");
+	cfprintf(cout, "  ${blu}-status${rs}\n");
+	cfprintf(cout, "      Display a status bar while searching\n");
+	cfprintf(cout, "  ${blu}-unique${rs}\n");
+	cfprintf(cout, "      Skip any files that have already been seen\n");
+	cfprintf(cout, "  ${blu}-warn${rs}\n");
+	cfprintf(cout, "  ${blu}-nowarn${rs}\n");
+	cfprintf(cout, "      Turn on or off warnings about the command line\n");
+	cfprintf(cout, "  ${blu}-xdev${rs}\n");
+	cfprintf(cout, "      Don't descend into other mount points\n\n");
+
+	cfprintf(cout, "${bld}Tests:${rs}\n\n");
+
+#if BFS_CAN_CHECK_ACL
+	cfprintf(cout, "  ${blu}-acl${rs}\n");
+	cfprintf(cout, "      Find files with a non-trivial Access Control List\n");
+#endif
+	cfprintf(cout, "  ${blu}-${rs}[${blu}aBcm${rs}]${blu}min${rs} ${bld}[-+]N${rs}\n");
+	cfprintf(cout, "      Find files ${blu}a${rs}ccessed/${blu}B${rs}irthed/${blu}c${rs}hanged/${blu}m${rs}odified ${bld}N${rs} minutes ago\n");
+	cfprintf(cout, "  ${blu}-${rs}[${blu}aBcm${rs}]${blu}newer${rs} ${bld}FILE${rs}\n");
+	cfprintf(cout, "      Find files ${blu}a${rs}ccessed/${blu}B${rs}irthed/${blu}c${rs}hanged/${blu}m${rs}odified more recently than ${bld}FILE${rs} was\n"
+	               "      modified\n");
+	cfprintf(cout, "  ${blu}-${rs}[${blu}aBcm${rs}]${blu}since${rs} ${bld}TIME${rs}\n");
+	cfprintf(cout, "      Find files ${blu}a${rs}ccessed/${blu}B${rs}irthed/${blu}c${rs}hanged/${blu}m${rs}odified more recently than ${bld}TIME${rs}\n");
+	cfprintf(cout, "  ${blu}-${rs}[${blu}aBcm${rs}]${blu}time${rs} ${bld}[-+]N${rs}\n");
+	cfprintf(cout, "      Find files ${blu}a${rs}ccessed/${blu}B${rs}irthed/${blu}c${rs}hanged/${blu}m${rs}odified ${bld}N${rs} days ago\n");
+#if BFS_CAN_CHECK_CAPABILITIES
+	cfprintf(cout, "  ${blu}-capable${rs}\n");
+	cfprintf(cout, "      Find files with POSIX.1e capabilities set\n");
+#endif
+	cfprintf(cout, "  ${blu}-depth${rs} ${bld}[-+]N${rs}\n");
+	cfprintf(cout, "      Find files with depth ${bld}N${rs}\n");
+	cfprintf(cout, "  ${blu}-empty${rs}\n");
+	cfprintf(cout, "      Find empty files/directories\n");
+	cfprintf(cout, "  ${blu}-executable${rs}\n");
+	cfprintf(cout, "  ${blu}-readable${rs}\n");
+	cfprintf(cout, "  ${blu}-writable${rs}\n");
+	cfprintf(cout, "      Find files the current user can execute/read/write\n");
+	cfprintf(cout, "  ${blu}-false${rs}\n");
+	cfprintf(cout, "  ${blu}-true${rs}\n");
+	cfprintf(cout, "      Always false/true\n");
+	cfprintf(cout, "  ${blu}-fstype${rs} ${bld}TYPE${rs}\n");
+	cfprintf(cout, "      Find files on file systems with the given ${bld}TYPE${rs}\n");
+	cfprintf(cout, "  ${blu}-gid${rs} ${bld}[-+]N${rs}\n");
+	cfprintf(cout, "  ${blu}-uid${rs} ${bld}[-+]N${rs}\n");
+	cfprintf(cout, "      Find files owned by group/user ID ${bld}N${rs}\n");
+	cfprintf(cout, "  ${blu}-group${rs} ${bld}NAME${rs}\n");
+	cfprintf(cout, "  ${blu}-user${rs}  ${bld}NAME${rs}\n");
+	cfprintf(cout, "      Find files owned by the group/user ${bld}NAME${rs}\n");
+	cfprintf(cout, "  ${blu}-hidden${rs}\n");
+	cfprintf(cout, "      Find hidden files\n");
+#ifdef FNM_CASEFOLD
+	cfprintf(cout, "  ${blu}-ilname${rs} ${bld}GLOB${rs}\n");
+	cfprintf(cout, "  ${blu}-iname${rs}  ${bld}GLOB${rs}\n");
+	cfprintf(cout, "  ${blu}-ipath${rs}  ${bld}GLOB${rs}\n");
+	cfprintf(cout, "  ${blu}-iregex${rs} ${bld}REGEX${rs}\n");
+	cfprintf(cout, "  ${blu}-iwholename${rs} ${bld}GLOB${rs}\n");
+	cfprintf(cout, "      Case-insensitive versions of ${blu}-lname${rs}/${blu}-name${rs}/${blu}-path${rs}"
+	               "/${blu}-regex${rs}/${blu}-wholename${rs}\n");
+#endif
+	cfprintf(cout, "  ${blu}-inum${rs} ${bld}[-+]N${rs}\n");
+	cfprintf(cout, "      Find files with inode number ${bld}N${rs}\n");
+	cfprintf(cout, "  ${blu}-links${rs} ${bld}[-+]N${rs}\n");
+	cfprintf(cout, "      Find files with ${bld}N${rs} hard links\n");
+	cfprintf(cout, "  ${blu}-lname${rs} ${bld}GLOB${rs}\n");
+	cfprintf(cout, "      Find symbolic links whose target matches the ${bld}GLOB${rs}\n");
+	cfprintf(cout, "  ${blu}-name${rs} ${bld}GLOB${rs}\n");
+	cfprintf(cout, "      Find files whose name matches the ${bld}GLOB${rs}\n");
+	cfprintf(cout, "  ${blu}-newer${rs} ${bld}FILE${rs}\n");
+	cfprintf(cout, "      Find files newer than ${bld}FILE${rs}\n");
+	cfprintf(cout, "  ${blu}-newer${bld}XY${rs} ${bld}REFERENCE${rs}\n");
+	cfprintf(cout, "      Find files whose ${bld}X${rs} time is newer than the ${bld}Y${rs} time of"
+	               " ${bld}REFERENCE${rs}.  ${bld}X${rs} and ${bld}Y${rs}\n");
+	cfprintf(cout, "      can be any of [${bld}aBcm${rs}].  ${bld}Y${rs} may also be ${bld}t${rs} to parse ${bld}REFERENCE${rs} an explicit\n");
+	cfprintf(cout, "      timestamp.\n");
+	cfprintf(cout, "  ${blu}-nogroup${rs}\n");
+	cfprintf(cout, "  ${blu}-nouser${rs}\n");
+	cfprintf(cout, "      Find files owned by nonexistent groups/users\n");
+	cfprintf(cout, "  ${blu}-path${rs} ${bld}GLOB${rs}\n");
+	cfprintf(cout, "  ${blu}-wholename${rs} ${bld}GLOB${rs}\n");
+	cfprintf(cout, "      Find files whose entire path matches the ${bld}GLOB${rs}\n");
+	cfprintf(cout, "  ${blu}-perm${rs} ${bld}[-]MODE${rs}\n");
+	cfprintf(cout, "      Find files with a matching mode\n");
+	cfprintf(cout, "  ${blu}-regex${rs} ${bld}REGEX${rs}\n");
+	cfprintf(cout, "      Find files whose entire path matches the regular expression ${bld}REGEX${rs}\n");
+	cfprintf(cout, "  ${blu}-samefile${rs} ${bld}FILE${rs}\n");
+	cfprintf(cout, "      Find hard links to ${bld}FILE${rs}\n");
+	cfprintf(cout, "  ${blu}-since${rs} ${bld}TIME${rs}\n");
+	cfprintf(cout, "      Find files modified since ${bld}TIME${rs}\n");
+	cfprintf(cout, "  ${blu}-size${rs} ${bld}[-+]N[cwbkMGTP]${rs}\n");
+	cfprintf(cout, "      Find files with the given size, in 1-byte ${bld}c${rs}haracters, 2-byte ${bld}w${rs}ords,\n");
+	cfprintf(cout, "      512-byte ${bld}b${rs}locks (default), or ${bld}k${rs}iB/${bld}M${rs}iB/${bld}G${rs}iB/${bld}T${rs}iB/${bld}P${rs}iB\n");
+	cfprintf(cout, "  ${blu}-sparse${rs}\n");
+	cfprintf(cout, "      Find files that occupy fewer disk blocks than expected\n");
+	cfprintf(cout, "  ${blu}-type${rs} ${bld}[bcdlpfswD]${rs}\n");
+	cfprintf(cout, "      Find files of the given type\n");
+	cfprintf(cout, "  ${blu}-used${rs} ${bld}[-+]N${rs}\n");
+	cfprintf(cout, "      Find files last accessed ${bld}N${rs} days after they were changed\n");
+#if BFS_CAN_CHECK_XATTRS
+	cfprintf(cout, "  ${blu}-xattr${rs}\n");
+	cfprintf(cout, "      Find files with extended attributes\n");
+	cfprintf(cout, "  ${blu}-xattrname${rs} ${bld}NAME${rs}\n");
+	cfprintf(cout, "      Find files with the extended attribute ${bld}NAME${rs}\n");
+#endif
+	cfprintf(cout, "  ${blu}-xtype${rs} ${bld}[bcdlpfswD]${rs}\n");
+	cfprintf(cout, "      Find files of the given type, following links when ${blu}-type${rs} would not, and\n");
+	cfprintf(cout, "      vice versa\n\n");
+
+	cfprintf(cout, "${bld}Actions:${rs}\n\n");
+
+	cfprintf(cout, "  ${blu}-delete${rs}\n");
+	cfprintf(cout, "  ${blu}-rm${rs}\n");
+	cfprintf(cout, "      Delete any found files (implies ${blu}-depth${rs})\n");
+	cfprintf(cout, "  ${blu}-exec${rs} ${bld}command ... {} ;${rs}\n");
+	cfprintf(cout, "      Execute a command\n");
+	cfprintf(cout, "  ${blu}-exec${rs} ${bld}command ... {} +${rs}\n");
+	cfprintf(cout, "      Execute a command with multiple files at once\n");
+	cfprintf(cout, "  ${blu}-ok${rs} ${bld}command ... {} ;${rs}\n");
+	cfprintf(cout, "      Prompt the user whether to execute a command\n");
+	cfprintf(cout, "  ${blu}-execdir${rs} ${bld}command ... {} ;${rs}\n");
+	cfprintf(cout, "  ${blu}-execdir${rs} ${bld}command ... {} +${rs}\n");
+	cfprintf(cout, "  ${blu}-okdir${rs} ${bld}command ... {} ;${rs}\n");
+	cfprintf(cout, "      Like ${blu}-exec${rs}/${blu}-ok${rs}, but run the command in the same directory as the found\n");
+	cfprintf(cout, "      file(s)\n");
+	cfprintf(cout, "  ${blu}-exit${rs} [${bld}STATUS${rs}]\n");
+	cfprintf(cout, "      Exit immediately with the given status (%d if unspecified)\n", EXIT_SUCCESS);
+	cfprintf(cout, "  ${blu}-fls${rs} ${bld}FILE${rs}\n");
+	cfprintf(cout, "  ${blu}-fprint${rs} ${bld}FILE${rs}\n");
+	cfprintf(cout, "  ${blu}-fprint0${rs} ${bld}FILE${rs}\n");
+	cfprintf(cout, "  ${blu}-fprintf${rs} ${bld}FILE${rs} ${bld}FORMAT${rs}\n");
+	cfprintf(cout, "      Like ${blu}-ls${rs}/${blu}-print${rs}/${blu}-print0${rs}/${blu}-printf${rs}, but write to ${bld}FILE${rs} instead of standard\n"
+	               "      output\n");
+	cfprintf(cout, "  ${blu}-ls${rs}\n");
+	cfprintf(cout, "      List files like ${ex}ls${rs} ${bld}-dils${rs}\n");
+	cfprintf(cout, "  ${blu}-print${rs}\n");
+	cfprintf(cout, "      Print the path to the found file\n");
+	cfprintf(cout, "  ${blu}-print0${rs}\n");
+	cfprintf(cout, "      Like ${blu}-print${rs}, but use the null character ('\\0') as a separator rather than\n");
+	cfprintf(cout, "      newlines\n");
+	cfprintf(cout, "  ${blu}-printf${rs} ${bld}FORMAT${rs}\n");
+	cfprintf(cout, "      Print according to a format string (see ${ex}man${rs} ${bld}find${rs}).  The additional format\n");
+	cfprintf(cout, "      directives %%w and %%W${bld}k${rs} for printing file birth times are supported.\n");
+	cfprintf(cout, "  ${blu}-printx${rs}\n");
+	cfprintf(cout, "      Like ${blu}-print${rs}, but escape whitespace and quotation characters, to make the\n");
+	cfprintf(cout, "      output safe for ${ex}xargs${rs}.  Consider using ${blu}-print0${rs} and ${ex}xargs${rs} ${bld}-0${rs} instead.\n");
+	cfprintf(cout, "  ${blu}-prune${rs}\n");
+	cfprintf(cout, "      Don't descend into this directory\n");
+	cfprintf(cout, "  ${blu}-quit${rs}\n");
+	cfprintf(cout, "      Quit immediately\n");
+	cfprintf(cout, "  ${blu}-version${rs}\n");
+	cfprintf(cout, "      Print version information\n");
+	cfprintf(cout, "  ${blu}-help${rs}\n");
+	cfprintf(cout, "      Print this help message\n\n");
+
+	cfprintf(cout, "%s\n", BFS_HOMEPAGE);
+
+	if (pager > 0) {
+		cfclose(cout);
+		waitpid(pager, NULL, 0);
+	}
+
+	state->just_info = true;
+	return NULL;
+}
+
+/**
+ * "Parse" -version.
+ */
+static struct bfs_expr *parse_version(struct parser_state *state, int arg1, int arg2) {
+	cfprintf(state->ctx->cout, "${ex}bfs${rs} ${bld}%s${rs}\n\n", BFS_VERSION);
+
+	printf("%s\n", BFS_HOMEPAGE);
+
+	state->just_info = true;
+	return NULL;
+}
+
+typedef struct bfs_expr *parse_fn(struct parser_state *state, int arg1, int arg2);
+
+/**
+ * An entry in the parse table for literals.
+ */
+struct table_entry {
+	char *arg;
+	enum token_type type;
+	parse_fn *parse;
+	int arg1;
+	int arg2;
+	bool prefix;
+};
+
+/**
+ * The parse table for literals.
+ */
+static const struct table_entry parse_table[] = {
+	{"--", T_FLAG},
+	{"--help", T_ACTION, parse_help},
+	{"--version", T_ACTION, parse_version},
+	{"-Bmin", T_TEST, parse_min, BFS_STAT_BTIME},
+	{"-Bnewer", T_TEST, parse_newer, BFS_STAT_BTIME},
+	{"-Bsince", T_TEST, parse_since, BFS_STAT_BTIME},
+	{"-Btime", T_TEST, parse_time, BFS_STAT_BTIME},
+	{"-D", T_FLAG, parse_debug},
+	{"-E", T_FLAG, parse_regex_extended},
+	{"-H", T_FLAG, parse_follow, BFTW_FOLLOW_ROOTS, false},
+	{"-L", T_FLAG, parse_follow, BFTW_FOLLOW_ALL, false},
+	{"-O", T_FLAG, parse_optlevel, 0, 0, true},
+	{"-P", T_FLAG, parse_follow, 0, false},
+	{"-S", T_FLAG, parse_search_strategy},
+	{"-X", T_FLAG, parse_xargs_safe},
+	{"-a", T_OPERATOR},
+	{"-acl", T_TEST, parse_acl},
+	{"-amin", T_TEST, parse_min, BFS_STAT_ATIME},
+	{"-and", T_OPERATOR},
+	{"-anewer", T_TEST, parse_newer, BFS_STAT_ATIME},
+	{"-asince", T_TEST, parse_since, BFS_STAT_ATIME},
+	{"-atime", T_TEST, parse_time, BFS_STAT_ATIME},
+	{"-capable", T_TEST, parse_capable},
+	{"-cmin", T_TEST, parse_min, BFS_STAT_CTIME},
+	{"-cnewer", T_TEST, parse_newer, BFS_STAT_CTIME},
+	{"-color", T_OPTION, parse_color, true},
+	{"-csince", T_TEST, parse_since, BFS_STAT_CTIME},
+	{"-ctime", T_TEST, parse_time, BFS_STAT_CTIME},
+	{"-d", T_FLAG, parse_depth},
+	{"-daystart", T_OPTION, parse_daystart},
+	{"-delete", T_ACTION, parse_delete},
+	{"-depth", T_OPTION, parse_depth_n},
+	{"-empty", T_TEST, parse_empty},
+	{"-exclude", T_OPERATOR},
+	{"-exec", T_ACTION, parse_exec, 0},
+	{"-execdir", T_ACTION, parse_exec, BFS_EXEC_CHDIR},
+	{"-executable", T_TEST, parse_access, X_OK},
+	{"-exit", T_ACTION, parse_exit},
+	{"-f", T_FLAG, parse_f},
+	{"-false", T_TEST, parse_const, false},
+	{"-files0-from", T_OPTION, parse_files0_from},
+	{"-flags", T_TEST, parse_flags},
+	{"-fls", T_ACTION, parse_fls},
+	{"-follow", T_OPTION, parse_follow, BFTW_FOLLOW_ALL, true},
+	{"-fprint", T_ACTION, parse_fprint},
+	{"-fprint0", T_ACTION, parse_fprint0},
+	{"-fprintf", T_ACTION, parse_fprintf},
+	{"-fstype", T_TEST, parse_fstype},
+	{"-gid", T_TEST, parse_group},
+	{"-group", T_TEST, parse_group},
+	{"-help", T_ACTION, parse_help},
+	{"-hidden", T_TEST, parse_hidden},
+	{"-ignore_readdir_race", T_OPTION, parse_ignore_races, true},
+	{"-ilname", T_TEST, parse_lname, true},
+	{"-iname", T_TEST, parse_name, true},
+	{"-inum", T_TEST, parse_inum},
+	{"-ipath", T_TEST, parse_path, true},
+	{"-iregex", T_TEST, parse_regex, BFS_REGEX_ICASE},
+	{"-iwholename", T_TEST, parse_path, true},
+	{"-links", T_TEST, parse_links},
+	{"-lname", T_TEST, parse_lname, false},
+	{"-ls", T_ACTION, parse_ls},
+	{"-maxdepth", T_OPTION, parse_depth_limit, false},
+	{"-mindepth", T_OPTION, parse_depth_limit, true},
+	{"-mmin", T_TEST, parse_min, BFS_STAT_MTIME},
+	{"-mnewer", T_TEST, parse_newer, BFS_STAT_MTIME},
+	{"-mount", T_OPTION, parse_mount},
+	{"-msince", T_TEST, parse_since, BFS_STAT_MTIME},
+	{"-mtime", T_TEST, parse_time, BFS_STAT_MTIME},
+	{"-name", T_TEST, parse_name, false},
+	{"-newer", T_TEST, parse_newer, BFS_STAT_MTIME},
+	{"-newer", T_TEST, parse_newerxy, 0, 0, true},
+	{"-nocolor", T_OPTION, parse_color, false},
+	{"-nogroup", T_TEST, parse_nogroup},
+	{"-nohidden", T_TEST, parse_nohidden},
+	{"-noignore_readdir_race", T_OPTION, parse_ignore_races, false},
+	{"-noleaf", T_OPTION, parse_noleaf},
+	{"-not", T_OPERATOR},
+	{"-nouser", T_TEST, parse_nouser},
+	{"-nowarn", T_OPTION, parse_warn, false},
+	{"-o", T_OPERATOR},
+	{"-ok", T_ACTION, parse_exec, BFS_EXEC_CONFIRM},
+	{"-okdir", T_ACTION, parse_exec, BFS_EXEC_CONFIRM | BFS_EXEC_CHDIR},
+	{"-or", T_OPERATOR},
+	{"-path", T_TEST, parse_path, false},
+	{"-perm", T_TEST, parse_perm},
+	{"-print", T_ACTION, parse_print},
+	{"-print0", T_ACTION, parse_print0},
+	{"-printf", T_ACTION, parse_printf},
+	{"-printx", T_ACTION, parse_printx},
+	{"-prune", T_ACTION, parse_prune},
+	{"-quit", T_ACTION, parse_quit},
+	{"-readable", T_TEST, parse_access, R_OK},
+	{"-regex", T_TEST, parse_regex, 0},
+	{"-regextype", T_OPTION, parse_regextype},
+	{"-rm", T_ACTION, parse_delete},
+	{"-s", T_FLAG, parse_s},
+	{"-samefile", T_TEST, parse_samefile},
+	{"-since", T_TEST, parse_since, BFS_STAT_MTIME},
+	{"-size", T_TEST, parse_size},
+	{"-sparse", T_TEST, parse_sparse},
+	{"-status", T_OPTION, parse_status},
+	{"-true", T_TEST, parse_const, true},
+	{"-type", T_TEST, parse_type, false},
+	{"-uid", T_TEST, parse_user},
+	{"-unique", T_OPTION, parse_unique},
+	{"-used", T_TEST, parse_used},
+	{"-user", T_TEST, parse_user},
+	{"-version", T_ACTION, parse_version},
+	{"-warn", T_OPTION, parse_warn, true},
+	{"-wholename", T_TEST, parse_path, false},
+	{"-writable", T_TEST, parse_access, W_OK},
+	{"-x", T_FLAG, parse_xdev},
+	{"-xattr", T_TEST, parse_xattr},
+	{"-xattrname", T_TEST, parse_xattrname},
+	{"-xdev", T_OPTION, parse_xdev},
+	{"-xtype", T_TEST, parse_type, true},
+	{0},
+};
+
+/** Look up an argument in the parse table. */
+static const struct table_entry *table_lookup(const char *arg) {
+	for (const struct table_entry *entry = parse_table; entry->arg; ++entry) {
+		bool match;
+		if (entry->prefix) {
+			match = strncmp(arg, entry->arg, strlen(entry->arg)) == 0;
+		} else {
+			match = strcmp(arg, entry->arg) == 0;
+		}
+		if (match) {
+			return entry;
+		}
+	}
+
+	return NULL;
+}
+
+/** Search for a fuzzy match in the parse table. */
+static const struct table_entry *table_lookup_fuzzy(const char *arg) {
+	const struct table_entry *best = NULL;
+	int best_dist;
+
+	for (const struct table_entry *entry = parse_table; entry->arg; ++entry) {
+		int dist = typo_distance(arg, entry->arg);
+		if (!best || dist < best_dist) {
+			best = entry;
+			best_dist = dist;
+		}
+	}
+
+	return best;
+}
+
+/**
+ * LITERAL : OPTION
+ *         | TEST
+ *         | ACTION
+ */
+static struct bfs_expr *parse_literal(struct parser_state *state) {
+	// Paths are already skipped at this point
+	const char *arg = state->argv[0];
+
+	if (arg[0] != '-') {
+		goto unexpected;
+	}
+
+	const struct table_entry *match = table_lookup(arg);
+	if (match) {
+		if (match->parse) {
+			goto matched;
+		} else {
+			goto unexpected;
+		}
+	}
+
+	match = table_lookup_fuzzy(arg);
+
+	CFILE *cerr = state->ctx->cerr;
+	parse_error(state, "Unknown argument; did you mean ");
+	switch (match->type) {
+	case T_FLAG:
+		cfprintf(cerr, "${cyn}%s${rs}?", match->arg);
+		break;
+	case T_OPERATOR:
+		cfprintf(cerr, "${red}%s${rs}?", match->arg);
+		break;
+	default:
+		cfprintf(cerr, "${blu}%s${rs}?", match->arg);
+		break;
+	}
+
+	if (!state->interactive || !match->parse) {
+		fprintf(stderr, "\n");
+		goto unmatched;
+	}
+
+	fprintf(stderr, " ");
+	if (ynprompt() <= 0) {
+		goto unmatched;
+	}
+
+	fprintf(stderr, "\n");
+	state->argv[0] = match->arg;
+
+matched:
+	return match->parse(state, match->arg1, match->arg2);
+
+unmatched:
+	return NULL;
+
+unexpected:
+	parse_error(state, "Expected a predicate.\n");
+	return NULL;
+}
+
+/**
+ * FACTOR : "(" EXPR ")"
+ *        | "!" FACTOR | "-not" FACTOR
+ *        | "-exclude" FACTOR
+ *        | LITERAL
+ */
+static struct bfs_expr *parse_factor(struct parser_state *state) {
+	if (skip_paths(state) != 0) {
+		return NULL;
+	}
+
+	const char *arg = state->argv[0];
+	if (!arg) {
+		parse_argv_error(state, state->last_arg, 1, "Expression terminated prematurely here.\n");
+		return NULL;
+	}
+
+	if (strcmp(arg, "(") == 0) {
+		parser_advance(state, T_OPERATOR, 1);
+
+		struct bfs_expr *expr = parse_expr(state);
+		if (!expr) {
+			return NULL;
+		}
+
+		if (skip_paths(state) != 0) {
+			bfs_expr_free(expr);
+			return NULL;
+		}
+
+		arg = state->argv[0];
+		if (!arg || strcmp(arg, ")") != 0) {
+			parse_argv_error(state, state->last_arg, 1, "Expected a ${red})${rs}.\n");
+			bfs_expr_free(expr);
+			return NULL;
+		}
+
+		parser_advance(state, T_OPERATOR, 1);
+		return expr;
+	} else if (strcmp(arg, "-exclude") == 0) {
+		if (state->excluding) {
+			parse_error(state, "${err}%s${rs} is not supported within ${red}-exclude${rs}.\n", arg);
+			return NULL;
+		}
+
+		parser_advance(state, T_OPERATOR, 1);
+		state->excluding = true;
+
+		struct bfs_expr *factor = parse_factor(state);
+		if (!factor) {
+			return NULL;
+		}
+
+		state->excluding = false;
+
+		if (parse_exclude(state, factor) != 0) {
+			return NULL;
+		}
+
+		return &bfs_true;
+	} else if (strcmp(arg, "!") == 0 || strcmp(arg, "-not") == 0) {
+		char **argv = parser_advance(state, T_OPERATOR, 1);
+
+		struct bfs_expr *factor = parse_factor(state);
+		if (!factor) {
+			return NULL;
+		}
+
+		return new_unary_expr(eval_not, factor, argv);
+	} else {
+		return parse_literal(state);
+	}
+}
+
+/**
+ * TERM : FACTOR
+ *      | TERM FACTOR
+ *      | TERM "-a" FACTOR
+ *      | TERM "-and" FACTOR
+ */
+static struct bfs_expr *parse_term(struct parser_state *state) {
+	struct bfs_expr *term = parse_factor(state);
+
+	while (term) {
+		if (skip_paths(state) != 0) {
+			bfs_expr_free(term);
+			return NULL;
+		}
+
+		const char *arg = state->argv[0];
+		if (!arg) {
+			break;
+		}
+
+		if (strcmp(arg, "-o") == 0 || strcmp(arg, "-or") == 0
+		    || strcmp(arg, ",") == 0
+		    || strcmp(arg, ")") == 0) {
+			break;
+		}
+
+		char **argv = &fake_and_arg;
+		if (strcmp(arg, "-a") == 0 || strcmp(arg, "-and") == 0) {
+			argv = parser_advance(state, T_OPERATOR, 1);
+		}
+
+		struct bfs_expr *lhs = term;
+		struct bfs_expr *rhs = parse_factor(state);
+		if (!rhs) {
+			bfs_expr_free(lhs);
+			return NULL;
+		}
+
+		term = new_binary_expr(eval_and, lhs, rhs, argv);
+	}
+
+	return term;
+}
+
+/**
+ * CLAUSE : TERM
+ *        | CLAUSE "-o" TERM
+ *        | CLAUSE "-or" TERM
+ */
+static struct bfs_expr *parse_clause(struct parser_state *state) {
+	struct bfs_expr *clause = parse_term(state);
+
+	while (clause) {
+		if (skip_paths(state) != 0) {
+			bfs_expr_free(clause);
+			return NULL;
+		}
+
+		const char *arg = state->argv[0];
+		if (!arg) {
+			break;
+		}
+
+		if (strcmp(arg, "-o") != 0 && strcmp(arg, "-or") != 0) {
+			break;
+		}
+
+		char **argv = parser_advance(state, T_OPERATOR, 1);
+
+		struct bfs_expr *lhs = clause;
+		struct bfs_expr *rhs = parse_term(state);
+		if (!rhs) {
+			bfs_expr_free(lhs);
+			return NULL;
+		}
+
+		clause = new_binary_expr(eval_or, lhs, rhs, argv);
+	}
+
+	return clause;
+}
+
+/**
+ * EXPR : CLAUSE
+ *      | EXPR "," CLAUSE
+ */
+static struct bfs_expr *parse_expr(struct parser_state *state) {
+	struct bfs_expr *expr = parse_clause(state);
+
+	while (expr) {
+		if (skip_paths(state) != 0) {
+			bfs_expr_free(expr);
+			return NULL;
+		}
+
+		const char *arg = state->argv[0];
+		if (!arg) {
+			break;
+		}
+
+		if (strcmp(arg, ",") != 0) {
+			break;
+		}
+
+		char **argv = parser_advance(state, T_OPERATOR, 1);
+
+		struct bfs_expr *lhs = expr;
+		struct bfs_expr *rhs = parse_clause(state);
+		if (!rhs) {
+			bfs_expr_free(lhs);
+			return NULL;
+		}
+
+		expr = new_binary_expr(eval_comma, lhs, rhs, argv);
+	}
+
+	return expr;
+}
+
+/**
+ * Parse the top-level expression.
+ */
+static struct bfs_expr *parse_whole_expr(struct parser_state *state) {
+	if (skip_paths(state) != 0) {
+		return NULL;
+	}
+
+	struct bfs_expr *expr = &bfs_true;
+	if (state->argv[0]) {
+		expr = parse_expr(state);
+		if (!expr) {
+			return NULL;
+		}
+	}
+
+	if (state->argv[0]) {
+		parse_error(state, "Unexpected argument.\n");
+		goto fail;
+	}
+
+	if (state->implicit_print) {
+		struct bfs_expr *print = bfs_expr_new(eval_fprint, 1, &fake_print_arg);
+		if (!print) {
+			goto fail;
+		}
+		init_print_expr(state, print);
+		print->synthetic = true;
+
+		expr = new_binary_expr(eval_and, expr, print, &fake_and_arg);
+		if (!expr) {
+			goto fail;
+		}
+	}
+
+	if (state->mount_arg && state->xdev_arg) {
+		parse_conflict_warning(state, state->mount_arg, 1, state->xdev_arg, 1,
+		                       "${blu}%s${rs} is redundant in the presence of ${blu}%s${rs}.\n\n",
+		                       state->xdev_arg[0], state->mount_arg[0]);
+	}
+
+	if (state->ctx->warn && state->depth_arg && state->prune_arg) {
+		parse_conflict_warning(state, state->depth_arg, 1, state->prune_arg, 1,
+		                       "${blu}%s${rs} does not work in the presence of ${blu}%s${rs}.\n",
+		                       state->prune_arg[0], state->depth_arg[0]);
+
+		if (state->interactive) {
+			bfs_warning(state->ctx, "Do you want to continue? ");
+			if (ynprompt() == 0) {
+				goto fail;
+			}
+		}
+
+		fprintf(stderr, "\n");
+	}
+
+	if (state->ok_expr && state->files0_stdin_arg) {
+		parse_conflict_error(state, state->ok_expr->argv, state->ok_expr->argc, state->files0_stdin_arg, 2,
+		                     "${blu}%s${rs} conflicts with ${blu}%s${rs} ${bld}%s${rs}.\n",
+		                     state->ok_expr->argv[0], state->files0_stdin_arg[0], state->files0_stdin_arg[1]);
+		goto fail;
+	}
+
+	return expr;
+
+fail:
+	bfs_expr_free(expr);
+	return NULL;
+}
+
+void bfs_ctx_dump(const struct bfs_ctx *ctx, enum debug_flags flag) {
+	if (!bfs_debug_prefix(ctx, flag)) {
+		return;
+	}
+
+	CFILE *cerr = ctx->cerr;
+
+	cfprintf(cerr, "${ex}%s${rs} ", ctx->argv[0]);
+
+	if (ctx->flags & BFTW_FOLLOW_ALL) {
+		cfprintf(cerr, "${cyn}-L${rs} ");
+	} else if (ctx->flags & BFTW_FOLLOW_ROOTS) {
+		cfprintf(cerr, "${cyn}-H${rs} ");
+	} else {
+		cfprintf(cerr, "${cyn}-P${rs} ");
+	}
+
+	if (ctx->xargs_safe) {
+		cfprintf(cerr, "${cyn}-X${rs} ");
+	}
+
+	if (ctx->flags & BFTW_SORT) {
+		cfprintf(cerr, "${cyn}-s${rs} ");
+	}
+
+	if (ctx->optlevel != 3) {
+		cfprintf(cerr, "${cyn}-O${bld}%d${rs} ", ctx->optlevel);
+	}
+
+	const char *strategy = NULL;
+	switch (ctx->strategy) {
+	case BFTW_BFS:
+		strategy = "bfs";
+		break;
+	case BFTW_DFS:
+		strategy = "dfs";
+		break;
+	case BFTW_IDS:
+		strategy = "ids";
+		break;
+	case BFTW_EDS:
+		strategy = "eds";
+		break;
+	}
+	assert(strategy);
+	cfprintf(cerr, "${cyn}-S${rs} ${bld}%s${rs} ", strategy);
+
+	enum debug_flags debug = ctx->debug;
+	if (debug == DEBUG_ALL) {
+		cfprintf(cerr, "${cyn}-D${rs} ${bld}all${rs} ");
+	} else if (debug) {
+		cfprintf(cerr, "${cyn}-D${rs} ");
+		for (enum debug_flags i = 1; DEBUG_ALL & i; i <<= 1) {
+			if (debug & i) {
+				cfprintf(cerr, "${bld}%s${rs}", debug_flag_name(i));
+				debug ^= i;
+				if (debug) {
+					cfprintf(cerr, ",");
+				}
+			}
+		}
+		cfprintf(cerr, " ");
+	}
+
+	for (size_t i = 0; i < darray_length(ctx->paths); ++i) {
+		const char *path = ctx->paths[i];
+		char c = path[0];
+		if (c == '-' || c == '(' || c == ')' || c == '!' || c == ',') {
+			cfprintf(cerr, "${cyn}-f${rs} ");
+		}
+		cfprintf(cerr, "${mag}%s${rs} ", path);
+	}
+
+	if (ctx->cout->colors) {
+		cfprintf(cerr, "${blu}-color${rs} ");
+	} else {
+		cfprintf(cerr, "${blu}-nocolor${rs} ");
+	}
+	if (ctx->flags & BFTW_POST_ORDER) {
+		cfprintf(cerr, "${blu}-depth${rs} ");
+	}
+	if (ctx->ignore_races) {
+		cfprintf(cerr, "${blu}-ignore_readdir_race${rs} ");
+	}
+	if (ctx->mindepth != 0) {
+		cfprintf(cerr, "${blu}-mindepth${rs} ${bld}%d${rs} ", ctx->mindepth);
+	}
+	if (ctx->maxdepth != INT_MAX) {
+		cfprintf(cerr, "${blu}-maxdepth${rs} ${bld}%d${rs} ", ctx->maxdepth);
+	}
+	if (ctx->flags & BFTW_SKIP_MOUNTS) {
+		cfprintf(cerr, "${blu}-mount${rs} ");
+	}
+	if (ctx->status) {
+		cfprintf(cerr, "${blu}-status${rs} ");
+	}
+	if (ctx->unique) {
+		cfprintf(cerr, "${blu}-unique${rs} ");
+	}
+	if ((ctx->flags & (BFTW_SKIP_MOUNTS | BFTW_PRUNE_MOUNTS)) == BFTW_PRUNE_MOUNTS) {
+		cfprintf(cerr, "${blu}-xdev${rs} ");
+	}
+
+	if (flag == DEBUG_RATES) {
+		if (ctx->exclude != &bfs_false) {
+			cfprintf(cerr, "(${red}-exclude${rs} %pE) ", ctx->exclude);
+		}
+		cfprintf(cerr, "%pE", ctx->expr);
+	} else {
+		if (ctx->exclude != &bfs_false) {
+			cfprintf(cerr, "(${red}-exclude${rs} %pe) ", ctx->exclude);
+		}
+		cfprintf(cerr, "%pe", ctx->expr);
+	}
+
+	fputs("\n", stderr);
+}
+
+/**
+ * Dump the estimated costs.
+ */
+static void dump_costs(const struct bfs_ctx *ctx) {
+	const struct bfs_expr *expr = ctx->expr;
+	bfs_debug(ctx, DEBUG_COST, "       Cost: ~${ylw}%g${rs}\n", expr->cost);
+	bfs_debug(ctx, DEBUG_COST, "Probability: ~${ylw}%g%%${rs}\n", 100.0*expr->probability);
+}
+
+/**
+ * Get the current time.
+ */
+static int parse_gettime(const struct bfs_ctx *ctx, struct timespec *ts) {
+#if _POSIX_TIMERS > 0
+	int ret = clock_gettime(CLOCK_REALTIME, ts);
+	if (ret != 0) {
+		bfs_perror(ctx, "clock_gettime()");
+	}
+	return ret;
+#else
+	struct timeval tv;
+	int ret = gettimeofday(&tv, NULL);
+	if (ret == 0) {
+		ts->tv_sec = tv.tv_sec;
+		ts->tv_nsec = tv.tv_usec * 1000L;
+	} else {
+		bfs_perror(ctx, "gettimeofday()");
+	}
+	return ret;
+#endif
+}
+
+struct bfs_ctx *bfs_parse_cmdline(int argc, char *argv[]) {
+	struct bfs_ctx *ctx = bfs_ctx_new();
+	if (!ctx) {
+		perror("bfs_new_ctx()");
+		goto fail;
+	}
+
+	static char* default_argv[] = {"bfs", NULL};
+	if (argc < 1) {
+		argc = 1;
+		argv = default_argv;
+	}
+
+	ctx->argc = argc;
+	ctx->argv = malloc((argc + 1)*sizeof(*ctx->argv));
+	if (!ctx->argv) {
+		perror("malloc()");
+		goto fail;
+	}
+	for (int i = 0; i <= argc; ++i) {
+		ctx->argv[i] = argv[i];
+	}
+
+	enum use_color use_color = COLOR_AUTO;
+	if (getenv("NO_COLOR")) {
+		// https://no-color.org/
+		use_color = COLOR_NEVER;
+	}
+
+	ctx->colors = parse_colors();
+	if (!ctx->colors) {
+		ctx->colors_error = errno;
+	}
+
+	ctx->cerr = cfwrap(stderr, use_color ? ctx->colors : NULL, false);
+	if (!ctx->cerr) {
+		perror("cfwrap()");
+		goto fail;
+	}
+
+	ctx->cout = cfwrap(stdout, use_color ? ctx->colors : NULL, false);
+	if (!ctx->cout) {
+		bfs_perror(ctx, "cfwrap()");
+		goto fail;
+	}
+
+	if (!bfs_ctx_dedup(ctx, ctx->cout, NULL) || !bfs_ctx_dedup(ctx, ctx->cerr, NULL)) {
+		bfs_perror(ctx, "bfs_ctx_dedup()");
+		goto fail;
+	}
+
+	bool stdin_tty = isatty(STDIN_FILENO);
+	bool stdout_tty = isatty(STDOUT_FILENO);
+	bool stderr_tty = isatty(STDERR_FILENO);
+
+	if (getenv("POSIXLY_CORRECT")) {
+		ctx->posixly_correct = true;
+	} else {
+		ctx->warn = stdin_tty;
+	}
+
+	struct parser_state state = {
+		.ctx = ctx,
+		.argv = ctx->argv + 1,
+		.command = ctx->argv[0],
+		.regex_type = BFS_REGEX_POSIX_BASIC,
+		.stdout_tty = stdout_tty,
+		.interactive = stdin_tty && stderr_tty,
+		.use_color = use_color,
+		.implicit_print = true,
+		.implicit_root = true,
+		.just_info = false,
+		.excluding = false,
+		.last_arg = NULL,
+		.depth_arg = NULL,
+		.prune_arg = NULL,
+		.mount_arg = NULL,
+		.xdev_arg = NULL,
+		.files0_arg = NULL,
+		.files0_stdin_arg = NULL,
+		.ok_expr = NULL,
+	};
+
+	if (strcmp(xbasename(state.command), "find") == 0) {
+		// Operate depth-first when invoked as "find"
+		ctx->strategy = BFTW_DFS;
+	}
+
+	if (parse_gettime(ctx, &state.now) != 0) {
+		goto fail;
+	}
+
+	ctx->exclude = &bfs_false;
+	ctx->expr = parse_whole_expr(&state);
+	if (!ctx->expr) {
+		if (state.just_info) {
+			goto done;
+		} else {
+			goto fail;
+		}
+	}
+
+	if (bfs_optimize(ctx) != 0) {
+		goto fail;
+	}
+
+	if (darray_length(ctx->paths) == 0) {
+		if (!state.implicit_root) {
+			parse_argv_error(&state, state.files0_arg, 2, "No root paths specified.\n");
+			goto fail;
+		} else if (parse_root(&state, ".") != 0) {
+			goto fail;
+		}
+	}
+
+	if ((ctx->flags & BFTW_FOLLOW_ALL) && !ctx->unique) {
+		// We need bftw() to detect cycles unless -unique does it for us
+		ctx->flags |= BFTW_DETECT_CYCLES;
+	}
+
+	bfs_ctx_dump(ctx, DEBUG_TREE);
+	dump_costs(ctx);
+
+done:
+	return ctx;
+
+fail:
+	bfs_ctx_free(ctx);
+	return NULL;
+}
diff --git a/src/parse.h b/src/parse.h
new file mode 100644
index 0000000..7e29a03
--- /dev/null
+++ b/src/parse.h
@@ -0,0 +1,36 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2020 Tavian Barnes <tavianator@tavianator.com>             *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * bfs command line parsing.
+ */
+
+#ifndef BFS_PARSE_H
+#define BFS_PARSE_H
+
+/**
+ * Parse the command line.
+ *
+ * @param argc
+ *         The number of arguments.
+ * @param argv
+ *         The arguments to parse.
+ * @return
+ *         A new bfs context, or NULL on failure.
+ */
+struct bfs_ctx *bfs_parse_cmdline(int argc, char *argv[]);
+
+#endif // BFS_PARSE_H
diff --git a/src/printf.c b/src/printf.c
new file mode 100644
index 0000000..8fdde41
--- /dev/null
+++ b/src/printf.c
@@ -0,0 +1,927 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2017-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+#include "printf.h"
+#include "bftw.h"
+#include "color.h"
+#include "ctx.h"
+#include "darray.h"
+#include "diag.h"
+#include "dir.h"
+#include "dstring.h"
+#include "expr.h"
+#include "mtab.h"
+#include "pwcache.h"
+#include "stat.h"
+#include "util.h"
+#include "xtime.h"
+#include <assert.h>
+#include <errno.h>
+#include <grp.h>
+#include <pwd.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+/**
+ * A function implementing a printf directive.
+ */
+typedef int bfs_printf_fn(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf);
+
+/**
+ * A single printf directive like %f or %#4m.  The whole format string is stored
+ * as a darray of these.
+ */
+struct bfs_printf {
+	/** The printing function to invoke. */
+	bfs_printf_fn *fn;
+	/** String data associated with this directive. */
+	char *str;
+	/** The stat field to print. */
+	enum bfs_stat_field stat_field;
+	/** Character data associated with this directive. */
+	char c;
+	/** Some data used by the directive. */
+	const void *ptr;
+};
+
+/** Print some text as-is. */
+static int bfs_printf_literal(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	size_t len = dstrlen(directive->str);
+	if (fwrite(directive->str, 1, len, cfile->file) == len) {
+		return 0;
+	} else {
+		return -1;
+	}
+}
+
+/** \c: flush */
+static int bfs_printf_flush(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	return fflush(cfile->file);
+}
+
+/** Check if we can safely colorize this directive. */
+static bool should_color(CFILE *cfile, const struct bfs_printf *directive) {
+	return cfile->colors && strcmp(directive->str, "%s") == 0;
+}
+
+/**
+ * Print a value to a temporary buffer before formatting it.
+ */
+#define BFS_PRINTF_BUF(buf, format, ...)				\
+	char buf[256];							\
+	int ret = snprintf(buf, sizeof(buf), format, __VA_ARGS__);	\
+	assert(ret >= 0 && (size_t)ret < sizeof(buf));			\
+	(void)ret
+
+/** %a, %c, %t: ctime() */
+static int bfs_printf_ctime(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	// Not using ctime() itself because GNU find adds nanoseconds
+	static const char *days[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};
+	static const char *months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
+
+	const struct bfs_stat *statbuf = bftw_stat(ftwbuf, ftwbuf->stat_flags);
+	if (!statbuf) {
+		return -1;
+	}
+
+	const struct timespec *ts = bfs_stat_time(statbuf, directive->stat_field);
+	if (!ts) {
+		return -1;
+	}
+
+	struct tm tm;
+	if (xlocaltime(&ts->tv_sec, &tm) != 0) {
+		return -1;
+	}
+
+	BFS_PRINTF_BUF(buf, "%s %s %2d %.2d:%.2d:%.2d.%09ld0 %4d",
+	               days[tm.tm_wday],
+	               months[tm.tm_mon],
+	               tm.tm_mday,
+	               tm.tm_hour,
+	               tm.tm_min,
+	               tm.tm_sec,
+	               (long)ts->tv_nsec,
+	               1900 + tm.tm_year);
+
+	return fprintf(cfile->file, directive->str, buf);
+}
+
+/** %A, %B/%W, %C, %T: strftime() */
+static int bfs_printf_strftime(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	const struct bfs_stat *statbuf = bftw_stat(ftwbuf, ftwbuf->stat_flags);
+	if (!statbuf) {
+		return -1;
+	}
+
+	const struct timespec *ts = bfs_stat_time(statbuf, directive->stat_field);
+	if (!ts) {
+		return -1;
+	}
+
+	struct tm tm;
+	if (xlocaltime(&ts->tv_sec, &tm) != 0) {
+		return -1;
+	}
+
+	int ret;
+	char buf[256];
+	char format[] = "% ";
+	switch (directive->c) {
+	// Non-POSIX strftime() features
+	case '@':
+		ret = snprintf(buf, sizeof(buf), "%lld.%09ld0", (long long)ts->tv_sec, (long)ts->tv_nsec);
+		break;
+	case '+':
+		ret = snprintf(buf, sizeof(buf), "%4d-%.2d-%.2d+%.2d:%.2d:%.2d.%09ld0",
+		               1900 + tm.tm_year,
+		               tm.tm_mon + 1,
+		               tm.tm_mday,
+		               tm.tm_hour,
+		               tm.tm_min,
+		               tm.tm_sec,
+		               (long)ts->tv_nsec);
+		break;
+	case 'k':
+		ret = snprintf(buf, sizeof(buf), "%2d", tm.tm_hour);
+		break;
+	case 'l':
+		ret = snprintf(buf, sizeof(buf), "%2d", (tm.tm_hour + 11)%12 + 1);
+		break;
+	case 's':
+		ret = snprintf(buf, sizeof(buf), "%lld", (long long)ts->tv_sec);
+		break;
+	case 'S':
+		ret = snprintf(buf, sizeof(buf), "%.2d.%09ld0", tm.tm_sec, (long)ts->tv_nsec);
+		break;
+	case 'T':
+		ret = snprintf(buf, sizeof(buf), "%.2d:%.2d:%.2d.%09ld0",
+			       tm.tm_hour,
+			       tm.tm_min,
+			       tm.tm_sec,
+			       (long)ts->tv_nsec);
+		break;
+
+	// POSIX strftime() features
+	default:
+		format[1] = directive->c;
+		ret = strftime(buf, sizeof(buf), format, &tm);
+		break;
+	}
+
+	assert(ret >= 0 && (size_t)ret < sizeof(buf));
+	(void)ret;
+
+	return fprintf(cfile->file, directive->str, buf);
+}
+
+/** %b: blocks */
+static int bfs_printf_b(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	const struct bfs_stat *statbuf = bftw_stat(ftwbuf, ftwbuf->stat_flags);
+	if (!statbuf) {
+		return -1;
+	}
+
+	uintmax_t blocks = ((uintmax_t)statbuf->blocks*BFS_STAT_BLKSIZE + 511)/512;
+	BFS_PRINTF_BUF(buf, "%ju", blocks);
+	return fprintf(cfile->file, directive->str, buf);
+}
+
+/** %d: depth */
+static int bfs_printf_d(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	return fprintf(cfile->file, directive->str, (intmax_t)ftwbuf->depth);
+}
+
+/** %D: device */
+static int bfs_printf_D(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	const struct bfs_stat *statbuf = bftw_stat(ftwbuf, ftwbuf->stat_flags);
+	if (!statbuf) {
+		return -1;
+	}
+
+	BFS_PRINTF_BUF(buf, "%ju", (uintmax_t)statbuf->dev);
+	return fprintf(cfile->file, directive->str, buf);
+}
+
+/** %f: file name */
+static int bfs_printf_f(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	if (should_color(cfile, directive)) {
+		return cfprintf(cfile, "%pF", ftwbuf);
+	} else {
+		return fprintf(cfile->file, directive->str, ftwbuf->path + ftwbuf->nameoff);
+	}
+}
+
+/** %F: file system type */
+static int bfs_printf_F(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	const struct bfs_stat *statbuf = bftw_stat(ftwbuf, ftwbuf->stat_flags);
+	if (!statbuf) {
+		return -1;
+	}
+
+	const char *type = bfs_fstype(directive->ptr, statbuf);
+	return fprintf(cfile->file, directive->str, type);
+}
+
+/** %G: gid */
+static int bfs_printf_G(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	const struct bfs_stat *statbuf = bftw_stat(ftwbuf, ftwbuf->stat_flags);
+	if (!statbuf) {
+		return -1;
+	}
+
+	BFS_PRINTF_BUF(buf, "%ju", (uintmax_t)statbuf->gid);
+	return fprintf(cfile->file, directive->str, buf);
+}
+
+/** %g: group name */
+static int bfs_printf_g(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	const struct bfs_stat *statbuf = bftw_stat(ftwbuf, ftwbuf->stat_flags);
+	if (!statbuf) {
+		return -1;
+	}
+
+	const struct bfs_groups *groups = directive->ptr;
+	const struct group *grp = groups ? bfs_getgrgid(groups, statbuf->gid) : NULL;
+	if (!grp) {
+		return bfs_printf_G(cfile, directive, ftwbuf);
+	}
+
+	return fprintf(cfile->file, directive->str, grp->gr_name);
+}
+
+/** %h: leading directories */
+static int bfs_printf_h(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	char *copy = NULL;
+	const char *buf;
+
+	if (ftwbuf->nameoff > 0) {
+		size_t len = ftwbuf->nameoff;
+		if (len > 1) {
+			--len;
+		}
+
+		buf = copy = strndup(ftwbuf->path, len);
+	} else if (ftwbuf->path[0] == '/') {
+		buf = "/";
+	} else {
+		buf = ".";
+	}
+
+	if (!buf) {
+		return -1;
+	}
+
+	int ret;
+	if (should_color(cfile, directive)) {
+		ret = cfprintf(cfile, "${di}%s${rs}", buf);
+	} else {
+		ret = fprintf(cfile->file, directive->str, buf);
+	}
+
+	free(copy);
+	return ret;
+}
+
+/** %H: current root */
+static int bfs_printf_H(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	if (should_color(cfile, directive)) {
+		if (ftwbuf->depth == 0) {
+			return cfprintf(cfile, "%pP", ftwbuf);
+		} else {
+			return cfprintf(cfile, "${di}%s${rs}", ftwbuf->root);
+		}
+	} else {
+		return fprintf(cfile->file, directive->str, ftwbuf->root);
+	}
+}
+
+/** %i: inode */
+static int bfs_printf_i(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	const struct bfs_stat *statbuf = bftw_stat(ftwbuf, ftwbuf->stat_flags);
+	if (!statbuf) {
+		return -1;
+	}
+
+	BFS_PRINTF_BUF(buf, "%ju", (uintmax_t)statbuf->ino);
+	return fprintf(cfile->file, directive->str, buf);
+}
+
+/** %k: 1K blocks */
+static int bfs_printf_k(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	const struct bfs_stat *statbuf = bftw_stat(ftwbuf, ftwbuf->stat_flags);
+	if (!statbuf) {
+		return -1;
+	}
+
+	uintmax_t blocks = ((uintmax_t)statbuf->blocks*BFS_STAT_BLKSIZE + 1023)/1024;
+	BFS_PRINTF_BUF(buf, "%ju", blocks);
+	return fprintf(cfile->file, directive->str, buf);
+}
+
+/** %l: link target */
+static int bfs_printf_l(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	char *buf = NULL;
+	const char *target = "";
+
+	if (ftwbuf->type == BFS_LNK) {
+		if (should_color(cfile, directive)) {
+			return cfprintf(cfile, "%pL", ftwbuf);
+		}
+
+		const struct bfs_stat *statbuf = bftw_cached_stat(ftwbuf, BFS_STAT_NOFOLLOW);
+		size_t len = statbuf ? statbuf->size : 0;
+
+		target = buf = xreadlinkat(ftwbuf->at_fd, ftwbuf->at_path, len);
+		if (!target) {
+			return -1;
+		}
+	}
+
+	int ret = fprintf(cfile->file, directive->str, target);
+	free(buf);
+	return ret;
+}
+
+/** %m: mode */
+static int bfs_printf_m(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	const struct bfs_stat *statbuf = bftw_stat(ftwbuf, ftwbuf->stat_flags);
+	if (!statbuf) {
+		return -1;
+	}
+
+	return fprintf(cfile->file, directive->str, (unsigned int)(statbuf->mode & 07777));
+}
+
+/** %M: symbolic mode */
+static int bfs_printf_M(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	const struct bfs_stat *statbuf = bftw_stat(ftwbuf, ftwbuf->stat_flags);
+	if (!statbuf) {
+		return -1;
+	}
+
+	char buf[11];
+	xstrmode(statbuf->mode, buf);
+	return fprintf(cfile->file, directive->str, buf);
+}
+
+/** %n: link count */
+static int bfs_printf_n(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	const struct bfs_stat *statbuf = bftw_stat(ftwbuf, ftwbuf->stat_flags);
+	if (!statbuf) {
+		return -1;
+	}
+
+	BFS_PRINTF_BUF(buf, "%ju", (uintmax_t)statbuf->nlink);
+	return fprintf(cfile->file, directive->str, buf);
+}
+
+/** %p: full path */
+static int bfs_printf_p(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	if (should_color(cfile, directive)) {
+		return cfprintf(cfile, "%pP", ftwbuf);
+	} else {
+		return fprintf(cfile->file, directive->str, ftwbuf->path);
+	}
+}
+
+/** %P: path after root */
+static int bfs_printf_P(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	size_t offset = strlen(ftwbuf->root);
+	if (ftwbuf->path[offset] == '/') {
+		++offset;
+	}
+
+	if (should_color(cfile, directive)) {
+		if (ftwbuf->depth == 0) {
+			return 0;
+		}
+
+		struct BFTW copybuf = *ftwbuf;
+		copybuf.path += offset;
+		copybuf.nameoff -= offset;
+		return cfprintf(cfile, "%pP", &copybuf);
+	} else {
+		return fprintf(cfile->file, directive->str, ftwbuf->path + offset);
+	}
+}
+
+/** %s: size */
+static int bfs_printf_s(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	const struct bfs_stat *statbuf = bftw_stat(ftwbuf, ftwbuf->stat_flags);
+	if (!statbuf) {
+		return -1;
+	}
+
+	BFS_PRINTF_BUF(buf, "%ju", (uintmax_t)statbuf->size);
+	return fprintf(cfile->file, directive->str, buf);
+}
+
+/** %S: sparseness */
+static int bfs_printf_S(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	const struct bfs_stat *statbuf = bftw_stat(ftwbuf, ftwbuf->stat_flags);
+	if (!statbuf) {
+		return -1;
+	}
+
+	double sparsity;
+	if (statbuf->size == 0 && statbuf->blocks == 0) {
+		sparsity = 1.0;
+	} else {
+		sparsity = (double)BFS_STAT_BLKSIZE*statbuf->blocks/statbuf->size;
+	}
+	return fprintf(cfile->file, directive->str, sparsity);
+}
+
+/** %U: uid */
+static int bfs_printf_U(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	const struct bfs_stat *statbuf = bftw_stat(ftwbuf, ftwbuf->stat_flags);
+	if (!statbuf) {
+		return -1;
+	}
+
+	BFS_PRINTF_BUF(buf, "%ju", (uintmax_t)statbuf->uid);
+	return fprintf(cfile->file, directive->str, buf);
+}
+
+/** %u: user name */
+static int bfs_printf_u(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	const struct bfs_stat *statbuf = bftw_stat(ftwbuf, ftwbuf->stat_flags);
+	if (!statbuf) {
+		return -1;
+	}
+
+	const struct bfs_users *users = directive->ptr;
+	const struct passwd *pwd = users ? bfs_getpwuid(users, statbuf->uid) : NULL;
+	if (!pwd) {
+		return bfs_printf_U(cfile, directive, ftwbuf);
+	}
+
+	return fprintf(cfile->file, directive->str, pwd->pw_name);
+}
+
+static const char *bfs_printf_type(enum bfs_type type) {
+	switch (type) {
+	case BFS_BLK:
+		return "b";
+	case BFS_CHR:
+		return "c";
+	case BFS_DIR:
+		return "d";
+	case BFS_DOOR:
+		return "D";
+	case BFS_FIFO:
+		return "p";
+	case BFS_LNK:
+		return "l";
+	case BFS_REG:
+		return "f";
+	case BFS_SOCK:
+		return "s";
+	default:
+		return "U";
+	}
+}
+
+/** %y: type */
+static int bfs_printf_y(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	const char *type = bfs_printf_type(ftwbuf->type);
+	return fprintf(cfile->file, directive->str, type);
+}
+
+/** %Y: target type */
+static int bfs_printf_Y(CFILE *cfile, const struct bfs_printf *directive, const struct BFTW *ftwbuf) {
+	int error = 0;
+
+	if (ftwbuf->type != BFS_LNK) {
+		return bfs_printf_y(cfile, directive, ftwbuf);
+	}
+
+	const char *type = "U";
+
+	const struct bfs_stat *statbuf = bftw_stat(ftwbuf, BFS_STAT_FOLLOW);
+	if (statbuf) {
+		type = bfs_printf_type(bfs_mode_to_type(statbuf->mode));
+	} else {
+		switch (errno) {
+		case ELOOP:
+			type = "L";
+			break;
+		case ENOENT:
+		case ENOTDIR:
+			type = "N";
+			break;
+		default:
+			type = "?";
+			error = errno;
+			break;
+		}
+	}
+
+	int ret = fprintf(cfile->file, directive->str, type);
+	if (error != 0) {
+		ret = -1;
+		errno = error;
+	}
+	return ret;
+}
+
+/**
+ * Append a literal string to the chain.
+ */
+static int append_literal(const struct bfs_ctx *ctx, struct bfs_printf **format, char **literal) {
+	if (dstrlen(*literal) == 0) {
+		return 0;
+	}
+
+	struct bfs_printf directive = {
+		.fn = bfs_printf_literal,
+		.str = *literal,
+	};
+
+	if (DARRAY_PUSH(format, &directive) != 0) {
+		bfs_perror(ctx, "DARRAY_PUSH()");
+		return -1;
+	}
+
+	*literal = dstralloc(0);
+	if (!*literal) {
+		bfs_perror(ctx, "dstralloc()");
+		return -1;
+	}
+
+	return 0;
+}
+
+/**
+ * Append a printf directive to the chain.
+ */
+static int append_directive(const struct bfs_ctx *ctx, struct bfs_printf **format, char **literal, struct bfs_printf *directive) {
+	if (append_literal(ctx, format, literal) != 0) {
+		return -1;
+	}
+
+	if (DARRAY_PUSH(format, directive) != 0) {
+		bfs_perror(ctx, "DARRAY_PUSH()");
+		return -1;
+	}
+
+	return 0;
+}
+
+int bfs_printf_parse(const struct bfs_ctx *ctx, struct bfs_expr *expr, const char *format) {
+	expr->printf = NULL;
+
+	char *literal = dstralloc(0);
+	if (!literal) {
+		bfs_perror(ctx, "dstralloc()");
+		goto error;
+	}
+
+	for (const char *i = format; *i; ++i) {
+		char c = *i;
+
+		if (c == '\\') {
+			c = *++i;
+
+			if (c >= '0' && c < '8') {
+				c = 0;
+				for (int j = 0; j < 3 && *i >= '0' && *i < '8'; ++i, ++j) {
+					c *= 8;
+					c += *i - '0';
+				}
+				--i;
+				goto one_char;
+			}
+
+			switch (c) {
+			case 'a':  c = '\a'; break;
+			case 'b':  c = '\b'; break;
+			case 'f':  c = '\f'; break;
+			case 'n':  c = '\n'; break;
+			case 'r':  c = '\r'; break;
+			case 't':  c = '\t'; break;
+			case 'v':  c = '\v'; break;
+			case '\\': c = '\\'; break;
+
+			case 'c':
+				{
+					struct bfs_printf directive = {
+						.fn = bfs_printf_flush,
+					};
+					if (append_directive(ctx, &expr->printf, &literal, &directive) != 0) {
+						goto error;
+					}
+					goto done;
+				}
+
+			case '\0':
+				bfs_expr_error(ctx, expr);
+				bfs_error(ctx, "Incomplete escape sequence '\\'.\n");
+				goto error;
+
+			default:
+				bfs_expr_error(ctx, expr);
+				bfs_error(ctx, "Unrecognized escape sequence '\\%c'.\n", c);
+				goto error;
+			}
+		} else if (c == '%') {
+			if (i[1] == '%') {
+				c = *++i;
+				goto one_char;
+			}
+
+			struct bfs_printf directive = {
+				.str = dstralloc(2),
+			};
+			if (!directive.str) {
+				goto directive_error;
+			}
+			if (dstrapp(&directive.str, c) != 0) {
+				bfs_perror(ctx, "dstrapp()");
+				goto directive_error;
+			}
+
+			const char *specifier = "s";
+
+			// Parse any flags
+			bool must_be_numeric = false;
+			while (true) {
+				c = *++i;
+
+				switch (c) {
+				case '#':
+				case '0':
+				case '+':
+					must_be_numeric = true;
+					BFS_FALLTHROUGH;
+				case ' ':
+				case '-':
+					if (strchr(directive.str, c)) {
+						bfs_expr_error(ctx, expr);
+						bfs_error(ctx, "Duplicate flag '%c'.\n", c);
+						goto directive_error;
+					}
+					if (dstrapp(&directive.str, c) != 0) {
+						bfs_perror(ctx, "dstrapp()");
+						goto directive_error;
+					}
+					continue;
+				}
+
+				break;
+			}
+
+			// Parse the field width
+			while (c >= '0' && c <= '9') {
+				if (dstrapp(&directive.str, c) != 0) {
+					bfs_perror(ctx, "dstrapp()");
+					goto directive_error;
+				}
+				c = *++i;
+			}
+
+			// Parse the precision
+			if (c == '.') {
+				do {
+					if (dstrapp(&directive.str, c) != 0) {
+						bfs_perror(ctx, "dstrapp()");
+						goto directive_error;
+					}
+					c = *++i;
+				} while (c >= '0' && c <= '9');
+			}
+
+			switch (c) {
+			case 'a':
+				directive.fn = bfs_printf_ctime;
+				directive.stat_field = BFS_STAT_ATIME;
+				break;
+			case 'b':
+				directive.fn = bfs_printf_b;
+				break;
+			case 'c':
+				directive.fn = bfs_printf_ctime;
+				directive.stat_field = BFS_STAT_CTIME;
+				break;
+			case 'd':
+				directive.fn = bfs_printf_d;
+				specifier = "jd";
+				break;
+			case 'D':
+				directive.fn = bfs_printf_D;
+				break;
+			case 'f':
+				directive.fn = bfs_printf_f;
+				break;
+			case 'F':
+				directive.ptr = bfs_ctx_mtab(ctx);
+				if (!directive.ptr) {
+					int error = errno;
+					bfs_expr_error(ctx, expr);
+					bfs_error(ctx, "Couldn't parse the mount table: %s.\n", strerror(error));
+					goto directive_error;
+				}
+				directive.fn = bfs_printf_F;
+				break;
+			case 'g':
+				directive.ptr = bfs_ctx_groups(ctx);
+				if (!directive.ptr) {
+					int error = errno;
+					bfs_expr_error(ctx, expr);
+					bfs_error(ctx, "Couldn't parse the group table: %s.\n", strerror(error));
+					goto directive_error;
+				}
+				directive.fn = bfs_printf_g;
+				break;
+			case 'G':
+				directive.fn = bfs_printf_G;
+				break;
+			case 'h':
+				directive.fn = bfs_printf_h;
+				break;
+			case 'H':
+				directive.fn = bfs_printf_H;
+				break;
+			case 'i':
+				directive.fn = bfs_printf_i;
+				break;
+			case 'k':
+				directive.fn = bfs_printf_k;
+				break;
+			case 'l':
+				directive.fn = bfs_printf_l;
+				break;
+			case 'm':
+				directive.fn = bfs_printf_m;
+				specifier = "o";
+				break;
+			case 'M':
+				directive.fn = bfs_printf_M;
+				break;
+			case 'n':
+				directive.fn = bfs_printf_n;
+				break;
+			case 'p':
+				directive.fn = bfs_printf_p;
+				break;
+			case 'P':
+				directive.fn = bfs_printf_P;
+				break;
+			case 's':
+				directive.fn = bfs_printf_s;
+				break;
+			case 'S':
+				directive.fn = bfs_printf_S;
+				specifier = "g";
+				break;
+			case 't':
+				directive.fn = bfs_printf_ctime;
+				directive.stat_field = BFS_STAT_MTIME;
+				break;
+			case 'u':
+				directive.ptr = bfs_ctx_users(ctx);
+				if (!directive.ptr) {
+					int error = errno;
+					bfs_expr_error(ctx, expr);
+					bfs_error(ctx, "Couldn't parse the user table: %s.\n", strerror(error));
+					goto directive_error;
+				}
+				directive.fn = bfs_printf_u;
+				break;
+			case 'U':
+				directive.fn = bfs_printf_U;
+				break;
+			case 'w':
+				directive.fn = bfs_printf_ctime;
+				directive.stat_field = BFS_STAT_BTIME;
+				break;
+			case 'y':
+				directive.fn = bfs_printf_y;
+				break;
+			case 'Y':
+				directive.fn = bfs_printf_Y;
+				break;
+
+			case 'A':
+				directive.stat_field = BFS_STAT_ATIME;
+				goto directive_strftime;
+			case 'B':
+			case 'W':
+				directive.stat_field = BFS_STAT_BTIME;
+				goto directive_strftime;
+			case 'C':
+				directive.stat_field = BFS_STAT_CTIME;
+				goto directive_strftime;
+			case 'T':
+				directive.stat_field = BFS_STAT_MTIME;
+				goto directive_strftime;
+
+			directive_strftime:
+				directive.fn = bfs_printf_strftime;
+				c = *++i;
+				if (!c) {
+					bfs_expr_error(ctx, expr);
+					bfs_error(ctx, "Incomplete time specifier '%s%c'.\n", directive.str, i[-1]);
+					goto directive_error;
+				} else if (strchr("%+@aAbBcCdDeFgGhHIjklmMnprRsStTuUVwWxXyYzZ", c)) {
+					directive.c = c;
+				} else {
+					bfs_expr_error(ctx, expr);
+					bfs_error(ctx, "Unrecognized time specifier '%%%c%c'.\n", i[-1], c);
+					goto directive_error;
+				}
+				break;
+
+			case '\0':
+				bfs_expr_error(ctx, expr);
+				bfs_error(ctx, "Incomplete format specifier '%s'.\n", directive.str);
+				goto directive_error;
+
+			default:
+				bfs_expr_error(ctx, expr);
+				bfs_error(ctx, "Unrecognized format specifier '%%%c'.\n", c);
+				goto directive_error;
+			}
+
+			if (must_be_numeric && strcmp(specifier, "s") == 0) {
+				bfs_expr_error(ctx, expr);
+				bfs_error(ctx, "Invalid flags '%s' for string format '%%%c'.\n", directive.str + 1, c);
+				goto directive_error;
+			}
+
+			if (dstrcat(&directive.str, specifier) != 0) {
+				bfs_perror(ctx, "dstrcat()");
+				goto directive_error;
+			}
+
+			if (append_directive(ctx, &expr->printf, &literal, &directive) != 0) {
+				goto directive_error;
+			}
+
+			continue;
+
+		directive_error:
+			dstrfree(directive.str);
+			goto error;
+		}
+
+	one_char:
+		if (dstrapp(&literal, c) != 0) {
+			bfs_perror(ctx, "dstrapp()");
+			goto error;
+		}
+	}
+
+done:
+	if (append_literal(ctx, &expr->printf, &literal) != 0) {
+		goto error;
+	}
+	dstrfree(literal);
+	return 0;
+
+error:
+	dstrfree(literal);
+	bfs_printf_free(expr->printf);
+	expr->printf = NULL;
+	return -1;
+}
+
+int bfs_printf(CFILE *cfile, const struct bfs_printf *format, const struct BFTW *ftwbuf) {
+	int ret = 0, error = 0;
+
+	for (size_t i = 0; i < darray_length(format); ++i) {
+		const struct bfs_printf *directive = &format[i];
+		if (directive->fn(cfile, directive, ftwbuf) < 0) {
+			ret = -1;
+			error = errno;
+		}
+	}
+
+	errno = error;
+	return ret;
+}
+
+void bfs_printf_free(struct bfs_printf *format) {
+	for (size_t i = 0; i < darray_length(format); ++i) {
+		dstrfree(format[i].str);
+	}
+	darray_free(format);
+}
diff --git a/src/printf.h b/src/printf.h
new file mode 100644
index 0000000..a8c5f2a
--- /dev/null
+++ b/src/printf.h
@@ -0,0 +1,68 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2017-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * Implementation of -printf/-fprintf.
+ */
+
+#ifndef BFS_PRINTF_H
+#define BFS_PRINTF_H
+
+#include "color.h"
+
+struct BFTW;
+struct bfs_ctx;
+struct bfs_expr;
+
+/**
+ * A printf command, the result of parsing a single format string.
+ */
+struct bfs_printf;
+
+/**
+ * Parse a -printf format string.
+ *
+ * @param ctx
+ *         The bfs context.
+ * @param expr
+ *         The expression to fill in.
+ * @param format
+ *         The format string to parse.
+ * @return
+ *         0 on success, -1 on failure.
+ */
+int bfs_printf_parse(const struct bfs_ctx *ctx, struct bfs_expr *expr, const char *format);
+
+/**
+ * Evaluate a parsed format string.
+ *
+ * @param cfile
+ *         The CFILE to print to.
+ * @param format
+ *         The parsed printf format.
+ * @param ftwbuf
+ *         The bftw() data for the current file.
+ * @return
+ *         0 on success, -1 on failure.
+ */
+int bfs_printf(CFILE *cfile, const struct bfs_printf *format, const struct BFTW *ftwbuf);
+
+/**
+ * Free a parsed format string.
+ */
+void bfs_printf_free(struct bfs_printf *format);
+
+#endif // BFS_PRINTF_H
diff --git a/src/pwcache.c b/src/pwcache.c
new file mode 100644
index 0000000..91435bd
--- /dev/null
+++ b/src/pwcache.c
@@ -0,0 +1,293 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2020 Tavian Barnes <tavianator@tavianator.com>             *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+#include "pwcache.h"
+#include "darray.h"
+#include "trie.h"
+#include <errno.h>
+#include <grp.h>
+#include <pwd.h>
+#include <stdbool.h>
+#include <stdlib.h>
+#include <string.h>
+
+struct bfs_users {
+	/** The array of passwd entries. */
+	struct passwd *entries;
+	/** A map from usernames to entries. */
+	struct trie by_name;
+	/** A map from UIDs to entries. */
+	struct trie by_uid;
+};
+
+struct bfs_users *bfs_users_parse(void) {
+	int error;
+
+	struct bfs_users *users = malloc(sizeof(*users));
+	if (!users) {
+		return NULL;
+	}
+
+	users->entries = NULL;
+	trie_init(&users->by_name);
+	trie_init(&users->by_uid);
+
+	setpwent();
+
+	while (true) {
+		errno = 0;
+		struct passwd *ent = getpwent();
+		if (!ent) {
+			if (errno) {
+				error = errno;
+				goto fail_end;
+			} else {
+				break;
+			}
+		}
+
+		if (DARRAY_PUSH(&users->entries, ent) != 0) {
+			error = errno;
+			goto fail_end;
+		}
+
+		ent = users->entries + darray_length(users->entries) - 1;
+		ent->pw_name = strdup(ent->pw_name);
+		ent->pw_dir = strdup(ent->pw_dir);
+		ent->pw_shell = strdup(ent->pw_shell);
+		if (!ent->pw_name || !ent->pw_dir || !ent->pw_shell) {
+			error = ENOMEM;
+			goto fail_end;
+		}
+	}
+
+	endpwent();
+
+	for (size_t i = 0; i < darray_length(users->entries); ++i) {
+		struct passwd *entry = &users->entries[i];
+		struct trie_leaf *leaf = trie_insert_str(&users->by_name, entry->pw_name);
+		if (leaf) {
+			if (!leaf->value) {
+				leaf->value = entry;
+			}
+		} else {
+			error = errno;
+			goto fail_free;
+		}
+
+		leaf = trie_insert_mem(&users->by_uid, &entry->pw_uid, sizeof(entry->pw_uid));
+		if (leaf) {
+			if (!leaf->value) {
+				leaf->value = entry;
+			}
+		} else {
+			error = errno;
+			goto fail_free;
+		}
+	}
+
+	return users;
+
+fail_end:
+	endpwent();
+fail_free:
+	bfs_users_free(users);
+	errno = error;
+	return NULL;
+}
+
+const struct passwd *bfs_getpwnam(const struct bfs_users *users, const char *name) {
+	const struct trie_leaf *leaf = trie_find_str(&users->by_name, name);
+	if (leaf) {
+		return leaf->value;
+	} else {
+		return NULL;
+	}
+}
+
+const struct passwd *bfs_getpwuid(const struct bfs_users *users, uid_t uid) {
+	const struct trie_leaf *leaf = trie_find_mem(&users->by_uid, &uid, sizeof(uid));
+	if (leaf) {
+		return leaf->value;
+	} else {
+		return NULL;
+	}
+}
+
+void bfs_users_free(struct bfs_users *users) {
+	if (users) {
+		trie_destroy(&users->by_uid);
+		trie_destroy(&users->by_name);
+
+		for (size_t i = 0; i < darray_length(users->entries); ++i) {
+			struct passwd *entry = &users->entries[i];
+			free(entry->pw_shell);
+			free(entry->pw_dir);
+			free(entry->pw_name);
+		}
+		darray_free(users->entries);
+
+		free(users);
+	}
+}
+
+struct bfs_groups {
+	/** The array of group entries. */
+	struct group *entries;
+	/** A map from group names to entries. */
+	struct trie by_name;
+	/** A map from GIDs to entries. */
+	struct trie by_gid;
+};
+
+/**
+ * struct group::gr_mem isn't properly aligned on macOS, so do this to avoid
+ * ASAN warnings.
+ */
+static char *next_gr_mem(void **gr_mem) {
+	char *mem;
+	memcpy(&mem, *gr_mem, sizeof(mem));
+	*gr_mem = (char *)*gr_mem + sizeof(mem);
+	return mem;
+}
+
+struct bfs_groups *bfs_groups_parse(void) {
+	int error;
+
+	struct bfs_groups *groups = malloc(sizeof(*groups));
+	if (!groups) {
+		return NULL;
+	}
+
+	groups->entries = NULL;
+	trie_init(&groups->by_name);
+	trie_init(&groups->by_gid);
+
+	setgrent();
+
+	while (true) {
+		errno = 0;
+		struct group *ent = getgrent();
+		if (!ent) {
+			if (errno) {
+				error = errno;
+				goto fail_end;
+			} else {
+				break;
+			}
+		}
+
+		if (DARRAY_PUSH(&groups->entries, ent) != 0) {
+			error = errno;
+			goto fail_end;
+		}
+		ent = groups->entries + darray_length(groups->entries) - 1;
+
+		void *members = ent->gr_mem;
+		ent->gr_mem = NULL;
+
+		ent->gr_name = strdup(ent->gr_name);
+		if (!ent->gr_name) {
+			error = errno;
+			goto fail_end;
+		}
+
+		for (char *mem = next_gr_mem(&members); mem; mem = next_gr_mem(&members)) {
+			char *dup = strdup(mem);
+			if (!dup) {
+				error = errno;
+				goto fail_end;
+			}
+
+			if (DARRAY_PUSH(&ent->gr_mem, &dup) != 0) {
+				error = errno;
+				free(dup);
+				goto fail_end;
+			}
+		}
+	}
+
+	endgrent();
+
+	for (size_t i = 0; i < darray_length(groups->entries); ++i) {
+		struct group *entry = &groups->entries[i];
+		struct trie_leaf *leaf = trie_insert_str(&groups->by_name, entry->gr_name);
+		if (leaf) {
+			if (!leaf->value) {
+				leaf->value = entry;
+			}
+		} else {
+			error = errno;
+			goto fail_free;
+		}
+
+		leaf = trie_insert_mem(&groups->by_gid, &entry->gr_gid, sizeof(entry->gr_gid));
+		if (leaf) {
+			if (!leaf->value) {
+				leaf->value = entry;
+			}
+		} else {
+			error = errno;
+			goto fail_free;
+		}
+	}
+
+	return groups;
+
+fail_end:
+	endgrent();
+fail_free:
+	bfs_groups_free(groups);
+	errno = error;
+	return NULL;
+}
+
+const struct group *bfs_getgrnam(const struct bfs_groups *groups, const char *name) {
+	const struct trie_leaf *leaf = trie_find_str(&groups->by_name, name);
+	if (leaf) {
+		return leaf->value;
+	} else {
+		return NULL;
+	}
+}
+
+const struct group *bfs_getgrgid(const struct bfs_groups *groups, gid_t gid) {
+	const struct trie_leaf *leaf = trie_find_mem(&groups->by_gid, &gid, sizeof(gid));
+	if (leaf) {
+		return leaf->value;
+	} else {
+		return NULL;
+	}
+}
+
+void bfs_groups_free(struct bfs_groups *groups) {
+	if (groups) {
+		trie_destroy(&groups->by_gid);
+		trie_destroy(&groups->by_name);
+
+		for (size_t i = 0; i < darray_length(groups->entries); ++i) {
+			struct group *entry = &groups->entries[i];
+			for (size_t j = 0; j < darray_length(entry->gr_mem); ++j) {
+				free(entry->gr_mem[j]);
+			}
+			darray_free(entry->gr_mem);
+			free(entry->gr_name);
+		}
+		darray_free(groups->entries);
+
+		free(groups);
+	}
+}
diff --git a/src/pwcache.h b/src/pwcache.h
new file mode 100644
index 0000000..f1a1db3
--- /dev/null
+++ b/src/pwcache.h
@@ -0,0 +1,117 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2020 Tavian Barnes <tavianator@tavianator.com>             *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * A caching wrapper for /etc/{passwd,group}.
+ */
+
+#ifndef BFS_PWCACHE_H
+#define BFS_PWCACHE_H
+
+#include <grp.h>
+#include <pwd.h>
+
+/**
+ * The user table.
+ */
+struct bfs_users;
+
+/**
+ * Parse the user table.
+ *
+ * @return
+ *         The parsed user table, or NULL on failure.
+ */
+struct bfs_users *bfs_users_parse(void);
+
+/**
+ * Get a user entry by name.
+ *
+ * @param users
+ *         The user table.
+ * @param name
+ *         The username to look up.
+ * @return
+ *         The matching user, or NULL if not found.
+ */
+const struct passwd *bfs_getpwnam(const struct bfs_users *users, const char *name);
+
+/**
+ * Get a user entry by ID.
+ *
+ * @param users
+ *         The user table.
+ * @param uid
+ *         The ID to look up.
+ * @return
+ *         The matching user, or NULL if not found.
+ */
+const struct passwd *bfs_getpwuid(const struct bfs_users *users, uid_t uid);
+
+/**
+ * Free a user table.
+ *
+ * @param users
+ *         The user table to free.
+ */
+void bfs_users_free(struct bfs_users *users);
+
+/**
+ * The group table.
+ */
+struct bfs_groups;
+
+/**
+ * Parse the group table.
+ *
+ * @return
+ *         The parsed group table, or NULL on failure.
+ */
+struct bfs_groups *bfs_groups_parse(void);
+
+/**
+ * Get a group entry by name.
+ *
+ * @param groups
+ *         The group table.
+ * @param name
+ *         The group name to look up.
+ * @return
+ *         The matching group, or NULL if not found.
+ */
+const struct group *bfs_getgrnam(const struct bfs_groups *groups, const char *name);
+
+/**
+ * Get a group entry by ID.
+ *
+ * @param groups
+ *         The group table.
+ * @param uid
+ *         The ID to look up.
+ * @return
+ *         The matching group, or NULL if not found.
+ */
+const struct group *bfs_getgrgid(const struct bfs_groups *groups, gid_t gid);
+
+/**
+ * Free a group table.
+ *
+ * @param groups
+ *         The group table to free.
+ */
+void bfs_groups_free(struct bfs_groups *groups);
+
+#endif // BFS_PWCACHE_H
diff --git a/src/stat.c b/src/stat.c
new file mode 100644
index 0000000..47e60b6
--- /dev/null
+++ b/src/stat.c
@@ -0,0 +1,376 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2018-2019 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+#include "stat.h"
+#include "util.h"
+#include <assert.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <stdbool.h>
+#include <string.h>
+#include <sys/types.h>
+#include <sys/stat.h>
+
+#if BFS_HAS_SYS_PARAM
+#	include <sys/param.h>
+#endif
+
+#ifdef STATX_BASIC_STATS
+#	define HAVE_STATX true
+#elif __linux__
+#	include <linux/stat.h>
+#	include <sys/syscall.h>
+#	include <unistd.h>
+#endif
+
+#if HAVE_STATX || defined(__NR_statx)
+#	define HAVE_BFS_STATX true
+#endif
+
+#if __APPLE__
+#	define st_atim st_atimespec
+#	define st_ctim st_ctimespec
+#	define st_mtim st_mtimespec
+#	define st_birthtim st_birthtimespec
+#endif
+
+const char *bfs_stat_field_name(enum bfs_stat_field field) {
+	switch (field) {
+	case BFS_STAT_DEV:
+		return "device number";
+	case BFS_STAT_INO:
+		return "inode nunmber";
+	case BFS_STAT_TYPE:
+		return "type";
+	case BFS_STAT_MODE:
+		return "mode";
+	case BFS_STAT_NLINK:
+		return "link count";
+	case BFS_STAT_GID:
+		return "group ID";
+	case BFS_STAT_UID:
+		return "user ID";
+	case BFS_STAT_SIZE:
+		return "size";
+	case BFS_STAT_BLOCKS:
+		return "block count";
+	case BFS_STAT_RDEV:
+		return "underlying device";
+	case BFS_STAT_ATTRS:
+		return "attributes";
+	case BFS_STAT_ATIME:
+		return "access time";
+	case BFS_STAT_BTIME:
+		return "birth time";
+	case BFS_STAT_CTIME:
+		return "change time";
+	case BFS_STAT_MTIME:
+		return "modification time";
+	}
+
+	assert(!"Unrecognized stat field");
+	return "???";
+}
+
+/**
+ * Check if we should retry a failed stat() due to a potentially broken link.
+ */
+static bool bfs_stat_retry(int ret, enum bfs_stat_flags flags) {
+	return ret != 0
+		&& (flags & (BFS_STAT_NOFOLLOW | BFS_STAT_TRYFOLLOW)) == BFS_STAT_TRYFOLLOW
+		&& is_nonexistence_error(errno);
+}
+
+/**
+ * Convert a struct stat to a struct bfs_stat.
+ */
+static void bfs_stat_convert(const struct stat *statbuf, struct bfs_stat *buf) {
+	buf->mask = 0;
+
+	buf->dev = statbuf->st_dev;
+	buf->mask |= BFS_STAT_DEV;
+
+	buf->ino = statbuf->st_ino;
+	buf->mask |= BFS_STAT_INO;
+
+	buf->mode = statbuf->st_mode;
+	buf->mask |= BFS_STAT_TYPE | BFS_STAT_MODE;
+
+	buf->nlink = statbuf->st_nlink;
+	buf->mask |= BFS_STAT_NLINK;
+
+	buf->gid = statbuf->st_gid;
+	buf->mask |= BFS_STAT_GID;
+
+	buf->uid = statbuf->st_uid;
+	buf->mask |= BFS_STAT_UID;
+
+	buf->size = statbuf->st_size;
+	buf->mask |= BFS_STAT_SIZE;
+
+	buf->blocks = statbuf->st_blocks;
+	buf->mask |= BFS_STAT_BLOCKS;
+
+	buf->rdev = statbuf->st_rdev;
+	buf->mask |= BFS_STAT_RDEV;
+
+#if BSD
+	buf->attrs = statbuf->st_flags;
+	buf->mask |= BFS_STAT_ATTRS;
+#endif
+
+	buf->atime = statbuf->st_atim;
+	buf->mask |= BFS_STAT_ATIME;
+
+	buf->ctime = statbuf->st_ctim;
+	buf->mask |= BFS_STAT_CTIME;
+
+	buf->mtime = statbuf->st_mtim;
+	buf->mask |= BFS_STAT_MTIME;
+
+#if __APPLE__ || __FreeBSD__ || __NetBSD__
+	buf->btime = statbuf->st_birthtim;
+	buf->mask |= BFS_STAT_BTIME;
+#endif
+}
+
+/**
+ * bfs_stat() implementation backed by stat().
+ */
+static int bfs_stat_impl(int at_fd, const char *at_path, int at_flags, enum bfs_stat_flags flags, struct bfs_stat *buf) {
+	struct stat statbuf;
+	int ret = fstatat(at_fd, at_path, &statbuf, at_flags);
+
+	if (bfs_stat_retry(ret, flags)) {
+		at_flags |= AT_SYMLINK_NOFOLLOW;
+		ret = fstatat(at_fd, at_path, &statbuf, at_flags);
+	}
+
+	if (ret == 0) {
+		bfs_stat_convert(&statbuf, buf);
+	}
+
+	return ret;
+}
+
+#if HAVE_BFS_STATX
+
+/**
+ * Wrapper for the statx() system call, which had no glibc wrapper prior to 2.28.
+ */
+static int bfs_statx(int at_fd, const char *at_path, int at_flags, unsigned int mask, struct statx *buf) {
+#if BFS_HAS_FEATURE(memory_sanitizer, false)
+	// -fsanitize=memory doesn't know about statx(), so tell it the memory
+	// got initialized
+	memset(buf, 0, sizeof(*buf));
+#endif
+
+#if HAVE_STATX
+	return statx(at_fd, at_path, at_flags, mask, buf);
+#else
+	return syscall(__NR_statx, at_fd, at_path, at_flags, mask, buf);
+#endif
+}
+
+/**
+ * bfs_stat() implementation backed by statx().
+ */
+static int bfs_statx_impl(int at_fd, const char *at_path, int at_flags, enum bfs_stat_flags flags, struct bfs_stat *buf) {
+	unsigned int mask = STATX_BASIC_STATS | STATX_BTIME;
+	struct statx xbuf;
+	int ret = bfs_statx(at_fd, at_path, at_flags, mask, &xbuf);
+
+	if (bfs_stat_retry(ret, flags)) {
+		at_flags |= AT_SYMLINK_NOFOLLOW;
+		ret = bfs_statx(at_fd, at_path, at_flags, mask, &xbuf);
+	}
+
+	if (ret != 0) {
+		return ret;
+	}
+
+	// Callers shouldn't have to check anything except the times
+	const unsigned int guaranteed = STATX_BASIC_STATS ^ (STATX_ATIME | STATX_CTIME | STATX_MTIME);
+	if ((xbuf.stx_mask & guaranteed) != guaranteed) {
+		errno = ENOTSUP;
+		return -1;
+	}
+
+	buf->mask = 0;
+
+	buf->dev = bfs_makedev(xbuf.stx_dev_major, xbuf.stx_dev_minor);
+	buf->mask |= BFS_STAT_DEV;
+
+	if (xbuf.stx_mask & STATX_INO) {
+		buf->ino = xbuf.stx_ino;
+		buf->mask |= BFS_STAT_INO;
+	}
+
+	buf->mode = xbuf.stx_mode;
+	if (xbuf.stx_mask & STATX_TYPE) {
+		buf->mask |= BFS_STAT_TYPE;
+	}
+	if (xbuf.stx_mask & STATX_MODE) {
+		buf->mask |= BFS_STAT_MODE;
+	}
+
+	if (xbuf.stx_mask & STATX_NLINK) {
+		buf->nlink = xbuf.stx_nlink;
+		buf->mask |= BFS_STAT_NLINK;
+	}
+
+	if (xbuf.stx_mask & STATX_GID) {
+		buf->gid = xbuf.stx_gid;
+		buf->mask |= BFS_STAT_GID;
+	}
+
+	if (xbuf.stx_mask & STATX_UID) {
+		buf->uid = xbuf.stx_uid;
+		buf->mask |= BFS_STAT_UID;
+	}
+
+	if (xbuf.stx_mask & STATX_SIZE) {
+		buf->size = xbuf.stx_size;
+		buf->mask |= BFS_STAT_SIZE;
+	}
+
+	if (xbuf.stx_mask & STATX_BLOCKS) {
+		buf->blocks = xbuf.stx_blocks;
+		buf->mask |= BFS_STAT_BLOCKS;
+	}
+
+	buf->rdev = bfs_makedev(xbuf.stx_rdev_major, xbuf.stx_rdev_minor);
+	buf->mask |= BFS_STAT_RDEV;
+
+	buf->attrs = xbuf.stx_attributes;
+	buf->mask |= BFS_STAT_ATTRS;
+
+	if (xbuf.stx_mask & STATX_ATIME) {
+		buf->atime.tv_sec = xbuf.stx_atime.tv_sec;
+		buf->atime.tv_nsec = xbuf.stx_atime.tv_nsec;
+		buf->mask |= BFS_STAT_ATIME;
+	}
+
+	if (xbuf.stx_mask & STATX_BTIME) {
+		buf->btime.tv_sec = xbuf.stx_btime.tv_sec;
+		buf->btime.tv_nsec = xbuf.stx_btime.tv_nsec;
+		buf->mask |= BFS_STAT_BTIME;
+	}
+
+	if (xbuf.stx_mask & STATX_CTIME) {
+		buf->ctime.tv_sec = xbuf.stx_ctime.tv_sec;
+		buf->ctime.tv_nsec = xbuf.stx_ctime.tv_nsec;
+		buf->mask |= BFS_STAT_CTIME;
+	}
+
+	if (xbuf.stx_mask & STATX_MTIME) {
+		buf->mtime.tv_sec = xbuf.stx_mtime.tv_sec;
+		buf->mtime.tv_nsec = xbuf.stx_mtime.tv_nsec;
+		buf->mask |= BFS_STAT_MTIME;
+	}
+
+	return ret;
+}
+
+#endif // HAVE_BFS_STATX
+
+/**
+ * Allows calling stat with custom at_flags.
+ */
+static int bfs_stat_explicit(int at_fd, const char *at_path, int at_flags, enum bfs_stat_flags flags, struct bfs_stat *buf) {
+#if HAVE_BFS_STATX
+	static bool has_statx = true;
+
+	if (has_statx) {
+		int ret = bfs_statx_impl(at_fd, at_path, at_flags, flags, buf);
+		// EPERM is commonly returned in a seccomp() sandbox that does
+		// not allow statx()
+		if (ret != 0 && (errno == ENOSYS || errno == EPERM)) {
+			has_statx = false;
+		} else {
+			return ret;
+		}
+	}
+#endif
+
+	return bfs_stat_impl(at_fd, at_path, at_flags, flags, buf);
+}
+
+int bfs_stat(int at_fd, const char *at_path, enum bfs_stat_flags flags, struct bfs_stat *buf) {
+	int at_flags = 0;
+	if (flags & BFS_STAT_NOFOLLOW) {
+		at_flags |= AT_SYMLINK_NOFOLLOW;
+	}
+
+#ifdef AT_STATX_DONT_SYNC
+	if (flags & BFS_STAT_NOSYNC) {
+		at_flags |= AT_STATX_DONT_SYNC;
+	}
+#endif
+
+	if (at_path) {
+		return bfs_stat_explicit(at_fd, at_path, at_flags, flags, buf);
+	}
+
+	// Check __GNU__ to work around https://lists.gnu.org/archive/html/bug-hurd/2021-12/msg00001.html
+#if defined(AT_EMPTY_PATH) && !__GNU__
+	static bool has_at_ep = true;
+	if (has_at_ep) {
+		at_flags |= AT_EMPTY_PATH;
+		int ret = bfs_stat_explicit(at_fd, "", at_flags, flags, buf);
+		if (ret != 0 && errno == EINVAL) {
+			has_at_ep = false;
+		} else {
+			return ret;
+		}
+	}
+#endif
+
+	struct stat statbuf;
+	if (fstat(at_fd, &statbuf) == 0) {
+		bfs_stat_convert(&statbuf, buf);
+		return 0;
+	} else {
+		return -1;
+	}
+}
+
+const struct timespec *bfs_stat_time(const struct bfs_stat *buf, enum bfs_stat_field field) {
+	if (!(buf->mask & field)) {
+		errno = ENOTSUP;
+		return NULL;
+	}
+
+	switch (field) {
+	case BFS_STAT_ATIME:
+		return &buf->atime;
+	case BFS_STAT_BTIME:
+		return &buf->btime;
+	case BFS_STAT_CTIME:
+		return &buf->ctime;
+	case BFS_STAT_MTIME:
+		return &buf->mtime;
+	default:
+		assert(!"Invalid stat field for time");
+		errno = EINVAL;
+		return NULL;
+	}
+}
+
+void bfs_stat_id(const struct bfs_stat *buf, bfs_file_id *id) {
+	memcpy(*id, &buf->dev, sizeof(buf->dev));
+	memcpy(*id + sizeof(buf->dev), &buf->ino, sizeof(buf->ino));
+}
diff --git a/src/stat.h b/src/stat.h
new file mode 100644
index 0000000..55c75e9
--- /dev/null
+++ b/src/stat.h
@@ -0,0 +1,155 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2018-2019 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * A facade over the stat() API that unifies some details that diverge between
+ * implementations, like the names of the timespec fields and the presence of
+ * file "birth" times.  On new enough Linux kernels, the facade is backed by
+ * statx() instead, and so it exposes a similar interface with a mask for which
+ * fields were successfully returned.
+ */
+
+#ifndef BFS_STAT_H
+#define BFS_STAT_H
+
+#include "util.h"
+#include <sys/types.h>
+#include <time.h>
+
+#if BFS_HAS_SYS_PARAM
+#	include <sys/param.h>
+#endif
+
+/**
+ * bfs_stat field bitmask.
+ */
+enum bfs_stat_field {
+	BFS_STAT_DEV    = 1 << 0,
+	BFS_STAT_INO    = 1 << 1,
+	BFS_STAT_TYPE   = 1 << 2,
+	BFS_STAT_MODE   = 1 << 3,
+	BFS_STAT_NLINK  = 1 << 4,
+	BFS_STAT_GID    = 1 << 5,
+	BFS_STAT_UID    = 1 << 6,
+	BFS_STAT_SIZE   = 1 << 7,
+	BFS_STAT_BLOCKS = 1 << 8,
+	BFS_STAT_RDEV   = 1 << 9,
+	BFS_STAT_ATTRS  = 1 << 10,
+	BFS_STAT_ATIME  = 1 << 11,
+	BFS_STAT_BTIME  = 1 << 12,
+	BFS_STAT_CTIME  = 1 << 13,
+	BFS_STAT_MTIME  = 1 << 14,
+};
+
+/**
+ * Get the human-readable name of a bfs_stat field.
+ */
+const char *bfs_stat_field_name(enum bfs_stat_field field);
+
+/**
+ * bfs_stat() flags.
+ */
+enum bfs_stat_flags {
+	/** Follow symlinks (the default). */
+	BFS_STAT_FOLLOW = 0,
+	/** Never follow symlinks. */
+	BFS_STAT_NOFOLLOW = 1 << 0,
+	/** Try to follow symlinks, but fall back to the link itself if broken. */
+	BFS_STAT_TRYFOLLOW = 1 << 1,
+	/** Try to use cached values without synchronizing remote filesystems. */
+	BFS_STAT_NOSYNC = 1 << 2,
+};
+
+#ifdef DEV_BSIZE
+#	define BFS_STAT_BLKSIZE DEV_BSIZE
+#elif defined(S_BLKSIZE)
+#	define BFS_STAT_BLKSIZE S_BLKSIZE
+#else
+#	define BFS_STAT_BLKSIZE 512
+#endif
+
+/**
+ * Facade over struct stat.
+ */
+struct bfs_stat {
+	/** Bitmask indicating filled fields. */
+	enum bfs_stat_field mask;
+
+	/** Device ID containing the file. */
+	dev_t dev;
+	/** Inode number. */
+	ino_t ino;
+	/** File type and access mode. */
+	mode_t mode;
+	/** Number of hard links. */
+	nlink_t nlink;
+	/** Owner group ID. */
+	gid_t gid;
+	/** Owner user ID. */
+	uid_t uid;
+	/** File size in bytes. */
+	off_t size;
+	/** Number of disk blocks allocated (of size BFS_STAT_BLKSIZE). */
+	blkcnt_t blocks;
+	/** The device ID represented by this file. */
+	dev_t rdev;
+
+	/** Attributes/flags set on the file. */
+	unsigned long long attrs;
+
+	/** Access time. */
+	struct timespec atime;
+	/** Birth/creation time. */
+	struct timespec btime;
+	/** Status change time. */
+	struct timespec ctime;
+	/** Modification time. */
+	struct timespec mtime;
+};
+
+/**
+ * Facade over fstatat().
+ *
+ * @param at_fd
+ *         The base file descriptor for the lookup.
+ * @param at_path
+ *         The path to stat, relative to at_fd.  Pass NULL to fstat() at_fd
+ *         itself.
+ * @param flags
+ *         Flags that affect the lookup.
+ * @param[out] buf
+ *         A place to store the stat buffer, if successful.
+ * @return
+ *         0 on success, -1 on error.
+ */
+int bfs_stat(int at_fd, const char *at_path, enum bfs_stat_flags flags, struct bfs_stat *buf);
+
+/**
+ * Get a particular time field from a bfs_stat() buffer.
+ */
+const struct timespec *bfs_stat_time(const struct bfs_stat *buf, enum bfs_stat_field field);
+
+/**
+ * A unique ID for a file.
+ */
+typedef unsigned char bfs_file_id[sizeof(dev_t) + sizeof(ino_t)];
+
+/**
+ * Compute a unique ID for a file.
+ */
+void bfs_stat_id(const struct bfs_stat *buf, bfs_file_id *id);
+
+#endif // BFS_STAT_H
diff --git a/src/trie.c b/src/trie.c
new file mode 100644
index 0000000..bae9acb
--- /dev/null
+++ b/src/trie.c
@@ -0,0 +1,693 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2019 Tavian Barnes <tavianator@tavianator.com>             *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * This is an implementation of a "qp trie," as documented at
+ * https://dotat.at/prog/qp/README.html
+ *
+ * An uncompressed trie over the dataset {AAAA, AADD, ABCD, DDAA, DDDD} would
+ * look like
+ *
+ *       A    A    A    A
+ *     *--->*--->*--->*--->$
+ *     |    |    | D    D
+ *     |    |    +--->*--->$
+ *     |    | B    C    D
+ *     |    +--->*--->*--->$
+ *     | D    D    A    A
+ *     +--->*--->*--->*--->$
+ *               | D    D
+ *               +--->*--->$
+ *
+ * A compressed (PATRICIA) trie collapses internal nodes that have only a single
+ * child, like this:
+ *
+ *       A    A    AA
+ *     *--->*--->*---->$
+ *     |    |    | DD
+ *     |    |    +---->$
+ *     |    | BCD
+ *     |    +----->$
+ *     | DD    AA
+ *     +---->*---->$
+ *           | DD
+ *           +---->$
+ *
+ * The nodes can be compressed further by dropping the actual compressed
+ * sequences from the nodes, storing it only in the leaves.  This is the
+ * technique applied in QP tries, and the crit-bit trees that inspired them
+ * (https://cr.yp.to/critbit.html).  Only the index to test, and the values to
+ * branch on, need to be stored in each node.
+ *
+ *       A    A    A
+ *     0--->1--->2--->AAAA
+ *     |    |    | D
+ *     |    |    +--->AADD
+ *     |    | B
+ *     |    +--->ABCD
+ *     | D    A
+ *     +--->2--->DDAA
+ *          | D
+ *          +--->DDDD
+ *
+ * Nodes are represented very compactly.  Rather than a dense array of children,
+ * a sparse array of only the non-NULL children directly follows the node in
+ * memory.  A bitmap is used to track which children exist; the index of a child
+ * i is found by counting the number of bits below bit i that are set.  A tag
+ * bit is used to tell pointers to internal nodes apart from pointers to leaves.
+ *
+ * This implementation tests a whole nibble (half byte/hex digit) at every
+ * branch, so the bitmap takes up 16 bits.  The remainder of a machine word is
+ * used to hold the offset, which severely constrains its range on 32-bit
+ * platforms.  As a workaround, we store relative instead of absolute offsets,
+ * and insert intermediate singleton "jump" nodes when necessary.
+ */
+
+#include "trie.h"
+#include "util.h"
+#include <assert.h>
+#include <limits.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+#if CHAR_BIT != 8
+#	error "This trie implementation assumes 8-bit bytes."
+#endif
+
+/** Number of bits for the sparse array bitmap, aka the range of a nibble. */
+#define BITMAP_BITS 16
+/** The number of remaining bits in a word, to hold the offset. */
+#define OFFSET_BITS (sizeof(size_t)*CHAR_BIT - BITMAP_BITS)
+/** The highest representable offset (only 64k on a 32-bit architecture). */
+#define OFFSET_MAX (((size_t)1 << OFFSET_BITS) - 1)
+
+/**
+ * An internal node of the trie.
+ */
+struct trie_node {
+	/**
+	 * A bitmap that hold which indices exist in the sparse children array.
+	 * Bit i will be set if a child exists at logical index i, and its index
+	 * into the array will be popcount(bitmap & ((1 << i) - 1)).
+	 */
+	size_t bitmap : BITMAP_BITS;
+
+	/**
+	 * The offset into the key in nibbles.  This is relative to the parent
+	 * node, to support offsets larger than OFFSET_MAX.
+	 */
+	size_t offset : OFFSET_BITS;
+
+	/**
+	 * Flexible array of children.  Each pointer uses the lowest bit as a
+	 * tag to distinguish internal nodes from leaves.  This is safe as long
+	 * as all dynamic allocations are aligned to more than a single byte.
+	 */
+	uintptr_t children[];
+};
+
+/** Check if an encoded pointer is to a leaf. */
+static bool trie_is_leaf(uintptr_t ptr) {
+	return ptr & 1;
+}
+
+/** Decode a pointer to a leaf. */
+static struct trie_leaf *trie_decode_leaf(uintptr_t ptr) {
+	assert(trie_is_leaf(ptr));
+	return (struct trie_leaf *)(ptr ^ 1);
+}
+
+/** Encode a pointer to a leaf. */
+static uintptr_t trie_encode_leaf(const struct trie_leaf *leaf) {
+	uintptr_t ptr = (uintptr_t)leaf ^ 1;
+	assert(trie_is_leaf(ptr));
+	return ptr;
+}
+
+/** Decode a pointer to an internal node. */
+static struct trie_node *trie_decode_node(uintptr_t ptr) {
+	assert(!trie_is_leaf(ptr));
+	return (struct trie_node *)ptr;
+}
+
+/** Encode a pointer to an internal node. */
+static uintptr_t trie_encode_node(const struct trie_node *node) {
+	uintptr_t ptr = (uintptr_t)node;
+	assert(!trie_is_leaf(ptr));
+	return ptr;
+}
+
+void trie_init(struct trie *trie) {
+	trie->root = 0;
+}
+
+/** Compute the popcount (Hamming weight) of a bitmap. */
+static unsigned int trie_popcount(unsigned int n) {
+#if __POPCNT__
+	// Use the x86 instruction if we have it.  Otherwise, GCC generates a
+	// library call, so use the below implementation instead.
+	return __builtin_popcount(n);
+#else
+	// See https://en.wikipedia.org/wiki/Hamming_weight#Efficient_implementation
+	n -= (n >> 1) & 0x5555;
+	n = (n & 0x3333) + ((n >> 2) & 0x3333);
+	n = (n + (n >> 4)) & 0x0F0F;
+	n = (n + (n >> 8)) & 0xFF;
+	return n;
+#endif
+}
+
+/** Extract the nibble at a certain offset from a byte sequence. */
+static unsigned char trie_key_nibble(const void *key, size_t offset) {
+	const unsigned char *bytes = key;
+	size_t byte = offset >> 1;
+
+	// A branchless version of
+	// if (offset & 1) {
+	//         return bytes[byte] >> 4;
+	// } else {
+	//         return bytes[byte] & 0xF;
+	// }
+	unsigned int shift = (offset & 1) << 2;
+	return (bytes[byte] >> shift) & 0xF;
+}
+
+/**
+ * Finds a leaf in the trie that matches the key at every branch.  If the key
+ * exists in the trie, the representative will match the searched key.  But
+ * since only branch points are tested, it can be different from the key.  In
+ * that case, the first mismatch between the key and the representative will be
+ * the depth at which to make a new branch to insert the key.
+ */
+static struct trie_leaf *trie_representative(const struct trie *trie, const void *key, size_t length) {
+	uintptr_t ptr = trie->root;
+	if (!ptr) {
+		return NULL;
+	}
+
+	size_t offset = 0;
+	while (!trie_is_leaf(ptr)) {
+		struct trie_node *node = trie_decode_node(ptr);
+		offset += node->offset;
+
+		unsigned int index = 0;
+		if ((offset >> 1) < length) {
+			unsigned char nibble = trie_key_nibble(key, offset);
+			unsigned int bit = 1U << nibble;
+			if (node->bitmap & bit) {
+				index = trie_popcount(node->bitmap & (bit - 1));
+			}
+		}
+		ptr = node->children[index];
+	}
+
+	return trie_decode_leaf(ptr);
+}
+
+struct trie_leaf *trie_first_leaf(const struct trie *trie) {
+	return trie_representative(trie, NULL, 0);
+}
+
+struct trie_leaf *trie_find_str(const struct trie *trie, const char *key) {
+	return trie_find_mem(trie, key, strlen(key) + 1);
+}
+
+struct trie_leaf *trie_find_mem(const struct trie *trie, const void *key, size_t length) {
+	struct trie_leaf *rep = trie_representative(trie, key, length);
+	if (rep && rep->length == length && memcmp(rep->key, key, length) == 0) {
+		return rep;
+	} else {
+		return NULL;
+	}
+}
+
+struct trie_leaf *trie_find_postfix(const struct trie *trie, const char *key) {
+	size_t length = strlen(key);
+	struct trie_leaf *rep = trie_representative(trie, key, length + 1);
+	if (rep && rep->length >= length && memcmp(rep->key, key, length) == 0) {
+		return rep;
+	} else {
+		return NULL;
+	}
+}
+
+/**
+ * Find a leaf that may end at the current node.
+ */
+static struct trie_leaf *trie_terminal_leaf(const struct trie_node *node) {
+	// Finding a terminating NUL byte may take two nibbles
+	for (int i = 0; i < 2; ++i) {
+		if (!(node->bitmap & 1)) {
+			break;
+		}
+
+		uintptr_t ptr = node->children[0];
+		if (trie_is_leaf(ptr)) {
+			return trie_decode_leaf(ptr);
+		} else {
+			node = trie_decode_node(ptr);
+		}
+	}
+
+	return NULL;
+}
+
+/** Check if a leaf is a prefix of a search key. */
+static bool trie_check_prefix(struct trie_leaf *leaf, size_t skip, const char *key, size_t length) {
+	if (leaf && leaf->length <= length) {
+		return memcmp(key + skip, leaf->key + skip, leaf->length - skip - 1) == 0;
+	} else {
+		return false;
+	}
+}
+
+struct trie_leaf *trie_find_prefix(const struct trie *trie, const char *key) {
+	uintptr_t ptr = trie->root;
+	if (!ptr) {
+		return NULL;
+	}
+
+	struct trie_leaf *best = NULL;
+	size_t skip = 0;
+	size_t length = strlen(key) + 1;
+
+	size_t offset = 0;
+	while (!trie_is_leaf(ptr)) {
+		struct trie_node *node = trie_decode_node(ptr);
+		offset += node->offset;
+		if ((offset >> 1) >= length) {
+			return best;
+		}
+
+		struct trie_leaf *leaf = trie_terminal_leaf(node);
+		if (trie_check_prefix(leaf, skip, key, length)) {
+			best = leaf;
+			skip = offset >> 1;
+		}
+
+		unsigned char nibble = trie_key_nibble(key, offset);
+		unsigned int bit = 1U << nibble;
+		if (node->bitmap & bit) {
+			unsigned int index = trie_popcount(node->bitmap & (bit - 1));
+			ptr = node->children[index];
+		} else {
+			return best;
+		}
+	}
+
+	struct trie_leaf *leaf = trie_decode_leaf(ptr);
+	if (trie_check_prefix(leaf, skip, key, length)) {
+		best = leaf;
+	}
+
+	return best;
+}
+
+/** Create a new leaf, holding a copy of the given key. */
+static struct trie_leaf *new_trie_leaf(const void *key, size_t length) {
+	struct trie_leaf *leaf = malloc(BFS_FLEX_SIZEOF(struct trie_leaf, key, length));
+	if (leaf) {
+		leaf->value = NULL;
+		leaf->length = length;
+		memcpy(leaf->key, key, length);
+	}
+	return leaf;
+}
+
+/** Compute the size of a trie node with a certain number of children. */
+static size_t trie_node_size(unsigned int size) {
+	// Empty nodes aren't supported
+	assert(size > 0);
+	// Node size must be a power of two
+	assert((size & (size - 1)) == 0);
+
+	return BFS_FLEX_SIZEOF(struct trie_node, children, size);
+}
+
+/** Find the offset of the first nibble that differs between two keys. */
+static size_t trie_key_mismatch(const void *key1, const void *key2, size_t length) {
+	const unsigned char *bytes1 = key1;
+	const unsigned char *bytes2 = key2;
+	size_t i = 0;
+	size_t offset = 0;
+	const size_t chunk = sizeof(size_t);
+
+	for (; i + chunk <= length; i += chunk) {
+		if (memcmp(bytes1 + i, bytes2 + i, chunk) != 0) {
+			break;
+		}
+	}
+
+	for (; i < length; ++i) {
+		unsigned char b1 = bytes1[i], b2 = bytes2[i];
+		if (b1 != b2) {
+			offset = (b1 & 0xF) == (b2 & 0xF);
+			break;
+		}
+	}
+
+	offset |= i << 1;
+	return offset;
+}
+
+/**
+ * Insert a key into a node.  The node must not have a child in that position
+ * already.  Effectively takes a subtrie like this:
+ *
+ *     ptr
+ *      |
+ *      v X
+ *      *--->...
+ *      | Z
+ *      +--->...
+ *
+ * and transforms it to:
+ *
+ *     ptr
+ *      |
+ *      v X
+ *      *--->...
+ *      | Y
+ *      +--->key
+ *      | Z
+ *      +--->...
+ */
+static struct trie_leaf *trie_node_insert(uintptr_t *ptr, const void *key, size_t length, size_t offset) {
+	struct trie_node *node = trie_decode_node(*ptr);
+	unsigned int size = trie_popcount(node->bitmap);
+
+	// Double the capacity every power of two
+	if ((size & (size - 1)) == 0) {
+		node = realloc(node, trie_node_size(2*size));
+		if (!node) {
+			return NULL;
+		}
+		*ptr = trie_encode_node(node);
+	}
+
+	struct trie_leaf *leaf = new_trie_leaf(key, length);
+	if (!leaf) {
+		return NULL;
+	}
+
+	unsigned char nibble = trie_key_nibble(key, offset);
+	unsigned int bit = 1U << nibble;
+
+	// The child must not already be present
+	assert(!(node->bitmap & bit));
+	node->bitmap |= bit;
+
+	unsigned int index = trie_popcount(node->bitmap & (bit - 1));
+	uintptr_t *child = &node->children[index];
+	if (index < size) {
+		memmove(child + 1, child, (size - index)*sizeof(*child));
+	}
+	*child = trie_encode_leaf(leaf);
+	return leaf;
+}
+
+/**
+ * When the current offset exceeds OFFSET_MAX, insert "jump" nodes that bridge
+ * the gap.  This function takes a subtrie like this:
+ *
+ *     ptr
+ *      |
+ *      v
+ *      *--->rep
+ *
+ * and changes it to:
+ *
+ *     ptr  ret
+ *      |    |
+ *      v    v
+ *      *--->*--->rep
+ *
+ * so that a new key can be inserted like:
+ *
+ *     ptr  ret
+ *      |    |
+ *      v    v X
+ *      *--->*--->rep
+ *           | Y
+ *           +--->key
+ */
+static uintptr_t *trie_jump(uintptr_t *ptr, const char *key, size_t *offset) {
+	// We only ever need to jump to leaf nodes, since internal nodes are
+	// guaranteed to be within OFFSET_MAX anyway
+	assert(trie_is_leaf(*ptr));
+
+	struct trie_node *node = malloc(trie_node_size(1));
+	if (!node) {
+		return NULL;
+	}
+
+	*offset += OFFSET_MAX;
+	node->offset = OFFSET_MAX;
+
+	unsigned char nibble = trie_key_nibble(key, *offset);
+	node->bitmap = 1 << nibble;
+
+	node->children[0] = *ptr;
+	*ptr = trie_encode_node(node);
+	return node->children;
+}
+
+/**
+ * Split a node in the trie.  Changes a subtrie like this:
+ *
+ *     ptr
+ *      |
+ *      v
+ *      *...>--->rep
+ *
+ * into this:
+ *
+ *     ptr
+ *      |
+ *      v X
+ *      *--->*...>--->rep
+ *      | Y
+ *      +--->key
+ */
+static struct trie_leaf *trie_split(uintptr_t *ptr, const void *key, size_t length, struct trie_leaf *rep, size_t offset, size_t mismatch) {
+	unsigned char key_nibble = trie_key_nibble(key, mismatch);
+	unsigned char rep_nibble = trie_key_nibble(rep->key, mismatch);
+	assert(key_nibble != rep_nibble);
+
+	struct trie_node *node = malloc(trie_node_size(2));
+	if (!node) {
+		return NULL;
+	}
+
+	struct trie_leaf *leaf = new_trie_leaf(key, length);
+	if (!leaf) {
+		free(node);
+		return NULL;
+	}
+
+	node->bitmap = (1 << key_nibble) | (1 << rep_nibble);
+
+	size_t delta = mismatch - offset;
+	if (!trie_is_leaf(*ptr)) {
+		struct trie_node *child = trie_decode_node(*ptr);
+		child->offset -= delta;
+	}
+	node->offset = delta;
+
+	unsigned int key_index = key_nibble > rep_nibble;
+	node->children[key_index] = trie_encode_leaf(leaf);
+	node->children[key_index ^ 1] = *ptr;
+	*ptr = trie_encode_node(node);
+	return leaf;
+}
+
+struct trie_leaf *trie_insert_str(struct trie *trie, const char *key) {
+	return trie_insert_mem(trie, key, strlen(key) + 1);
+}
+
+struct trie_leaf *trie_insert_mem(struct trie *trie, const void *key, size_t length) {
+	struct trie_leaf *rep = trie_representative(trie, key, length);
+	if (!rep) {
+		struct trie_leaf *leaf = new_trie_leaf(key, length);
+		if (leaf) {
+			trie->root = trie_encode_leaf(leaf);
+		}
+		return leaf;
+	}
+
+	size_t limit = length < rep->length ? length : rep->length;
+	size_t mismatch = trie_key_mismatch(key, rep->key, limit);
+	if ((mismatch >> 1) >= length) {
+		return rep;
+	}
+
+	size_t offset = 0;
+	uintptr_t *ptr = &trie->root;
+	while (!trie_is_leaf(*ptr)) {
+		struct trie_node *node = trie_decode_node(*ptr);
+		if (offset + node->offset > mismatch) {
+			break;
+		}
+		offset += node->offset;
+
+		unsigned char nibble = trie_key_nibble(key, offset);
+		unsigned int bit = 1U << nibble;
+		if (node->bitmap & bit) {
+			assert(offset < mismatch);
+			unsigned int index = trie_popcount(node->bitmap & (bit - 1));
+			ptr = &node->children[index];
+		} else {
+			assert(offset == mismatch);
+			return trie_node_insert(ptr, key, length, offset);
+		}
+	}
+
+	while (mismatch - offset > OFFSET_MAX) {
+		ptr = trie_jump(ptr, key, &offset);
+		if (!ptr) {
+			return NULL;
+		}
+	}
+
+	return trie_split(ptr, key, length, rep, offset, mismatch);
+}
+
+/** Free a chain of singleton nodes. */
+static void trie_free_singletons(uintptr_t ptr) {
+	while (!trie_is_leaf(ptr)) {
+		struct trie_node *node = trie_decode_node(ptr);
+
+		// Make sure the bitmap is a power of two, i.e. it has just one child
+		assert((node->bitmap & (node->bitmap - 1)) == 0);
+
+		ptr = node->children[0];
+		free(node);
+	}
+
+	free(trie_decode_leaf(ptr));
+}
+
+/**
+ * Try to collapse a two-child node like:
+ *
+ *     parent child
+ *       |      |
+ *       v      v
+ *       *----->*----->*----->leaf
+ *       |
+ *       +----->other
+ *
+ * into
+ *
+ *     parent
+ *       |
+ *       v
+ *     other
+ */
+static int trie_collapse_node(uintptr_t *parent, struct trie_node *parent_node, unsigned int child_index) {
+	uintptr_t other = parent_node->children[child_index ^ 1];
+	if (!trie_is_leaf(other)) {
+		struct trie_node *other_node = trie_decode_node(other);
+		if (other_node->offset + parent_node->offset <= OFFSET_MAX) {
+			other_node->offset += parent_node->offset;
+		} else {
+			return -1;
+		}
+	}
+
+	*parent = other;
+	free(parent_node);
+	return 0;
+}
+
+void trie_remove(struct trie *trie, struct trie_leaf *leaf) {
+	uintptr_t *child = &trie->root;
+	uintptr_t *parent = NULL;
+	unsigned int child_bit = 0, child_index = 0;
+	size_t offset = 0;
+	while (!trie_is_leaf(*child)) {
+		struct trie_node *node = trie_decode_node(*child);
+		offset += node->offset;
+		assert((offset >> 1) < leaf->length);
+
+		unsigned char nibble = trie_key_nibble(leaf->key, offset);
+		unsigned int bit = 1U << nibble;
+		unsigned int bitmap = node->bitmap;
+		assert(bitmap & bit);
+		unsigned int index = trie_popcount(bitmap & (bit - 1));
+
+		// Advance the parent pointer, unless this node had only one child
+		if (bitmap & (bitmap - 1)) {
+			parent = child;
+			child_bit = bit;
+			child_index = index;
+		}
+
+		child = &node->children[index];
+	}
+
+	assert(trie_decode_leaf(*child) == leaf);
+
+	if (!parent) {
+		trie_free_singletons(trie->root);
+		trie->root = 0;
+		return;
+	}
+
+	struct trie_node *node = trie_decode_node(*parent);
+	child = node->children + child_index;
+	trie_free_singletons(*child);
+
+	node->bitmap ^= child_bit;
+	unsigned int parent_size = trie_popcount(node->bitmap);
+	assert(parent_size > 0);
+	if (parent_size == 1 && trie_collapse_node(parent, node, child_index) == 0) {
+		return;
+	}
+
+	if (child_index < parent_size) {
+		memmove(child, child + 1, (parent_size - child_index)*sizeof(*child));
+	}
+
+	if ((parent_size & (parent_size - 1)) == 0) {
+		node = realloc(node, trie_node_size(parent_size));
+		if (node) {
+			*parent = trie_encode_node(node);
+		}
+	}
+}
+
+/** Free an encoded pointer to a node. */
+static void free_trie_ptr(uintptr_t ptr) {
+	if (trie_is_leaf(ptr)) {
+		free(trie_decode_leaf(ptr));
+	} else {
+		struct trie_node *node = trie_decode_node(ptr);
+		size_t size = trie_popcount(node->bitmap);
+		for (size_t i = 0; i < size; ++i) {
+			free_trie_ptr(node->children[i]);
+		}
+		free(node);
+	}
+}
+
+void trie_destroy(struct trie *trie) {
+	if (trie->root) {
+		free_trie_ptr(trie->root);
+	}
+}
diff --git a/src/trie.h b/src/trie.h
new file mode 100644
index 0000000..2d29ac7
--- /dev/null
+++ b/src/trie.h
@@ -0,0 +1,156 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2019 Tavian Barnes <tavianator@tavianator.com>             *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+#ifndef BFS_TRIE_H
+#define BFS_TRIE_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+/**
+ * A trie that holds a set of fixed- or variable-length strings.
+ */
+struct trie {
+	uintptr_t root;
+};
+
+/**
+ * A leaf of a trie.
+ */
+struct trie_leaf {
+	/**
+	 * An arbitrary value associated with this leaf.
+	 */
+	void *value;
+
+	/**
+	 * The length of the key in bytes.
+	 */
+	size_t length;
+
+	/**
+	 * The key itself, stored inline.
+	 */
+	char key[];
+};
+
+/**
+ * Initialize an empty trie.
+ */
+void trie_init(struct trie *trie);
+
+/**
+ * Get the first (lexicographically earliest) leaf in the trie.
+ *
+ * @param trie
+ *         The trie to search.
+ * @return
+ *         The first leaf, or NULL if the trie is empty.
+ */
+struct trie_leaf *trie_first_leaf(const struct trie *trie);
+
+/**
+ * Find the leaf for a string key.
+ *
+ * @param trie
+ *         The trie to search.
+ * @param key
+ *         The key to look up.
+ * @return
+ *         The found leaf, or NULL if the key is not present.
+ */
+struct trie_leaf *trie_find_str(const struct trie *trie, const char *key);
+
+/**
+ * Find the leaf for a fixed-size key.
+ *
+ * @param trie
+ *         The trie to search.
+ * @param key
+ *         The key to look up.
+ * @param length
+ *         The length of the key in bytes.
+ * @return
+ *         The found leaf, or NULL if the key is not present.
+ */
+struct trie_leaf *trie_find_mem(const struct trie *trie, const void *key, size_t length);
+
+/**
+ * Find the shortest leaf that starts with a given key.
+ *
+ * @param trie
+ *         The trie to search.
+ * @param key
+ *         The key to look up.
+ * @return
+ *         A leaf that starts with the given key, or NULL.
+ */
+struct trie_leaf *trie_find_postfix(const struct trie *trie, const char *key);
+
+/**
+ * Find the leaf that is the longest prefix of the given key.
+ *
+ * @param trie
+ *         The trie to search.
+ * @param key
+ *         The key to look up.
+ * @return
+ *         The longest prefix match for the given key, or NULL.
+ */
+struct trie_leaf *trie_find_prefix(const struct trie *trie, const char *key);
+
+/**
+ * Insert a string key into the trie.
+ *
+ * @param trie
+ *         The trie to modify.
+ * @param key
+ *         The key to insert.
+ * @return
+ *         The inserted leaf, or NULL on failure.
+ */
+struct trie_leaf *trie_insert_str(struct trie *trie, const char *key);
+
+/**
+ * Insert a fixed-size key into the trie.
+ *
+ * @param trie
+ *         The trie to modify.
+ * @param key
+ *         The key to insert.
+ * @param length
+ *         The length of the key in bytes.
+ * @return
+ *         The inserted leaf, or NULL on failure.
+ */
+struct trie_leaf *trie_insert_mem(struct trie *trie, const void *key, size_t length);
+
+/**
+ * Remove a leaf from a trie.
+ *
+ * @param trie
+ *         The trie to modify.
+ * @param leaf
+ *         The leaf to remove.
+ */
+void trie_remove(struct trie *trie, struct trie_leaf *leaf);
+
+/**
+ * Destroy a trie and its contents.
+ */
+void trie_destroy(struct trie *trie);
+
+#endif // BFS_TRIE_H
diff --git a/src/typo.c b/src/typo.c
new file mode 100644
index 0000000..4012730
--- /dev/null
+++ b/src/typo.c
@@ -0,0 +1,176 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2016 Tavian Barnes <tavianator@tavianator.com>             *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+#include "typo.h"
+#include <limits.h>
+#include <stdlib.h>
+#include <string.h>
+
+// Assume QWERTY layout for now
+static const int key_coords[UCHAR_MAX][3] = {
+	['`']  = { 0,  0, 0},
+	['~']  = { 0,  0, 1},
+	['1']  = { 3,  0, 0},
+	['!']  = { 3,  0, 1},
+	['2']  = { 6,  0, 0},
+	['@']  = { 6,  0, 1},
+	['3']  = { 9,  0, 0},
+	['#']  = { 9,  0, 1},
+	['4']  = {12,  0, 0},
+	['$']  = {12,  0, 1},
+	['5']  = {15,  0, 0},
+	['%']  = {15,  0, 1},
+	['6']  = {18,  0, 0},
+	['^']  = {18,  0, 1},
+	['7']  = {21,  0, 0},
+	['&']  = {21,  0, 1},
+	['8']  = {24,  0, 0},
+	['*']  = {24,  0, 1},
+	['9']  = {27,  0, 0},
+	['(']  = {27,  0, 1},
+	['0']  = {30,  0, 0},
+	[')']  = {30,  0, 1},
+	['-']  = {33,  0, 0},
+	['_']  = {33,  0, 1},
+	['=']  = {36,  0, 0},
+	['+']  = {36,  0, 1},
+
+	['\t'] = { 1,  3, 0},
+	['q']  = { 4,  3, 0},
+	['Q']  = { 4,  3, 1},
+	['w']  = { 7,  3, 0},
+	['W']  = { 7,  3, 1},
+	['e']  = {10,  3, 0},
+	['E']  = {10,  3, 1},
+	['r']  = {13,  3, 0},
+	['R']  = {13,  3, 1},
+	['t']  = {16,  3, 0},
+	['T']  = {16,  3, 1},
+	['y']  = {19,  3, 0},
+	['Y']  = {19,  3, 1},
+	['u']  = {22,  3, 0},
+	['U']  = {22,  3, 1},
+	['i']  = {25,  3, 0},
+	['I']  = {25,  3, 1},
+	['o']  = {28,  3, 0},
+	['O']  = {28,  3, 1},
+	['p']  = {31,  3, 0},
+	['P']  = {31,  3, 1},
+	['[']  = {34,  3, 0},
+	['{']  = {34,  3, 1},
+	[']']  = {37,  3, 0},
+	['}']  = {37,  3, 1},
+	['\\'] = {40,  3, 0},
+	['|']  = {40,  3, 1},
+
+	['a']  = { 5,  6, 0},
+	['A']  = { 5,  6, 1},
+	['s']  = { 8,  6, 0},
+	['S']  = { 8,  6, 1},
+	['d']  = {11,  6, 0},
+	['D']  = {11,  6, 1},
+	['f']  = {14,  6, 0},
+	['F']  = {14,  6, 1},
+	['g']  = {17,  6, 0},
+	['G']  = {17,  6, 1},
+	['h']  = {20,  6, 0},
+	['H']  = {20,  6, 1},
+	['j']  = {23,  6, 0},
+	['J']  = {23,  6, 1},
+	['k']  = {26,  6, 0},
+	['K']  = {26,  6, 1},
+	['l']  = {29,  6, 0},
+	['L']  = {29,  6, 1},
+	[';']  = {32,  6, 0},
+	[':']  = {32,  6, 1},
+	['\''] = {35,  6, 0},
+	['"']  = {35,  6, 1},
+	['\n'] = {38,  6, 0},
+
+	['z']  = { 6,  9, 0},
+	['Z']  = { 6,  9, 1},
+	['x']  = { 9,  9, 0},
+	['X']  = { 9,  9, 1},
+	['c']  = {12,  9, 0},
+	['C']  = {12,  9, 1},
+	['v']  = {15,  9, 0},
+	['V']  = {15,  9, 1},
+	['b']  = {18,  9, 0},
+	['B']  = {18,  9, 1},
+	['n']  = {21,  9, 0},
+	['N']  = {21,  9, 1},
+	['m']  = {24,  9, 0},
+	['M']  = {24,  9, 1},
+	[',']  = {27,  9, 0},
+	['<']  = {27,  9, 1},
+	['.']  = {30,  9, 0},
+	['>']  = {30,  9, 1},
+	['/']  = {33,  9, 0},
+	['?']  = {33,  9, 1},
+
+	[' ']  = {18, 12, 0},
+};
+
+static int char_distance(char a, char b) {
+	const int *ac = key_coords[(unsigned char)a], *bc = key_coords[(unsigned char)b];
+	int ret = 0;
+	for (int i = 0; i < 3; ++i) {
+		ret += abs(ac[i] - bc[i]);
+	}
+	return ret;
+}
+
+int typo_distance(const char *actual, const char *expected) {
+	// This is the Wagner-Fischer algorithm for Levenshtein distance, using
+	// Manhattan distance on the keyboard for individual characters.
+
+	const int insert_cost = 12;
+
+	size_t rows = strlen(actual) + 1;
+	size_t cols = strlen(expected) + 1;
+
+	int arr0[cols], arr1[cols];
+	int *row0 = arr0, *row1 = arr1;
+
+	for (size_t j = 0; j < cols; ++j) {
+		row0[j] = insert_cost * j;
+	}
+
+	for (size_t i = 1; i < rows; ++i) {
+		row1[0] = row0[0] + insert_cost;
+
+		char a = actual[i - 1];
+		for (size_t j = 1; j < cols; ++j) {
+			char b = expected[j - 1];
+			int cost = row0[j - 1] + char_distance(a, b);
+			int del_cost = row0[j] + insert_cost;
+			if (del_cost < cost) {
+				cost = del_cost;
+			}
+			int ins_cost = row1[j - 1] + insert_cost;
+			if (ins_cost < cost) {
+				cost = ins_cost;
+			}
+			row1[j] = cost;
+		}
+
+		int *tmp = row0;
+		row0 = row1;
+		row1 = tmp;
+	}
+
+	return row0[cols - 1];
+}
diff --git a/src/typo.h b/src/typo.h
new file mode 100644
index 0000000..0347aae
--- /dev/null
+++ b/src/typo.h
@@ -0,0 +1,31 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2016 Tavian Barnes <tavianator@tavianator.com>             *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+#ifndef BFS_TYPO_H
+#define BFS_TYPO_H
+
+/**
+ * Find the "typo" distance between two strings.
+ *
+ * @param actual
+ *         The actual string typed by the user.
+ * @param expected
+ *         The expected valid string.
+ * @return The distance between the two strings.
+ */
+int typo_distance(const char *actual, const char *expected);
+
+#endif // BFS_TYPO_H
diff --git a/src/util.c b/src/util.c
new file mode 100644
index 0000000..a62e66c
--- /dev/null
+++ b/src/util.c
@@ -0,0 +1,510 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2016-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+#include "util.h"
+#include "dstring.h"
+#include "xregex.h"
+#include <assert.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <langinfo.h>
+#include <nl_types.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+#include <wchar.h>
+
+#if BFS_HAS_SYS_PARAM
+#	include <sys/param.h>
+#endif
+
+#if BFS_HAS_SYS_SYSMACROS
+#	include <sys/sysmacros.h>
+#elif BFS_HAS_SYS_MKDEV
+#	include <sys/mkdev.h>
+#endif
+
+#if BFS_HAS_UTIL
+#	include <util.h>
+#endif
+
+char *xreadlinkat(int fd, const char *path, size_t size) {
+	ssize_t len;
+	char *name = NULL;
+
+	if (size == 0) {
+		size = 64;
+	} else {
+		++size; // NUL terminator
+	}
+
+	while (true) {
+		char *new_name = realloc(name, size);
+		if (!new_name) {
+			goto error;
+		}
+		name = new_name;
+
+		len = readlinkat(fd, path, name, size);
+		if (len < 0) {
+			goto error;
+		} else if ((size_t)len >= size) {
+			size *= 2;
+		} else {
+			break;
+		}
+	}
+
+	name[len] = '\0';
+	return name;
+
+error:
+	free(name);
+	return NULL;
+}
+
+int dup_cloexec(int fd) {
+#ifdef F_DUPFD_CLOEXEC
+	return fcntl(fd, F_DUPFD_CLOEXEC, 0);
+#else
+	int ret = dup(fd);
+	if (ret < 0) {
+		return -1;
+	}
+
+	if (fcntl(ret, F_SETFD, FD_CLOEXEC) == -1) {
+		close_quietly(ret);
+		return -1;
+	}
+
+	return ret;
+#endif
+}
+
+int pipe_cloexec(int pipefd[2]) {
+#if __linux__ || (BSD && !__APPLE__)
+	return pipe2(pipefd, O_CLOEXEC);
+#else
+	if (pipe(pipefd) != 0) {
+		return -1;
+	}
+
+	if (fcntl(pipefd[0], F_SETFD, FD_CLOEXEC) == -1 || fcntl(pipefd[1], F_SETFD, FD_CLOEXEC) == -1) {
+		close_quietly(pipefd[1]);
+		close_quietly(pipefd[0]);
+		return -1;
+	}
+
+	return 0;
+#endif
+}
+
+/** Get the single character describing the given file type. */
+static char type_char(mode_t mode) {
+	switch (mode & S_IFMT) {
+	case S_IFREG:
+		return '-';
+	case S_IFBLK:
+		return 'b';
+	case S_IFCHR:
+		return 'c';
+	case S_IFDIR:
+		return 'd';
+	case S_IFLNK:
+		return 'l';
+	case S_IFIFO:
+		return 'p';
+	case S_IFSOCK:
+		return 's';
+#ifdef S_IFDOOR
+	case S_IFDOOR:
+		return 'D';
+#endif
+#ifdef S_IFPORT
+	case S_IFPORT:
+		return 'P';
+#endif
+#ifdef S_IFWHT
+	case S_IFWHT:
+		return 'w';
+#endif
+	}
+
+	return '?';
+}
+
+void xstrmode(mode_t mode, char str[11]) {
+	strcpy(str, "----------");
+
+	str[0] = type_char(mode);
+
+	if (mode & 00400) {
+		str[1] = 'r';
+	}
+	if (mode & 00200) {
+		str[2] = 'w';
+	}
+	if ((mode & 04100) == 04000) {
+		str[3] = 'S';
+	} else if (mode & 04000) {
+		str[3] = 's';
+	} else if (mode & 00100) {
+		str[3] = 'x';
+	}
+
+	if (mode & 00040) {
+		str[4] = 'r';
+	}
+	if (mode & 00020) {
+		str[5] = 'w';
+	}
+	if ((mode & 02010) == 02000) {
+		str[6] = 'S';
+	} else if (mode & 02000) {
+		str[6] = 's';
+	} else if (mode & 00010) {
+		str[6] = 'x';
+	}
+
+	if (mode & 00004) {
+		str[7] = 'r';
+	}
+	if (mode & 00002) {
+		str[8] = 'w';
+	}
+	if ((mode & 01001) == 01000) {
+		str[9] = 'T';
+	} else if (mode & 01000) {
+		str[9] = 't';
+	} else if (mode & 00001) {
+		str[9] = 'x';
+	}
+}
+
+const char *xbasename(const char *path) {
+	const char *i;
+
+	// Skip trailing slashes
+	for (i = path + strlen(path); i > path && i[-1] == '/'; --i);
+
+	// Find the beginning of the name
+	for (; i > path && i[-1] != '/'; --i);
+
+	// Skip leading slashes
+	for (; i[0] == '/' && i[1]; ++i);
+
+	return i;
+}
+
+int xfaccessat(int fd, const char *path, int amode) {
+	int ret = faccessat(fd, path, amode, 0);
+
+#ifdef AT_EACCESS
+	// Some platforms, like Hurd, only support AT_EACCESS.  Other platforms,
+	// like Android, don't support AT_EACCESS at all.
+	if (ret != 0 && (errno == EINVAL || errno == ENOTSUP)) {
+		ret = faccessat(fd, path, amode, AT_EACCESS);
+	}
+#endif
+
+	return ret;
+}
+
+int xstrtofflags(const char **str, unsigned long long *set, unsigned long long *clear) {
+#if BSD && !__GNU__
+	char *str_arg = (char *)*str;
+	unsigned long set_arg = 0;
+	unsigned long clear_arg = 0;
+
+#if __NetBSD__
+	int ret = string_to_flags(&str_arg, &set_arg, &clear_arg);
+#else
+	int ret = strtofflags(&str_arg, &set_arg, &clear_arg);
+#endif
+
+	*str = str_arg;
+	*set = set_arg;
+	*clear = clear_arg;
+
+	if (ret != 0) {
+		errno = EINVAL;
+	}
+	return ret;
+#else // !BSD
+	errno = ENOTSUP;
+	return -1;
+#endif
+}
+
+size_t xstrwidth(const char *str) {
+	size_t len = strlen(str);
+	size_t ret = 0;
+
+	mbstate_t mb;
+	memset(&mb, 0, sizeof(mb));
+
+	while (len > 0) {
+		wchar_t wc;
+		size_t mblen = mbrtowc(&wc, str, len, &mb);
+		int cwidth;
+		if (mblen == (size_t)-1) {
+			// Invalid byte sequence, assume a single-width '?'
+			mblen = 1;
+			cwidth = 1;
+			memset(&mb, 0, sizeof(mb));
+		} else if (mblen == (size_t)-2) {
+			// Incomplete byte sequence, assume a single-width '?'
+			mblen = len;
+			cwidth = 1;
+		} else {
+			cwidth = wcwidth(wc);
+			if (cwidth < 0) {
+				cwidth = 0;
+			}
+		}
+
+		str += mblen;
+		len -= mblen;
+		ret += cwidth;
+	}
+
+	return ret;
+}
+
+bool is_nonexistence_error(int error) {
+	return error == ENOENT || errno == ENOTDIR;
+}
+
+/** Compile and execute a regular expression for xrpmatch(). */
+static int xrpregex(nl_item item, const char *response) {
+	const char *pattern = nl_langinfo(item);
+	if (!pattern) {
+		return -1;
+	}
+
+	struct bfs_regex *regex;
+	int ret = bfs_regcomp(&regex, pattern, BFS_REGEX_POSIX_EXTENDED, 0);
+	if (ret == 0) {
+		ret = bfs_regexec(regex, response, 0);
+	}
+
+	bfs_regfree(regex);
+	return ret;
+}
+
+/** Check if a response is affirmative or negative. */
+static int xrpmatch(const char *response) {
+	int ret = xrpregex(NOEXPR, response);
+	if (ret > 0) {
+		return 0;
+	} else if (ret < 0) {
+		return -1;
+	}
+
+	ret = xrpregex(YESEXPR, response);
+	if (ret > 0) {
+		return 1;
+	} else if (ret < 0) {
+		return -1;
+	}
+
+	// Failsafe: always handle y/n
+	char c = response[0];
+	if (c == 'n' || c == 'N') {
+		return 0;
+	} else if (c == 'y' || c == 'Y') {
+		return 1;
+	} else {
+		return -1;
+	}
+}
+
+int ynprompt(void) {
+	fflush(stderr);
+
+	char *line = xgetdelim(stdin, '\n');
+	int ret = line ? xrpmatch(line) : -1;
+	free(line);
+	return ret;
+}
+
+dev_t bfs_makedev(int ma, int mi) {
+#ifdef makedev
+	return makedev(ma, mi);
+#else
+	return (ma << 8) | mi;
+#endif
+}
+
+int bfs_major(dev_t dev) {
+#ifdef major
+	return major(dev);
+#else
+	return dev >> 8;
+#endif
+}
+
+int bfs_minor(dev_t dev) {
+#ifdef minor
+	return minor(dev);
+#else
+	return dev & 0xFF;
+#endif
+}
+
+size_t xread(int fd, void *buf, size_t nbytes) {
+	size_t count = 0;
+
+	while (count < nbytes) {
+		ssize_t ret = read(fd, (char *)buf + count, nbytes - count);
+		if (ret < 0) {
+			if (errno == EINTR) {
+				continue;
+			} else {
+				break;
+			}
+		} else if (ret == 0) {
+			// EOF
+			errno = 0;
+			break;
+		} else {
+			count += ret;
+		}
+	}
+
+	return count;
+}
+
+size_t xwrite(int fd, const void *buf, size_t nbytes) {
+	size_t count = 0;
+
+	while (count < nbytes) {
+		ssize_t ret = write(fd, (const char *)buf + count, nbytes - count);
+		if (ret < 0) {
+			if (errno == EINTR) {
+				continue;
+			} else {
+				break;
+			}
+		} else if (ret == 0) {
+			// EOF?
+			errno = 0;
+			break;
+		} else {
+			count += ret;
+		}
+	}
+
+	return count;
+}
+
+char *xconfstr(int name) {
+	size_t len = confstr(name, NULL, 0);
+	if (len == 0) {
+		return NULL;
+	}
+
+	char *str = malloc(len);
+	if (!str) {
+		return NULL;
+	}
+
+	if (confstr(name, str, len) != len) {
+		free(str);
+		return NULL;
+	}
+
+	return str;
+}
+
+char *xgetdelim(FILE *file, char delim) {
+	char *chunk = NULL;
+	size_t n = 0;
+	ssize_t len = getdelim(&chunk, &n, delim, file);
+	if (len >= 0) {
+		if (chunk[len] == delim) {
+			chunk[len] = '\0';
+		}
+		return chunk;
+	} else {
+		free(chunk);
+		if (!ferror(file)) {
+			errno = 0;
+		}
+		return NULL;
+	}
+}
+
+FILE *xfopen(const char *path, int flags) {
+	char mode[4];
+
+	switch (flags & O_ACCMODE) {
+	case O_RDONLY:
+		strcpy(mode, "rb");
+		break;
+	case O_WRONLY:
+		strcpy(mode, "wb");
+		break;
+	case O_RDWR:
+		strcpy(mode, "r+b");
+		break;
+	default:
+		assert(!"Invalid access mode");
+		errno = EINVAL;
+		return NULL;
+	}
+
+	if (flags & O_APPEND) {
+		mode[0] = 'a';
+	}
+
+	int fd;
+	if (flags & O_CREAT) {
+		fd = open(path, flags, 0666);
+	} else {
+		fd = open(path, flags);
+	}
+
+	if (fd < 0) {
+		return NULL;
+	}
+
+	FILE *ret = fdopen(fd, mode);
+	if (!ret) {
+		close_quietly(fd);
+		return NULL;
+	}
+
+	return ret;
+}
+
+int xclose(int fd) {
+	int ret = close(fd);
+	if (ret != 0) {
+		assert(errno != EBADF);
+	}
+	return ret;
+}
+
+void close_quietly(int fd) {
+	int error = errno;
+	xclose(fd);
+	errno = error;
+}
diff --git a/src/util.h b/src/util.h
new file mode 100644
index 0000000..b5c7d80
--- /dev/null
+++ b/src/util.h
@@ -0,0 +1,317 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2016-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * Assorted utilities that don't belong anywhere else.
+ */
+
+#ifndef BFS_UTIL_H
+#define BFS_UTIL_H
+
+#include <fcntl.h>
+#include <fnmatch.h>
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdio.h>
+#include <sys/types.h>
+
+// Some portability concerns
+
+#ifdef __has_feature
+#	define BFS_HAS_FEATURE(feature, fallback) __has_feature(feature)
+#else
+#	define BFS_HAS_FEATURE(feature, fallback) fallback
+#endif
+
+#ifdef __has_include
+#	define BFS_HAS_INCLUDE(header, fallback) __has_include(header)
+#else
+#	define BFS_HAS_INCLUDE(header, fallback) fallback
+#endif
+
+#ifdef __has_c_attribute
+#	define BFS_HAS_C_ATTRIBUTE(attr) __has_c_attribute(attr)
+#else
+#	define BFS_HAS_C_ATTRIBUTE(attr) false
+#endif
+
+#if __GNUC__ && defined(__has_attribute)
+#	define BFS_HAS_GNU_ATTRIBUTE(attr) __has_attribute(attr)
+#else
+#	define BFS_HAS_GNU_ATTRIBUTE(attr) false
+#endif
+
+#ifndef BFS_HAS_MNTENT
+#	define BFS_HAS_MNTENT BFS_HAS_INCLUDE(<mntent.h>, __GLIBC__)
+#endif
+
+#ifndef BFS_HAS_SYS_ACL
+#	define BFS_HAS_SYS_ACL BFS_HAS_INCLUDE(<sys/acl.h>, true)
+#endif
+
+#ifndef BFS_HAS_SYS_CAPABILITY
+#	define BFS_HAS_SYS_CAPABILITY BFS_HAS_INCLUDE(<sys/capability.h>, __linux__)
+#endif
+
+#ifndef BFS_HAS_SYS_EXTATTR
+#	define BFS_HAS_SYS_EXTATTR BFS_HAS_INCLUDE(<sys/extattr.h>, __FreeBSD__)
+#endif
+
+#ifndef BFS_HAS_SYS_MKDEV
+#	define BFS_HAS_SYS_MKDEV BFS_HAS_INCLUDE(<sys/mkdev.h>, false)
+#endif
+
+#ifndef BFS_HAS_SYS_PARAM
+#	define BFS_HAS_SYS_PARAM BFS_HAS_INCLUDE(<sys/param.h>, true)
+#endif
+
+#ifndef BFS_HAS_SYS_SYSMACROS
+#	define BFS_HAS_SYS_SYSMACROS BFS_HAS_INCLUDE(<sys/sysmacros.h>, __GLIBC__)
+#endif
+
+#ifndef BFS_HAS_SYS_XATTR
+#	define BFS_HAS_SYS_XATTR BFS_HAS_INCLUDE(<sys/xattr.h>, __linux__)
+#endif
+
+#ifndef BFS_HAS_UTIL
+#	define BFS_HAS_UTIL BFS_HAS_INCLUDE(<util.h>, __NetBSD__)
+#endif
+
+#if !defined(FNM_CASEFOLD) && defined(FNM_IGNORECASE)
+#	define FNM_CASEFOLD FNM_IGNORECASE
+#endif
+
+#ifndef O_DIRECTORY
+#	define O_DIRECTORY 0
+#endif
+
+#if BFS_HAS_C_ATTRIBUTE(fallthrough)
+#	define BFS_FALLTHROUGH [[fallthrough]]
+#elif BFS_HAS_GNU_ATTRIBUTE(fallthrough)
+#	define BFS_FALLTHROUGH __attribute__((fallthrough))
+#else
+#	define BFS_FALLTHROUGH ((void)0)
+#endif
+
+/**
+ * Adds compiler warnings for bad printf()-style function calls, if supported.
+ */
+#if BFS_HAS_GNU_ATTRIBUTE(format)
+#	define BFS_FORMATTER(fmt, args) __attribute__((format(printf, fmt, args)))
+#else
+#	define BFS_FORMATTER(fmt, args)
+#endif
+
+// Lower bound on BFS_FLEX_SIZEOF()
+#define BFS_FLEX_LB(type, member, length) (offsetof(type, member) + sizeof(((type *)NULL)->member[0]) * (length))
+
+// Maximum macro for BFS_FLEX_SIZE()
+#define BFS_FLEX_MAX(a, b) ((a) > (b) ? (a) : (b))
+
+/**
+ * Computes the size of a struct containing a flexible array member of the given
+ * length.
+ *
+ * @param type
+ *         The type of the struct containing the flexible array.
+ * @param member
+ *         The name of the flexible array member.
+ * @param length
+ *         The length of the flexible array.
+ */
+#define BFS_FLEX_SIZEOF(type, member, length) \
+	(sizeof(type) <= BFS_FLEX_LB(type, member, 0) \
+		? BFS_FLEX_LB(type, member, length) \
+		: BFS_FLEX_MAX(sizeof(type), BFS_FLEX_LB(type, member, length)))
+
+/**
+ * readlinkat() wrapper that dynamically allocates the result.
+ *
+ * @param fd
+ *         The base directory descriptor.
+ * @param path
+ *         The path to the link, relative to fd.
+ * @param size
+ *         An estimate for the size of the link name (pass 0 if unknown).
+ * @return The target of the link, allocated with malloc(), or NULL on failure.
+ */
+char *xreadlinkat(int fd, const char *path, size_t size);
+
+/**
+ * Like dup(), but set the FD_CLOEXEC flag.
+ *
+ * @param fd
+ *         The file descriptor to duplicate.
+ * @return A duplicated file descriptor, or -1 on failure.
+ */
+int dup_cloexec(int fd);
+
+/**
+ * Like pipe(), but set the FD_CLOEXEC flag.
+ *
+ * @param pipefd
+ *         The array to hold the two file descriptors.
+ * @return 0 on success, -1 on failure.
+ */
+int pipe_cloexec(int pipefd[2]);
+
+/**
+ * Format a mode like ls -l (e.g. -rw-r--r--).
+ *
+ * @param mode
+ *         The mode to format.
+ * @param str
+ *         The string to hold the formatted mode.
+ */
+void xstrmode(mode_t mode, char str[11]);
+
+/**
+ * basename() variant that doesn't modify the input.
+ *
+ * @param path
+ *         The path in question.
+ * @return A pointer into path at the base name offset.
+ */
+const char *xbasename(const char *path);
+
+/**
+ * Wrapper for faccessat() that handles some portability issues.
+ */
+int xfaccessat(int fd, const char *path, int amode);
+
+/**
+ * Portability wrapper for strtofflags().
+ *
+ * @param str
+ *         The string to parse.  The pointee will be advanced to the first
+ *         invalid position on error.
+ * @param set
+ *         The flags that are set in the string.
+ * @param clear
+ *         The flags that are cleared in the string.
+ * @return
+ *         0 on success, -1 on failure.
+ */
+int xstrtofflags(const char **str, unsigned long long *set, unsigned long long *clear);
+
+/**
+ * wcswidth() variant that works on narrow strings.
+ *
+ * @param str
+ *         The string to measure.
+ * @return
+ *         The likely width of that string in a terminal.
+ */
+size_t xstrwidth(const char *str);
+
+/**
+ * Return whether an error code is due to a path not existing.
+ */
+bool is_nonexistence_error(int error);
+
+/**
+ * Process a yes/no prompt.
+ *
+ * @return 1 for yes, 0 for no, and -1 for unknown.
+ */
+int ynprompt(void);
+
+/**
+ * Portable version of makedev().
+ */
+dev_t bfs_makedev(int ma, int mi);
+
+/**
+ * Portable version of major().
+ */
+int bfs_major(dev_t dev);
+
+/**
+ * Portable version of minor().
+ */
+int bfs_minor(dev_t dev);
+
+/**
+ * A safe version of read() that handles interrupted system calls and partial
+ * reads.
+ *
+ * @return
+ *         The number of bytes read.  A value != nbytes indicates an error
+ *         (errno != 0) or end of file (errno == 0).
+ */
+size_t xread(int fd, void *buf, size_t nbytes);
+
+/**
+ * A safe version of write() that handles interrupted system calls and partial
+ * writes.
+ *
+ * @return
+           The number of bytes written.  A value != nbytes indicates an error.
+ */
+size_t xwrite(int fd, const void *buf, size_t nbytes);
+
+/**
+ * Wrapper for confstr() that allocates with malloc().
+ *
+ * @param name
+ *         The ID of the confstr to look up.
+ * @return
+ *         The value of the confstr, or NULL on failure.
+ */
+char *xconfstr(int name);
+
+/**
+ * Convenience wrapper for getdelim().
+ *
+ * @param file
+ *         The file to read.
+ * @param delim
+ *         The delimiter character to split on.
+ * @return
+ *         The read chunk (without the delimiter), allocated with malloc().
+ *         NULL is returned on error (errno != 0) or end of file (errno == 0).
+ */
+char *xgetdelim(FILE *file, char delim);
+
+/**
+ * fopen() variant that takes open() style flags.
+ *
+ * @param path
+ *         The path to open.
+ * @param flags
+ *         Flags to pass to open().
+ */
+FILE *xfopen(const char *path, int flags);
+
+/**
+ * close() wrapper that asserts the file descriptor is valid.
+ *
+ * @param fd
+ *         The file descriptor to close.
+ * @return
+ *         0 on success, or -1 on error.
+ */
+int xclose(int fd);
+
+/**
+ * close() variant that preserves errno.
+ *
+ * @param fd
+ *         The file descriptor to close.
+ */
+void close_quietly(int fd);
+
+#endif // BFS_UTIL_H
diff --git a/src/xregex.c b/src/xregex.c
new file mode 100644
index 0000000..3c3cf35
--- /dev/null
+++ b/src/xregex.c
@@ -0,0 +1,301 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2022 Tavian Barnes <tavianator@tavianator.com>             *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+#include "xregex.h"
+#include "util.h"
+#include <assert.h>
+#include <errno.h>
+#include <stdlib.h>
+#include <string.h>
+
+#if BFS_WITH_ONIGURUMA
+#	include <langinfo.h>
+#	include <oniguruma.h>
+#else
+#	include <regex.h>
+#endif
+
+struct bfs_regex {
+#if BFS_WITH_ONIGURUMA
+	unsigned char *pattern;
+	OnigRegex impl;
+	int err;
+	OnigErrorInfo einfo;
+#else
+	regex_t impl;
+	int err;
+#endif
+};
+
+#if BFS_WITH_ONIGURUMA
+/** Get (and initialize) the appropriate encoding for the current locale. */
+static int bfs_onig_encoding(OnigEncoding *penc) {
+	static OnigEncoding enc = NULL;
+	if (enc) {
+		*penc = enc;
+		return ONIG_NORMAL;
+	}
+
+	// Fall back to ASCII by default
+	enc = ONIG_ENCODING_ASCII;
+
+	// Oniguruma has no locale support, so try to guess the right encoding
+	// from the current locale.
+	const char *charmap = nl_langinfo(CODESET);
+	if (charmap) {
+#define BFS_MAP_ENCODING(name, value)				\
+		do {						\
+			if (strcmp(charmap, name) == 0) {	\
+				enc = value;			\
+			}					\
+		} while (0)
+#define BFS_MAP_ENCODING2(name1, name2, value)		\
+		do {					\
+			BFS_MAP_ENCODING(name1, value);	\
+			BFS_MAP_ENCODING(name2, value);	\
+		} while (0)
+
+		// These names were found with locale -m on Linux and FreeBSD
+#define BFS_MAP_ISO_8859(n)						\
+		BFS_MAP_ENCODING2("ISO-8859-" #n, "ISO8859-" #n, ONIG_ENCODING_ISO_8859_ ## n)
+
+		BFS_MAP_ISO_8859(1);
+		BFS_MAP_ISO_8859(2);
+		BFS_MAP_ISO_8859(3);
+		BFS_MAP_ISO_8859(4);
+		BFS_MAP_ISO_8859(5);
+		BFS_MAP_ISO_8859(6);
+		BFS_MAP_ISO_8859(7);
+		BFS_MAP_ISO_8859(8);
+		BFS_MAP_ISO_8859(9);
+		BFS_MAP_ISO_8859(10);
+		BFS_MAP_ISO_8859(11);
+		// BFS_MAP_ISO_8859(12);
+		BFS_MAP_ISO_8859(13);
+		BFS_MAP_ISO_8859(14);
+		BFS_MAP_ISO_8859(15);
+		BFS_MAP_ISO_8859(16);
+
+		BFS_MAP_ENCODING("UTF-8", ONIG_ENCODING_UTF8);
+
+#define BFS_MAP_EUC(name)						\
+		BFS_MAP_ENCODING2("EUC-" #name, "euc" #name, ONIG_ENCODING_EUC_ ## name)
+
+		BFS_MAP_EUC(JP);
+		BFS_MAP_EUC(TW);
+		BFS_MAP_EUC(KR);
+		BFS_MAP_EUC(CN);
+
+		BFS_MAP_ENCODING2("SHIFT_JIS", "SJIS", ONIG_ENCODING_SJIS);
+
+		// BFS_MAP_ENCODING("KOI-8", ONIG_ENCODING_KOI8);
+		BFS_MAP_ENCODING("KOI8-R", ONIG_ENCODING_KOI8_R);
+
+		BFS_MAP_ENCODING("CP1251", ONIG_ENCODING_CP1251);
+
+		BFS_MAP_ENCODING("GB18030", ONIG_ENCODING_BIG5);
+	}
+
+	int ret = onig_initialize(&enc, 1);
+	if (ret != ONIG_NORMAL) {
+		enc = NULL;
+	}
+	*penc = enc;
+	return ret;
+}
+#endif
+
+int bfs_regcomp(struct bfs_regex **preg, const char *pattern, enum bfs_regex_type type, enum bfs_regcomp_flags flags) {
+	struct bfs_regex *regex = *preg = malloc(sizeof(*regex));
+	if (!regex) {
+		return -1;
+	}
+
+#if BFS_WITH_ONIGURUMA
+	// onig_error_code_to_str() says
+	//
+	//     don't call this after the pattern argument of onig_new() is freed
+	//
+	// so make a defensive copy.
+	regex->pattern = (unsigned char *)strdup(pattern);
+	if (!regex->pattern) {
+		goto fail;
+	}
+
+	regex->impl = NULL;
+	regex->err = ONIG_NORMAL;
+
+	OnigSyntaxType *syntax = NULL;
+	switch (type) {
+	case BFS_REGEX_POSIX_BASIC:
+		syntax = ONIG_SYNTAX_POSIX_BASIC;
+		break;
+	case BFS_REGEX_POSIX_EXTENDED:
+		syntax = ONIG_SYNTAX_POSIX_EXTENDED;
+		break;
+	case BFS_REGEX_EMACS:
+		syntax = ONIG_SYNTAX_EMACS;
+		break;
+	case BFS_REGEX_GREP:
+		syntax = ONIG_SYNTAX_GREP;
+		break;
+	}
+	assert(syntax);
+
+	OnigOptionType options = syntax->options;
+	if (flags & BFS_REGEX_ICASE) {
+		options |= ONIG_OPTION_IGNORECASE;
+	}
+
+	OnigEncoding enc;
+	regex->err = bfs_onig_encoding(&enc);
+	if (regex->err != ONIG_NORMAL) {
+		return -1;
+	}
+
+	const unsigned char *end = regex->pattern + strlen(pattern);
+	regex->err = onig_new(&regex->impl, regex->pattern, end, options, enc, syntax, &regex->einfo);
+	if (regex->err != ONIG_NORMAL) {
+		return -1;
+	}
+#else
+	int cflags = 0;
+	switch (type) {
+	case BFS_REGEX_POSIX_BASIC:
+		break;
+	case BFS_REGEX_POSIX_EXTENDED:
+		cflags |= REG_EXTENDED;
+		break;
+	default:
+		errno = EINVAL;
+		goto fail;
+	}
+
+	if (flags & BFS_REGEX_ICASE) {
+		cflags |= REG_ICASE;
+	}
+
+#if BFS_HAS_FEATURE(memory_sanitizer, false)
+	// https://github.com/google/sanitizers/issues/1496
+	memset(&regex->impl, 0, sizeof(regex->impl));
+#endif
+
+	regex->err = regcomp(&regex->impl, pattern, cflags);
+	if (regex->err != 0) {
+		return -1;
+	}
+#endif
+
+	return 0;
+
+fail:
+	free(regex);
+	*preg = NULL;
+	return -1;
+}
+
+int bfs_regexec(struct bfs_regex *regex, const char *str, enum bfs_regexec_flags flags) {
+	size_t len = strlen(str);
+
+#if BFS_WITH_ONIGURUMA
+	const unsigned char *ustr = (const unsigned char *)str;
+	const unsigned char *end = ustr + len;
+
+	// The docs for onig_{match,search}() say
+	//
+	//     Do not pass invalid byte string in the regex character encoding.
+	if (!onigenc_is_valid_mbc_string(onig_get_encoding(regex->impl), ustr, end)) {
+		return 0;
+	}
+
+	int ret;
+	if (flags & BFS_REGEX_ANCHOR) {
+		ret = onig_match(regex->impl, ustr, end, ustr, NULL, ONIG_OPTION_DEFAULT);
+	} else {
+		ret = onig_search(regex->impl, ustr, end, ustr, end, NULL, ONIG_OPTION_DEFAULT);
+	}
+
+	if (ret >= 0) {
+		if (flags & BFS_REGEX_ANCHOR) {
+			return (size_t)ret == len;
+		} else {
+			return 1;
+		}
+	} else if (ret == ONIG_MISMATCH) {
+		return 0;
+	} else {
+		regex->err = ret;
+		return -1;
+	}
+#else
+	regmatch_t match = {
+		.rm_so = 0,
+		.rm_eo = len,
+	};
+
+	int eflags = 0;
+#ifdef REG_STARTEND
+	eflags |= REG_STARTEND;
+#endif
+
+	int ret = regexec(&regex->impl, str, 1, &match, eflags);
+	if (ret == 0) {
+		if (flags & BFS_REGEX_ANCHOR) {
+			return match.rm_so == 0 && (size_t)match.rm_eo == len;
+		} else {
+			return 1;
+		}
+	} else if (ret == REG_NOMATCH) {
+		return 0;
+	} else {
+		regex->err = ret;
+		return -1;
+	}
+#endif
+}
+
+void bfs_regfree(struct bfs_regex *regex) {
+	if (regex) {
+#if BFS_WITH_ONIGURUMA
+		onig_free(regex->impl);
+		free(regex->pattern);
+#else
+		regfree(&regex->impl);
+#endif
+		free(regex);
+	}
+}
+
+char *bfs_regerror(const struct bfs_regex *regex) {
+	if (!regex) {
+		return strdup(strerror(ENOMEM));
+	}
+
+#if BFS_WITH_ONIGURUMA
+	unsigned char *str = malloc(ONIG_MAX_ERROR_MESSAGE_LEN);
+	if (str) {
+		onig_error_code_to_str(str, regex->err, &regex->einfo);
+	}
+	return (char *)str;
+#else
+	size_t len = regerror(regex->err, &regex->impl, NULL, 0);
+	char *str = malloc(len);
+	if (str) {
+		regerror(regex->err, &regex->impl, str, len);
+	}
+	return str;
+#endif
+}
diff --git a/src/xregex.h b/src/xregex.h
new file mode 100644
index 0000000..b2f56a5
--- /dev/null
+++ b/src/xregex.h
@@ -0,0 +1,97 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2022 Tavian Barnes <tavianator@tavianator.com> and bfs     *
+ * contributors                                                             *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+#ifndef BFS_XREGEX_H
+#define BFS_XREGEX_H
+
+/**
+ * A compiled regular expression.
+ */
+struct bfs_regex;
+
+/**
+ * Regex syntax flavors.
+ */
+enum bfs_regex_type {
+	BFS_REGEX_POSIX_BASIC,
+	BFS_REGEX_POSIX_EXTENDED,
+	BFS_REGEX_EMACS,
+	BFS_REGEX_GREP,
+};
+
+/**
+ * Regex compilation flags.
+ */
+enum bfs_regcomp_flags {
+	/** Treat the regex case-insensitively. */
+	BFS_REGEX_ICASE = 1 << 0,
+};
+
+/**
+ * Regex execution flags.
+ */
+enum bfs_regexec_flags {
+	/** Only treat matches of the entire string as successful. */
+	BFS_REGEX_ANCHOR = 1 << 0,
+};
+
+/**
+ * Wrapper for regcomp() that supports additional regex types.
+ *
+ * @param[out] preg
+ *         Will hold the compiled regex.
+ * @param pattern
+ *         The regular expression to compile.
+ * @param type
+ *         The regular expression syntax to use.
+ * @param flags
+ *         Regex compilation flags.
+ * @return
+ *         0 on success, -1 on failure.
+ */
+int bfs_regcomp(struct bfs_regex **preg, const char *pattern, enum bfs_regex_type type, enum bfs_regcomp_flags flags);
+
+/**
+ * Wrapper for regexec().
+ *
+ * @param regex
+ *         The regular expression to execute.
+ * @param str
+ *         The string to match against.
+ * @param flags
+ *         Regex execution flags.
+ * @return
+ *         1 for a match, 0 for no match, -1 on failure.
+ */
+int bfs_regexec(struct bfs_regex *regex, const char *str, enum bfs_regexec_flags flags);
+
+/**
+ * Free a compiled regex.
+ */
+void bfs_regfree(struct bfs_regex *regex);
+
+/**
+ * Get a human-readable regex error message.
+ *
+ * @param regex
+ *         The compiled regex.
+ * @return
+ *         A human-readable description of the error, which should be free()'d.
+ */
+char *bfs_regerror(const struct bfs_regex *regex);
+
+#endif // BFS_XREGEX_H
diff --git a/src/xspawn.c b/src/xspawn.c
new file mode 100644
index 0000000..93c270a
--- /dev/null
+++ b/src/xspawn.c
@@ -0,0 +1,318 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2018-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+#include "xspawn.h"
+#include "util.h"
+#include <errno.h>
+#include <fcntl.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/types.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+/**
+ * Types of spawn actions.
+ */
+enum bfs_spawn_op {
+	BFS_SPAWN_CLOSE,
+	BFS_SPAWN_DUP2,
+	BFS_SPAWN_FCHDIR,
+	BFS_SPAWN_SETRLIMIT,
+};
+
+/**
+ * A spawn action.
+ */
+struct bfs_spawn_action {
+	struct bfs_spawn_action *next;
+
+	enum bfs_spawn_op op;
+	int in_fd;
+	int out_fd;
+	int resource;
+	struct rlimit rlimit;
+};
+
+int bfs_spawn_init(struct bfs_spawn *ctx) {
+	ctx->flags = 0;
+	ctx->actions = NULL;
+	ctx->tail = &ctx->actions;
+	return 0;
+}
+
+int bfs_spawn_destroy(struct bfs_spawn *ctx) {
+	struct bfs_spawn_action *action = ctx->actions;
+	while (action) {
+		struct bfs_spawn_action *next = action->next;
+		free(action);
+		action = next;
+	}
+	return 0;
+}
+
+int bfs_spawn_setflags(struct bfs_spawn *ctx, enum bfs_spawn_flags flags) {
+	ctx->flags = flags;
+	return 0;
+}
+
+/** Add a spawn action to the chain. */
+static struct bfs_spawn_action *bfs_spawn_add(struct bfs_spawn *ctx, enum bfs_spawn_op op) {
+	struct bfs_spawn_action *action = malloc(sizeof(*action));
+	if (action) {
+		action->next = NULL;
+		action->op = op;
+		action->in_fd = -1;
+		action->out_fd = -1;
+
+		*ctx->tail = action;
+		ctx->tail = &action->next;
+	}
+	return action;
+}
+
+int bfs_spawn_addclose(struct bfs_spawn *ctx, int fd) {
+	if (fd < 0) {
+		errno = EBADF;
+		return -1;
+	}
+
+	struct bfs_spawn_action *action = bfs_spawn_add(ctx, BFS_SPAWN_CLOSE);
+	if (action) {
+		action->out_fd = fd;
+		return 0;
+	} else {
+		return -1;
+	}
+}
+
+int bfs_spawn_adddup2(struct bfs_spawn *ctx, int oldfd, int newfd) {
+	if (oldfd < 0 || newfd < 0) {
+		errno = EBADF;
+		return -1;
+	}
+
+	struct bfs_spawn_action *action = bfs_spawn_add(ctx, BFS_SPAWN_DUP2);
+	if (action) {
+		action->in_fd = oldfd;
+		action->out_fd = newfd;
+		return 0;
+	} else {
+		return -1;
+	}
+}
+
+int bfs_spawn_addfchdir(struct bfs_spawn *ctx, int fd) {
+	if (fd < 0) {
+		errno = EBADF;
+		return -1;
+	}
+
+	struct bfs_spawn_action *action = bfs_spawn_add(ctx, BFS_SPAWN_FCHDIR);
+	if (action) {
+		action->in_fd = fd;
+		return 0;
+	} else {
+		return -1;
+	}
+}
+
+int bfs_spawn_addsetrlimit(struct bfs_spawn *ctx, int resource, const struct rlimit *rl) {
+	struct bfs_spawn_action *action = bfs_spawn_add(ctx, BFS_SPAWN_SETRLIMIT);
+	if (action) {
+		action->resource = resource;
+		action->rlimit = *rl;
+		return 0;
+	} else {
+		return -1;
+	}
+}
+
+/** Actually exec() the new process. */
+static void bfs_spawn_exec(const char *exe, const struct bfs_spawn *ctx, char **argv, char **envp, int pipefd[2]) {
+	int error;
+	const struct bfs_spawn_action *actions = ctx ? ctx->actions : NULL;
+
+	xclose(pipefd[0]);
+
+	for (const struct bfs_spawn_action *action = actions; action; action = action->next) {
+		// Move the error-reporting pipe out of the way if necessary...
+		if (action->out_fd == pipefd[1]) {
+			int fd = dup_cloexec(pipefd[1]);
+			if (fd < 0) {
+				goto fail;
+			}
+			xclose(pipefd[1]);
+			pipefd[1] = fd;
+		}
+
+		// ... and pretend the pipe doesn't exist
+		if (action->in_fd == pipefd[1]) {
+			errno = EBADF;
+			goto fail;
+		}
+
+		switch (action->op) {
+		case BFS_SPAWN_CLOSE:
+			if (close(action->out_fd) != 0) {
+				goto fail;
+			}
+			break;
+		case BFS_SPAWN_DUP2:
+			if (dup2(action->in_fd, action->out_fd) < 0) {
+				goto fail;
+			}
+			break;
+		case BFS_SPAWN_FCHDIR:
+			if (fchdir(action->in_fd) != 0) {
+				goto fail;
+			}
+			break;
+		case BFS_SPAWN_SETRLIMIT:
+			if (setrlimit(action->resource, &action->rlimit) != 0) {
+				goto fail;
+			}
+			break;
+		}
+	}
+
+	execve(exe, argv, envp);
+
+fail:
+	error = errno;
+
+	// In case of a write error, the parent will still see that we exited
+	// unsuccessfully, but won't know why
+	(void) xwrite(pipefd[1], &error, sizeof(error));
+
+	xclose(pipefd[1]);
+	_Exit(127);
+}
+
+pid_t bfs_spawn(const char *exe, const struct bfs_spawn *ctx, char **argv, char **envp) {
+	extern char **environ;
+	if (!envp) {
+		envp = environ;
+	}
+
+	enum bfs_spawn_flags flags = ctx ? ctx->flags : 0;
+	char *resolved = NULL;
+	if (flags & BFS_SPAWN_USEPATH) {
+		exe = resolved = bfs_spawn_resolve(exe);
+		if (!resolved) {
+			return -1;
+		}
+	}
+
+	// Use a pipe to report errors from the child
+	int pipefd[2];
+	if (pipe_cloexec(pipefd) != 0) {
+		free(resolved);
+		return -1;
+	}
+
+	pid_t pid = fork();
+	if (pid < 0) {
+		close_quietly(pipefd[1]);
+		close_quietly(pipefd[0]);
+		free(resolved);
+		return -1;
+	} else if (pid == 0) {
+		// Child
+		bfs_spawn_exec(exe, ctx, argv, envp, pipefd);
+	}
+
+	// Parent
+	xclose(pipefd[1]);
+	free(resolved);
+
+	int error;
+	ssize_t nbytes = xread(pipefd[0], &error, sizeof(error));
+	xclose(pipefd[0]);
+	if (nbytes == sizeof(error)) {
+		int wstatus;
+		waitpid(pid, &wstatus, 0);
+		errno = error;
+		return -1;
+	}
+
+	return pid;
+}
+
+char *bfs_spawn_resolve(const char *exe) {
+	if (strchr(exe, '/')) {
+		return strdup(exe);
+	}
+
+	const char *path = getenv("PATH");
+
+	char *confpath = NULL;
+	if (!path) {
+		path = confpath = xconfstr(_CS_PATH);
+		if (!path) {
+			return NULL;
+		}
+	}
+
+	size_t cap = 0;
+	char *ret = NULL;
+	while (true) {
+		const char *end = strchr(path, ':');
+		size_t len = end ? (size_t)(end - path) : strlen(path);
+
+		// POSIX 8.3: "A zero-length prefix is a legacy feature that
+		// indicates the current working directory."
+		if (len == 0) {
+			path = ".";
+			len = 1;
+		}
+
+		size_t total = len + 1 + strlen(exe) + 1;
+		if (cap < total) {
+			char *grown = realloc(ret, total);
+			if (!grown) {
+				goto fail;
+			}
+			ret = grown;
+			cap = total;
+		}
+
+		memcpy(ret, path, len);
+		if (ret[len - 1] != '/') {
+			ret[len++] = '/';
+		}
+		strcpy(ret + len, exe);
+
+		if (xfaccessat(AT_FDCWD, ret, X_OK) == 0) {
+			break;
+		}
+
+		if (!end) {
+			errno = ENOENT;
+			goto fail;
+		}
+
+		path = end + 1;
+	}
+
+	free(confpath);
+	return ret;
+
+fail:
+	free(confpath);
+	free(ret);
+	return NULL;
+}
diff --git a/src/xspawn.h b/src/xspawn.h
new file mode 100644
index 0000000..cd6a42e
--- /dev/null
+++ b/src/xspawn.h
@@ -0,0 +1,123 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2018-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * A process-spawning library inspired by posix_spawn().
+ */
+
+#ifndef BFS_XSPAWN_H
+#define BFS_XSPAWN_H
+
+#include <sys/resource.h>
+#include <sys/types.h>
+
+/**
+ * bfs_spawn() flags.
+ */
+enum bfs_spawn_flags {
+	/** Use the PATH variable to resolve the executable (like execvp()). */
+	BFS_SPAWN_USEPATH = 1 << 0,
+};
+
+/**
+ * bfs_spawn() attributes, controlling the context of the new process.
+ */
+struct bfs_spawn {
+	enum bfs_spawn_flags flags;
+	struct bfs_spawn_action *actions;
+	struct bfs_spawn_action **tail;
+};
+
+/**
+ * Create a new bfs_spawn() context.
+ *
+ * @return 0 on success, -1 on failure.
+ */
+int bfs_spawn_init(struct bfs_spawn *ctx);
+
+/**
+ * Destroy a bfs_spawn() context.
+ *
+ * @return 0 on success, -1 on failure.
+ */
+int bfs_spawn_destroy(struct bfs_spawn *ctx);
+
+/**
+ * Set the flags for a bfs_spawn() context.
+ *
+ * @return 0 on success, -1 on failure.
+ */
+int bfs_spawn_setflags(struct bfs_spawn *ctx, enum bfs_spawn_flags flags);
+
+/**
+ * Add a close() action to a bfs_spawn() context.
+ *
+ * @return 0 on success, -1 on failure.
+ */
+int bfs_spawn_addclose(struct bfs_spawn *ctx, int fd);
+
+/**
+ * Add a dup2() action to a bfs_spawn() context.
+ *
+ * @return 0 on success, -1 on failure.
+ */
+int bfs_spawn_adddup2(struct bfs_spawn *ctx, int oldfd, int newfd);
+
+/**
+ * Add an fchdir() action to a bfs_spawn() context.
+ *
+ * @return 0 on success, -1 on failure.
+ */
+int bfs_spawn_addfchdir(struct bfs_spawn *ctx, int fd);
+
+/**
+ * Add a setrlimit() action to a bfs_spawn() context.
+ *
+ * @return 0 on success, -1 on failure.
+ */
+int bfs_spawn_addsetrlimit(struct bfs_spawn *ctx, int resource, const struct rlimit *rl);
+
+/**
+ * Spawn a new process.
+ *
+ * @param exe
+ *         The executable to run.
+ * @param ctx
+ *         The context for the new process.
+ * @param argv
+ *         The arguments for the new process.
+ * @param envp
+ *         The environment variables for the new process (NULL for the current
+ *         environment).
+ * @return
+ *         The PID of the new process, or -1 on error.
+ */
+pid_t bfs_spawn(const char *exe, const struct bfs_spawn *ctx, char **argv, char **envp);
+
+/**
+ * Look up an executable in the current PATH, as BFS_SPAWN_USEPATH or execvp()
+ * would do.
+ *
+ * @param exe
+ *         The name of the binary to execute.  Bare names without a '/' will be
+ *         searched on the provided PATH.
+ * @return
+ *         The full path to the executable, which should be free()'d, or NULL on
+ *         failure.
+ */
+char *bfs_spawn_resolve(const char *exe);
+
+#endif // BFS_XSPAWN_H
diff --git a/src/xtime.c b/src/xtime.c
new file mode 100644
index 0000000..8ca963b
--- /dev/null
+++ b/src/xtime.c
@@ -0,0 +1,323 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2020-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+#include "xtime.h"
+#include <errno.h>
+#include <limits.h>
+#include <stdbool.h>
+#include <stdlib.h>
+#include <time.h>
+
+/** Whether tzset() has been called. */
+static bool tz_is_set = false;
+
+int xlocaltime(const time_t *timep, struct tm *result) {
+	// Should be called before localtime_r() according to POSIX.1-2004
+	if (!tz_is_set) {
+		tzset();
+		tz_is_set = true;
+	}
+
+	if (localtime_r(timep, result)) {
+		return 0;
+	} else {
+		return -1;
+	}
+}
+
+int xgmtime(const time_t *timep, struct tm *result) {
+	// Should be called before gmtime_r() according to POSIX.1-2004
+	if (!tz_is_set) {
+		tzset();
+		tz_is_set = true;
+	}
+
+	if (gmtime_r(timep, result)) {
+		return 0;
+	} else {
+		return -1;
+	}
+}
+
+int xmktime(struct tm *tm, time_t *timep) {
+	*timep = mktime(tm);
+
+	if (*timep == -1) {
+		int error = errno;
+
+		struct tm tmp;
+		if (xlocaltime(timep, &tmp) != 0) {
+			return -1;
+		}
+
+		if (tm->tm_year != tmp.tm_year || tm->tm_yday != tmp.tm_yday
+		    || tm->tm_hour != tmp.tm_hour || tm->tm_min != tmp.tm_min || tm->tm_sec != tmp.tm_sec) {
+			errno = error;
+			return -1;
+		}
+	}
+
+	return 0;
+}
+
+static int safe_add(int *value, int delta) {
+	if (*value >= 0) {
+		if (delta > INT_MAX - *value) {
+			return -1;
+		}
+	} else {
+		if (delta < INT_MIN - *value) {
+			return -1;
+		}
+	}
+
+	*value += delta;
+	return 0;
+}
+
+static int floor_div(int n, int d) {
+	int a = n < 0;
+	return (n + a)/d - a;
+}
+
+static int wrap(int *value, int max, int *next) {
+	int carry = floor_div(*value, max);
+	*value -= carry * max;
+	return safe_add(next, carry);
+}
+
+static int month_length(int year, int month) {
+	static const int month_lengths[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
+	int ret = month_lengths[month];
+	if (month == 1 && year%4 == 0 && (year%100 != 0 || (year + 300)%400 == 0)) {
+		++ret;
+	}
+	return ret;
+}
+
+int xtimegm(struct tm *tm, time_t *timep) {
+	tm->tm_isdst = 0;
+
+	if (wrap(&tm->tm_sec, 60, &tm->tm_min) != 0) {
+		goto overflow;
+	}
+	if (wrap(&tm->tm_min, 60, &tm->tm_hour) != 0) {
+		goto overflow;
+	}
+	if (wrap(&tm->tm_hour, 24, &tm->tm_mday) != 0) {
+		goto overflow;
+	}
+
+	// In order to wrap the days of the month, we first need to know what
+	// month it is
+	if (wrap(&tm->tm_mon, 12, &tm->tm_year) != 0) {
+		goto overflow;
+	}
+
+	if (tm->tm_mday < 1) {
+		do {
+			--tm->tm_mon;
+			if (wrap(&tm->tm_mon, 12, &tm->tm_year) != 0) {
+				goto overflow;
+			}
+
+			tm->tm_mday += month_length(tm->tm_year, tm->tm_mon);
+		} while (tm->tm_mday < 1);
+	} else {
+		while (true) {
+			int days = month_length(tm->tm_year, tm->tm_mon);
+			if (tm->tm_mday <= days) {
+				break;
+			}
+
+			tm->tm_mday -= days;
+			++tm->tm_mon;
+			if (wrap(&tm->tm_mon, 12, &tm->tm_year) != 0) {
+				goto overflow;
+			}
+		}
+	}
+
+	tm->tm_yday = 0;
+	for (int i = 0; i < tm->tm_mon; ++i) {
+		tm->tm_yday += month_length(tm->tm_year, i);
+	}
+	tm->tm_yday += tm->tm_mday - 1;
+
+	int leap_days;
+	// Compute floor((year - 69)/4) - floor((year - 1)/100) + floor((year + 299)/400) without overflows
+	if (tm->tm_year >= 0) {
+		leap_days = floor_div(tm->tm_year - 69, 4) - floor_div(tm->tm_year - 1, 100) + floor_div(tm->tm_year - 101, 400) + 1;
+	} else {
+		leap_days = floor_div(tm->tm_year + 3, 4) - floor_div(tm->tm_year + 99, 100) + floor_div(tm->tm_year + 299, 400) - 17;
+	}
+
+	long long epoch_days = 365LL*(tm->tm_year - 70) + leap_days + tm->tm_yday;
+	tm->tm_wday = (epoch_days + 4)%7;
+	if (tm->tm_wday < 0) {
+		tm->tm_wday += 7;
+	}
+
+	long long epoch_time = tm->tm_sec + 60*(tm->tm_min + 60*(tm->tm_hour + 24*epoch_days));
+	*timep = (time_t)epoch_time;
+	if ((long long)*timep != epoch_time) {
+		goto overflow;
+	}
+	return 0;
+
+overflow:
+	errno = EOVERFLOW;
+	return -1;
+}
+
+/** Parse some digits from a timestamp. */
+static int parse_timestamp_part(const char **str, size_t n, int *result) {
+	char buf[n + 1];
+	for (size_t i = 0; i < n; ++i, ++*str) {
+		char c = **str;
+		if (c < '0' || c > '9') {
+			return -1;
+		}
+		buf[i] = c;
+	}
+	buf[n] = '\0';
+
+	*result = atoi(buf);
+	return 0;
+}
+
+int parse_timestamp(const char *str, struct timespec *result) {
+	struct tm tm = {
+		.tm_isdst = -1,
+	};
+
+	int tz_hour = 0;
+	int tz_min = 0;
+	bool tz_negative = false;
+	bool local = true;
+
+	// YYYY
+	if (parse_timestamp_part(&str, 4, &tm.tm_year) != 0) {
+		goto invalid;
+	}
+	tm.tm_year -= 1900;
+
+	// MM
+	if (*str == '-') {
+		++str;
+	}
+	if (parse_timestamp_part(&str, 2, &tm.tm_mon) != 0) {
+		goto invalid;
+	}
+	tm.tm_mon -= 1;
+
+	// DD
+	if (*str == '-') {
+		++str;
+	}
+	if (parse_timestamp_part(&str, 2, &tm.tm_mday) != 0) {
+		goto invalid;
+	}
+
+	if (!*str) {
+		goto end;
+	} else if (*str == 'T') {
+		++str;
+	}
+
+	// hh
+	if (parse_timestamp_part(&str, 2, &tm.tm_hour) != 0) {
+		goto invalid;
+	}
+
+	// mm
+	if (!*str) {
+		goto end;
+	} else if (*str == ':') {
+		++str;
+	}
+	if (parse_timestamp_part(&str, 2, &tm.tm_min) != 0) {
+		goto invalid;
+	}
+
+	// ss
+	if (!*str) {
+		goto end;
+	} else if (*str == ':') {
+		++str;
+	}
+	if (parse_timestamp_part(&str, 2, &tm.tm_sec) != 0) {
+		goto invalid;
+	}
+
+	if (!*str) {
+		goto end;
+	} else if (*str == 'Z') {
+		local = false;
+		++str;
+	} else if (*str == '+' || *str == '-') {
+		local = false;
+		tz_negative = *str == '-';
+		++str;
+
+		// hh
+		if (parse_timestamp_part(&str, 2, &tz_hour) != 0) {
+			goto invalid;
+		}
+
+		// mm
+		if (!*str) {
+			goto end;
+		} else if (*str == ':') {
+			++str;
+		}
+		if (parse_timestamp_part(&str, 2, &tz_min) != 0) {
+			goto invalid;
+		}
+	} else {
+		goto invalid;
+	}
+
+	if (*str) {
+		goto invalid;
+	}
+
+end:
+	if (local) {
+		if (xmktime(&tm, &result->tv_sec) != 0) {
+			goto error;
+		}
+	} else {
+		if (xtimegm(&tm, &result->tv_sec) != 0) {
+			goto error;
+		}
+
+		int offset = 60*tz_hour + tz_min;
+		if (tz_negative) {
+			result->tv_sec -= offset;
+		} else {
+			result->tv_sec += offset;
+		}
+	}
+
+	result->tv_nsec = 0;
+	return 0;
+
+invalid:
+	errno = EINVAL;
+error:
+	return -1;
+}
diff --git a/src/xtime.h b/src/xtime.h
new file mode 100644
index 0000000..ceff48f
--- /dev/null
+++ b/src/xtime.h
@@ -0,0 +1,86 @@
+/****************************************************************************
+ * bfs                                                                      *
+ * Copyright (C) 2020-2022 Tavian Barnes <tavianator@tavianator.com>        *
+ *                                                                          *
+ * Permission to use, copy, modify, and/or distribute this software for any *
+ * purpose with or without fee is hereby granted.                           *
+ *                                                                          *
+ * 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.           *
+ ****************************************************************************/
+
+/**
+ * Date/time handling.
+ */
+
+#ifndef BFS_XTIME_H
+#define BFS_XTIME_H
+
+#include <time.h>
+
+/**
+ * localtime_r() wrapper that calls tzset() first.
+ *
+ * @param[in] timep
+ *         The time_t to convert.
+ * @param[out] result
+ *         Buffer to hold the result.
+ * @return
+ *         0 on success, -1 on failure.
+ */
+int xlocaltime(const time_t *timep, struct tm *result);
+
+/**
+ * gmtime_r() wrapper that calls tzset() first.
+ *
+ * @param[in] timep
+ *         The time_t to convert.
+ * @param[out] result
+ *         Buffer to hold the result.
+ * @return
+ *         0 on success, -1 on failure.
+ */
+int xgmtime(const time_t *timep, struct tm *result);
+
+/**
+ * mktime() wrapper that reports errors more reliably.
+ *
+ * @param[in,out] tm
+ *         The struct tm to convert.
+ * @param[out] timep
+ *         A pointer to the result.
+ * @return
+ *         0 on success, -1 on failure.
+ */
+int xmktime(struct tm *tm, time_t *timep);
+
+/**
+ * A portable timegm(), the inverse of gmtime().
+ *
+ * @param[in,out] tm
+ *         The struct tm to convert.
+ * @param[out] timep
+ *         A pointer to the result.
+ * @return
+ *         0 on success, -1 on failure.
+ */
+int xtimegm(struct tm *tm, time_t *timep);
+
+/**
+ * Parse an ISO 8601-style timestamp.
+ *
+ * @param[in] str
+ *         The string to parse.
+ * @param[out] result
+ *         A pointer to the result.
+ * @return
+ *         0 on success, -1 on failure.
+ */
+int parse_timestamp(const char *str, struct timespec *result);
+
+#endif // BFS_XTIME_H
-- 
cgit v1.2.3