~sircmpwn/hare-dev

hare: Rewrite build driver and hare::module v3 SUPERSEDED

Ember Sawady: 1
 Rewrite build driver and hare::module

 46 files changed, 2399 insertions(+), 3398 deletions(-)
#1003059 alpine.yml success
#1003060 freebsd.yml success
thanks for the feedback! there were some things i wanted to follow up
on, but you can assume that if i didn't mention something i'll be
implementing the change you suggested in v4
Next
nope, if you've got a better suggestion i'm all ears. i chose it cause
it's what we use for type ids. i remember that i used fnv32 at one point
in the past, and i remember changing it to fnv64, but i don't remember
if i had any rationale there
Next
(things i didn't bring up were either addressed elsewhere in the thread
or are now in my todo list)
Next
to assume the sequence of commands involved in the build process, one of 
which is qbe. so i think leaving this as-is is fine (...)
Next
non-qbe-based implementations to ignore qbe files in modules.
Next
haven't yet looked through the rest of the feedback, but
Next
Got it. Could improve the error message but not a big deal.
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.sr.ht/~sircmpwn/hare-dev/patches/41706/mbox | git am -3
Learn more about email & git

[PATCH hare v3] Rewrite build driver and hare::module Export this patch

Co-authored-by: Autumn! <autumnull@posteo.net>
Co-authored-by: Sebastian <sebastian@sebsite.pw>
Signed-off-by: Ember Sawady <ecs@d2evs.net>
---
honestly i kinda forgot all the stuff i did since v2, probably should've
kept a better changelog. p sure i got everything from v2 review, and i
fixed a pretty tricky caching bug
 .gitignore                                    |   1 +
 Makefile                                      |  60 +-
 cmd/hare/arch.ha                              |  62 ++
 cmd/hare/build.ha                             | 713 ++++++++++++++++++
 cmd/hare/cache.ha                             |  49 ++
 cmd/hare/deps.ha                              | 129 ++--
 cmd/{haredoc/main.ha => hare/doc.ha}          | 194 ++---
 cmd/{haredoc => hare/doc}/color.ha            |   0
 cmd/{haredoc => hare/doc}/hare.ha             |  13 +-
 cmd/{haredoc => hare/doc}/html.ha             |  71 +-
 .../resolver.ha => hare/doc/resolve.ha}       |  63 +-
 cmd/{haredoc => hare/doc}/sort.ha             |  10 +-
 cmd/{haredoc => hare/doc}/tty.ha              |  13 +-
 cmd/hare/doc/types.ha                         |  55 ++
 cmd/{haredoc => hare/doc}/util.ha             |  39 +-
 cmd/hare/error.ha                             |  23 +
 cmd/hare/main.ha                              | 107 ++-
 cmd/hare/plan.ha                              | 328 --------
 cmd/hare/progress.ha                          |  64 --
 cmd/hare/release.ha                           |  51 ++
 cmd/hare/schedule.ha                          | 388 ----------
 cmd/hare/subcmds.ha                           | 624 ---------------
 cmd/hare/target.ha                            |  81 --
 cmd/hare/util.ha                              |  36 +
 cmd/hare/version.ha                           |  43 ++
 cmd/harec/gen.ha                              |   2 +-
 cmd/haredoc/env.ha                            | 104 ---
 cmd/haredoc/errors.ha                         |  26 -
 docs/hare-doc.5.scd                           |  29 +
 docs/{hare.scd => hare.1.scd}                 | 172 ++++-
 docs/haredoc.scd                              | 116 ---
 hare/module/README                            |  15 +-
 hare/module/cache.ha                          |  29 +
 hare/module/context.ha                        | 129 ----
 hare/module/deps.ha                           | 214 ++++++
 hare/module/format.ha                         |  71 ++
 hare/module/manifest.ha                       | 403 ----------
 hare/module/scan.ha                           | 491 ------------
 hare/module/srcs.ha                           | 296 ++++++++
 hare/module/types.ha                          | 152 ++--
 hare/module/util.ha                           |  46 ++
 hare/module/walk.ha                           |  91 ---
 hare/parse/decl.ha                            |  32 +-
 .../docstr.ha => hare/parse/doc/doc.ha        |  20 +-
 scripts/gen-stdlib                            |  38 +-
 stdlib.mk                                     | 104 ++-
 46 files changed, 2399 insertions(+), 3398 deletions(-)
 create mode 100644 cmd/hare/arch.ha
 create mode 100644 cmd/hare/build.ha
 create mode 100644 cmd/hare/cache.ha
 rename cmd/{haredoc/main.ha => hare/doc.ha} (59%)
 rename cmd/{haredoc => hare/doc}/color.ha (100%)
 rename cmd/{haredoc => hare/doc}/hare.ha (94%)
 rename cmd/{haredoc => hare/doc}/html.ha (94%)
 rename cmd/{haredoc/resolver.ha => hare/doc/resolve.ha} (75%)
 rename cmd/{haredoc => hare/doc}/sort.ha (93%)
 rename cmd/{haredoc => hare/doc}/tty.ha (98%)
 create mode 100644 cmd/hare/doc/types.ha
 rename cmd/{haredoc => hare/doc}/util.ha (52%)
 create mode 100644 cmd/hare/error.ha
 delete mode 100644 cmd/hare/plan.ha
 delete mode 100644 cmd/hare/progress.ha
 delete mode 100644 cmd/hare/schedule.ha
 delete mode 100644 cmd/hare/subcmds.ha
 delete mode 100644 cmd/hare/target.ha
 create mode 100644 cmd/hare/util.ha
 create mode 100644 cmd/hare/version.ha
 delete mode 100644 cmd/haredoc/env.ha
 delete mode 100644 cmd/haredoc/errors.ha
 create mode 100644 docs/hare-doc.5.scd
 rename docs/{hare.scd => hare.1.scd} (74%)
 delete mode 100644 docs/haredoc.scd
 create mode 100644 hare/module/cache.ha
 delete mode 100644 hare/module/context.ha
 create mode 100644 hare/module/deps.ha
 create mode 100644 hare/module/format.ha
 delete mode 100644 hare/module/manifest.ha
 delete mode 100644 hare/module/scan.ha
 create mode 100644 hare/module/srcs.ha
 create mode 100644 hare/module/util.ha
 delete mode 100644 hare/module/walk.ha
 rename cmd/haredoc/docstr.ha => hare/parse/doc/doc.ha (92%)

diff --git a/.gitignore b/.gitignore
index 30310524..06d477e0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,5 @@ config.mk
.cache
.bin
*.1
*.5
docs/html
diff --git a/Makefile b/Makefile
index 728d2d26..1d4b9940 100644
--- a/Makefile
+++ b/Makefile
@@ -9,7 +9,7 @@ testlib_env = env

all:

.SUFFIXES: .ha .ssa .s .o .scd .1
.SUFFIXES: .ha .ssa .s .o .scd
.ssa.s:
	@printf 'QBE\t%s\n' "$@"
	@$(QBE) -o $@ $<
@@ -18,35 +18,29 @@ all:
	@printf 'AS\t%s\n' "$@"
	@$(AS) -g -o $@ $<

.scd.1:
.scd:
	@printf 'SCDOC\t%s\n' "$@"
	@$(SCDOC) < $< > $@


include stdlib.mk

hare_srcs = \
	./cmd/hare/arch.ha \
	./cmd/hare/build.ha \
	./cmd/hare/cache.ha \
	./cmd/hare/deps.ha \
	./cmd/hare/doc.ha \
	./cmd/hare/error.ha \
	./cmd/hare/main.ha \
	./cmd/hare/plan.ha \
	./cmd/hare/progress.ha \
	./cmd/hare/release.ha \
	./cmd/hare/schedule.ha \
	./cmd/hare/subcmds.ha \
	./cmd/hare/target.ha
	./cmd/hare/util.ha \
	./cmd/hare/version.ha

harec_srcs = \
	./cmd/harec/main.ha \
	./cmd/harec/errors.ha

haredoc_srcs = \
	./cmd/haredoc/main.ha \
	./cmd/haredoc/errors.ha \
	./cmd/haredoc/env.ha \
	./cmd/haredoc/hare.ha \
	./cmd/haredoc/html.ha \
	./cmd/haredoc/sort.ha \
	./cmd/haredoc/resolver.ha

include targets.mk

$(HARECACHE)/hare.ssa: $(hare_srcs) $(stdlib_deps_any) $(stdlib_deps_$(PLATFORM)) scripts/version
@@ -77,26 +71,16 @@ $(BINOUT)/harec2: $(BINOUT)/hare $(harec_srcs)
	@env HAREPATH=. HAREC=$(HAREC) QBE=$(QBE) $(BINOUT)/hare build \
		$(HARE_DEFINES) -o $(BINOUT)/harec2 cmd/harec

# Prevent $(BINOUT)/hare from running builds in parallel, workaround for build
# driver bugs
PARALLEL_HACK=$(BINOUT)/harec2

$(BINOUT)/haredoc: $(BINOUT)/hare $(haredoc_srcs) $(PARALLEL_HACK)
	@mkdir -p $(BINOUT)
	@printf 'HARE\t%s\n' "$@"
	@env HAREPATH=. HAREC=$(HAREC) QBE=$(QBE) $(BINOUT)/hare build \
		$(HARE_DEFINES) -o $(BINOUT)/haredoc ./cmd/haredoc

docs/html: $(BINOUT)/haredoc scripts/gen-docs.sh
docs/html: $(BINOUT)/hare scripts/gen-docs.sh
	BINOUT=$(BINOUT) $(SHELL) ./scripts/gen-docs.sh

docs/hare.1: docs/hare.scd
docs/haredoc.1: docs/haredoc.scd
docs/hare.1: docs/hare.1.scd
docs/hare-doc.5: docs/hare-doc.5.scd

docs: docs/hare.1 docs/haredoc.1
docs: docs/hare.1 docs/hare-doc.5

clean:
	rm -rf $(HARECACHE) $(BINOUT) docs/hare.1 docs/haredoc.1 docs/html
	rm -rf $(HARECACHE) $(BINOUT) docs/hare.1 docs/hare-doc.5 docs/html

check: $(BINOUT)/hare-tests
	@$(BINOUT)/hare-tests
@@ -104,22 +88,22 @@ check: $(BINOUT)/hare-tests
scripts/gen-docs.sh: scripts/gen-stdlib
scripts/gen-stdlib: scripts/gen-stdlib.sh

all: $(BINOUT)/hare $(BINOUT)/harec2 $(BINOUT)/haredoc
all: $(BINOUT)/hare $(BINOUT)/harec2 docs

install: docs scripts/install-mods
	mkdir -p $(DESTDIR)$(BINDIR) $(DESTDIR)$(MANDIR)/man1 \
	mkdir -p \
		$(DESTDIR)$(BINDIR) $(DESTDIR)$(MANDIR)/man1 \
		$(DESTDIR)$(BINDIR) $(DESTDIR)$(MANDIR)/man5 \
		$(DESTDIR)$(SRCDIR)/hare/stdlib
	install -m755 $(BINOUT)/hare $(DESTDIR)$(BINDIR)/hare
	install -m755 $(BINOUT)/haredoc $(DESTDIR)$(BINDIR)/haredoc
	install -m644 docs/hare.1 $(DESTDIR)$(MANDIR)/man1/hare.1
	install -m644 docs/haredoc.1 $(DESTDIR)$(MANDIR)/man1/haredoc.1
	install -m644 docs/hare-doc.5 $(DESTDIR)$(MANDIR)/man5/hare-doc.5
	./scripts/install-mods "$(DESTDIR)$(SRCDIR)/hare/stdlib"

uninstall:
	$(RM) $(DESTDIR)$(BINDIR)/hare
	$(RM) $(DESTDIR)$(BINDIR)/haredoc
	$(RM) $(DESTDIR)$(MANDIR)/man1/hare.1
	$(RM) $(DESTDIR)$(MANDIR)/man1/haredoc.1
	$(RM) $(DESTDIR)$(MANDIR)/man5/hare-doc.5
	$(RM) -r $(DESTDIR)$(SRCDIR)/hare/stdlib

.PHONY: all clean check docs install uninstall $(BINOUT)/harec2 $(BINOUT)/haredoc
.PHONY: all clean check docs install uninstall
diff --git a/cmd/hare/arch.ha b/cmd/hare/arch.ha
new file mode 100644
index 00000000..f7b0d515
--- /dev/null
+++ b/cmd/hare/arch.ha
@@ -0,0 +1,62 @@
use hare::module;
use os;

def AARCH64_AS = "as";
def AARCH64_CC = "cc";
def AARCH64_LD = "ld";
def RISCV64_AS = "as";
def RISCV64_CC = "cc";
def RISCV64_LD = "ld";
def X86_64_AS  = "as";
def X86_64_CC  = "cc";
def X86_64_LD  = "ld";

type arch = struct {
	name: str,
	qbe_name: str,
	as_cmd: str,
	cc_cmd: str,
	ld_cmd: str,
};

type unknown_arch = !str;

// TODO: Implement cross compiling to other kernels (e.g. Linux => FreeBSD)
// TODO: sysroots
const arches: [_]arch = [
	arch {
		name = "aarch64",
		qbe_name = "arm64",
		as_cmd = AARCH64_AS,
		cc_cmd = AARCH64_CC,
		ld_cmd = AARCH64_LD,
	},
	arch {
		name = "riscv64",
		qbe_name = "rv64",
		as_cmd = RISCV64_AS,
		cc_cmd = RISCV64_CC,
		ld_cmd = RISCV64_LD,
	},
	arch {
		name = "x86_64",
		qbe_name = "amd64_sysv",
		as_cmd = X86_64_AS,
		cc_cmd = X86_64_CC,
		ld_cmd = X86_64_LD,
	},
];

fn set_arch_tags(tags: *[]str, a: *arch) void = {
	merge_tags(tags, "-aarch64-riscv64-x86_64")!;
	append(tags, a.name);
};

fn get_arch(name: str) (*arch | unknown_arch) = {
	for (let i = 0z; i < len(arches); i += 1) {
		if (arches[i].name == name) {
			return &arches[i];
		};
	};
	return name: unknown_arch;
};
diff --git a/cmd/hare/build.ha b/cmd/hare/build.ha
new file mode 100644
index 00000000..187e10f8
--- /dev/null
+++ b/cmd/hare/build.ha
@@ -0,0 +1,713 @@
use bufio;
use bytes;
use encoding::hex;
use errors;
use fmt;
use fs;
use getopt;
use hare::ast;
use hare::lex;
use hare::module;
use hare::module::{cachekind};
use hare::parse;
use hare::unparse;
use hash::fnv;
use hash;
use io;
use os::exec;
use os;
use path;
use shlex;
use sort;
use strconv;
use strings;
use strio;
use unix::tty;

type unknown_type = !str;

type output_not_file = !str;

type unknown_output = !void;

type task = struct {
	rdeps: []*task,
	ndeps: size,
	kind: cachekind,
	mod: *modctx,
};

type job = struct {
	pid: exec::process,
	task: *task,
	out: str,
	tmp: io::file,
};

type modctx = struct {
	mod: module::module,
	cache: str,
	prefixes: [cachekind::BIN + 1]str,
	flags: [cachekind::BIN + 1][]str,
	libs: []str,
};

type output = enum {
	DEFAULT,
	VERBOSE,
	VVERBOSE,
};

type context = struct {
	ctx: module::context,
	arch: *arch,
	output: str,
	goal: cachekind,
	defines: []ast::decl_const,
	libdirs: []str,
	libs: []str,
	jobs: size,
	ns: ast::ident,
	top: size,
	name: str,
	version: []u8,
	buf: path::buffer,
	submods: bool,

	cmds: [cachekind::BIN + 1]str,
	envs: [cachekind::BIN + 1]str,

	mode: output,
	interactive: bool,
	completed: size,
	total: size,
	nlines: size,
};

fn build(name: str, cmd: *getopt::command) (void | error) = {
	let ctx = context {
		ctx = module::context {
			harepath = harepath(),
			harecache = harecache(),
			tags = default_tags()?,
		},
		arch = get_arch(os::machine())?,
		goal = cachekind::BIN,
		jobs = match (os::cpucount()) {
		case errors::error => yield 1;
		case let ncpu: int => yield if (ncpu <= 0) 1 else ncpu: size;
		},
		cmds = ["", "harec", "qbe", "as", "ld"],
		envs = ["", "HARECFLAGS", "QBEFLAGS", "ASFLAGS", "LDLINKFLAGS"],
		name = name,
		mode = output::DEFAULT,
		interactive = tty::isatty(os::stderr_file),
		...
	};
	defer ctx_finish(&ctx);
	for (let i = 0z; i < len(cmd.opts); i += 1) {
		let opt = cmd.opts[i];
		switch (opt.0) {
		case 'a' =>
			ctx.arch = get_arch(opt.1)?;
		case 'D' =>
			const buf = bufio::fixed(strings::toutf8(opt.1),
				io::mode::READ);
			const lexer = lex::init(&buf, "<define>");
			defer lex::finish(&lexer);
			append(ctx.defines, parse::define(&lexer)?);
		case 'j' =>
			ctx.jobs = strconv::stoz(opt.1)?;
		case 'L' =>
			append(ctx.libdirs, opt.1);
		case 'l' =>
			append(ctx.libs, opt.1);
		case 'N' =>
			ctx.ns = parse::identstr(opt.1)?;
		case 'o' =>
			ctx.output = opt.1;
		case 'T' =>
			merge_tags(&ctx.ctx.tags, opt.1)?;
		case 't' =>
			switch (opt.1) {
			case "ssa" => ctx.goal = cachekind::SSA;
			case "s" => ctx.goal = cachekind::S;
			case "o" => ctx.goal = cachekind::O;
			case "bin" => ctx.goal = cachekind::BIN;
			case => return opt.1: unknown_type;
			};
		case 'v' =>
			ctx.interactive = tty::isatty(os::stdout_file);
			if (ctx.mode == output::DEFAULT) {
				ctx.mode = output::VERBOSE;
			} else {
				ctx.mode = output::VVERBOSE;
			};
		case =>
			abort();
		};
	};
	if (len(cmd.args) > 1 && ctx.name == "build") {
		getopt::printusage(os::stderr, ctx.name, cmd.help...)!;
		os::exit(1);
	};

	ctx.cmds[cachekind::O] = ctx.arch.as_cmd;
	set_arch_tags(&ctx.ctx.tags, ctx.arch);
	if (len(ctx.libs) > 0) {
		merge_tags(&ctx.ctx.tags, "+libc")?;
		ctx.cmds[cachekind::BIN] = os::tryenv("CC", ctx.arch.cc_cmd);
		ctx.envs[cachekind::BIN] = "LDFLAGS";
	} else {
		ctx.cmds[cachekind::BIN] = os::tryenv("LD", ctx.arch.ld_cmd);
	};
	let envs = ["", "HAREC", "QBE", "AS"];
	for (let i = cachekind::SSA; i < cachekind::BIN; i += 1) {
		ctx.cmds[i] = os::tryenv(envs[i], ctx.cmds[i]);
	};
	if (ctx.name == "test") merge_tags(&ctx.ctx.tags, "+test")?;

	const input = if (len(cmd.args) == 0) os::getcwd() else cmd.args[0];
	ctx.submods = ctx.name == "test" && len(cmd.args) == 0;

	let mods: []module::module = [];
	path::set(&ctx.buf, os::realpath(input)?)?;
	module::gather(&ctx.ctx, &mods, ["rt"])?;
	if (ctx.name == "test") module::gather(&ctx.ctx, &mods, ["test"])?;
	if (ctx.submods) {
		let id: ast::ident = [];
		defer ast::ident_free(id);
		gather_submodules(&ctx, &mods, &ctx.buf, &id)?;
	};
	ctx.top = module::gather(&ctx.ctx, &mods, &ctx.buf)?;
	let mods = getcontexts(&ctx, mods)?;
	defer modctx_finish(mods);

	let q: []*task = [];
	queue(&q, mods, ctx.goal, ctx.top);
	// sort by cachekind, harec then qbe then as then ld
	sort::sort(q, size(*task), &task_cmp);
	ctx.total = len(q);

	let jobs: [](job | void) = alloc([void...], ctx.jobs);
	defer free(jobs);

	let version = exec::cmd("harec", "-v")?;
	let pipe = exec::pipe();
	exec::addfile(&version, os::stdout_file, pipe.1);
	let proc = exec::start(&version)?;
	io::close(pipe.1)?;
	ctx.version = io::drain(pipe.0)?;
	let status = exec::wait(&proc)?;
	io::close(pipe.0)?;
	match (exec::check(&status)) {
	case void => void;
	case let status: !exec::exit_status =>
		fmt::fatal("harec -v", exec::exitstr(status));
	};

	if (len(os::tryenv("NO_COLOR", "")) == 0
			&& os::getenv("HAREC_COLOR") is void
			&& ctx.interactive) {
		if (ctx.mode == output::VVERBOSE) {
			fmt::printfln("# HAREC_COLOR=1")?;
		};
		os::setenv("HAREC_COLOR", "1")!;
	};

	if (ctx.mode == output::DEFAULT && ctx.interactive) fmt::errorln()?;
	for (let i = 0z; len(q) != 0; i += 1) {
		if (i == len(q)) {
			await_task(&ctx, &jobs)?;
			i = 0;
		};
		if (runtask(&ctx, &jobs, mods, q[i])?) {
			delete(q[i]);
			i = -1;
		};
	};
	ui_update(&ctx, jobs)?;
	for (await_task(&ctx, &jobs) is size) ui_update(&ctx, jobs)?;
	ui_finish(&ctx)?;

	let built = getcache(&mods[ctx.top], ctx.goal);

	if (ctx.output == "" && ctx.name == "build") {
		let stat = os::stat(input)?;
		path::set(&ctx.buf, os::realpath(input)?)?;
		if (!fs::isdir(stat.mode)) {
			path::pop(&ctx.buf);
		};
		if (ctx.goal != cachekind::BIN) {
			path::push_ext(&ctx.buf, module::cachekind_ext[ctx.goal])?;
		};
		ctx.output = match (path::peek(&ctx.buf)) {
		case let s: str => yield s;
		case void => return unknown_output;
		};
	};

	if (ctx.output == "") {
		const run = exec::cmd(built)?;
		if (len(cmd.args) != 0) append(run.argv, cmd.args[1..]...);
		exec::setname(&run, input);
		exec::exec(&run);
	};

	match (os::stat(ctx.output)) {
	case let stat: fs::filestat =>
		if (!fs::isfile(stat.mode)) return ctx.output: output_not_file;
	case => void;
	};

	let src = os::open(built)?;
	defer io::close(src)!;
	// TODO needs replacing with os::copy whenever that appears
	let mode = fs::mode::USER_RW | fs::mode::GROUP_R;
	if (ctx.goal == cachekind::BIN) {
		mode |= fs::mode::USER_X | fs::mode::GROUP_X | fs::mode::OTHER_X;
	};
	let dest = os::stdout_file;
	if (ctx.output != "-") dest = os::create(ctx.output, mode)?;
	defer if (dest != os::stdout_file) io::close(dest)!;
	io::copy(dest, src)?;
};

fn gather_submodules(
	ctx: *context,
	mods: *[]module::module,
	buf: *path::buffer,
	mod: *ast::ident,
) (void | error) = {
	let it = os::iter(path::string(buf))?;
	defer fs::finish(it);
	for (true) match (module::next(it)) {
	case void => break;
	case let dir: fs::dirent =>
		path::push(buf, dir.name)?;
		defer path::pop(buf);
		append(mod, dir.name);
		defer delete(mod[len(mod) - 1]);
		module::gather(&ctx.ctx, mods, *mod)?;
		gather_submodules(ctx, mods, buf, mod)?;
	};
};

fn getcontexts(ctx: *context, mods: []module::module) ([]modctx | error) = {
	if (len(ctx.ns) != 0) mods[ctx.top].ns = ctx.ns;
	defer free(mods);
	let ret: []modctx = alloc([], len(mods));
	for (let i = 0z; i < len(mods); i += 1) {
		let flags: [cachekind::BIN + 1][]str = [[]...];
		if (len(mods[i].ns) != 0 || len(ctx.libs) != 0) {
			append(flags[cachekind::SSA], strings::dup("-N"));
			append(flags[cachekind::SSA], unparse::identstr(mods[i].ns));
		};
		path::set(&ctx.buf, mods[i].path)?;
		if (path::trimprefix(&ctx.buf, os::getcwd()) is str
				&& ctx.submods) {
			append(flags[cachekind::SSA], strings::dup("-T"));
		};
		for (let j = 0z; j < len(ctx.defines); j += 1) :next {
			let ident = ctx.defines[j].ident;
			if (len(ident) != len(mods[i].ns) + 1) continue;
			for (let k = 0z; k < len(mods[i].ns); k += 1) {
				if (ident[k] != mods[i].ns[k]) continue :next;
			};
			let buf = strio::dynamic();
			strio::concat(&buf, "-D", ident[len(ident) - 1])!;
			match (ctx.defines[j]._type) {
			case null => void;
			case let t: *ast::_type =>
				strio::concat(&buf, ":")!;
				unparse::_type(&buf, 0, *t)!;
			};
			strio::concat(&buf, "=")!;
			unparse::expr(&buf, 0, *ctx.defines[j].init)!;
			append(flags[cachekind::SSA], strio::string(&buf));
		};
		for (let i = cachekind::SSA; i <= cachekind::BIN; i += 1) {
			let env = shlex::split(os::tryenv(ctx.envs[i], ""))!;
			defer free(env);
			append(flags[i], env...);
		};
		let cache = module::getcache(ctx.ctx.harecache, mods[i].path)?;
		os::mkdirs(cache, 0o755)!;
		append(ret, modctx {
			mod = mods[i],
			flags = flags,
			libs = [],
			cache = strings::dup(cache),
			prefixes = [""...],
		});
	};
	for (let i = 0z; i < len(ctx.libdirs); i += 1) {
		append(ret[ctx.top].flags[cachekind::BIN],
			strings::concat("-L", ctx.libdirs[i]));
	};
	for (let i = 0z; i < len(ctx.libs); i += 1) {
		append(ret[ctx.top].libs, strings::concat("-l", ctx.libs[i]));
	};
	if (ctx.name == "test") {
		append(ret[ctx.top].flags[cachekind::SSA], strings::dup("-T"));
	};
	return ret;
};

fn queue(q: *[]*task, mods: []modctx, kind: cachekind, idx: size,) *task = {
	for (let i = 0z; i < len(q); i += 1) {
		if (q[i].kind == kind && q[i].mod == &mods[idx]) return q[i];
	};
	let t = alloc(task {
		kind = kind,
		mod = &mods[idx],
		...
	});
	switch (kind) {
	case cachekind::BIN =>
		t.ndeps = len(mods);
		for (let i = 0z; i < len(mods); i += 1) {
			append(queue(q, mods, cachekind::O, i).rdeps, t);
		};
	case cachekind::O, cachekind::S =>
		t.ndeps = 1;
		append(queue(q, mods, kind - 1, idx).rdeps, t);
	case cachekind::SSA =>
		t.ndeps = len(mods[idx].mod.deps);
		for (let i = 0z; i < len(mods[idx].mod.deps); i += 1) {
			let j = mods[idx].mod.deps[i].0;
			append(queue(q, mods, cachekind::SSA, j).rdeps, t);
		};
	case cachekind::TD => abort();
	};
	append(q, t);
	return t;
};

fn runtask(
	ctx: *context,
	jobs: *[](job | void),
	mods: []modctx,
	t: *task,
) (bool | error) = {
	let slot = getslot(ctx, jobs)?;
	if (t.ndeps != 0) return false;
	let mod = t.mod.mod;

	let depfiles: []str = [];
	defer free(depfiles);
	let cmd = exec::cmd(ctx.cmds[t.kind], t.mod.flags[t.kind]...)?;
	let exec = true;
	let outidx = 0z;
	let tdidx: (size | void) = void;

	let h = fnv::fnv64a();
	switch (t.kind) {
	case cachekind::TD => abort();
	case cachekind::SSA =>
		hash::write(&h, ctx.version);
		hash::write(&h, [0]);
		append(depfiles, mod.srcs.ha...);
		for (let i = 0z; i < len(mod.deps); i += 1) {
			let dep = &mods[mod.deps[i].0];
			append(depfiles, getcache(dep, cachekind::TD));
		};
		append(cmd.argv, ["-o", ""]...);
		outidx = len(cmd.argv) - 1;
		append(cmd.argv, ["-t", ""]...);
		tdidx = len(cmd.argv) - 1;
		append(cmd.argv, mod.srcs.ha...);
		if (len(mod.srcs.ha) == 0) exec = false;
	case cachekind::S, cachekind::O =>
		let srcs = mod.srcs;
		let srcs = if (t.kind == cachekind::S) srcs.ssa else srcs.s;
		let in = getcache(t.mod, t.kind - 1);
		append(depfiles, strings::dup(in));
		append(depfiles, srcs...);
		append(cmd.argv, ["-o", ""]...);
		outidx = len(cmd.argv) - 1;
		if (t.kind == cachekind::S) {
			append(cmd.argv, ["-t", ctx.arch.qbe_name]...);
		};
		append(cmd.argv, in);
		append(cmd.argv, srcs...);
	case cachekind::BIN =>
		append(cmd.argv, ["-o", ""]...);
		outidx = len(cmd.argv) - 1;
		for (let i = 0z; i < len(mods); i += 1) {
			let srcs = mods[i].mod.srcs;
			let o = getcache(&mods[i], cachekind::O);
			append(depfiles, strings::dup(o));
			append(depfiles, srcs.o...);
			for (let i = 0z; i < len(srcs.sc); i += 1) {
				append(cmd.argv,["-T", srcs.sc[i]]...);
			};
			append(cmd.argv, o);
			append(cmd.argv, srcs.o...);
		};
		append(cmd.argv, t.mod.libs...);
	};

	for (let i = 0z; i < len(mod.deps); i += 1) {
		let ns = unparse::identstr(mod.deps[i].1);
		defer free(ns);
		let var = strings::concat("HARE_TD_", ns);
		defer free(var);
		hash::write(&h, strings::toutf8(var));
		hash::write(&h, strings::toutf8("="));
		hash::write(&h, strings::toutf8(os::getenv(var) as str));
		hash::write(&h, [0]);
	};

	for (let i = 0z; i < len(cmd.argv); i += 1) {
		hash::write(&h, strings::toutf8(cmd.argv[i]));
		hash::write(&h, [0]);
	};

	if (t.mod.prefixes[t.kind] == "") {
		let prefix: [8]u8 = [0...];
		hash::sum(&h, prefix);
		t.mod.prefixes[t.kind] = hex::encodestr(prefix);
	};

	let out = getcache(t.mod, t.kind);
	let tmp = strings::concat(out, ".tmp");
	cmd.argv[outidx] = tmp;
	let tmp = os::create(tmp, 0o644, fs::flags::WRONLY)?;
	if (!io::lock(tmp, false, io::lockop::EXCLUSIVE)?) {
		io::close(tmp)?;
		exec::finish(&cmd);
		return false;
	};

	match (tdidx) {
	case void => void;
	case let tdidx: size =>
		let td = getcache(t.mod, cachekind::SSA);
		cmd.argv[tdidx] = strings::concat(td, ".td.tmp");
	};

	let outdated = module::outdated(out, depfiles, mod.srcs.mtime);
	if (!exec || !outdated) {
		io::close(tmp)?;
		exec::finish(&cmd);
		if (outdated) {
			cleanup_task(ctx, t)?;
		} else if (t.kind == cachekind::SSA) {
			get_td(ctx, t.mod)?;
		};
		free_task(t);
		ctx.total -= 1;
		return true;
	};
	path::set(&ctx.buf, out)?;
	let stderr = os::create(path::push_ext(&ctx.buf, "log")?, 0o644)?;
	defer io::close(stderr)!;
	exec::addfile(&cmd, os::stderr_file, stderr);
	switch (ctx.mode) {
	case output::DEFAULT => void;
	case output::VERBOSE =>
		if (ctx.interactive) {
			fmt::printfln("\x1b[1m" "{}" "\x1b[0m" "\t{}",
				cmd.argv[0], mod.id)?;
		} else {
			fmt::printfln("{}\t{}", cmd.argv[0], mod.id)?;
		};
	case output::VVERBOSE =>
		fmt::print(cmd.argv[0])?;
		for (let i = 1z; i < len(cmd.argv); i += 1) {
			fmt::print("", cmd.argv[i])?;
		};
		fmt::println()?;
	};
	let proc = exec::start(&cmd)?;
	jobs[slot] = job { pid = proc, task = t, out = out, tmp = tmp };
	ui_update(ctx, *jobs)?;
	return true;
};

fn getslot(ctx: *context, jobs: *[](job | void)) (size | error) = {
	for (let i = 0z; i < len(jobs); i += 1) if (jobs[i] is void) return i;
	return await_task(ctx, jobs)? as size;
};

fn await_task(ctx: *context, jobs: *[](job | void)) (size | void | error) = {
	let havejob = false;
	for (let i = 0z; i < len(jobs); i += 1) if (jobs[i] is job) {
		havejob = true;
	};
	if (!havejob) return void;
	let (proc, status) = exec::waitany()?;
	let i = 0z;
	for (i < len(jobs); i += 1) match (jobs[i]) {
	case void => void;
	case let j: job => if (j.pid == proc) break;
	};
	assert(i < len(jobs), "Unknown PID returned from waitany");
	let j = jobs[i] as job;
	let t = j.task;
	jobs[i] = void;
	path::set(&ctx.buf, j.out)?;
	let stderr = path::push_ext(&ctx.buf, "log")?;
	defer os::remove(stderr): void;
	match (exec::check(&status)) {
	case void => void;
	case let e: !exec::exit_status =>
		let stderr = os::open(stderr)?;
		defer io::close(stderr)!;
		ui_finish(ctx)?;
		io::copy(os::stderr, stderr)?;
		fmt::fatal(t.mod.mod.id, ctx.cmds[t.kind], exec::exitstr(e));
	};
	cleanup_task(ctx, t)?;
	free_task(t);
	io::close(j.tmp)?;
	ctx.completed += 1;
	return i;
};

// update the cache after a task has been run
fn cleanup_task(ctx: *context, t: *task) (void | fs::error) = {
	let out = getcache(t.mod, t.kind);
	defer free(out);
	let tmp = strings::concat(out, ".tmp");
	defer free(tmp);
	os::move(tmp, out)?;
	if (t.kind != cachekind::SSA) return;

	// td file is hashed solely based on its contents. not worth doing this
	// for other types of outputs, but it gets us better caching behavior
	// for tds since we need to include the dependency tds in the ssa hash
	let tmp = strings::concat(out, ".td.tmp");
	defer free(tmp);
	if (!os::exists(tmp)) return;

	let f = os::open(tmp)?;
	defer io::close(f)!;
	let contents = io::drain(f)?;
	defer free(contents);
	let h = fnv::fnv64a();
	hash::write(&h, contents);
	let prefix: [8]u8 = [0...];
	hash::sum(&h, prefix);
	t.mod.prefixes[cachekind::TD] = hex::encodestr(prefix);
	let td = getcache(t.mod, cachekind::TD);
	defer free(td);

	let ptr = strings::concat(out, ".td");
	defer free(ptr);
	let ptr = os::create(ptr, 0o644)?;
	defer io::close(ptr)!;
	io::writeall(ptr, strings::toutf8(td))?;

	let ns = unparse::identstr(t.mod.mod.ns);
	defer free(ns);
	if (ctx.mode == output::VVERBOSE) {
		fmt::printfln("# HARE_TD_{}={}", ns, td)?;
	};
	let var = strings::concat("HARE_TD_", ns);
	defer free(var);
	os::setenv(var, td)!;
	if (os::exists(td)) {
		os::remove(tmp)?;
	} else {
		os::move(tmp, td)?;
	};
};

fn get_td(ctx: *context, mod: *modctx) (void | fs::error) = {
	let ssa = getcache(mod, cachekind::SSA);
	defer free(ssa);
	let ptr = strings::concat(ssa, ".td");
	defer free(ptr);
	let ptr = match (os::open(ptr)) {
	case fs::error => return;
	case let ptr: io::file => yield ptr;
	};
	defer io::close(ptr)!;
	let path = strings::fromutf8(io::drain(ptr)?)!;
	defer free(path);
	let ns = unparse::identstr(mod.mod.ns);
	defer free(ns);
	if (ctx.mode == output::VVERBOSE) {
		fmt::printfln("# HARE_TD_{}={}", ns, path)?;
	};
	let var = strings::concat("HARE_TD_", ns);
	defer free(var);
	os::setenv(var, path)!;
};

fn ui_update(ctx: *context, jobs: [](job | void)) (void | io::error) = {
	if (ctx.mode != output::DEFAULT || !ctx.interactive) return;
	let buf = strio::dynamic();
	defer io::close(&buf)!;
	fmt::fprintfln(&buf, "\x1b[{}A" "\x1b[0J" "{}/{} tasks completed ({}%)",
		ctx.nlines + 1, ctx.completed, ctx.total,
		if (ctx.total == 0) 100 else ctx.completed * 100 / ctx.total)?;
	for (let i = ctx.nlines; i < len(jobs); i += 1) {
		if (jobs[i] is job) ctx.nlines = i + 1;
	};
	for (let i = 0z; i < ctx.nlines; i += 1) match (jobs[i]) {
	case void =>
		fmt::fprintln(&buf)?;
	case let job: job =>
		fmt::fprintfln(&buf, "\x1b[1m" "{}" "\x1b[0m\t{}",
			ctx.cmds[job.task.kind], job.task.mod.mod.id)?;
	};
	fmt::error(strio::string(&buf))?;
};

fn ui_finish(ctx: *context) (void | error) = {
	if (ctx.mode != output::DEFAULT) return;
	if (ctx.interactive) {
		if (ctx.completed == ctx.total) ctx.nlines += 1;
		if (ctx.nlines != 0) fmt::errorf("\x1b[{}A" "\x1b[0J", ctx.nlines)?;
		return;
	};
	fmt::errorfln("{}/{} tasks completed ({}%)", ctx.completed, ctx.total,
		if (ctx.total == 0) 100 else ctx.completed * 100 / ctx.total)?;
};

fn task_cmp(a: const *void, b: const *void) int = {
	let a = a: const **task, b = b: const **task;
	return if (a.kind < b.kind) -1 else if (a.kind == b.kind) 0 else 1;
};

fn ctx_finish(ctx: *context) void = {
	free(ctx.ctx.tags);
	for (let i = 0z; i < len(ctx.defines); i += 1) {
		ast::ident_free(ctx.defines[i].ident);
		ast::type_finish(ctx.defines[i]._type);
		ast::expr_finish(ctx.defines[i].init);
	};
	free(ctx.defines);
	free(ctx.libdirs);
	free(ctx.libs);
	ast::ident_free(ctx.ns);
};

fn getcache(mod: *modctx, kind: cachekind) str =
	module::cache_getfile(mod.cache, mod.prefixes[kind], kind);

fn modctx_finish(mods: []modctx) void = {
	for (let i = 0z; i < len(mods); i += 1) {
		module::finish(&mods[i].mod);
		free(mods[i].cache);
		for (let j = 0z; j < len(mods[i].prefixes); j += 1) {
			free(mods[i].prefixes[j]);
		};
		strings::freeall(mods[i].libs);
		for (let j = 0z; j < len(mods[i].flags); j += 1) {
			strings::freeall(mods[i].flags[j]);
		};
	};
	free(mods);
};

fn free_task(t: *task) void = {
	for (let i = 0z; i < len(t.rdeps); i += 1) {
		t.rdeps[i].ndeps -= 1;
	};
	free(t.rdeps);
	free(t);
};
diff --git a/cmd/hare/cache.ha b/cmd/hare/cache.ha
new file mode 100644
index 00000000..5ce69133
--- /dev/null
+++ b/cmd/hare/cache.ha
@@ -0,0 +1,49 @@
use fmt;
use fs;
use getopt;
use os;
use path;

fn cache(name: str, cmd: *getopt::command) (void | error) = {
	let clear = false;
	for (let i = 0z; i < len(cmd.opts); i += 1) {
		let opt = cmd.opts[i];
		switch (opt.0) {
		case 'c' => clear = true;
		case => abort();
		};
	};
	if (len(cmd.args) != 0) getopt::printusage(os::stderr, name, cmd.help)?;
	let cachedir = harecache();
	if (clear) {
		os::rmdirall(cachedir)?;
		fmt::println(cachedir, "(0 B)")?;
		return;
	};
	os::mkdirs(cachedir, 0o755)!;
	let buf = path::init(cachedir)?;
	let sz = dirsize(&buf)?;
	const suffix = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"];
	let i = 0z;
	for (i < len(suffix) && 1024 < sz; i += 1) {
		sz /= 1024;
	};
	fmt::printfln("{} ({} {})", cachedir, sz, suffix[i])?;
};

fn dirsize(buf: *path::buffer) (size | error) = {
	let s = 0z;
	let it = os::iter(path::string(buf))?;
	defer os::finish(it);
	for (true) match (fs::next(it)) {
	case void => break;
	case let d: fs::dirent =>
		if (d.name == "." || d.name == "..") continue;
		path::push(buf, d.name)?;
		let stat = os::stat(path::string(buf))?;
		s += stat.sz;
		if (fs::isdir(stat.mode)) s += dirsize(buf)?;
		path::pop(buf);
	};
	return s;
};
diff --git a/cmd/hare/deps.ha b/cmd/hare/deps.ha
index f54c9d1e..6073d3fe 100644
--- a/cmd/hare/deps.ha
+++ b/cmd/hare/deps.ha
@@ -1,79 +1,93 @@
use fmt;
use getopt;
use hare::ast;
use hare::module;
use hare::parse;
use io;
use os;
use path;
use sort;
use strings;

type depnode = struct {
	ident: str,
	depends: []size,
type deps_fmt = enum {
	DOT,
	TERM,
};

type link = struct {
	depth: uint,
	child: size,
	final: bool,
};

// the start of the cycle in the stack
type dep_cycle = !size;
fn deps(name: str, cmd: *getopt::command) (void | error) = {
	let tags = default_tags()?;
	defer free(tags);

// depth-first initial exploration, cycle-detection, reverse topological sort
fn explore_deps(ctx: *module::context, stack: *[]str, visited: *[]depnode, ident: str) (size | dep_cycle) = {
	// check for cycles
	for (let i = 0z; i < len(stack); i += 1) {
		if (ident == stack[i]) {
			append(stack, ident);
			return i: dep_cycle;
	let build_dir: str = "";
	let goal = deps_fmt::TERM;
	for (let i = 0z; i < len(cmd.opts); i += 1) {
		let opt = cmd.opts[i];
		switch (opt.0) {
		case 'd' =>
			goal = deps_fmt::DOT;
		case 'T' =>
			merge_tags(&tags, opt.1)?;
		case =>
			abort();
		};
	};

	// return existing depnode if visited already
	for (let i = 0z; i < len(visited); i += 1) {
		if (ident == visited[i].ident) return i;
	if (1 < len(cmd.args)) {
		getopt::printusage(os::stderr, name, cmd.help...)!;
		os::exit(1);
	};
	append(stack, ident);

	let this = depnode{ident = strings::dup(ident), depends = [], depth = 0};
	let ver = match (module::lookup(ctx, parse::identstr(ident)!)) {
	case let e: module::error =>
		fmt::fatal(module::strerror(e));
	case let ver: module::version =>
		yield ver;
	};
	for (let i = 0z; i < len(ver.depends); i += 1) {
		const name = strings::join("::", ver.depends[i]...);
		defer free(name);
		const child = explore_deps(ctx, stack, visited, name)?;
		append(this.depends, child);
	};
	// reverse-sort depends so that we know the last in the list is the
	// "final" child during show_deps
	sort::sort(this.depends, size(size), &cmpsz);

	static delete(stack[len(stack)-1]);
	append(visited, this);
	return len(visited) - 1;
};
	const input = if (len(cmd.args) == 0) os::getcwd() else cmd.args[0];

// sorts in reverse
fn cmpsz(a: const *void, b: const *void) int = (*(b: *size) - *(a: *size)): int;
	let mods: []module::module = [];
	static let buf = path::buffer { ... };
	path::set(&buf, input)?;
	let ctx = module::context {
		harepath = harepath(),
		harecache = harecache(),
		tags = tags,
	};
	module::gather(&ctx, &mods, &buf)?;
	defer module::free_slice(mods);

type link = struct {
	depth: uint,
	child: size,
	final: bool,
	switch (goal) {
	case deps_fmt::TERM =>
		deps_graph(&mods);
	case deps_fmt::DOT =>
		fmt::println("strict digraph deps {")!;
		for (let i = 0z; i < len(mods); i += 1) {
			for (let j = 0z; j < len(mods[i].deps); j += 1) {
				const child = mods[mods[i].deps[j].0];
				fmt::printfln("\t\"{}\" -> \"{}\";", mods[i].id, child.id)!;
			};
		};
		fmt::println("}")!;
	};
};

fn show_deps(depnodes: *[]depnode) void = {
fn deps_graph(mods: *[]module::module) void = {
	let links: []link = [];
	defer free(links);
	let depth: []uint = alloc([0...], len(mods));
	// traverse in reverse because reverse-topo-sort
	for (let i = len(depnodes) - 1; 0 <= i && i < len(depnodes); i -= 1) {
	for (let i = len(mods) - 1; 0 <= i && i < len(mods); i -= 1) {
		// reverse-sort deps so that we know the last in the list is the
		// "final" child during show_deps
		sort::sort(mods[i].deps, size((size, ast::ident)), &revsort);

		for (let j = 0z; j < len(links); j += 1) {
			if (i < links[j].child) continue;
			if (depnodes[i].depth < links[j].depth + 1) depnodes[i].depth = links[j].depth + 1;
			if (depth[i] <= links[j].depth) depth[i] = links[j].depth + 1;
		};

		// print in-between row
		for (let d = 0u; d < depnodes[i].depth; d += 1) {
		for (let d = 0u; d < depth[i]; d += 1) {
			let passing = false;
			for (let j = 0z; j < len(links); j += 1) {
				if (i < links[j].child) continue;
@@ -83,11 +97,11 @@ fn show_deps(depnodes: *[]depnode) void = {
			};
			fmt::print(if (passing) "│  " else "   ")!;
		};
		if (i < len(depnodes) - 1) fmt::println()!;
		if (i < len(mods) - 1) fmt::println()!;

		// print row itself
		let on_path = false;
		for (let d = 0u; d < depnodes[i].depth; d += 1) {
		for (let d = 0u; d < depth[i]; d += 1) {
			let connected = false;
			let passing = false;
			let final = false;
@@ -110,13 +124,20 @@ fn show_deps(depnodes: *[]depnode) void = {
				else "   "
			)!;
		};
		fmt::println(depnodes[i].ident)!;
		for (let j = 0z; j < len(depnodes[i].depends); j += 1) {
		fmt::println(mods[i].id)!;
		for (let j = 0z; j < len(mods[i].deps); j += 1) {
			append(links, link{
				depth = depnodes[i].depth,
				child = depnodes[i].depends[j],
				final = len(depnodes[i].depends) == j + 1,
				depth = depth[i],
				child = mods[i].deps[j].0,
				final = len(mods[i].deps) == j + 1,
			});
		};
	};
};

// sorts in reverse
fn revsort(a: const *void, b: const *void) int = {
	let a = *(a: *(size, str));
	let b = *(b: *(size, str));
	return (b.0 - a.0): int;
};
diff --git a/cmd/haredoc/main.ha b/cmd/hare/doc.ha
similarity index 59%
rename from cmd/haredoc/main.ha
rename to cmd/hare/doc.ha
index eaf9dedd..119a609c 100644
--- a/cmd/haredoc/main.ha
+++ b/cmd/hare/doc.ha
@@ -4,6 +4,7 @@
// (c) 2021 Ember Sawady <ecs@d2evs.net>
// (c) 2022 Sebastian <sebastian@sebsite.pw>
use bufio;
use cmd::hare::doc;
use fmt;
use fs;
use getopt;
@@ -19,53 +20,16 @@ use path;
use strings;
use unix::tty;

type format = enum {
	HARE,
	TTY,
	HTML,
};

type context = struct {
	mctx: *module::context,
	ident: ast::ident,
	tags: []module::tag,
	version: module::version,
	summary: summary,
	format: format,
	template: bool,
	show_undocumented: bool,
	readme: (io::file | void),
	out: io::handle,
	pager: (exec::process | void),
};

export fn main() void = {
fn doc(name: str, cmd: *getopt::command) (void | error) = {
	let fmt = if (tty::isatty(os::stdout_file)) {
		yield format::TTY;
		yield doc::format::TTY;
	} else {
		yield format::HARE;
		yield doc::format::HARE;
	};
	let template = true;
	let show_undocumented = false;
	let tags = match (default_tags()) {
	case let t: []module::tag =>
		yield t;
	case let err: exec::error =>
		fmt::fatal(strerror(err));
	};
	defer module::tags_free(tags);

	const help: [_]getopt::help = [
		"reads and formats Hare documentation",
		('F', "format", "specify output format (hare, tty, or html)"),
		('T', "tags...", "set build tags"),
		('X', "tags...", "unset build tags"),
		('a', "show undocumented members (only applies to -Fhare and -Ftty)"),
		('t', "disable HTML template (requires postprocessing)"),
		"[identifiers...]",
	];
	const cmd = getopt::parse(os::args, help...);
	defer getopt::finish(&cmd);
	let tags: []str = default_tags()?;
	defer free(tags);

	for (let i = 0z; i < len(cmd.opts); i += 1) {
		let opt = cmd.opts[i];
@@ -73,28 +37,16 @@ export fn main() void = {
		case 'F' =>
			switch (opt.1) {
			case "hare" =>
				fmt = format::HARE;
				fmt = doc::format::HARE;
			case "tty" =>
				fmt = format::TTY;
				fmt = doc::format::TTY;
			case "html" =>
				fmt = format::HTML;
				fmt = doc::format::HTML;
			case =>
				fmt::fatal("Invalid format", opt.1);
			};
		case 'T' =>
			tags = match (addtags(tags, opt.1)) {
			case void =>
				fmt::fatal("Error parsing tags");
			case let t: []module::tag =>
				yield t;
			};
		case 'X' =>
			tags = match (deltags(tags, opt.1)) {
			case void =>
				fmt::fatal("Error parsing tags");
			case let t: []module::tag =>
				yield t;
			};
			merge_tags(&tags, opt.1)?;
		case 't' =>
			template = false;
		case 'a' =>
@@ -104,7 +56,7 @@ export fn main() void = {
	};

	if (show_undocumented) switch (fmt) {
	case format::HARE, format::TTY => void;
	case doc::format::HARE, doc::format::TTY => void;
	case =>
		fmt::fatal("Option -a must be used only with -Fhare or -Ftty");
	};
@@ -112,51 +64,39 @@ export fn main() void = {
	let decls: []ast::decl = [];
	defer free(decls);

	let ctx = module::context_init(tags, [], default_harepath());
	defer module::context_finish(&ctx);

	const id: ast::ident =
		if (len(cmd.args) < 1) []
		else match (parseident(cmd.args[0])) {
		case let err: parse::error =>
			fmt::fatal(parse::strerror(err));
		case let id: ast::ident =>
			yield id;
		};
	let ctx = module::context {
		harepath = harepath(),
		harecache = harecache(),
		tags = tags,
	};

	let decl = "";
	let dirname: ast::ident = if (len(id) < 2) [] else id[..len(id) - 1];
	const version = match (module::lookup(&ctx, id)) {
	case let ver: module::version =>
		yield ver;
	case let err: module::error =>
		yield match (module::lookup(&ctx, dirname)) {
		case let ver: module::version =>
			assert(len(id) >= 1);
			decl = id[len(id) - 1];
			yield ver;
		case let err: module::error =>
			fmt::fatal("Error scanning input module:",
				module::strerror(err));
	let (modpath, id) = if (len(cmd.args) == 0) {
		yield (".", []: ast::ident);
	} else match (parseident(cmd.args[0])) {
	case let id: ast::ident =>
		// first assume it's a module
		yield match (module::findinpath(ctx.harepath, id)) {
		case let modpath: str =>
			yield (modpath, id);
		case let e: module::error =>
			module::finish_error(e);
			// then assume it's an ident inside a module
			decl = id[len(id)-1];
			id = id[..len(id)-1];
			yield (module::findinpath(ctx.harepath, id)?, id);
		};
	case => yield (cmd.args[0], []: ast::ident);
	};

	for (let i = 0z; i < len(version.inputs); i += 1) {
		const in = version.inputs[i];
		const ext = path::peek_ext(&path::init(in.path)!);
		if (ext is void || ext as str != "ha") {
			continue;
		};
		match (scan(in.path)) {
		case let u: ast::subunit =>
			ast::imports_finish(u.imports);
			append(decls, u.decls...);
		case let err: error =>
			fmt::fatal("Error:", strerror(err));
		};
	const srcs = module::findsrcs(modpath, tags)?;
	for (let i = 0z; i < len(srcs.ha); i += 1) {
		let u = doc::scan(srcs.ha[i])?;
		ast::imports_finish(u.imports);
		append(decls, u.decls...);
	};

	const rpath = path::init(version.basedir, "README")!;
	const rpath = path::init(modpath, "README")!;
	const readme: (io::file | void) = if (decl == "") {
		yield match (os::open(path::string(&rpath))) {
		case let err: fs::error =>
@@ -183,7 +123,7 @@ export fn main() void = {
		};
		if (len(new) == 0) {
			fmt::fatalf("Could not find {}::{}",
				unparse::identstr(dirname), decl);
				unparse::identstr(id), decl);
		};
		free(decls);
		decls = new;
@@ -195,12 +135,14 @@ export fn main() void = {
		ast::decl_finish(decls[i]);
	};

	const ctx = context {
	const ctx = doc::context {
		mctx = &ctx,
		ident = id,
		tags = tags,
		version = version,
		summary = sort_decls(decls),
		modpath = modpath,
		srcs = srcs,
		submods = if (decl == "") doc::submodules(modpath)? else [],
		summary = doc::sort_decls(decls),
		format = fmt,
		template = template,
		readme = readme,
@@ -209,17 +151,13 @@ export fn main() void = {
		pager = void,
	};

	if (fmt == format::TTY) {
	if (fmt == doc::format::TTY) {
		ctx.out = init_tty(&ctx);
	};

	match (emit(&ctx)) {
	case void => void;
	case let err: error =>
		fmt::fatal("Error:", strerror(err));
	};
	emit(&ctx)?;

	io::close(ctx.out)!;
	io::close(ctx.out)?;
	match (ctx.pager) {
	case void => void;
	case let proc: exec::process =>
@@ -235,7 +173,10 @@ fn parseident(in: str) (ast::ident | parse::error) = {
	const buf = bufio::fixed(strings::toutf8(in), io::mode::READ);
	const lexer = lex::init(&buf, "<string>");
	defer lex::finish(&lexer);
	let ident: []str = []; // TODO: errdefer
	// XXX: errdefer
	let success = false;
	let ident: ast::ident = [];
	defer if (!success) ast::ident_free(ident);
	let z = 0z;
	for (true) {
		const tok = lex::lex(&lexer)?;
@@ -270,10 +211,11 @@ fn parseident(in: str) (ast::ident | parse::error) = {
		const why = "Identifier exceeds maximum length";
		return (loc, why): lex::syntax: parse::error;
	};
	success = true;
	return ident;
};

fn init_tty(ctx: *context) io::handle = {
fn init_tty(ctx: *doc::context) io::handle = {
	const pager = match (os::getenv("PAGER")) {
	case let name: str =>
		yield match (exec::cmd(name)) {
@@ -340,32 +282,22 @@ fn has_decl(decl: ast::decl, name: str) bool = {
	return false;
};

fn scan(path: str) (ast::subunit | error) = {
	const input = match (os::open(path)) {
	case let f: io::file =>
		yield f;
	case let err: fs::error =>
		fmt::fatalf("Error reading {}: {}", path, fs::strerror(err));
	};
	defer io::close(input)!;
	const lexer = lex::init(input, path, lex::flags::COMMENTS);
	return parse::subunit(&lexer)?;
};

fn emit(ctx: *context) (void | error) = {
fn emit(ctx: *doc::context) (void | error) = {
	switch (ctx.format) {
	case format::HARE =>
		emit_hare(ctx)?;
	case format::TTY =>
		emit_tty(ctx)?;
	case format::HTML =>
		emit_html(ctx)?;
	case doc::format::HARE =>
		doc::emit_hare(ctx)?;
	case doc::format::TTY =>
		doc::emit_tty(ctx)?;
	case doc::format::HTML =>
		doc::emit_html(ctx)?;
	};
};

@test fn parseident() void = {
	assert(parseident("hare::lex") is ast::ident);
	assert(ast::ident_eq(parseident("hare::lex") as ast::ident,
		["hare", "lex"]));
	assert(ast::ident_eq(parseident("rt::abort") as ast::ident,
		["rt", "abort"]));
	assert(parseident("strings::dup*{}&@") is parse::error);
	assert(parseident("foo::bar::") is parse::error);
	assert(parseident("rt::abort") is ast::ident);
};
diff --git a/cmd/haredoc/color.ha b/cmd/hare/doc/color.ha
similarity index 100%
rename from cmd/haredoc/color.ha
rename to cmd/hare/doc/color.ha
diff --git a/cmd/haredoc/hare.ha b/cmd/hare/doc/hare.ha
similarity index 94%
rename from cmd/haredoc/hare.ha
rename to cmd/hare/doc/hare.ha
index e1a9cf4f..16c99e68 100644
--- a/cmd/haredoc/hare.ha
+++ b/cmd/hare/doc/hare.ha
@@ -14,7 +14,7 @@ use strings;
use strio;

// Formats output as Hare source code (prototypes)
fn emit_hare(ctx: *context) (void | error) = {
export fn emit_hare(ctx: *context) (void | error) = {
	const summary = ctx.summary;

	let first = true;
@@ -74,23 +74,20 @@ fn emit_hare(ctx: *context) (void | error) = {
};

fn emit_submodules_hare(ctx: *context) (void | error) = {
	const submodules = submodules(ctx)?;
	defer strings::freeall(submodules);

	if (len(submodules) != 0) {
	if (len(ctx.submods) != 0) {
		fmt::fprintln(ctx.out)?;
		if (len(ctx.ident) == 0) {
			fmt::fprintln(ctx.out, "// Modules")?;
		} else {
			fmt::fprintln(ctx.out, "// Submodules")?;
		};
		for (let i = 0z; i < len(submodules); i += 1) {
		for (let i = 0z; i < len(ctx.submods); i += 1) {
			let submodule = if (len(ctx.ident) != 0) {
				const s = unparse::identstr(ctx.ident);
				defer free(s);
				yield strings::concat(s, "::", submodules[i]);
				yield strings::concat(s, "::", ctx.submods[i]);
			} else {
				yield strings::dup(submodules[i]);
				yield strings::dup(ctx.submods[i]);
			};
			defer free(submodule);

diff --git a/cmd/haredoc/html.ha b/cmd/hare/doc/html.ha
similarity index 94%
rename from cmd/haredoc/html.ha
rename to cmd/hare/doc/html.ha
index a5ff75ab..5718d407 100644
--- a/cmd/haredoc/html.ha
+++ b/cmd/hare/doc/html.ha
@@ -14,6 +14,7 @@ use hare::ast;
use hare::ast::{variadism};
use hare::lex;
use hare::module;
use hare::parse::doc;
use hare::unparse;
use io;
use net::ip;
@@ -69,7 +70,7 @@ fn html_escape(out: io::handle, in: str) (size | io::error) = {
};

// Formats output as HTML
fn emit_html(ctx: *context) (void | error) = {
export fn emit_html(ctx: *context) (void | error) = {
	const decls = ctx.summary;
	const ident = unparse::identstr(ctx.ident);
	defer free(ident);
@@ -82,13 +83,7 @@ fn emit_html(ctx: *context) (void | error) = {
		fmt::fprintf(ctx.out, "<h2><span class='heading-body'>{}</span><span class='heading-extra'>", ident)?;
	};
	for (let i = 0z; i < len(ctx.tags); i += 1) {
		const mode = switch (ctx.tags[i].mode) {
		case module::tag_mode::INCLUSIVE =>
			yield '+';
		case module::tag_mode::EXCLUSIVE =>
			yield '-';
		};
		fmt::fprintf(ctx.out, "{}{} ", mode, ctx.tags[i].name)?;
		fmt::fprintf(ctx.out, "+{} ", ctx.tags[i])?;
	};
	fmt::fprintln(ctx.out, "</span></h2>")?;

@@ -100,40 +95,18 @@ fn emit_html(ctx: *context) (void | error) = {
		fmt::fprintln(ctx.out, "</div>")?;
	};

	let identpath = module::identpath(ctx.ident);
	let identpath = strings::join("/", ctx.ident...);
	defer free(identpath);

	let submodules: []str = [];
	defer free(submodules);

	for (let i = 0z; i < len(ctx.version.subdirs); i += 1) {
		let dir = ctx.version.subdirs[i];
		// XXX: the list of reserved directory names is not yet
		// finalized. See https://todo.sr.ht/~sircmpwn/hare/516
		if (dir == "contrib") continue;
		if (dir == "cmd") continue;
		if (dir == "docs") continue;
		if (dir == "ext") continue;
		if (dir == "vendor") continue;
		if (dir == "scripts") continue;

		let submod = [identpath, dir]: ast::ident;
		if (module::lookup(ctx.mctx, submod) is module::error) {
			continue;
		};

		append(submodules, dir);
	};

	if (len(submodules) != 0) {
	if (len(ctx.submods) != 0) {
		if (len(ctx.ident) == 0) {
			fmt::fprintln(ctx.out, "<h3>Modules</h3>")?;
		} else {
			fmt::fprintln(ctx.out, "<h3>Submodules</h3>")?;
		};
		fmt::fprintln(ctx.out, "<ul class='submodules'>")?;
		for (let i = 0z; i < len(submodules); i += 1) {
			let submodule = submodules[i];
		for (let i = 0z; i < len(ctx.submods); i += 1) {
			let submodule = ctx.submods[i];
			let path = path::init("/", identpath, submodule)!;

			fmt::fprintf(ctx.out, "<li><a href='")?;
@@ -340,9 +313,9 @@ fn details(ctx: *context, decl: ast::decl) (void | error) = {
	return;
};

fn htmlref(ctx: *context, ref: ast::ident) (void | io::error) = {
fn htmlref(ctx: *context, ref: ast::ident) (void | error) = {
	const ik =
		match (resolve(ctx, ref)) {
		match (resolve(ctx, ref)?) {
		case let ik: (ast::ident, symkind) =>
			yield ik;
		case void =>
@@ -363,12 +336,12 @@ fn htmlref(ctx: *context, ref: ast::ident) (void | io::error) = {
	case symkind::LOCAL =>
		fmt::fprintf(ctx.out, "<a href='#{0}' class='ref'>{0}</a>", ident)?;
	case symkind::MODULE =>
		let ipath = module::identpath(id);
		let ipath = strings::join("/", id...);
		defer free(ipath);
		fmt::fprintf(ctx.out, "<a href='/{}' class='ref'>{}</a>",
			ipath, ident)?;
	case symkind::SYMBOL =>
		let ipath = module::identpath(id[..len(id) - 1]);
		let ipath = strings::join("/", id[..len(id) - 1]...);
		defer free(ipath);
		fmt::fprintf(ctx.out, "<a href='/{}#{}' class='ref'>{}</a>",
			ipath, id[len(id) - 1], ident)?;
@@ -376,7 +349,7 @@ fn htmlref(ctx: *context, ref: ast::ident) (void | io::error) = {
		fmt::fprintf(ctx.out, "<a href='#{}' class='ref'>{}</a>",
			id[len(id) - 2], ident)?;
	case symkind::ENUM_REMOTE =>
		let ipath = module::identpath(id[..len(id) - 2]);
		let ipath = strings::join("/", id[..len(id) - 2]...);
		defer free(ipath);
		fmt::fprintf(ctx.out, "<a href='/{}#{}' class='ref'>{}</a>",
			ipath, id[len(id) - 2], ident)?;
@@ -384,28 +357,28 @@ fn htmlref(ctx: *context, ref: ast::ident) (void | io::error) = {
	free(ident);
};

fn markup_html(ctx: *context, in: io::handle) (void | io::error) = {
	let parser = parsedoc(in);
fn markup_html(ctx: *context, in: io::handle) (void | error) = {
	let parser = doc::parse(in);
	let waslist = false;
	for (true) {
		const tok = match (scandoc(&parser)) {
		const tok = match (doc::scan(&parser)) {
			case void =>
				if (waslist) {
					fmt::fprintln(ctx.out, "</ul>")?;
				};
				break;
			case let tok: token =>
			case let tok: doc::token =>
				yield tok;
			};
		match (tok) {
		case paragraph =>
		case doc::paragraph =>
			if (waslist) {
				fmt::fprintln(ctx.out, "</ul>")?;
				waslist = false;
			};
			fmt::fprintln(ctx.out)?;
			fmt::fprint(ctx.out, "<p>")?;
		case let tx: text =>
		case let tx: doc::text =>
			defer free(tx);
			match (uri::parse(strings::trim(tx))) {
			case let uri: uri::uri =>
@@ -422,9 +395,9 @@ fn markup_html(ctx: *context, in: io::handle) (void | io::error) = {
			case uri::invalid =>
				html_escape(ctx.out, tx)?;
			};
		case let re: reference =>
		case let re: doc::reference =>
			htmlref(ctx, re)?;
		case let sa: sample =>
		case let sa: doc::sample =>
			if (waslist) {
				fmt::fprintln(ctx.out, "</ul>")?;
				waslist = false;
@@ -433,7 +406,7 @@ fn markup_html(ctx: *context, in: io::handle) (void | io::error) = {
			html_escape(ctx.out, sa)?;
			fmt::fprint(ctx.out, "</pre>")?;
			free(sa);
		case listitem =>
		case doc::listitem =>
			if (!waslist) {
				fmt::fprintln(ctx.out, "<ul>")?;
				waslist = true;
@@ -857,7 +830,7 @@ fn breadcrumb(ident: ast::ident) str = {
	let buf = strio::dynamic();
	fmt::fprintf(&buf, "<a href='/'>stdlib</a> » ")!;
	for (let i = 0z; i < len(ident) - 1; i += 1) {
		let ipath = module::identpath(ident[..i+1]);
		let ipath = strings::join("/", ident[..i+1]...);
		defer free(ipath);
		fmt::fprintf(&buf, "<a href='/{}'>{}</a>::", ipath, ident[i])!;
	};
diff --git a/cmd/haredoc/resolver.ha b/cmd/hare/doc/resolve.ha
similarity index 75%
rename from cmd/haredoc/resolver.ha
rename to cmd/hare/doc/resolve.ha
index 0e3be7af..b0fc2114 100644
--- a/cmd/haredoc/resolver.ha
+++ b/cmd/hare/doc/resolve.ha
@@ -3,8 +3,13 @@
// (c) 2021 Ember Sawady <ecs@d2evs.net>
// (c) 2022 Alexey Yerin <yyp@disroot.org>
use fmt;
use fs;
use hare::ast;
use hare::lex;
use hare::module;
use hare::parse;
use io;
use os;
use path;

type symkind = enum {
@@ -18,7 +23,7 @@ type symkind = enum {
// Resolves a reference. Given an identifier, determines if it refers to a local
// symbol, a module, or a symbol in a remote module, then returns this
// information combined with a corrected ident if necessary.
fn resolve(ctx: *context, what: ast::ident) ((ast::ident, symkind) | void) = {
fn resolve(ctx: *context, what: ast::ident) ((ast::ident, symkind) | void | error) = {
	if (is_local(ctx, what)) {
		return (what, symkind::LOCAL);
	};
@@ -27,8 +32,8 @@ fn resolve(ctx: *context, what: ast::ident) ((ast::ident, symkind) | void) = {
		// Look for symbol in remote module
		let partial = what[..len(what) - 1];

		match (module::lookup(ctx.mctx, partial)) {
		case let ver: module::version =>
		match (module::findinpath(ctx.mctx.harepath, partial)) {
		case str =>
			return (what, symkind::SYMBOL);
		case module::error => void;
		};
@@ -41,15 +46,15 @@ fn resolve(ctx: *context, what: ast::ident) ((ast::ident, symkind) | void) = {
		};
	};
	if (len(what) > 2) {
		match (lookup_remote_enum(ctx, what)) {
		match (lookup_remote_enum(ctx, what)?) {
		case let id: ast::ident =>
			return (id, symkind::ENUM_REMOTE);
		case => void;
		};
	};

	match (module::lookup(ctx.mctx, what)) {
	case let ver: module::version =>
	match (module::findinpath(ctx.mctx.harepath, what)) {
	case str =>
		return (what, symkind::MODULE);
	case module::error => void;
	};
@@ -118,17 +123,23 @@ fn lookup_local_enum(ctx: *context, what: ast::ident) (ast::ident | void) = {
	};
};

fn lookup_remote_enum(ctx: *context, what: ast::ident) (ast::ident | void) = {
fn lookup_remote_enum(ctx: *context, what: ast::ident) (ast::ident | void | error) = {
	// mod::decl_name::member
	const mod = what[..len(what) - 2];
	const decl_name = what[len(what) - 2];
	const member = what[len(what) - 1];

	const version = match (module::lookup(ctx.mctx, mod)) {
	case let ver: module::version =>
		yield ver;
	case module::error =>
		abort();
	const modpath = match (module::findinpath(ctx.mctx.harepath, mod)) {
	case let s: str => yield s;
	case let e: module::error =>
		module::finish_error(e);
		return void;
	};
	const srcs = match (module::findsrcs(modpath, ctx.tags)) {
	case let s: module::srcset => yield s;
	case let e: module::error =>
		module::finish_error(e);
		return void;
	};

	// This would take a lot of memory to load
@@ -139,18 +150,10 @@ fn lookup_remote_enum(ctx: *context, what: ast::ident) (ast::ident | void) = {
		};
		free(decls);
	};
	for (let i = 0z; i < len(version.inputs); i += 1) {
		const in = version.inputs[i];
		const ext = path::peek_ext(&path::init(in.path)!);
		if (ext is void || ext as str != "ha") {
			continue;
		};
		match (scan(in.path)) {
		case let u: ast::subunit =>
			append(decls, u.decls...);
		case let err: error =>
			fmt::fatal("Error:", strerror(err));
		};
	for (let i = 0z; i < len(srcs.ha); i += 1) {
		const in = srcs.ha[i];
		let u = scan(in)?;
		append(decls, u.decls...);
	};

	for (let i = 0z; i < len(decls); i += 1) {
@@ -176,3 +179,15 @@ fn lookup_remote_enum(ctx: *context, what: ast::ident) (ast::ident | void) = {
		};
	};
};

export fn scan(path: str) (ast::subunit | error) = {
	const input = match (os::open(path)) {
	case let f: io::file =>
		yield f;
	case let err: fs::error =>
		fmt::fatalf("Error reading {}: {}", path, fs::strerror(err));
	};
	defer io::close(input)!;
	const lexer = lex::init(input, path, lex::flags::COMMENTS);
	return parse::subunit(&lexer)?;
};
diff --git a/cmd/haredoc/sort.ha b/cmd/hare/doc/sort.ha
similarity index 93%
rename from cmd/haredoc/sort.ha
rename to cmd/hare/doc/sort.ha
index cc5a0928..c6ed617a 100644
--- a/cmd/haredoc/sort.ha
+++ b/cmd/hare/doc/sort.ha
@@ -5,19 +5,11 @@ use hare::ast;
use sort;
use strings;

type summary = struct {
	constants: []ast::decl,
	errors: []ast::decl,
	types: []ast::decl,
	globals: []ast::decl,
	funcs: []ast::decl,
};

// Sorts declarations by removing unexported declarations, moving undocumented
// declarations to the end, sorting by identifier, and ensuring that only one
// member is present in each declaration (so that "let x: int = 10, y: int = 20"
// becomes two declarations: "let x: int = 10; let y: int = 20;").
fn sort_decls(decls: []ast::decl) summary = {
export fn sort_decls(decls: []ast::decl) summary = {
	let sorted = summary { ... };

	for (let i = 0z; i < len(decls); i += 1) {
diff --git a/cmd/haredoc/tty.ha b/cmd/hare/doc/tty.ha
similarity index 98%
rename from cmd/haredoc/tty.ha
rename to cmd/hare/doc/tty.ha
index d983ca17..e48dc978 100644
--- a/cmd/haredoc/tty.ha
+++ b/cmd/hare/doc/tty.ha
@@ -17,7 +17,7 @@ use strio;
let firstline: bool = true;

// Formats output as Hare source code (prototypes) with syntax highlighting
fn emit_tty(ctx: *context) (void | error) = {
export fn emit_tty(ctx: *context) (void | error) = {
	init_colors();
	const summary = ctx.summary;

@@ -55,10 +55,7 @@ fn emit_tty(ctx: *context) (void | error) = {
};

fn emit_submodules_tty(ctx: *context) (void | error) = {
	const submodules = submodules(ctx)?;
	defer strings::freeall(submodules);

	if (len(submodules) != 0) {
	if (len(ctx.submods) != 0) {
		fmt::fprintln(ctx.out)?;
		if (len(ctx.ident) == 0) {
			render(ctx.out, syn::COMMENT)?;
@@ -69,13 +66,13 @@ fn emit_submodules_tty(ctx: *context) (void | error) = {
			fmt::fprintln(ctx.out, "// Submodules")?;
			render(ctx.out, syn::NORMAL)?;
		};
		for (let i = 0z; i < len(submodules); i += 1) {
		for (let i = 0z; i < len(ctx.submods); i += 1) {
			let submodule = if (len(ctx.ident) != 0) {
				const s = unparse::identstr(ctx.ident);
				defer free(s);
				yield strings::concat(s, "::", submodules[i]);
				yield strings::concat(s, "::", ctx.submods[i]);
			} else {
				yield strings::dup(submodules[i]);
				yield strings::dup(ctx.submods[i]);
			};
			defer free(submodule);

diff --git a/cmd/hare/doc/types.ha b/cmd/hare/doc/types.ha
new file mode 100644
index 00000000..8398f6e3
--- /dev/null
+++ b/cmd/hare/doc/types.ha
@@ -0,0 +1,55 @@
// License: GPL-3.0
// (c) 2021 Drew DeVault <sir@cmpwn.com>
// (c) 2021 Ember Sawady <ecs@d2evs.net>
use fs;
use hare::ast;
use hare::lex;
use hare::module;
use hare::parse;
use io;
use os::exec;

export type error = !(lex::error | parse::error | io::error | module::error | exec::error | fs::error);

export fn strerror(err: error) str = {
	match (err) {
	case let err: lex::error =>
		return lex::strerror(err);
	case let err: parse::error =>
		return parse::strerror(err);
	case let err: io::error =>
		return io::strerror(err);
	case let err: module::error =>
		return module::strerror(err);
	};
};

export type format = enum {
	HARE,
	TTY,
	HTML,
};

export type context = struct {
	mctx: *module::context,
	ident: ast::ident,
	tags: []str,
	modpath: str,
	srcs: module::srcset,
	submods: []str,
	summary: summary,
	format: format,
	template: bool,
	show_undocumented: bool,
	readme: (io::file | void),
	out: io::handle,
	pager: (exec::process | void),
};

export type summary = struct {
	constants: []ast::decl,
	errors: []ast::decl,
	types: []ast::decl,
	globals: []ast::decl,
	funcs: []ast::decl,
};
diff --git a/cmd/haredoc/util.ha b/cmd/hare/doc/util.ha
similarity index 52%
rename from cmd/haredoc/util.ha
rename to cmd/hare/doc/util.ha
index ba17bab0..623713a3 100644
--- a/cmd/haredoc/util.ha
+++ b/cmd/hare/doc/util.ha
@@ -2,9 +2,11 @@
// (c) 2022 Byron Torres <b@torresjrjr.com>
// (c) 2022 Sebastian <sebastian@sebsite.pw>
use fmt;
use hare::ast;
use fs;
use hare::module;
use io;
use os;
use sort;
use strings;
use strio;

@@ -36,35 +38,14 @@ fn trim_comment(s: str) str = {
	return strings::dup(strio::string(&trimmed));
};

fn submodules(ctx: *context) ([]str | error) = {
	let identpath = module::identpath(ctx.ident);
	defer free(identpath);

export fn submodules(path: str) ([]str | error) = {
	let submodules: []str = [];
	for (let i = 0z; i < len(ctx.version.subdirs); i += 1) {
		let dir = ctx.version.subdirs[i];
		// XXX: the list of reserved directory names is not yet
		// finalized. See https://todo.sr.ht/~sircmpwn/hare/516
		if (dir == "contrib") continue;
		if (dir == "cmd") continue;
		if (dir == "docs") continue;
		if (dir == "ext") continue;
		if (dir == "vendor") continue;
		if (dir == "scripts") continue;

		let submod = [identpath, dir]: ast::ident;
		match (module::lookup(ctx.mctx, submod)) {
		case let ver: module::version =>
			// TODO: free version data
			void;
		case module::notfound =>
			continue;
		case let err: module::error =>
			return err;
		};

		append(submodules, dir);
	let it = os::iter(path)?;
	defer fs::finish(it);
	for (true) match (module::next(it)) {
	case void => break;
	case let d: fs::dirent => append(submodules, strings::dup(d.name));
	};

	sort::strings(submodules);
	return submodules;
};
diff --git a/cmd/hare/error.ha b/cmd/hare/error.ha
new file mode 100644
index 00000000..57c4f984
--- /dev/null
+++ b/cmd/hare/error.ha
@@ -0,0 +1,23 @@
use cmd::hare::doc;
use fs;
use hare::module;
use hare::parse;
use io;
use os::exec;
use path;
use strconv;

type error = !(
	exec::error |
	fs::error |
	io::error |
	module::error |
	path::error |
	parse::error |
	strconv::error |
	unknown_arch |
	unknown_output |
	unknown_type |
	output_not_file |
	doc::error |
);
diff --git a/cmd/hare/main.ha b/cmd/hare/main.ha
index 0164779e..8f2da0e5 100644
--- a/cmd/hare/main.ha
+++ b/cmd/hare/main.ha
@@ -1,12 +1,18 @@
// License: GPL-3.0
// (c) 2021 Drew DeVault <sir@cmpwn.com>
// (c) 2021 Ember Sawady <ecs@d2evs.net>
use cmd::hare::doc;
use fmt;
use fs;
use getopt;
use hare::module;
use io;
use os;
use fmt;
use os::exec;
use path;
use strconv;

def VERSION: str = "unknown";
def PLATFORM: str = "unknown";
def HAREPATH: str = ".";

const help: []getopt::help = [
@@ -15,31 +21,35 @@ const help: []getopt::help = [
	"args...",
	("build", [
		"compiles the Hare program at <path>",
		('c', "build object instead of executable"),
		('v', "print executed commands"),
		('v', "print executed commands (specify twice to print arguments)"),
		('a', "arch", "set target architecture"),
		('D', "ident[:type]=value", "define a constant"),
		('j', "jobs", "set parallelism for build"),
		('L', "libdir", "add directory to linker library search path"),
		('l', "name", "link with a system library"),
		('l', "libname", "link with a system library"),
		('N', "namespace", "override namespace for module"),
		('o', "path", "set output file name"),
		('t', "arch", "set target architecture"),
		('T', "tags...", "set build tags"),
		('X', "tags...", "unset build tags"),
		"<path>"
		('T', "tagset", "set/unset build tags"),
		('t', "type", "build type (ssa/s/o/bin)"),
		"[path]"
	]: []getopt::help),
	("cache", [
		"manages the build cache",
		('c', "cleans the specified modules"),
		"modules...",
		('c', "clears the cache"),
	]: []getopt::help),
	("deps", [
		"prints dependency information for a Hare program",
		('d', "print dot syntax for use with graphviz"),
		('M', "build-dir", "print rules for POSIX make"),
		('T', "tags...", "set build tags"),
		('X', "tags...", "unset build tags"),
		"<path|module>",
		('T', "tagset", "set/unset build tags"),
		"[path|module]",
	]: []getopt::help),
	("doc", [
		"reads and formats Hare documentation",
		('a', "show undocumented members (only applies to -Fhare and -Ftty)"),
		('t', "disable HTML template (requires postprocessing)"),
		('F', "format", "specify output format (hare, tty, or html)"),
		('T', "tagset", "set/unset build tags"),
		"[identifiers...]",
	]: []getopt::help),
	("release", [
		"prepares a new release for a program or library",
@@ -48,26 +58,26 @@ const help: []getopt::help = [
	]: []getopt::help),
	("run", [
		"compiles and runs the Hare program at <path>",
		('v', "print executed commands"),
		('v', "print executed commands (specify twice to print arguments)"),
		('a', "arch", "set target architecture"),
		('D', "ident[:type]=value", "define a constant"),
		('j', "jobs", "set parallelism for build"),
		('L', "libdir", "add directory to linker library search path"),
		('l', "name", "link with a system library"),
		('T', "tags...", "set build tags"),
		('X', "tags...", "unset build tags"),
		"<path>", "<args...>",
		('l', "libname", "link with a system library"),
		('T', "tagset", "set/unset build tags"),
		"[path [args...]]",
	]: []getopt::help),
	("test", [
		"compiles and runs tests for Hare programs",
		('v', "print executed commands"),
		('v', "print executed commands (specify twice to print arguments)"),
		('a', "arch", "set target architecture"),
		('D', "ident[:type]=value", "define a constant"),
		('j', "jobs", "set parallelism for build"),
		('L', "libdir", "add directory to linker library search path"),
		('l', "name", "link with a system library"),
		('l', "libname", "link with a system library"),
		('o', "path", "set output file name"),
		('T', "tags...", "set build tags"),
		('X', "tags...", "unset build tags"),
		"[tests...]"
		('T', "tagset", "set/unset build tags"),
		"[path]"
	]: []getopt::help),
	("version", [
		"provides version information for the Hare environment",
@@ -84,21 +94,38 @@ export fn main() void = {
		os::exit(1);
	case let subcmd: (str, *getopt::command) =>
		const task = switch (subcmd.0) {
		case "build" =>
			yield &build;
		case "cache" =>
			yield &cache;
		case "deps" =>
			yield &deps;
		case "release" =>
			yield &release;
		case "run" =>
			yield &run;
		case "test" =>
			yield &test;
		case "version" =>
			yield &version;
		case "build", "run", "test" => yield &build;
		case "cache" => yield &cache;
		case "deps" => yield &deps;
		case "doc" => yield &doc;
		case "release" => yield &release;
		case "version" => yield &version;
		case => abort();
		};
		match (task(subcmd.0, subcmd.1)) {
		case let e: doc::error =>
			fmt::fatal(doc::strerror(e));
		case let e: exec::error =>
			fmt::fatal(exec::strerror(e));
		case let e: fs::error =>
			fmt::fatal(fs::strerror(e));
		case let e: io::error =>
			fmt::fatal(io::strerror(e));
		case let e: module::error =>
			fmt::fatal(module::strerror(e));
		case let e: path::error =>
			fmt::fatal(path::strerror(e));
		case let e: strconv::error =>
			fmt::fatal(strconv::strerror(e));
		case let e: unknown_arch =>
			fmt::fatalf("unknown arch: {}", e);
		case unknown_output =>
			fmt::fatal("can't guess output in root directory");
		case let e: unknown_type =>
			fmt::fatalf("unknown build type: {}", e);
		case let e: output_not_file =>
			fmt::fatalf("output '{}' exists and is not a file", e);
		case void => void;
		};
		task(subcmd.1);
	};
};
diff --git a/cmd/hare/plan.ha b/cmd/hare/plan.ha
deleted file mode 100644
index d8e7ac5d..00000000
--- a/cmd/hare/plan.ha
@@ -1,328 +0,0 @@
// License: GPL-3.0
// (c) 2021-2022 Alexey Yerin <yyp@disroot.org>
// (c) 2021-2022 Drew DeVault <sir@cmpwn.com>
// (c) 2021 Ember Sawady <ecs@d2evs.net>
use fmt;
use fs;
use hare::ast;
use hare::module;
use io;
use os::exec;
use os;
use path;
use shlex;
use strings;
use temp;
use unix::tty;

type status = enum {
	SCHEDULED,
	COMPLETE,
	SKIP,
};

type task = struct {
	status: status,
	depend: []*task,
	output: str,
	cmd: []str,
	module: (str | void),
};

fn task_free(task: *task) void = {
	free(task.depend);
	free(task.output);
	free(task.cmd);
	match (task.module) {
	case let s: str =>
		free(s);
	case => void;
	};
	free(task);
};

type modcache = struct {
	hash: u32,
	task: *task,
	ident: ast::ident,
	version: module::version,
};

type plan = struct {
	context: *module::context,
	target: *target,
	workdir: str,
	counter: uint,
	scheduled: []*task,
	complete: []*task,
	script: str,
	libdir: []str,
	libs: []str,
	environ: [](str, str),
	modmap: [64][]modcache,
	progress: plan_progress,
};

type plan_progress = struct {
	tty: (io::file | void),
	complete: size,
	total: size,
	current_module: str,
	maxwidth: size,
};

fn mkplan(
	ctx: *module::context,
	libdir: []str,
	libs: []str,
	target: *target,
) plan = {
	const rtdir = match (module::lookup(ctx, ["rt"])) {
	case let err: module::error =>
		fmt::fatal("Error resolving rt:", module::strerror(err));
	case let ver: module::version =>
		yield ver.basedir;
	};

	// Look up the most appropriate hare.sc file
	let ntag = 0z;
	const buf = path::init()!;
	const iter = os::iter(rtdir)!;
	defer os::finish(iter);
	for (true) match (fs::next(iter)) {
	case let d: fs::dirent =>
		const p = module::parsename(d.name);
		const name = p.0, ext = p.1, tags = p.2;
		defer module::tags_free(tags);

		if (len(tags) >= ntag && name == "hare" && ext == "sc"
				&& module::tagcompat(ctx.tags, tags)) {
			ntag = len(tags);
			path::set(&buf, rtdir, d.name)!;
		};
	case void =>
		break;
	};

	ar_tool.0 = target.ar_cmd;
	as_tool.0 = target.as_cmd;
	ld_tool.0 = if (len(libs) > 0) {
		yield target.cc_cmd;
	} else {
		yield target.ld_cmd;
	};

	let environ: [](str, str) = alloc([
		(strings::dup("HARECACHE"), strings::dup(ctx.cache)),
	]);

	if (len(os::tryenv("NO_COLOR", "")) == 0
			&& os::getenv("HAREC_COLOR") is void
			&& tty::isatty(os::stderr_file)) {
		append(environ,
			(strings::dup("HAREC_COLOR"), strings::dup("1"))
		);
	};

	return plan {
		context = ctx,
		target = target,
		workdir = os::tryenv("HARE_DEBUG_WORKDIR", temp::dir()),
		script = strings::dup(path::string(&buf)),
		environ = environ,
		libdir = libdir,
		libs = libs,
		progress = plan_progress {
			tty = if (tty::isatty(os::stderr_file)) os::stderr_file
				else void,
			...
		},
		...
	};
};

fn plan_finish(plan: *plan) void = {
	if (os::getenv("HARE_DEBUG_WORKDIR") is void) {
		os::rmdirall(plan.workdir)!;
	};

	for (let i = 0z; i < len(plan.complete); i += 1) {
		let task = plan.complete[i];
		task_free(task);
	};
	free(plan.complete);

	for (let i = 0z; i < len(plan.scheduled); i += 1) {
		let task = plan.scheduled[i];
		task_free(task);
	};
	free(plan.scheduled);

	for (let i = 0z; i < len(plan.environ); i += 1) {
		free(plan.environ[i].0);
		free(plan.environ[i].1);
	};
	free(plan.environ);

	free(plan.script);

	for (let i = 0z; i < len(plan.modmap); i += 1) {
		free(plan.modmap[i]);
	};
};

fn plan_execute(plan: *plan, verbose: bool) (void | !exec::exit_status) = {
	plan.progress.total = len(plan.scheduled);

	if (verbose) {
		plan.progress.tty = void;
		for (let i = 0z; i < len(plan.environ); i += 1) {
			let item = plan.environ[i];
			fmt::errorf("# {}=", item.0)!;
			shlex::quote(os::stderr, item.1)!;
			fmt::errorln()!;
		};
	};

	for (len(plan.scheduled) != 0) {
		let next: nullable *task = null;
		let i = 0z;
		for (i < len(plan.scheduled); i += 1) {
			let task = plan.scheduled[i];
			let eligible = true;
			for (let j = 0z; j < len(task.depend); j += 1) {
				if (task.depend[j].status == status::SCHEDULED) {
					eligible = false;
					break;
				};
			};
			if (eligible) {
				next = task;
				break;
			};
		};

		let task = next as *task;
		match (task.module) {
		case let s: str =>
			plan.progress.current_module = s;
		case => void;
		};

		progress_increment(plan);

		match (execute(plan, task, verbose)) {
		case let err: exec::error =>
			progress_clear(plan);
			fmt::fatalf("Error: {}: {}", task.cmd[0],
				exec::strerror(err));
		case let err: !exec::exit_status =>
			progress_clear(plan);
			fmt::errorfln("Error: {}: {}", task.cmd[0],
				exec::exitstr(err))!;
			return err;
		case void => void;
		};

		task.status = status::COMPLETE;

		delete(plan.scheduled[i]);
		append(plan.complete, task);
	};

	progress_clear(plan);
	update_modcache(plan);
};

fn update_cache(plan: *plan, mod: modcache) void = {
	let manifest = module::manifest {
		ident = mod.ident,
		inputs = mod.version.inputs,
		versions = [mod.version],
	};
	match (module::manifest_write(plan.context, &manifest)) {
	case let err: module::error =>
		fmt::fatal("Error updating module cache:",
			module::strerror(err));
	case void => void;
	};
};

fn update_modcache(plan: *plan) void = {
	for (let i = 0z; i < len(plan.modmap); i += 1) {
		let mods = plan.modmap[i];
		if (len(mods) == 0) {
			continue;
		};
		for (let j = 0z; j < len(mods); j += 1) {
			if (mods[j].task.status == status::COMPLETE) {
				update_cache(plan, mods[j]);
			};
		};
	};
};

fn execute(
	plan: *plan,
	task: *task,
	verbose: bool,
) (void | exec::error | !exec::exit_status) = {
	if (verbose) {
		for (let i = 0z; i < len(task.cmd); i += 1) {
			fmt::errorf("{} ", task.cmd[i])?;
		};
		fmt::errorln()?;
	};

	let cmd = match (exec::cmd(task.cmd[0], task.cmd[1..]...)) {
	case let cmd: exec::command =>
		yield cmd;
	case let err: exec::error =>
		progress_clear(plan);
		fmt::fatalf("Error resolving {}: {}", task.cmd[0],
			exec::strerror(err));
	};
	for (let i = 0z; i < len(plan.environ); i += 1) {
		let e = plan.environ[i];
		exec::setenv(&cmd, e.0, e.1)!;
	};

	const pipe = if (plan.progress.tty is io::file) {
		const pipe = exec::pipe();
		exec::addfile(&cmd, os::stderr_file, pipe.1);
		yield pipe;
	} else (0: io::file, 0: io::file);

	let proc = exec::start(&cmd)?;
	if (pipe.0 != 0) {
		io::close(pipe.1)?;
	};

	let cleared = false;
	if (pipe.0 != 0) {
		for (true) {
			let buf: [os::BUFSIZ]u8 = [0...];
			match (io::read(pipe.0, buf)?) {
			case let n: size =>
				if (!cleared) {
					progress_clear(plan);
					cleared = true;
				};
				io::writeall(os::stderr, buf[..n])?;
			case io::EOF =>
				break;
			};
		};
	};
	let st = exec::wait(&proc)?;
	return exec::check(&st);
};

fn mkfile(plan: *plan, input: str, ext: str) str = {
	static let namebuf: [32]u8 = [0...];
	const name = fmt::bsprintf(namebuf, "temp.{}.{}.{}",
		input, plan.counter, ext);
	plan.counter += 1;
	const buf = path::init(plan.workdir, name)!;
	return strings::dup(path::string(&buf));
};
diff --git a/cmd/hare/progress.ha b/cmd/hare/progress.ha
deleted file mode 100644
index 9d6cbd31..00000000
--- a/cmd/hare/progress.ha
@@ -1,64 +0,0 @@
use fmt;
use io;
use math;
use unix::tty;

fn progress_update(plan: *plan) void = {
	const tty = match (plan.progress.tty) {
	case let f: io::file =>
		yield f;
	case =>
		return;
	};

	const width = match (tty::winsize(tty)) {
	case let ts: tty::ttysize =>
		yield if (ts.columns > 80 || ts.columns == 0) 80 else ts.columns;
	case =>
		yield 64;
	}: size;

	const complete = plan.progress.complete,
		total = plan.progress.total,
		current_module = plan.progress.current_module;

	const total_width = math::ceilf64(math::log10f64(total: f64)): size;
	const counter_width = 1 + total_width + 1 + total_width + 3;
	const progress_width = width - counter_width - 2 - plan.progress.maxwidth;

	fmt::fprintf(tty, "\x1b[G\x1b[K[{%}/{}] [",
		complete, &fmt::modifiers {
			width = total_width: uint,
			...
		},
		total)!;
	const stop = (complete: f64 / total: f64 * progress_width: f64): size;
	for (let i = 0z; i < progress_width; i += 1) {
		if (i > stop) {
			fmt::fprint(tty, ".")!;
		} else {
			fmt::fprint(tty, "#")!;
		};
	};
	if (len(current_module) > 0) {
		fmt::fprintf(tty, "] {}", current_module)!;
	} else {
		// Don't print a leading space
		fmt::fprint(tty, "]")!;
	};
};

fn progress_clear(plan: *plan) void = {
	const tty = match (plan.progress.tty) {
	case let f: io::file =>
		yield f;
	case =>
		return;
	};
	fmt::fprint(tty, "\x1b[G\x1b[K")!;
};

fn progress_increment(plan: *plan) void = {
	plan.progress.complete += 1;
	progress_update(plan);
};
diff --git a/cmd/hare/release.ha b/cmd/hare/release.ha
index 08b033b5..5fd46ec3 100644
--- a/cmd/hare/release.ha
+++ b/cmd/hare/release.ha
@@ -6,6 +6,7 @@ use bufio;
use errors;
use fmt;
use fs;
use getopt;
use io;
use os::exec;
use os;
@@ -49,6 +50,56 @@ const initial_template: str = "# These are the release notes for the initial rel
{0} version {1}
";

fn release(name: str, cmd: *getopt::command) (void | error) = {
	let dryrun = false;
	for (let i = 0z; i < len(cmd.opts); i += 1) {
		let opt = cmd.opts[i];
		switch (opt.0) {
		case 'd' =>
			dryrun = true;
		case => abort();
		};
	};

	if (len(cmd.args) == 0) {
		getopt::printusage(os::stderr, name, cmd.help)!;
		os::exit(1);
	};

	const next = switch (cmd.args[0]) {
	case "major" =>
		yield increment::MAJOR;
	case "minor" =>
		yield increment::MINOR;
	case "patch" =>
		yield increment::PATCH;
	case =>
		yield match (parseversion(cmd.args[0])) {
		case badversion =>
			getopt::printusage(os::stderr, "release", cmd.help)!;
			os::exit(1);
		case let ver: modversion =>
			yield ver;
		};
	};

	match (do_release(next, dryrun)) {
	case void => void;
	case let err: exec::error =>
		fmt::fatal(exec::strerror(err));
	case let err: errors::error =>
		fmt::fatal(errors::strerror(err));
	case let err: io::error =>
		fmt::fatal(io::strerror(err));
	case let err: fs::error =>
		fmt::fatal(fs::strerror(err));
	case let err: git_error =>
		fmt::fatal("git:", exec::exitstr(err));
	case badversion =>
		fmt::fatal("Error: invalid format string. Hare uses semantic versioning, in the form major.minor.patch.");
	};
};

fn parseversion(in: str) (modversion | badversion) = {
	const items = strings::split(in, ".");
	defer free(items);
diff --git a/cmd/hare/schedule.ha b/cmd/hare/schedule.ha
deleted file mode 100644
index f0656605..00000000
--- a/cmd/hare/schedule.ha
@@ -1,388 +0,0 @@
// License: GPL-3.0
// (c) 2021-2022 Alexey Yerin <yyp@disroot.org>
// (c) 2021-2022 Drew DeVault <sir@cmpwn.com>
// (c) 2021 Ember Sawady <ecs@d2evs.net>
// (c) 2021 Thomas Bracht Laumann Jespersen <t@laumann.xyz>
// (c) 2022 Jon Eskin <eskinjp@gmail.com>
use encoding::hex;
use fmt;
use fs;
use hare::ast;
use hare::module;
use hare::unparse;
use hash::fnv;
use hash;
use os;
use path;
use shlex;
use strings;
use strio;

fn getenv(var: str) []str = {
	match (os::getenv(var)) {
	case let val: str =>
		match (shlex::split(val)) {
		case let fields: []str =>
			return fields;
		case => void;
		};
	case => void;
	};

	return [];
};

// (executable name, executable variable, flags variable)
type tool = (str, str, str);

let ld_tool: tool = ("", "LD", "LDLINKFLAGS");
let as_tool: tool = ("", "AS", "ASFLAGS");
let ar_tool: tool = ("", "AR", "ARFLAGS");
let qbe_tool: tool = ("qbe", "QBE", "QBEFLAGS");

fn getcmd(tool: *tool, args: str...) []str = {
	let execargs: []str = [];

	let vals = getenv(tool.1);
	defer free(vals);
	if (len(vals) == 0) {
		append(execargs, tool.0);
	} else {
		append(execargs, vals...);
	};

	let vals = getenv(tool.2);
	defer free(vals);
	append(execargs, vals...);

	append(execargs, args...);

	return execargs;
};

fn ident_hash(ident: ast::ident) u32 = {
	let hash = fnv::fnv32();
	for (let i = 0z; i < len(ident); i += 1) {
		hash::write(&hash, strings::toutf8(ident[i]));
		hash::write(&hash, [0]);
	};
	return fnv::sum32(&hash);
};

fn sched_module(plan: *plan, ident: ast::ident, link: *[]*task) *task = {
	let hash = ident_hash(ident);
	let bucket = &plan.modmap[hash % len(plan.modmap)];
	for (let i = 0z; i < len(bucket); i += 1) {
		if (bucket[i].hash == hash
				&& ast::ident_eq(bucket[i].ident, ident)) {
			return bucket[i].task;
		};
	};

	let ver = match (module::lookup(plan.context, ident)) {
	case let err: module::error =>
		let ident = unparse::identstr(ident);
		progress_clear(plan);
		fmt::fatalf("Error resolving {}: {}", ident,
			module::strerror(err));
	case let ver: module::version =>
		yield ver;
	};

	let depends: []*task = [];
	defer free(depends);
	for (let i = 0z; i < len(ver.depends); i += 1) {
		const dep = ver.depends[i];
		let obj = sched_module(plan, dep, link);
		append(depends, obj);
	};

	let obj = sched_hare_object(plan, ver, ident, void, depends...);
	append(bucket, modcache {
		hash = hash,
		task = obj,
		ident = ident,
		version = ver,
	});
	append(link, obj);
	return obj;
};

// Schedules a task which compiles objects into an executable.
fn sched_ld(plan: *plan, output: str, depend: *task...) *task = {
	let task = alloc(task {
		status = status::SCHEDULED,
		output = output,
		depend = alloc(depend...),
		cmd = getcmd(&ld_tool,
			"-T", plan.script,
			"-o", output),
		module = void,
	});

	if (len(plan.libdir) != 0) {
		for (let i = 0z; i < len(plan.libdir); i += 1) {
			append(task.cmd, strings::concat("-L", plan.libdir[i]));
		};
	};

	// Using --gc-sections will not work when using cc as the linker
	if (len(plan.libs) == 0 && task.cmd[0] == plan.target.ld_cmd) {
		append(task.cmd, "--gc-sections");
	};

	let archives: []str = [];
	defer free(archives);

	for (let i = 0z; i < len(depend); i += 1) {
		if (strings::hassuffix(depend[i].output, ".a")) {
			append(archives, depend[i].output);
		} else {
			append(task.cmd, depend[i].output);
		};
	};
	append(task.cmd, archives...);
	for (let i = 0z; i < len(plan.libs); i += 1) {
		append(task.cmd, strings::concat("-l", plan.libs[i]));
	};
	append(plan.scheduled, task);
	return task;
};

// Schedules a task which merges objects into an archive.
fn sched_ar(plan: *plan, output: str, depend: *task...) *task = {
	let task = alloc(task {
		status = status::SCHEDULED,
		output = output,
		depend = alloc(depend...),
		cmd = getcmd(&ar_tool, "-c", output),
		module = void,
	});

	// POSIX specifies `ar -r [-cuv] <archive> <file>`
	// Add -r here so it is always before any ARFLAGS
	insert(task.cmd[1], "-r");

	for (let i = 0z; i < len(depend); i += 1) {
		assert(strings::hassuffix(depend[i].output, ".o"));
		append(task.cmd, depend[i].output);
	};
	append(plan.scheduled, task);
	return task;
};

// Schedules a task which compiles assembly into an object.
fn sched_as(plan: *plan, output: str, input: str, depend: *task...) *task = {
	let task = alloc(task {
		status = status::SCHEDULED,
		output = output,
		depend = alloc(depend...),
		cmd = getcmd(&as_tool, "-g", "-o", output),
		module = void,
	});

	append(task.cmd, input);

	append(plan.scheduled, task);
	return task;
};

// Schedules a task which compiles an SSA file into assembly.
fn sched_qbe(plan: *plan, output: str, depend: *task) *task = {
	let task = alloc(task {
		status = status::SCHEDULED,
		output = output,
		depend = alloc([depend]),
		cmd = getcmd(&qbe_tool,
			"-t", plan.target.qbe_target,
			"-o", output,
			depend.output),
		module = void,
	});
	append(plan.scheduled, task);
	return task;
};

// Schedules tasks which compiles a Hare module into an object or archive.
fn sched_hare_object(
	plan: *plan,
	ver: module::version,
	namespace: ast::ident,
	output: (void | str),
	depend: *task...
) *task = {
	// XXX: Do we care to support assembly-only modules?
	let mixed = false;
	for (let i = 0z; i < len(ver.inputs); i += 1) {
		if (strings::hassuffix(ver.inputs[i].path, ".s")) {
			mixed = true;
			break;
		};
	};

	const ns = unparse::identstr(namespace);
	const displayed_ns = if (len(ns) == 0) "(root)" else ns;
	if (len(ns) > plan.progress.maxwidth)
		plan.progress.maxwidth = len(ns);

	let ssa = mkfile(plan, ns, "ssa");
	let harec = alloc(task {
		status = status::SCHEDULED,
		output = ssa,
		depend = alloc(depend...),
		cmd = alloc([
			os::tryenv("HAREC", "harec"), "-o", ssa,
		]),
		module = strings::dup(ns),
	});

	let libc = false;
	for (let i = 0z; i < len(plan.context.tags); i += 1) {
		if (plan.context.tags[i].mode == module::tag_mode::INCLUSIVE
				&& plan.context.tags[i].name == "test") {
			const opaths = plan.context.paths;
			plan.context.paths = ["."];
			const ver = module::lookup(plan.context, namespace);
			if (ver is module::version) {
				append(harec.cmd, "-T");
			};
			plan.context.paths = opaths;
		} else if (plan.context.tags[i].mode == module::tag_mode::INCLUSIVE
				&& plan.context.tags[i].name == "libc") {
			libc = true;
		};
	};

	if (len(ns) != 0 || libc) {
		append(harec.cmd, ["-N", ns]...);
	};

	let current = false;
	let output = if (output is str) {
		static let buf = path::buffer{...};
		path::set(&buf, output as str)!;
		// TODO: Should we use the cache here?
		const ext = match (path::peek_ext(&buf)) {
		case let s: str => yield s;
		case void => yield "";
		};
		const expected = if (mixed) "a" else "o";
		if (ext != expected) {
			fmt::errorfln("Warning: Expected output file extension {}, found {}",
				expected, output)!;
		};
		yield strings::dup(output as str);
	} else if (len(namespace) != 0) {
		let buf = path::init(plan.context.cache)!;
		path::push(&buf, namespace...)!;
		const path = path::string(&buf);
		match (os::mkdirs(path, 0o755)) {
		case void => void;
		case let err: fs::error =>
			progress_clear(plan);
			fmt::fatalf("Error: mkdirs {}: {}", path,
				fs::strerror(err));
		};

		let version = hex::encodestr(ver.hash);
		let td = fmt::asprintf("{}.td", version);
		defer free(td);
		let name = fmt::asprintf("{}.{}", version,
			if (mixed) "a" else "o");
		defer free(name);
		path::push(&buf, td)!;

		append(plan.environ, (
			fmt::asprintf("HARE_TD_{}", ns),
			strings::dup(path::string(&buf)),
		));

		// TODO: Keep this around and append new versions, rather than
		// overwriting with just the latest
		let manifest = match (module::manifest_load(
				plan.context, namespace)) {
		case let err: module::error =>
			progress_clear(plan);
			fmt::fatalf("Error reading cache entry for {}: {}",
				displayed_ns, module::strerror(err));
		case let m: module::manifest =>
			yield m;
		};
		defer module::manifest_finish(&manifest);
		current = module::current(&manifest, &ver);

		append(harec.cmd, ["-t", strings::dup(path::string(&buf))]...);
		yield strings::dup(path::push(&buf, "..", name)!);
	} else {
		// XXX: This is probably kind of dumb
		// It would be better to apply any defines which affect this
		// namespace instead
		for (let i = 0z; i < len(plan.context.defines); i += 1) {
			append(harec.cmd, ["-D", plan.context.defines[i]]...);
		};

		yield mkfile(plan, ns, "o"); // TODO: Should exes go in the cache?
	};

	let hare_inputs = 0z;
	for (let i = 0z; i < len(ver.inputs); i += 1) {
		let path = ver.inputs[i].path;
		if (strings::hassuffix(path, ".ha")) {
			append(harec.cmd, path);
			hare_inputs += 1;
		};
	};
	if (hare_inputs == 0) {
		progress_clear(plan);
		fmt::fatalf("Error: Module {} has no Hare input files",
			displayed_ns);
	};

	if (current) {
		harec.status = status::COMPLETE;
		harec.output = output;
		append(plan.complete, harec);
		return harec;
	} else {
		append(plan.scheduled, harec);
	};

	let s = mkfile(plan, ns, "s");
	let qbe = sched_qbe(plan, s, harec);
	let hare_obj = sched_as(plan,
		if (mixed) mkfile(plan, ns, "o") else output,
		s, qbe);
	if (!mixed) {
		return hare_obj;
	};

	let objs: []*task = alloc([hare_obj]);
	defer free(objs);
	for (let i = 0z; i < len(ver.inputs); i += 1) {
		// XXX: All of our assembly files don't depend on anything else,
		// but that may not be generally true. We may have to address
		// this at some point.
		let path = ver.inputs[i].path;
		if (!strings::hassuffix(path, ".s")) {
			continue;
		};
		append(objs, sched_as(plan, mkfile(plan, ns, "o"), path));
	};
	return sched_ar(plan, output, objs...);
};

// Schedules tasks which compiles hare sources into an executable.
fn sched_hare_exe(
	plan: *plan,
	ver: module::version,
	output: str,
	depend: *task...
) *task = {
	let obj = sched_hare_object(plan, ver, [], void, depend...);
	// TODO: We should be able to use partial variadic application
	let link: []*task = alloc([], len(depend));
	defer free(link);
	append(link, obj);
	append(link, depend...);
	return sched_ld(plan, strings::dup(output), link...);
};
diff --git a/cmd/hare/subcmds.ha b/cmd/hare/subcmds.ha
deleted file mode 100644
index 79a3439e..00000000
--- a/cmd/hare/subcmds.ha
@@ -1,624 +0,0 @@
// License: GPL-3.0
// (c) 2021-2022 Alexey Yerin <yyp@disroot.org>
// (c) 2021 Drew DeVault <sir@cmpwn.com>
// (c) 2021 Ember Sawady <ecs@d2evs.net>
use ascii;
use bufio;
use encoding::utf8;
use errors;
use fmt;
use fs;
use getopt;
use hare::ast;
use hare::module;
use hare::parse;
use io;
use os::exec;
use os;
use path;
use sort;
use strings;
use unix::tty;

fn addtags(tags: []module::tag, in: str) ([]module::tag | void) = {
	let in = match (module::parsetags(in)) {
	case void =>
		return void;
	case let t: []module::tag =>
		yield t;
	};
	defer free(in);
	append(tags, in...);
	return tags;
};

fn deltags(tags: []module::tag, in: str) ([]module::tag | void) = {
	if (in == "^") {
		module::tags_free(tags);
		return [];
	};
	let in = match (module::parsetags(in)) {
	case void =>
		return void;
	case let t: []module::tag =>
		yield t;
	};
	defer free(in);
	for (let i = 0z; i < len(tags); i += 1) {
		for (let j = 0z; j < len(in); j += 1) {
			if (tags[i].name == in[j].name
					&& tags[i].mode == in[j].mode) {
				free(tags[i].name);
				delete(tags[i]);
				i -= 1;
			};
		};
	};
	return tags;
};

type goal = enum {
	OBJ,
	EXE,
};

fn build(cmd: *getopt::command) void = {
	let build_target = default_target();
	let tags = module::tags_dup(build_target.tags);
	defer module::tags_free(tags);

	let verbose = false;
	let output = "";
	let goal = goal::EXE;
	let defines: []str = [];
	defer free(defines);
	let libdir: []str = [];
	defer free(libdir);
	let libs: []str = [];
	defer free(libs);
	let namespace: ast::ident = [];
	for (let i = 0z; i < len(cmd.opts); i += 1) {
		let opt = cmd.opts[i];
		switch (opt.0) {
		case 'c' =>
			goal = goal::OBJ;
		case 'v' =>
			verbose = true;
		case 'D' =>
			append(defines, opt.1);
		case 'j' =>
			abort("-j option not implemented yet."); // TODO
		case 'L' =>
			append(libdir, opt.1);
		case 'l' =>
			append(libs, opt.1);
		case 'N' =>
			namespace = match (parse::identstr(opt.1)) {
			case let id: ast::ident =>
				yield id;
			case let err: parse::error =>
				fmt::fatalf("Error parsing namespace {}: {}",
					opt.1, parse::strerror(err));
			};
		case 'o' =>
			output = opt.1;
		case 't' =>
			match (get_target(opt.1)) {
			case void =>
				fmt::fatalf("Unsupported target '{}'", opt.1);
			case let t: *target =>
				build_target = t;
				module::tags_free(tags);
				tags = module::tags_dup(t.tags);
			};
		case 'T' =>
			tags = match (addtags(tags, opt.1)) {
			case void =>
				fmt::fatal("Error parsing tags");
			case let t: []module::tag =>
				yield t;
			};
		case 'X' =>
			tags = match (deltags(tags, opt.1)) {
			case void =>
				fmt::fatal("Error parsing tags");
			case let t: []module::tag =>
				yield t;
			};
		case =>
			abort();
		};
	};

	const input =
		if (len(cmd.args) == 0) os::getcwd()
		else if (len(cmd.args) == 1) cmd.args[0]
		else {
			getopt::printusage(os::stderr, "build", cmd.help...)!;
			os::exit(1);
		};

	if (len(libs) > 0) {
		append(tags, module::tag {
			mode = module::tag_mode::INCLUSIVE,
			name = strings::dup("libc"),
		});
	};

	const ctx = module::context_init(tags, defines, HAREPATH);
	defer module::context_finish(&ctx);

	const plan = mkplan(&ctx, libdir, libs, build_target);
	defer plan_finish(&plan);

	const ver = match (module::scan(&ctx, input)) {
	case let ver: module::version =>
		yield ver;
	case let err: module::error =>
		fmt::fatal("Error scanning input module:",
			module::strerror(err));
	};

	const depends: []*task = [];
	sched_module(&plan, ["rt"], &depends);

	for (let i = 0z; i < len(ver.depends); i += 1z) {
		const dep = ver.depends[i];
		sched_module(&plan, dep, &depends);
	};

	// TODO: Choose this more intelligently
	if (output == "") {
		output = path::basename(ver.basedir);
	};
	switch (goal) {
	case goal::EXE =>
		sched_hare_exe(&plan, ver, output, depends...);
	case goal::OBJ =>
		let task = sched_hare_object(&plan, ver,
			namespace, output, depends...);
		append(plan.scheduled, task);
	};
	match (plan_execute(&plan, verbose)) {
	case void => void;
	case !exec::exit_status =>
		fmt::fatalf("{} build: build failed", os::args[0]);
	};
};

fn cache(cmd: *getopt::command) void = {
	abort("cache subcommand not implemented yet."); // TODO
};

type deps_goal = enum {
	DOT,
	MAKE,
	TERM,
};

fn deps(cmd: *getopt::command) void = {
	let build_target = default_target();
	let tags = module::tags_dup(build_target.tags);
	defer module::tags_free(tags);

	let build_dir: str = "";
	let goal = deps_goal::TERM;
	for (let i = 0z; i < len(cmd.opts); i += 1) {
		let opt = cmd.opts[i];
		switch (opt.0) {
		case 'd' =>
			goal = deps_goal::DOT;
		case 'M' =>
			goal = deps_goal::MAKE;
			build_dir = opt.1;
		case 'T' =>
			tags = match (addtags(tags, opt.1)) {
			case void =>
				fmt::fatal("Error parsing tags");
			case let t: []module::tag =>
				yield t;
			};
		case 'X' =>
			tags = match (deltags(tags, opt.1)) {
			case void =>
				fmt::fatal("Error parsing tags");
			case let t: []module::tag =>
				yield t;
			};
		case =>
			abort();
		};
	};

	const input =
		if (len(cmd.args) == 0) os::getcwd()
		else if (len(cmd.args) == 1) cmd.args[0]
		else {
			getopt::printusage(os::stderr, "deps", cmd.help...)!;
			os::exit(1);
		};

	const ctx = module::context_init(tags, [], HAREPATH);
	defer module::context_finish(&ctx);

	const ver = match (parse::identstr(input)) {
	case let ident: ast::ident =>
		yield match (module::lookup(&ctx, ident)) {
		case let ver: module::version =>
			yield ver;
		case let err: module::error =>
			fmt::fatal("Error scanning input module:",
				module::strerror(err));
		};
	case parse::error =>
		yield match (module::scan(&ctx, input)) {
		case let ver: module::version =>
			yield ver;
		case let err: module::error =>
			fmt::fatal("Error scanning input path:",
				module::strerror(err));
		};
	};

	let visited: []depnode = [];
	let stack: []str = [];
	defer free(stack);
	const ctx = module::context_init([], [], HAREPATH);

	let toplevel = depnode{ident = strings::dup(path::basename(input)), depends = [], depth = 0};

	for (let i = 0z; i < len(ver.depends); i += 1) {
		const name = strings::join("::", ver.depends[i]...);
		defer free(name);
		const child = match (explore_deps(&ctx, &stack, &visited, name)) {
		case let index: size => yield index;
		case let start: dep_cycle =>
			const chain = strings::join(" -> ", stack[start..]...);
			defer free(chain);
			fmt::errorln("Dependency cycle detected:", chain)!;
			os::exit(1);
		};
		append(toplevel.depends, child);
	};

	sort::sort(toplevel.depends, size(size), &cmpsz);
	append(visited, toplevel);
	defer for (let i = 0z; i < len(visited); i += 1) {
		free(visited[i].ident);
		free(visited[i].depends);
	};

	switch (goal) {
	case deps_goal::TERM =>
		show_deps(&visited);
	case deps_goal::DOT =>
		fmt::println("strict digraph deps {")!;
		for (let i = 0z; i < len(visited); i += 1) {
			for (let j = 0z; j < len(visited[i].depends); j += 1) {
				const child = visited[visited[i].depends[j]];
				fmt::printfln("\t\"{}\" -> \"{}\";", visited[i].ident, child.ident)!;
			};
		};
		fmt::println("}")!;
	case deps_goal::MAKE =>
		abort("-M option not implemented yet");
	};
};

fn release(cmd: *getopt::command) void = {
	let dryrun = false;
	for (let i = 0z; i < len(cmd.opts); i += 1) {
		let opt = cmd.opts[i];
		switch (opt.0) {
		case 'd' =>
			dryrun = true;
		case => abort();
		};
	};

	if (len(cmd.args) == 0) {
		getopt::printusage(os::stderr, "release", cmd.help)!;
		os::exit(1);
	};

	const next = switch (cmd.args[0]) {
	case "major" =>
		yield increment::MAJOR;
	case "minor" =>
		yield increment::MINOR;
	case "patch" =>
		yield increment::PATCH;
	case =>
		yield match (parseversion(cmd.args[0])) {
		case badversion =>
			getopt::printusage(os::stderr, "release", cmd.help)!;
			os::exit(1);
		case let ver: modversion =>
			yield ver;
		};
	};

	match (do_release(next, dryrun)) {
	case void => void;
	case let err: exec::error =>
		fmt::fatal(exec::strerror(err));
	case let err: errors::error =>
		fmt::fatal(errors::strerror(err));
	case let err: io::error =>
		fmt::fatal(io::strerror(err));
	case let err: fs::error =>
		fmt::fatal(fs::strerror(err));
	case let err: git_error =>
		fmt::fatal("git:", exec::exitstr(err));
	case badversion =>
		fmt::fatal("Error: invalid format string. Hare uses semantic versioning, in the form major.minor.patch.");
	};
};

fn run(cmd: *getopt::command) void = {
	const build_target = default_target();
	let tags = module::tags_dup(build_target.tags);
	defer module::tags_free(tags);

	let verbose = false;
	let defines: []str = [];
	defer free(defines);
	let libdir: []str = [];
	defer free(libdir);
	let libs: []str = [];
	defer free(libs);
	for (let i = 0z; i < len(cmd.opts); i += 1) {
		let opt = cmd.opts[i];
		switch (opt.0) {
		case 'v' =>
			verbose = true;
		case 'D' =>
			append(defines, opt.1);
		case 'j' =>
			abort("-j option not implemented yet."); // TODO
		case 'L' =>
			append(libdir, opt.1);
		case 'l' =>
			append(libs, opt.1);
		case 't' =>
			abort("-t option not implemented yet."); // TODO
		case 'T' =>
			tags = match (addtags(tags, opt.1)) {
			case void =>
				fmt::fatal("Error parsing tags");
			case let t: []module::tag =>
				yield t;
			};
		case 'X' =>
			tags = match (deltags(tags, opt.1)) {
			case void =>
				fmt::fatal("Error parsing tags");
			case let t: []module::tag =>
				yield t;
			};
		case =>
			abort();
		};
	};

	let input = "";
	let runargs: []str = [];
	if (len(cmd.args) == 0) {
		input = os::getcwd();
	} else {
		input = cmd.args[0];
		runargs = cmd.args[1..];
	};

	if (len(libs) > 0) {
		append(tags, module::tag {
			mode = module::tag_mode::INCLUSIVE,
			name = strings::dup("libc"),
		});
	};

	const ctx = module::context_init(tags, defines, HAREPATH);
	defer module::context_finish(&ctx);

	const plan = mkplan(&ctx, libdir, libs, build_target);
	defer plan_finish(&plan);

	const ver = match (module::scan(&ctx, input)) {
	case let ver: module::version =>
		yield ver;
	case let err: module::error =>
		fmt::fatal("Error scanning input module:",
			module::strerror(err));
	};

	let depends: []*task = [];
	sched_module(&plan, ["rt"], &depends);

	for (let i = 0z; i < len(ver.depends); i += 1z) {
		const dep = ver.depends[i];
		sched_module(&plan, dep, &depends);
	};

	const output = mkfile(&plan, "", "out");
	sched_hare_exe(&plan, ver, output, depends...);
	match (plan_execute(&plan, verbose)) {
	case void => void;
	case !exec::exit_status =>
		fmt::fatalf("{} run: build failed", os::args[0]);
	};
	const cmd = match (exec::cmd(output, runargs...)) {
	case let err: exec::error =>
		fmt::fatal("exec:", exec::strerror(err));
	case let cmd: exec::command =>
		yield cmd;
	};
	exec::setname(&cmd, input);
	exec::exec(&cmd);
};

fn test(cmd: *getopt::command) void = {
	const build_target = default_target();
	let tags = module::tags_dup(build_target.tags);
	append(tags, module::tag {
		name = strings::dup("test"),
		mode = module::tag_mode::INCLUSIVE,
	});

	let output = "";
	let verbose = false;
	let defines: []str = [];
	defer free(defines);
	let libdir: []str = [];
	defer free(libdir);
	let libs: []str = [];
	defer free(libs);
	for (let i = 0z; i < len(cmd.opts); i += 1) {
		const opt = cmd.opts[i];
		switch (opt.0) {
		case 'v' =>
			verbose = true;
		case 'D' =>
			append(defines, opt.1);
		case 'j' =>
			abort("-j option not implemented yet."); // TODO
		case 'L' =>
			append(libdir, opt.1);
		case 'l' =>
			append(libs, opt.1);
		case 't' =>
			abort("-t option not implemented yet."); // TODO
		case 'o' =>
			output = opt.1;
		case 'T' =>
			tags = match (addtags(tags, opt.1)) {
			case void =>
				fmt::fatal("Error parsing tags");
			case let t: []module::tag =>
				yield t;
			};
		case 'X' =>
			tags = match (deltags(tags, opt.1)) {
			case void =>
				fmt::fatal("Error parsing tags");
			case let t: []module::tag =>
				yield t;
			};
		case =>
			abort();
		};
	};

	if (len(libs) > 0) {
		append(tags, module::tag {
			mode = module::tag_mode::INCLUSIVE,
			name = strings::dup("libc"),
		});
	};

	const ctx = module::context_init(tags, defines, HAREPATH);
	defer module::context_finish(&ctx);

	const plan = mkplan(&ctx, libdir, libs, build_target);
	defer plan_finish(&plan);

	let depends: []*task = [];
	sched_module(&plan, ["test"], &depends);

	let items = match (module::walk(&ctx, ".")) {
	case let items: []ast::ident =>
		yield items;
	case let err: module::error =>
		fmt::fatal("Error scanning source root:",
			module::strerror(err));
	};

	defer module::walk_free(items);
	for (let i = 0z; i < len(items); i += 1) {
		if (len(items[i]) > 0 && items[i][0] == "cmd") {
			continue;
		};
		match (module::lookup(plan.context, items[i])) {
		case let ver: module::version =>
			if (len(ver.inputs) == 0) continue;
		case module::error =>
			continue;
		};
		sched_module(&plan, items[i], &depends);
	};

	const have_output = len(output) != 0;
	if (!have_output) {
		output = mkfile(&plan, "", "out");
	};
	sched_ld(&plan, strings::dup(output), depends...);
	match (plan_execute(&plan, verbose)) {
	case void => void;
	case !exec::exit_status =>
		fmt::fatalf("{} test: build failed", os::args[0]);
	};

	if (have_output) {
		return;
	};

	const cmd = match (exec::cmd(output, cmd.args...)) {
	case let err: exec::error =>
		fmt::fatal("exec:", exec::strerror(err));
	case let cmd: exec::command =>
		yield cmd;
	};
	exec::setname(&cmd, os::getcwd());
	exec::exec(&cmd);
};

fn version(cmd: *getopt::command) void = {
	let verbose = false;
	for (let i = 0z; i < len(cmd.opts); i += 1) {
		// The only option is verbose
		verbose = true;
	};

	fmt::printfln("Hare {}", VERSION)!;

	if (verbose) {
		fmt::printf("Build tags\t")!;
		const build_target = default_target();
		const tags = build_target.tags;
		for (let i = 0z; i < len(tags); i += 1) {
			const tag = tags[i];
			const inclusive = (tag.mode & module::tag_mode::INCLUSIVE) == 0;
			fmt::printf("{}{}", if (inclusive) '+' else '-', tag.name)!;
		};
		fmt::println()!;

		if (tty::isatty(os::stdout_file)) {
			// Pretty print
			match (os::getenv("HAREPATH")) {
			case void =>
				const items = strings::split(HAREPATH, ":");
				defer free(items);
				const items = strings::join("\n\t\t", items...);
				defer free(items);
				fmt::printfln("HAREPATH\t{}", items)!;
			case let env: str =>
				fmt::printf("HAREPATH\t")!;
				bufio::flush(os::stdout)!;
				fmt::errorf("(from environment)")!;
				const items = strings::split(env, ":");
				defer free(items);
				const items = strings::join("\n\t\t", items...);
				defer free(items);
				fmt::printfln("\n\t\t{}", items)!;
			};
		} else {
			// Print for ease of machine parsing
			const val = match (os::getenv("HAREPATH")) {
			case void =>
				yield HAREPATH;
			case let env: str =>
				yield env;
			};
			fmt::printfln("HAREPATH\t{}", val)!;
		};
	};
};
diff --git a/cmd/hare/target.ha b/cmd/hare/target.ha
deleted file mode 100644
index 58d8cef1..00000000
--- a/cmd/hare/target.ha
@@ -1,81 +0,0 @@
use hare::module;
use hare::module::{tag_mode};

type target = struct {
	name: str,
	ar_cmd: str,
	as_cmd: str,
	cc_cmd: str,
	ld_cmd: str,
	qbe_target: str,
	tags: []module::tag,
};

fn default_target() *target = {
	let default = get_target(ARCH);
	match (default) {
	case void =>
		abort("Build configuration error - unknown default target");
	case let t: *target =>
		return t;
	};
};

fn get_target(name: str) (*target | void) = {
	for (let i = 0z; i < len(targets); i += 1) {
		if (targets[i].name == name) {
			return &targets[i];
		};
	};
};

// TODO:
// - Implement cross compiling to other kernels (e.g. Linux => FreeBSD)
// - sysroots
const targets: [_]target = [
	target {
		name = "aarch64",
		ar_cmd = AARCH64_AR,
		as_cmd = AARCH64_AS,
		cc_cmd = AARCH64_CC,
		ld_cmd = AARCH64_LD,
		qbe_target = "arm64",
		tags = [module::tag {
			name = "aarch64",
			mode = tag_mode::INCLUSIVE,
		}, module::tag {
			name = PLATFORM,
			mode = module::tag_mode::INCLUSIVE,
		}],
	},
	target {
		name = "riscv64",
		ar_cmd = RISCV64_AR,
		as_cmd = RISCV64_AS,
		cc_cmd = RISCV64_CC,
		ld_cmd = RISCV64_LD,
		qbe_target = "rv64",
		tags = [module::tag {
			name = "riscv64",
			mode = tag_mode::INCLUSIVE,
		}, module::tag {
			name = PLATFORM,
			mode = module::tag_mode::INCLUSIVE,
		}],
	},
	target {
		name = "x86_64",
		ar_cmd = X86_64_AR,
		as_cmd = X86_64_AS,
		cc_cmd = X86_64_CC,
		ld_cmd = X86_64_LD,
		qbe_target = "amd64_sysv",
		tags = [module::tag {
			name = "x86_64",
			mode = tag_mode::INCLUSIVE,
		}, module::tag {
			name = PLATFORM,
			mode = module::tag_mode::INCLUSIVE,
		}],
	},
];
diff --git a/cmd/hare/util.ha b/cmd/hare/util.ha
new file mode 100644
index 00000000..9b66c6a3
--- /dev/null
+++ b/cmd/hare/util.ha
@@ -0,0 +1,36 @@
use ascii;
use dirs;
use errors;
use hare::module;
use os;
use strings;

fn merge_tags(current: *[]str, new: str) (void | module::error) = {
	let trimmed = strings::ltrim(new, '^');
	if (trimmed != new) static delete (current[..]);
	let newtags = module::parsetags(trimmed)?;
	for (let i = 0z; i < len(newtags); i += 1) :new {
		for (let j = 0z; j < len(current); j += 1) {
			if (newtags[i].name == current[j]) {
				if (!newtags[i].include) static delete(current[j]);
				continue :new;
			};
		};
		if (newtags[i].include) append(current, newtags[i].name);
	};
};

fn harepath() str = os::tryenv("HAREPATH", HAREPATH);

fn harecache() str = match (os::getenv("HARECACHE")) {
case let s: str => yield s;
case void => yield dirs::cache("hare");
};

// result must be freed, but internal strings are statically allocated
fn default_tags() ([]str | error) = {
	let arch = get_arch(os::machine())?;
	let platform = ascii::strlower(os::sysname());
	let tags: []str = alloc([arch.name, platform]);
	return tags;
};
diff --git a/cmd/hare/version.ha b/cmd/hare/version.ha
new file mode 100644
index 00000000..169fafda
--- /dev/null
+++ b/cmd/hare/version.ha
@@ -0,0 +1,43 @@
use ascii;
use bufio;
use fmt;
use getopt;
use os;
use strings;
use unix::tty;

fn version(name: str, cmd: *getopt::command) (void | error) = {
	let verbose = false;
	for (let i = 0z; i < len(cmd.opts); i += 1) {
		const opt = cmd.opts[i];
		switch (opt.0) {
		case 'v' =>
			verbose = true;
		case =>
			abort();
		};
	};

	fmt::printfln("hare {}", VERSION)!;
	if (!verbose) return;

	let build_arch = get_arch(os::machine())?;
	let build_platform = ascii::strlower(os::sysname());

	if (!tty::isatty(os::stdout_file)) {
		fmt::printfln("build tags\t+{}+{}\nHAREPATH\t{}",
			build_arch.name, build_platform, harepath())?;
		return;
	};

	fmt::printfln("build tags:\n\t+{}\n\t+{}\nHAREPATH{}:",
		build_arch.name, build_platform,
		if (os::getenv("HAREPATH") is str) " (from environment)" else "")?;

	let tok = strings::tokenize(harepath(), ":");
	for (true) match (strings::next_token(&tok)) {
	case void => break;
	case let s: str =>
		fmt::printfln("\t{}", s)?;
	};
};
diff --git a/cmd/harec/gen.ha b/cmd/harec/gen.ha
index 89ff60ee..814efed4 100644
--- a/cmd/harec/gen.ha
+++ b/cmd/harec/gen.ha
@@ -52,7 +52,7 @@ fn gen_func(ctx: *context, decl: *unit::decl) void = {
	assert(fntype.flags == 0);
	ctx.serial = 0;

	const ident = module::identuscore(fndecl.ident);
	const ident = strings::join("_", fndecl.ident...);
	defer free(ident);
	fmt::fprintf(ctx.out, "{}section \".text.{}\" \"ax\" function",
		if (decl.exported) "export " else "", ident)!;
diff --git a/cmd/haredoc/env.ha b/cmd/haredoc/env.ha
deleted file mode 100644
index a28f0ab4..00000000
--- a/cmd/haredoc/env.ha
@@ -1,104 +0,0 @@
// License: GPL-3.0
// (c) 2021 Drew DeVault <sir@cmpwn.com>
// (c) 2022 Haelwenn (lanodan) Monnier <contact@hacktivis.me>
use bufio;
use fmt;
use hare::module;
use io;
use os::exec;
use os;
use strings;

def PLATFORM: str = "unknown";

fn default_tags() ([]module::tag | error) = {
	let cmd = match (exec::cmd("hare", "version", "-v")) {
	case let cmd: exec::command =>
		yield cmd;
	case exec::nocmd =>
		let platform = strings::dup(PLATFORM);
		let machine = strings::dup(os::machine());
		fmt::errorln("Couldn't find hare binary in PATH")?;
		fmt::errorfln("Build tags defaulting to +{}+{}",
			platform, machine)?;

		return alloc([module::tag {
			name = platform,
			mode = module::tag_mode::INCLUSIVE,
		}, module::tag {
			name = machine,
			mode = module::tag_mode::INCLUSIVE,
		}]);
	case let err: exec::error =>
		return err;
	};

	let pipe = exec::pipe();
	defer io::close(pipe.0)!;
	exec::addfile(&cmd, os::stdout_file, pipe.1);
	let proc = exec::start(&cmd)?;
	io::close(pipe.1)?;

	let tags: []module::tag = [];
	for (true) match (bufio::scanline(pipe.0)?) {
	case let b: []u8 =>
		defer free(b);
		const (k, v) = strings::cut(strings::fromutf8(b)!, "\t");
		if (k == "Build tags") {
			tags = module::parsetags(v) as []module::tag;
			break;
		};
	case io::EOF =>
		// process exited with failure; handled below
		break;
	};

	let status = exec::wait(&proc)?;
	match (exec::check(&status)) {
	case void =>
		assert(len(tags) > 0);
	case let status: !exec::exit_status =>
		fmt::fatal("Error: hare:", exec::exitstr(status));
	};
	return tags;
};

fn addtags(tags: []module::tag, in: str) ([]module::tag | void) = {
	let in = match (module::parsetags(in)) {
	case void =>
		return void;
	case let t: []module::tag =>
		yield t;
	};
	defer free(in);
	append(tags, in...);
	return tags;
};

fn deltags(tags: []module::tag, in: str) ([]module::tag | void) = {
	if (in == "^") {
		module::tags_free(tags);
		return [];
	};
	let in = match (module::parsetags(in)) {
	case void =>
		return void;
	case let t: []module::tag =>
		yield t;
	};
	defer free(in);
	for (let i = 0z; i < len(tags); i += 1) {
		for (let j = 0z; j < len(in); j += 1) {
			if (tags[i].name == in[j].name
					&& tags[i].mode == in[j].mode) {
				free(tags[i].name);
				i -= 1;
			};
		};
	};
	return tags;
};

fn default_harepath() str = {
	return HAREPATH;
};
diff --git a/cmd/haredoc/errors.ha b/cmd/haredoc/errors.ha
deleted file mode 100644
index 79e98e49..00000000
--- a/cmd/haredoc/errors.ha
@@ -1,26 +0,0 @@
// License: GPL-3.0
// (c) 2021 Drew DeVault <sir@cmpwn.com>
// (c) 2021 Ember Sawady <ecs@d2evs.net>
use hare::lex;
use hare::module;
use hare::parse;
use io;
use os::exec;

type error = !(lex::error | parse::error | io::error | module::error |
	exec::error);

fn strerror(err: error) str = {
	match (err) {
	case let err: lex::error =>
		return lex::strerror(err);
	case let err: parse::error =>
		return parse::strerror(err);
	case let err: io::error =>
		return io::strerror(err);
	case let err: module::error =>
		return module::strerror(err);
	case let err: exec::error =>
		return exec::strerror(err);
	};
};
diff --git a/docs/hare-doc.5.scd b/docs/hare-doc.5.scd
new file mode 100644
index 00000000..9ca1fc3b
--- /dev/null
+++ b/docs/hare-doc.5.scd
@@ -0,0 +1,29 @@
hare-doc(5)

# NAME

hare-doc - hare documentation format.

# DESCRIPTION

The Hare formatting markup is a very simple markup language. Text may be written
normally, broken into several lines to conform to the column limit. Repeated
whitespace will be collapsed. To begin a new paragraph, insert an empty line.

Links to Hare symbols may be written in brackets, like this: [[os::stdout]]. A
bulleted list can be started by opening a line with "-". To complete the list,
insert an empty line. Code samples may be used by using more than one space
character at the start of a line (a tab character counts as 8 indents).

This markup language is extracted from Hare comments preceding exported symbols
in your source code, and from a file named "README" in your module directory, if
present.

```
// Foos the bars. See also [[foobar]].
export fn example() int;
```

# SEE ALSO

*hare*(1)
diff --git a/docs/hare.scd b/docs/hare.1.scd
similarity index 74%
rename from docs/hare.scd
rename to docs/hare.1.scd
index 2ad9057a..f715424e 100644
--- a/docs/hare.scd
+++ b/docs/hare.1.scd
@@ -6,35 +6,46 @@ hare - compiles, runs, and tests Hare programs

# SYNOPSIS

*hare* build [-cv]++
*hare* build [-hv]++
	[-a _arch_]++
	[-D _ident[:type]=value_]++
	[-j _jobs_]++
	[-L libdir]++
	[-l _name_]++
	[-L _libdir_]++
	[-l _libname_]++
	[-N _namespace_]++
	[-o _path_]++
	[-t _arch_]++
	[-T _tags_] [-X _tags_]++
	[-T _tagset_]++
	[-t _type_]++
	[_path_]

*hare* deps [-Mm] [-T _tags_] [-X _tags_] _path_
*hare* cache [-hc]

*hare* run [-v]++
*hare* deps [-hd] [-T _tagset_] [_path_|_module_]

*hare* doc [-hat] [-F _format_] [-T _tagset_] [_identifiers_...]

*hare* release [-hd] _major_|_minor_|_patch_|_x.y.z_

*hare* run [-hv]++
	[-a _arch_]++
	[-D _ident[:type]=value_]++
	[-l _name_]++
	[-L libdir]++
	[-j _jobs_]++
	[-T _tags_] [-X _tags_]++
	[_path_] [_args_...]
	[-L _libdir_]++
	[-l _libname_]++
	[-T _tagset_]++
	[_path_ [_args_...]]

*hare* test [-v]++
*hare* test [-hv]++
	[-a _arch_]++
	[-D _ident[:type]=value_]++
	[-l _name_]++
	[-L libdir]++
	[-j _jobs_]++
	[-T _tags_] [-X _tags_]++
	_tests_
	[-L _libdir_]++
	[-l _libname_]++
	[-o _path_]++
	[-T _tagset_]++
	[_path_]

*hare* version [-v]
*hare* version [-hv]

# DESCRIPTION

@@ -44,9 +55,19 @@ a path to a Hare source file or a directory which contains a Hare module (see
*MODULES* below). If no path is given, the Hare module contained in the current
working directory is built.

*hare cache* displays information about the build cache.

*hare deps* queries the dependencies graph of a Hare program. The _path_ argument
is equivalent in usage to *hare build*.

*hare doc* reads documentation for a set of identifiers from Hare source code,
and optionally prepares it for viewing in various output formats. By default,
*hare doc* will format documentation for your terminal. See *hare-doc*(5) for
details on the format.

; *hare release*
; TODO

*hare run* compiles and runs a Hare program. The _path_ argument is equivalent
in usage to *hare build*. If provided, any additional _args_ are passed to the
Hare program which is run. os::args[0] is set to the _path_ argument.
@@ -68,14 +89,17 @@ value, and optional context, separated by tabs.

## hare build

*-c*
	Compile only, do not link. The output is an object file (for Hare-only
	modules) or archive (for mixed source modules).
*-h*
	Prints the help text.

*-v*
	Enable verbose logging. Prints every command to stderr before executing
	it.

*-a* _arch_
	Set the desired architecture for cross-compiling. See *ARCHITECTURES*
	for supported architecture names.

*-D* _ident[:type]=value_
	Passed to *harec*(1) to define a constant in the type system. _ident_ is
	parsed as a Hare identifier (e.g. "foo::bar::baz"), _type_ as a Hare
@@ -87,43 +111,72 @@ value, and optional context, separated by tabs.
	Defines the maximum number of jobs which *hare* will execute in
	parallel. The default is the number of processors available on the host.

*-l* _name_
*-L libdir*
	Add directory to the linker library search path.

*-l* _libname_
	Link with the named system library. The name is passed to
	*pkg-config --libs* (see *pkg-config*(1)) to obtain the appropriate
	linker flags.

*-L libdir*
	Add directory to the linker library search path.
*-N* _namespace_
	Override the namespace for the module. 

*-o* _path_
	Set the output file to the given path.

*-t* _arch_
	Set the desired architecture for cross-compiling. See *ARCHITECTURES*
	for supported architecture names.
*-T* _tagset_
	Sets or unsets build tags. See *CUSTOMIZING BUILD TAGS*.

*-T* _tags_
	Adds additional build tags. See *CUSTOMIZING BUILD TAGS*.
*-t* _type_
	Set the build type. Should be one of ssa/s/o/bin, for qbe IL, assembly,
	compiled object, or compiled binary, respectively.

*-X* _tags_
	Unsets build tags. See *CUSTOMIZING BUILD TAGS*.
## hare cache

*-h*
	Prints the help text.

*-c*
	Clears the cache.

## hare deps

*-h*
	Prints the help text.

*-d*
	Print dependency graph as a dot file for use with *graphviz*(1).

*-M*
	Print rules compatible with POSIX *make*(1).
*-T* _tags_
	Sets or unsets build tags. See *CUSTOMIZING BUILD TAGS*.

## hare doc

*-h*
	Prints the help text.

*-a*
	Show undocumented members (only applies to -Fhare and -Ftty).

*-F* _format_
	Select output format (one of "html", "hare", or "tty").

*-t*
	Disable HTML template.

*-T* _tags_
	Adds additional build tags. See *CUSTOMIZING BUILD TAGS*.
	Sets or unsets build tags. See *CUSTOMIZING BUILD TAGS*.

*-X* _tags_
	Unsets build tags. See *CUSTOMIZING BUILD TAGS*.
## hare release

TODO

## hare run

*-h*
	Prints the help text.

*-v*
	Enable verbose logging. Prints every command to stderr before executing
	it.
@@ -155,6 +208,9 @@ value, and optional context, separated by tabs.

## hare test

*-h*
	Prints the help text.

*-v*
	Enable verbose logging. Prints every command to stderr before executing
	it.
@@ -186,6 +242,9 @@ value, and optional context, separated by tabs.

## hare version

*-h*
	Prints the help text.

*-v*
	Show build parameters.

@@ -275,17 +334,56 @@ Some tags are enabled by default, enabling features for the host platform. You
can view the default tagset by running *hare version -v*. To remove all default
tags, use "-X^".

# DOC TTY COLORS

The TTY output format of *hare doc* renders colors in the terminal with ANSI
SGR escape sequences, behaving similarly to this shell command:

	printf '\\033[0;%sm' '_seq_'

These sequences can be customised with the *HAREDOC_COLORS* environment
variable, which follows this whitespace-delimited format:

	HAREDOC\_COLORS='_key_=_seq_ _key_=_seq_ _..._'

where each _key_=_seq_ entry assigns a valid _seq_ SGR sequence to a _key_
syntax category. A valid _seq_ must contain either a single underscore "\_" or
digits and/or semicolons ";". Here are the initial default entries.

. normal        "0"
. comment       "1"
. primary       "0"
. secondary     "0"
. keyword       "94"
. type          "96"
. attribute     "33"
. operator      "1"
. punctuation   "0"
. constant      "91"
. string        "91"
. number        "95"

Any number of entries can be specified. If a _seq_ is an underscore "\_", the
sequence specified for "normal" is used. Otherwise, if a _seq_ is invalid,
blank, empty or absent, its corresponding default sequence is used.

For example:

	HAREDOC\_COLORS='comment=3 primary=1;4 attribute=41' hare doc -Ftty log

# ENVIRONMENT

The following environment variables affect *hare*'s execution:

|[ *HARECACHE*
:< The path to the object cache. Defaults to _$XDG_CACHE_HOME/hare_, or
:< The path to the build cache. Defaults to _$XDG_CACHE_HOME/hare_, or
   _~/.cache/hare_ if that doesn't exist.
|  *HAREPATH*
:  See *DEPENDENCY RESOLUTION*.
|  *HAREFLAGS*
:  Applies additional flags to the command line arguments.
|  *HAREDOC_COLORS*
:  Customizes TTY format color rendering for *hare doc*. See *TTY COLORS*.
|  *HAREC*
:  Name of the *harec*(1) command to use.
|  *AR*
@@ -303,4 +401,4 @@ The following environment variables affect *hare*'s execution:

# SEE ALSO

*harec*(1), *haredoc*(1), *as*(1), *ld*(1), *ar*(1), *make*(1)
*harec*(1), *hare-doc*(5), *as*(1), *ld*(1), *ar*(1), *make*(1)
diff --git a/docs/haredoc.scd b/docs/haredoc.scd
deleted file mode 100644
index 9269a317..00000000
--- a/docs/haredoc.scd
@@ -1,116 +0,0 @@
haredoc(1)

# NAME

haredoc - reads and formats Hare documentation

# SYNOPSIS

*haredoc* [-at] [-F _format_] [_identifiers_...]

# DESCRIPTION

*haredoc* reads documentation for a set of identifiers from Hare source code,
and optionally prepares it for viewing in various output formats. By default,
*haredoc* will format documentation for your terminal.

See *DOCUMENTATION FORMAT* for details on the format.

# OPTIONS

*-a*
	Show undocumented members (only applies to -Fhare and -Ftty).

*-F* _format_
	Select output format (one of "html", "hare", or "tty").

*-t*
	Disable HTML template.

*-T* _tags_
	Adds additional build tags. See *CUSTOMIZING BUILD TAGS* in *hare*(1).

*-X* _tags_
	Unsets build tags. See *CUSTOMIZING BUILD TAGS* in *hare*(1).

# DOCUMENTATION FORMAT

The Hare formatting markup is a very simple markup language. Text may be written
normally, broken into several lines to conform to the column limit. Repeated
whitespace will be collapsed. To begin a new paragraph, insert an empty line.

Links to Hare symbols may be written in brackets, like this: [[os::stdout]]. A
bulleted list can be started by opening a line with "-". To complete the list,
insert an empty line. Code samples may be used by using more than one space
character at the start of a line (a tab character counts as 8 indents).

This markup language is extracted from Hare comments preceding exported symbols
in your source code, and from a file named "README" in your module directory, if
present.

```
// Foos the bars. See also [[foobar]].
export fn example() int;
```

# TTY COLORS

The TTY output format renders colors in the terminal with ANSI SGR escape
sequences, behaving similarly to this shell command:

	printf '\\033[0;%sm' '_seq_'

These sequences can be customised with the *HAREDOC_COLORS* environment
variable, which follows this whitespace-delimited format:

	HAREDOC\_COLORS='_key_=_seq_ _key_=_seq_ _..._'

where each _key_=_seq_ entry assigns a valid _seq_ SGR sequence to a _key_
syntax category. A valid _seq_ must contain either a single underscore "\_"; or
digits and/or semicolons ";". Here are the initial default _key_=_seq_ entries.

. normal        "0"
. comment       "1"
. primary       "0"
. secondary     "0"
. keyword       "94"
. type          "96"
. attribute     "33"
. operator      "1"
. punctuation   "0"
. constant      "91"
. string        "91"
. number        "95"

Any number of entries can be specified. If a _seq_ is an underscore "\_", the
sequence specified for "normal" is used. Otherwise, if a _seq_ is invalid,
blank, empty or absent, its corresponding default sequence is used.

For example:

	HAREDOC\_COLORS='comment=3 primary=1;4 attribute=41' haredoc -Ftty log

# ENVIRONMENT

The following environment variables affect *haredoc*'s execution:

|[ *HAREDOC_COLORS*
:< Customizes TTY format color rendering. See *TTY COLORS*.

# EXAMPLES

Read the documentation for _io_:

	haredoc io

Read the documentation for _hash::fnv_:

	haredoc hash::fnv

Prepare documentation for _hare::parse_ as HTML:

	haredoc -Fhtml hare::parse >parse.html

# SEE ALSO

*hare*(1)
diff --git a/hare/module/README b/hare/module/README
index a51dd556..db29d979 100644
--- a/hare/module/README
+++ b/hare/module/README
@@ -1,10 +1,7 @@
hare::module implements the module resolution algorithm used by Hare. Given that
it is run within a Hare environment (i.e. with HAREPATH et al filled in), this
module will resolve module references from their identifiers, producing a list
of the source files which are necessary, including any necessary considerations
for build tags. This interface is stable, but specific to this Hare
implementation, and may not be portable to other Hare implementations.
hare::module provides an interface to the module system used by Hare. This
interface is stable, but specific to this Hare implementation, and may not be
portable to other Hare implementations.

This module also provides access to the Hare cache via [[manifest]]s and their
related functions, but this is not considered stable, and may be changed if we
overhaul the cache format to implement better caching strategies.
This module also provides basic access to the Hare cache, but this is not
considered stable, and may be changed if we overhaul the cache format to
implement better caching strategies.
diff --git a/hare/module/cache.ha b/hare/module/cache.ha
new file mode 100644
index 00000000..76ebba61
--- /dev/null
+++ b/hare/module/cache.ha
@@ -0,0 +1,29 @@
use path;
use strings;

// A kind of cache file. used by [[cache_getfile]].
export type cachekind = enum {
	TD,
	SSA,
	S,
	O,
	BIN,
};

// File extensions corresponding to each [[cachekind]].
export const cachekind_ext = ["td", "ssa", "s", "o", "bin"];

// Gets the cache directory for a given module, given the location of
// HARECACHE. The result is statically allocated and will be overwritten on
// subsequent calls. An error is returned if the resulting path would be longer
// than [[path::PATH_MAX]].
export fn getcache(harecache: str, modpath: str) (str | error) = {
	static let buf = path::buffer { ... };
	return path::set(&buf, harecache, modpath)?;
};

// Given the cache directory for a given module and the basename of the file,
// returns the location of the cache file of the given [[cachekind]]. The
// result must be freed by the caller.
export fn cache_getfile(modcache: str, basename: str, k: cachekind) str =
	strings::concat(modcache, "/", basename, ".", cachekind_ext[k]);
diff --git a/hare/module/context.ha b/hare/module/context.ha
deleted file mode 100644
index ac39ffbc..00000000
--- a/hare/module/context.ha
@@ -1,129 +0,0 @@
// License: MPL-2.0
// (c) 2022 Alexey Yerin <yyp@disroot.org>
// (c) 2021-2022 Drew DeVault <sir@cmpwn.com>
// (c) 2021 Ember Sawady <ecs@d2evs.net>
use dirs;
use fmt;
use fs;
use glob;
use hare::ast;
use os;
use path;
use strings;
use strio;

export type context = struct {
	// Filesystem to use for the cache and source files.
	fs: *fs::fs,
	// List of paths to search, generally populated from HAREPATH plus some
	// baked-in defaults.
	paths: []str,
	// Path to the Hare cache, generally populated from HARECACHE and
	// defaulting to $XDG_CACHE_HOME/hare.
	cache: str,
	// Build tags to apply to this context.
	tags: []tag,
	// List of -D arguments passed to harec
	defines: []str,
};

// Initializes a new context with the system default configuration. The tag list
// and list of defines (arguments passed with harec -D) is borrowed from the
// caller. The harepath parameter is not borrowed, but it is ignored if HAREPATH
// is set in the process environment.
export fn context_init(tags: []tag, defs: []str, harepath: str) context = {
	let ctx = context {
		fs = os::cwd,
		tags = tags,
		defines = defs,
		paths = {
			let harepath = match (os::getenv("HAREPATH")) {
			case void =>
				yield harepath;
			case let s: str =>
				yield s;
			};

			let path: []str = [];
			let tok = strings::tokenize(harepath, ":");
			for (true) match (strings::next_token(&tok)) {
			case void =>
				break;
			case let s: str =>
				append(path, strings::dup(s));
			};

			let vendor = glob::glob("vendor/*");
			defer glob::finish(&vendor);
			for (true) match (glob::next(&vendor)) {
			case void =>
				break;
			case glob::failure =>
				void; // XXX: Anything else?
			case let s: str =>
				append(path, strings::dup(s));
			};

			append(path, strings::dup("."));
			yield path;
		},
		cache: str = match (os::getenv("HARECACHE")) {
		case void =>
			yield strings::dup(dirs::cache("hare"));
		case let s: str =>
			yield strings::dup(s);
		},
		...
	};
	return ctx;
};

// Frees resources associated with this context.
export fn context_finish(ctx: *context) void = {
	for (let i = 0z; i < len(ctx.paths); i += 1) {
		free(ctx.paths[i]);
	};
	free(ctx.paths);
	free(ctx.cache);
};

// Converts an identifier to a partial path (e.g. foo::bar becomes foo/bar). The
// return value must be freed by the caller.
export fn identpath(name: ast::ident) str = {
	if (len(name) == 0) {
		return strings::dup(".");
	};
	let buf = path::init()!;
	for (let i = 0z; i < len(name); i += 1) {
		path::push(&buf, name[i])!;
	};
	return strings::dup(path::string(&buf));
};

@test fn identpath() void = {
	let ident: ast::ident = ["foo", "bar", "baz"];
	let p = identpath(ident);
	defer free(p);
	assert(p == "foo/bar/baz");
};

// Joins an ident string with underscores instead of double colons. The return
// value must be freed by the caller.
//
// This is used for module names in environment variables and some file names.
export fn identuscore(ident: ast::ident) str = {
	let buf = strio::dynamic();
	for (let i = 0z; i < len(ident); i += 1) {
		fmt::fprintf(&buf, "{}{}", ident[i],
			if (i + 1 < len(ident)) "_"
			else "") as size;
	};
	return strio::string(&buf);
};

@test fn identuscore() void = {
	let ident: ast::ident = ["foo", "bar", "baz"];
	let p = identuscore(ident);
	defer free(p);
	assert(p == "foo_bar_baz");
};
diff --git a/hare/module/deps.ha b/hare/module/deps.ha
new file mode 100644
index 00000000..2f990450
--- /dev/null
+++ b/hare/module/deps.ha
@@ -0,0 +1,214 @@
use bufio;
use fmt;
use fs;
use hare::ast;
use hare::lex;
use hare::parse;
use hare::unparse;
use io;
use os;
use path;
use sort;
use strings;
use strio;

// A hare module.
export type module = struct {
	id: str,
	ns: ast::ident,
	path: str,
	srcs: srcset,
	deps: [](size, ast::ident),
};

// Free the resources associated with a [[module]].
export fn finish(mod: *module) void = {
	free(mod.id);
	free(mod.path);
	finish_srcset(&mod.srcs);
	for (let i = 0z; i < len(mod.deps); i += 1) {
		free(mod.deps[i].1);
	};
	free(mod.deps);
};

// Free all the [[module]]s in a slice of modules, and then the slice itself.
export fn free_slice(mods: []module) void = {
	for (let i = 0z; i < len(mods); i += 1) {
		finish(&mods[i]);
	};
	free(mods);
};

// Finds the directory for a module in a given HAREPATH. The current directory
// will be searched before any of the items in harepath. The result will be an
// absolute path, and will be statically allocated.
export fn findinpath(harepath: str, mod: ast::ident) (str | error) = {
	let tok = strings::tokenize(harepath, ":");
	let next: (str | void) = ".";
	for (next is str; next = strings::next_token(&tok)) {
		static let buf = path::buffer { ... };
		if (!os::exists(next as str)) continue;
		path::set(&buf, os::realpath(next as str)?)?;
		for (let i = 0z; i < len(mod); i += 1) {
			path::push(&buf, mod[i])?;
		};
		match (os::stat(path::string(&buf))) {
		case fs::error => continue;
		case let stat: fs::filestat =>
			if (fs::isdir(stat.mode)) return path::string(&buf);
		};
	};
	return not_found;
};

// Get the list of dependencies referred to by a set of source files.
// The list will be sorted alphabetically and deduplicated. Use
// [[strings::freeall]] to free the result.
export fn parsedeps(files: str...) ([]ast::ident | error) = {
	let deps: []ast::ident = [];
	for (let i = 0z; i < len(files); i += 1) {
		let handle = match (os::open(files[i])) {
		case let f: io::file => yield f;
		case let e: fs::error =>
			return attach(strings::dup(files[i]), e);
		};
		defer io::close(handle)!;
		let lexer = lex::init(handle, files[i]);
		defer lex::finish(&lexer);
		let imports = parse::imports(&lexer)?;
		defer ast::imports_finish(imports);
		for (let i = 0z; i < len(imports); i += 1) {
			let id = imports[i].ident;
			let idx = sort::rbisect(deps, size(ast::ident), &id, &idcmp);
			if (idx == 0 || idcmp(&deps[idx - 1], &id) != 0) {
				insert(deps[idx], ast::ident_dup(id));
			};
		};
	};
	return deps;
};

fn idcmp(a: const *void, b: const *void) int = {
	const a = a: const *ast::ident, b = b: const *ast::ident;
	for (let i = 0z; i < len(a) && i < len(b); i += 1) {
		let cmp = strings::compare(a[i], b[i]);
		if (cmp != 0) return cmp;
	};
	return if (len(a) < len(b)) -1 else if (len(a) == len(b)) 0 else 1;
};

// Get the dependencies for a module from the cache, recalculating
// them if necessary. cachedir should be calculated with [[getcache]],
// and srcset should be calculated with [[findsrcs]].
fn getcachedeps(cachedir: str, srcs: *srcset) ([]ast::ident | error) = {
	static let buf = path::buffer{...};
	path::set(&buf, cachedir, "deps")?;
	let rest = strio::fixed(buf.buf[buf.end..]);
	buf.end += format_tags(&rest, srcs.seentags)?;
	buf.end += strio::concat(&rest, ".txt")?;
	let outofdate = outdated(path::string(&buf), srcs.ha, srcs.mtime);
	os::mkdirs(cachedir, 0o777)?;
	let depsfile = os::create(path::string(&buf), 0o666, fs::flags::RDWR)?;
	defer io::close(depsfile)!;
	io::lock(depsfile, true, io::lockop::EXCLUSIVE)?;
	let deps: []ast::ident = [];
	if (outofdate) {
		deps = parsedeps(srcs.ha...)?;
		io::trunc(depsfile, 0)?;
		let out = bufio::buffered(depsfile, [], buf.buf);
		for (let i = 0z; i < len(deps); i += 1) {
			unparse::ident(&out, deps[i])?;
			fmt::fprintln(&out)?;
		};
	} else {
		let in = bufio::newscanner_static(depsfile, buf.buf);
		for (true) match (bufio::scan_line(&in)?) {
		case io::EOF => break;
		case let s: const str => append(deps, parse::identstr(s)?);
		};
	};
	return deps;
};

// Gather a [[module]] and all its dependencies, appending them to an existing
// slice, deduplicated. The result will be sorted in reverse topological
// order, with the toplevel module last. The result should be freed with
// [[free_slice]].
export fn gather(
	ctx: *context,
	out: *[]module,
	mod: (*path::buffer | ast::ident),
) (size | error) = {
	let stack: []str = [];
	defer free(stack);
	return _gather(ctx, out, &stack, mod)?;
};

fn _gather(
	ctx: *context,
	out: *[]module,
	stack: *[]str,
	mod: (*path::buffer | ast::ident),
) (size | error) = {
	let modpath = match (mod) {
	case let mod: *path::buffer =>
		yield strings::dup(path::string(mod));
	case let mod: ast::ident =>
		yield match (findinpath(ctx.harepath, mod)) {
		case let s: str =>
			yield strings::dup(s);
		case let e: error =>
			let e = attach(unparse::identstr(mod), e);
			if (len(stack) == 0) {
				return e;
			} else {
				return attach(stack[len(stack) - 1], e);
			};
		};
	};

	for (let j = 0z; j < len(stack); j += 1) {
		if (modpath == stack[j]) {
			defer free(modpath);
			append(stack, modpath);
			return strings::dupall(stack[j..]): dep_cycle;
		};
	};
	for (let j = 0z; j < len(out); j += 1) {
		if (modpath == out[j].path) {