llm
Download
1743 lines 87.8 KiBCModified 2026-09-22 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743
// This code defines functions for transforming ASTs back into Tomo source text

#include <assert.h>
#include <limits.h>
#include <setjmp.h>
#include <stdbool.h>
#include <stdint.h>
#include <sys/param.h>
#include <unictype.h>

#include "../ast.h"
#include "../parse/context.h"
#include "../parse/files.h"
#include "../parse/utils.h"
#include "../stdlib/datatypes.h"
#include "../stdlib/integers.h"
#include "../stdlib/number.h"
#include "../stdlib/optionals.h"
#include "../stdlib/text.h"
#include "../util.h"
#include "args.h"
#include "enums.h"
#include "formatter.h"
#include "types.h"
#include "utils.h"

#define fmt_inline(...) must(format_inline_code(__VA_ARGS__))
#define fmt(...) format_code(__VA_ARGS__)
#define fmt_at(...) format_code_at(__VA_ARGS__)

static OptionalText_t format_binop_inline(ast_t *ast, Table_t comments, int tighten_from);
static Text_t format_binop(ast_t *ast, Table_t comments, Text_t indent, int64_t column, int tighten_from);

typedef struct {
    Text_t quote, unquote, interp;
    // Inline C is parsed verbatim (no backslash escapes), so its text has to be
    // written back out the same way: escaping it would turn `Text$from_str` in
    // the C code into a literal `Text\$from_str`, growing a backslash on every
    // reformat.
    bool verbatim;
} text_opts_t;

PUREFUNC text_opts_t choose_text_options(ast_list_t *chunks) {
    int double_quotes = 0, single_quotes = 0, backticks = 0;
    for (ast_list_t *chunk = chunks; chunk; chunk = chunk->next) {
        if (chunk->ast->tag == TextLiteral) {
            Text_t literal = Match(chunk->ast, TextLiteral)->text;
            if (Text$has(literal, Text("\""))) double_quotes += 1;
            if (Text$has(literal, Text("'"))) single_quotes += 1;
            if (Text$has(literal, Text("`"))) backticks += 1;
        }
    }
    Text_t quote;
    if (double_quotes == 0) quote = Text("\"");
    else if (single_quotes == 0) quote = Text("'");
    else if (backticks == 0) quote = Text("`");
    else quote = Text("\"");

    text_opts_t opts = {.quote = quote, .unquote = quote, .interp = Text("$")};
    return opts;
}

// Whether the author wrote this literal as a block: opening quote alone on its
// line, text indented beneath it. A block's line breaks belong to its text, so
// it keeps them. The other multi-line layout, a line split, starts its text
// right after the quote and its breaks mean nothing, so it is free to be
// rejoined. The quote is found rather than taken from the node's start, which
// sits before the `$Lang` or `C_code:` a literal can be introduced by.
PUREFUNC static bool is_block_text(ast_t *ast) {
    const char *p = ast->start + strcspn(ast->start, "\"'`");
    char quote = *p;
    p += (p[1] == quote && p[2] == quote) ? 3 : 1;
    for (;; p++) {
        if (*p == '\n') return true;
        if (*p != ' ' && *p != '\t' && *p != '\r') return false;
    }
}

// Word operators (`mod`, `and`, `_min_`, ...) always need surrounding spaces,
// however tightly they bind: `(x + y)modlen` isn't parseable code.
PUREFUNC static bool is_word_operator(const char *op) {
    return op && (uc_is_property_xid_start((ucs4_t)(unsigned char)op[0]) || op[0] == '_');
}

// `test` labels and `fails` expectations are stored as plain strings (they're
// parsed with interpolation disabled), so they have to be re-quoted from
// scratch rather than formatted as a text literal.
static Text_t quoted_label(const char *label) {
    return Text$quoted(Text$from_str(label), false, Text("\""));
}

// The flags a definition can carry after its arguments, as one `; a, b` list.
//
// The cache flag is written `cached` or `cache_size=N`; a bare `cached` parses
// to the sentinel size -1. There is no `cache=` flag: emitting one lost the
// caching entirely and left a function definition that didn't parse.
static flag_list_t signature_flags(ast_t *cache, bool is_inline, Table_t comments, Text_t indent) {
    flag_list_t flags = {};
    add_flag(&flags, is_inline, Text("inline"));
    if (cache) {
        if (cache->tag == Int && Int$equal_value(Match(cache, Int)->i, I_small(-1))) {
            add_flag(&flags, true, Text("cached"));
        } else {
            // Measured from the inner indent, the line of its own that this
            // flag gets whenever the arguments above it wrap.
            int64_t column = (int64_t)(indent.length + single_indent.length);
            add_flag(&flags, true, Texts("cache_size=", fmt_at(cache, comments, indent, column + 13)));
        }
    }
    return flags;
}

// What follows the parameters between the same delimiters -- the return type
// and the flags -- has no node of its own to carry a comment, and nothing
// scans the gaps between them: a comment written after `-> Int` or after
// `; inline` used to be read past and dropped. The one on the return type's
// own line stays on it; anything below joins the flags.
static Text_t format_signature(arg_ast_t *args, type_ast_t *ret_type, ast_t *cache, bool is_inline, ast_t *body,
                               Table_t comments, Text_t indent, int64_t column) {
    Text_t ret_code = ret_type ? Texts("-> ", format_type(ret_type)) : EMPTY_TEXT;
    flag_list_t flags = signature_flags(cache, is_inline, comments, indent);

    Text_t ret_comment = EMPTY_TEXT, tail_comment = EMPTY_TEXT;
    arg_ast_t *last = args;
    while (last && last->next)
        last = last->next;
    // The last parameter's span already reaches the first thing that is
    // neither whitespace nor a comment -- the `->`, the first `;`, or the
    // closing delimiter. What follows it is the suffix's, and nobody else's.
    const char *from = last ? last->comments_end : (ret_type ? ret_type->start : NULL);
    if (from && body) {
        const char *pos = from;
        if (ret_type) {
            const char *eol = ret_type->end;
            while (eol < body->start && *eol != '\n')
                eol++;
            ret_comment = comment_range(&pos, eol, EMPTY_TEXT, comments);
        }
        // At the indentation it will be written at, so that a comment running
        // to a second line has its own lines lined up under the first.
        tail_comment = comment_range(&pos, body->start, Texts(indent, single_indent), comments);
    }
    if (ret_comment.length > 0) ret_code = Texts(ret_code, " ", ret_comment);
    return format_bracketed_args(args, ret_code, flags, tail_comment, comments, indent, column, "(", ")");
}

static bool starts_with_id(Text_t text) {
    if (text.length <= 0) return false;
    List_t codepoints = Text$utf32(Text$slice(text, I_small(1), I_small(1)));
    if (codepoints.length <= 0 || codepoints.data == NULL) return false;
    return uc_is_property_xid_continue(*(ucs4_t *)codepoints.data);
}

// An interpolation is written bare only when nothing could run on from it: a
// `$x` followed by more name characters would read as one longer name. Anything
// but a plain variable gets parentheses whatever follows, since `$x.y` and
// `$f(1)` take their suffixes into the interpolation rather than the text.
PUREFUNC static bool interpolation_needs_parens(ast_list_t *chunk) {
    if (chunk->ast->tag != Var) return true;
    ast_list_t *next = chunk->next;
    return next && next->ast->tag == TextLiteral && starts_with_id(Match(next->ast, TextLiteral)->text);
}

// A block that opens partway along a line -- the body of an `if`, a `for`, a
// `func` -- does not own what was written at the end of that line. The
// construct it belongs to writes that comment back onto its header, where the
// author put it, so the block starts scanning on the line below.
static const char *block_scan_start(ast_t *block) {
    ast_list_t *first = Match(block, Block)->statements;
    if (first == NULL) return block->start;
    const char *line = block->start;
    while (line > block->file->text && line[-1] != '\n')
        line--;
    for (const char *p = line; p < block->start; p++) {
        if (*p == ' ' || *p == '\t') continue;
        // Something stands before the block on this line, so the line belongs
        // to a header. Give up the rest of it -- but only as far as the first
        // statement, since a body written on the same line shares it.
        for (const char *eol = block->start; eol < first->ast->start; eol++)
            if (*eol == '\n') return eol;
        break;
    }
    return block->start;
}

// What an author wrote on the rest of the line `pos` stands in, ready to be
// appended to that line. `pos` is left after it, so a caller can go on to
// collect whatever was written below.
static Text_t line_end_comment(const char **pos, const char *limit, Table_t comments) {
    const char *eol = *pos;
    while (eol < limit && *eol != '\n')
        eol++;
    Text_t found = comment_range(pos, eol, EMPTY_TEXT, comments);
    return found.length > 0 ? Texts(" ", found) : EMPTY_TEXT;
}

// The comment an author left at the end of a header line: the `# x is big` of
// `if x > 1 # x is big`. The body block steps over it, so it is written out
// here or nowhere.
static Text_t header_comment(ast_t *body, Table_t comments) {
    if (body == NULL || body->tag != Block) return EMPTY_TEXT;
    const char *pos = body->start;
    return line_end_comment(&pos, block_scan_start(body), comments);
}

// A definition's body starts on the line below its header rather than against
// it, so the comments between the two are this construct's to place: what sits
// on the header's own line stays there, and anything below takes a line at the
// body's indentation.
static Text_t definition_comments(const char **pos, ast_t *namespace, Text_t indent, Table_t comments) {
    Text_t code = line_end_comment(pos, namespace->start, comments);
    Text_t below = comment_range(pos, namespace->start, Texts(indent, single_indent), comments);
    if (below.length > 0) code = Texts(code, "\n", indent, single_indent, below);
    return code;
}

// A body indented under the line that introduces it, with whatever was written
// at the end of that line still on it.
static Text_t format_body(ast_t *body, Table_t comments, Text_t indent) {
    Text_t inner = Texts(indent, single_indent);
    return Texts(header_comment(body, comments), "\n", inner, fmt(body, comments, inner));
}

// The same, for a body that is allowed to be empty: a definition with nothing
// in its namespace writes no lines at all, rather than a `pass`.
Text_t format_namespace(ast_t *namespace, Table_t comments, Text_t indent) {
    if (unwrap_block(namespace) == NULL) return EMPTY_TEXT;
    return format_body(namespace, comments, indent);
}

// A comment written at the end of the line an item finished on, ready to be
// appended to that item. It belongs to the item it follows: read as the next
// one's it would say about `2` what its author said about `1`. An item that
// ends inside an indented block has already had that line written out one
// level in, so claiming it again would print it twice.
static Text_t line_comment(const char **pos, ast_t *item, Text_t item_text, Text_t indent, Table_t comments) {
    const char *eol = item->end;
    while (eol < item->file->text + item->file->len && *eol != '\n')
        eol++;
    Text_t found = comment_range(pos, eol, indent, comments);
    if (found.length == 0 || ends_deeper_than(item_text, indent)) return EMPTY_TEXT;
    return Texts(" ", found);
}

// Add the `$Lang` prefix to the text, if applicable.
static Text_t decorate_text(ast_t *ast, Text_t code) {
    DeclareMatch(text, ast, TextJoin);
    if (text->lang) code = Texts("$", format_type(text->lang), code);
    return code;
}

static OptionalText_t format_inline_text(text_opts_t opts, ast_list_t *chunks, Table_t comments) {
    Text_t code = opts.quote;
    for (ast_list_t *chunk = chunks; chunk; chunk = chunk->next) {
        if (chunk->ast->tag == TextLiteral) {
            Text_t literal = Match(chunk->ast, TextLiteral)->text;
            if (opts.verbatim) {
                // Nothing can be escaped here, so anything that would need to be
                // (a newline, or the quote character itself) has to go multiline.
                if (Text$has(literal, Text("\n")) || Text$has(literal, opts.quote)) return NONE_TEXT;
                code = Texts(code, literal);
                continue;
            }
            Text_t segment = Text$escaped(literal, false, Texts(opts.unquote, opts.interp));
            code = Texts(code, segment);
        } else {
            Text_t chunk_code = fmt_inline(chunk->ast, comments);
            if (interpolation_needs_parens(chunk)) chunk_code = Texts("(", chunk_code, ")");
            code = Texts(code, opts.interp, chunk_code);
        }
    }
    return Texts(code, opts.unquote);
}

// Two dots at the literal's own indentation is all the parser asks for to
// continue the line above; a full indent's worth of them lines the continued
// text up with where the text of a block would sit.
static const Text_t continuation_marker = Text("....");

// A continued line picks up where the one above it left off with nothing in
// between, so text too wide for the page can be broken across lines. It is
// built one atom at a time -- a single escaped grapheme, or a whole
// interpolation -- so a break can never land inside one.
typedef struct {
    Text_t code; // the lines finished so far, each ending in a newline
    Text_t line; // the line being built
    Text_t indent; // the indentation the literal itself sits at
    Text_t marker; // what stands between that indentation and `line`
    bool own_line; // false for the text that follows the opening quote
    bool verbatim; // whether this text can hold a backslash escape at all
} text_wrap_t;

// What stands before `line` on the page. Text sharing the opening quote's line
// is written after the quote by the caller, so it contributes its width
// without being written here. What the statement put in front of the literal
// is not counted -- nothing in the formatter knows its own column, so a first
// line can still come out over the limit by the width of the `x := ` before it.
static Text_t line_start(text_wrap_t *w) {
    return w->own_line ? Texts(w->indent, w->marker) : EMPTY_TEXT;
}

// A space at the end of a line is part of the text and invisible on the page,
// so anything that trims trailing whitespace -- an editor on save, a lint, a
// patch tool -- silently shortens the value. Written as an escape it survives.
// Verbatim text has no escapes; append_atom() below keeps a break away from a
// space there instead, which leaves only the spaces the author wrote at the
// end of a line of their own.
static Text_t protect_trailing_space(Text_t line, bool verbatim) {
    if (verbatim || !Text$ends_with(line, Text(" "), NULL)) return line;
    return Texts(Text$slice(line, I_small(1), I_small((int64_t)line.length - 1)), "\\x20");
}

static void end_line(text_wrap_t *w, Text_t marker) {
    if (w->line.length > 0) w->code = Texts(w->code, line_start(w), protect_trailing_space(w->line, w->verbatim));
    w->code = Texts(w->code, "\n");
    w->line = EMPTY_TEXT;
    w->marker = marker;
    w->own_line = true;
}

static void append_atom(text_wrap_t *w, Text_t atom) {
    if (w->line.length > 0 && w->indent.length + w->marker.length + w->line.length + atom.length > MAX_WIDTH) {
        // Every dot at the front of a continued line belongs to the `..` that
        // marks it, so a dot of the text's own has to be written escaped to
        // survive there. Verbatim text has no escapes, and a run of dots in it
        // simply can't be broken -- it stays on the line it started on.
        bool needs_escape =
            Text$starts_with(atom, Text("."), NULL) || (w->verbatim && Text$ends_with(w->line, Text(" "), NULL));
        if (!needs_escape || !w->verbatim) {
            end_line(w, continuation_marker);
            if (needs_escape) atom = Texts("\\", atom);
        }
    }
    w->line = Texts(w->line, atom);
}

// A literal that can't be written on one line is written as a block: the
// opening quote alone on its line, the text indented beneath it, and any line
// too wide for the page continued with dots. Every line then begins at one of
// two columns, which is what makes a column of text line up.
//
// A quote in the text needs no escape here. The parser only reads one as the
// closing quote at the very start of a line at the literal's own indentation,
// and every line written here starts either an indent deeper than that or with
// the dots of a continuation.
static Text_t format_text(text_opts_t opts, ast_list_t *chunks, Table_t comments, Text_t indent) {
    text_wrap_t w = {.indent = indent, .marker = single_indent, .own_line = true, .verbatim = opts.verbatim};
    for (ast_list_t *chunk = chunks; chunk; chunk = chunk->next) {
        if (chunk->ast->tag == TextLiteral) {
            Text_t literal = Match(chunk->ast, TextLiteral)->text;
            for (int64_t i = 1; i <= (int64_t)literal.length; i += 1) {
                Text_t grapheme = Text$slice(literal, I_small(i), I_small(i));
                if (Text$equal_values(grapheme, Text("\n"))) end_line(&w, single_indent);
                else append_atom(&w, opts.verbatim ? grapheme : Text$escaped(grapheme, false, opts.interp));
            }
        } else {
            // A newline inside a text literal is part of the text, so an
            // interpolation has to stay on one line whatever it holds. The
            // fallback is for the few expressions with no one-line form at
            // all, which cannot be written here in any case.
            OptionalText_t inlined_chunk = format_inline_code(chunk->ast, comments);
            Text_t chunk_code =
                inlined_chunk.tag != TEXT_NONE ? (Text_t)inlined_chunk : fmt(chunk->ast, comments, indent);
            if (interpolation_needs_parens(chunk)) chunk_code = Texts("(", chunk_code, ")");
            append_atom(&w, Texts(opts.interp, chunk_code));
        }
    }
    // The closing quote takes a line of its own, so the last line of text ends
    // where the others do and needs the same protection.
    Text_t last = w.line.length > 0 ? Texts(line_start(&w), protect_trailing_space(w.line, opts.verbatim)) : EMPTY_TEXT;
    return Texts(opts.quote, "\n", w.code, last, "\n", indent, opts.unquote);
}

// Whether a `!` sits on this expression's suffix spine. `@` and `&` bind every
// suffix but `!` into themselves and leave `!` outside (see parse_heap_alloc),
// so an operand carrying one has to be parenthesized to stay the operand:
// written bare, `&(f.A!)` comes back as `(&f.A)!`.
PUREFUNC static bool has_nonoptional_suffix(ast_t *ast) {
    for (;;) {
        switch (ast->tag) {
        case NonOptional: return true;
        case FieldAccess: ast = Match(ast, FieldAccess)->fielded; break;
        case Index: ast = Match(ast, Index)->indexed; break;
        case MethodCall: ast = Match(ast, MethodCall)->self; break;
        case FunctionCall: ast = Match(ast, FunctionCall)->fn; break;
        case RecordLiteral: ast = Match(ast, RecordLiteral)->type; break;
        default: return false;
        }
    }
}

// Where writing `code` from `column` leaves the cursor. A rendering that stayed
// on one line ends that many columns further along; one that wrapped carries its
// own indentation on its last line, so that line's length is already the column.
PUREFUNC static int64_t column_after(int64_t column, Text_t code) {
    int64_t last_line = trailing_line_len(code);
    return last_line < (int64_t)code.length ? last_line : column + last_line;
}

// The families the operators fall into for the purpose of reading an
// expression. `op_tightness` puts every operator into a single order, but a
// reader only reliably knows part of that order: that arithmetic binds tighter
// than comparison, and comparison tighter than `and`/`or`. Where a shift, a
// `_min_`, or `or` against `and` sits in it is a table lookup rather than
// knowledge, so those groupings are written out with parentheses instead of
// leaning on the order.
typedef enum { OP_ARITH, OP_CONCAT, OP_SHIFT, OP_MINMAX, OP_COMPARE, OP_LOGIC } op_family_e;

PUREFUNC static op_family_e op_family(ast_e op) {
    switch (op) {
    case Concat: return OP_CONCAT;
    case LeftShift:
    case RightShift:
    case UnsignedLeftShift:
    case UnsignedRightShift: return OP_SHIFT;
    case Min:
    case Max: return OP_MINMAX;
    case And:
    case Or:
    case Xor: return OP_LOGIC;
    case Equals:
    case NotEquals:
    case LessThan:
    case LessThanOrEquals:
    case GreaterThan:
    case GreaterThanOrEquals:
    case Compare: return OP_COMPARE;
    default: return OP_ARITH;
    }
}

// Whether an operand of `outer`, written bare, groups the way it reads. The
// relationships that count as ones a reader takes off the page are arithmetic
// within arithmetic, arithmetic within a comparison, a comparison within
// `and`/`or`/`xor`, and the two cases the comments below give.
PUREFUNC static bool is_modulus(ast_e op) {
    return op == Mod || op == Mod1;
}

PUREFUNC static bool grouping_is_obvious(ast_e outer, ast_t *inner) {
    if (!is_binary_operation(inner)) return true; // A prefix `-` groups visibly
    op_family_e outer_family = op_family(outer), inner_family = op_family(inner->tag);
    // A run of one operator groups the same way whichever way it is read, and
    // so needs nothing said about it -- unless it is a run of shifts, where the
    // grouping is the whole meaning, or of comparisons, where `a == b == c`
    // means `(a == b) == c` here and the chain it looks like elsewhere.
    if (outer == inner->tag) return outer_family != OP_SHIFT && outer_family != OP_COMPARE;
    // Arithmetic inside arithmetic is read off the page -- except where a
    // modulus is one of the two. `mod` and `mod1` are words sitting somewhere
    // in the middle of that band, and where exactly is not something anyone
    // carries around the way they do `*` before `+`: `i * 1337 mod 37` and
    // `y*(x//y) + x mod y` both have to be read twice. Against a comparison or
    // an `and` it needs nothing, arithmetic binding tighter than either.
    if (outer_family == inner_family) return outer_family == OP_ARITH && !is_modulus(outer) && !is_modulus(inner->tag);
    // Concatenation is not arithmetic, but a comparison is the one place it
    // reads like it: nobody has to be told that `a ++ b == c` compares the
    // joined text. Against arithmetic proper it is as murky as anything else.
    if (outer_family == OP_COMPARE) return inner_family == OP_ARITH || inner_family == OP_CONCAT;
    if (outer_family == OP_LOGIC) return inner_family == OP_COMPARE;
    return false;
}

// Whether this operand is written as part of the expression above it, rather
// than parenthesized off into one of its own. The operator above has to absorb
// it back -- written bare it would otherwise be read as belonging somewhere
// else -- and the grouping that leaves has to be one that can be read.
PUREFUNC static bool is_bare_operand(ast_t *inner, ast_e outer_op, bool on_left) {
    bool absorbed =
        on_left ? absorbs_lhs(outer_op, expr_tightness(inner)) : absorbs_rhs(outer_op, expr_tightness(inner));
    return absorbed && grouping_is_obvious(outer_op, inner);
}

// The operators the two functions below render. `is_binary_operation()` is a
// wider set: it takes in `_min_`/`_max_`, which carry a key (`a _min_.x b`)
// and have a renderer of their own.
PUREFUNC static bool is_binop_case(ast_t *ast) {
    switch (ast->tag) {
    case BINOP_CASES: return true;
    default: return false;
    }
}

// What an expression holds, for the purpose of deciding its spacing.
typedef struct {
    bool has_add; // An operator of the `+` band
    bool has_mul; // An operator of the `*` band
    bool has_div; // A division, which is one of the `*` band as well
} operator_bands_t;

// Which bands an expression's own operators fall into.
//
// Parentheses start a fresh expression with its own answer, so the walk stops
// at any operand that is written with them.
static void scan_operator_bands(ast_t *ast, operator_bands_t *bands) {
    if (!is_binop_case(ast) || is_update_assignment(ast)) return;
    int tightness = op_tightness[ast->tag];
    if (tightness == op_tightness[Plus] || tightness == op_tightness[Concat]) bands->has_add = true;
    else if (tightness >= op_tightness[Multiply]) bands->has_mul = true;
    if (ast->tag == Divide || ast->tag == FloorDivide) bands->has_div = true;

    binary_operands_t operands = BINARY_OPERANDS(ast);
    if (is_binop_case(operands.lhs) && is_bare_operand(operands.lhs, ast->tag, true))
        scan_operator_bands(operands.lhs, bands);
    if (is_binop_case(operands.rhs) && is_bare_operand(operands.rhs, ast->tag, false))
        scan_operator_bands(operands.rhs, bands);
}

// How tightly an operator has to bind to lose its spaces. An expression that
// mixes the bands drops them from its `*` band; one that doesn't keeps them
// everywhere; a subscript drops them from everything (see format_index()).
#define TIGHTEN_MIXED (op_tightness[Multiply])
#define TIGHTEN_NONE (INT_MAX)
#define TIGHTEN_ALL (0)

// Operators that can never be written without spaces, whatever the expression
// around them: a word operator would stop being a word (`(x + y)mod3` is not
// parseable code), and `!=` would run into the `!` suffix to its left, where
// `a!=b` reads as `a!` followed by `= b`.
PUREFUNC static bool always_spaced(const char *op) {
    return is_word_operator(op) || op[0] == '!';
}

// The spaces around a binary operator. `^` never takes them: an exponent sits
// against what it raises, `r^2`, however little else the expression holds.
static Text_t binop_spacing(ast_e op_tag, int tighten_from) {
    const char *op = binop_info[op_tag].operator;
    if (always_spaced(op)) return Text(" ");
    bool tight = op_tag == Power || op_tightness[op_tag] >= tighten_from;
    return tight ? EMPTY_TEXT : Text(" ");
}

// The spaces an operator takes between two operands already rendered. A tightened operator gives its spaces up, but not
// where that would leave it against a `.`: `3.` written straight onto `*3` gives `3.*3`, with no boundary left between
// the number and the operator. A division is the one exception, being how a quotient is written as a single quantity --
// `1./3.` is the value, and spacing it out would call it a sum to work out.
static Text_t binop_join_spacing(Text_t lhs, ast_e op_tag, Text_t rhs, int tighten_from) {
    Text_t space = binop_spacing(op_tag, tighten_from);
    if (space.length == 0 && op_tag != Divide && op_tag != FloorDivide
        && (Text$ends_with(lhs, Text("."), NULL) || Text$starts_with(rhs, Text("."), NULL)))
        space = Text(" ");
    return space;
}

// An operand keeps its parentheses unless it is written bare, which takes both
// of the things is_bare_operand() asks. `dt / (d2 * x)` is not `dt / d2 * x`,
// and `(2 ^ 3) ^ 2` is not `2 ^ 3 ^ 2`, but `2 ^ (3 ^ 2)` and `2 ^ 3 ^ 2` are
// the same. An `if`/`match` keeps them whatever the tightness: written bare it
// runs on through the rest of the operator.
static Text_t operand(Text_t code, ast_t *ast, ast_e outer_op, bool on_left, Text_t indent, int tighten_from) {
    if (ast->tag == If || ast->tag == Match || (is_operation(ast) && !is_bare_operand(ast, outer_op, on_left)))
        return parenthesize(code, indent);
    // `not` is a word, and a word in front of an expression reads as taking
    // all of it: `not x == y` is `not (x == y)` in most places it can be
    // written, and `(not x) == y` here. It binds tighter than any of them, so
    // nothing else gives it parentheses -- it carries no tightness at all, and
    // is_operation() above does not even count it as an operator.
    if (ast->tag == Not) return parenthesize(code, indent);
    // Spaces are a claim about grouping, so an operand that keeps its own
    // inside an operator that has given them up contradicts the operator it
    // belongs to: `a mod b/c` divides the modulus but reads as the modulus of
    // a quotient. A word operator can't give its spaces up -- it would stop
    // being a word -- so it takes parentheses instead, and `(a mod b)/c` says
    // what the spacing was trying to.
    if (is_binary_operation(ast) && binop_spacing(outer_op, tighten_from).length == 0
        && binop_spacing(ast->tag, tighten_from).length > 0)
        return parenthesize(code, indent);
    return code;
}

// The spacing an expression's own operators call for, before anything the
// surrounding syntax has to say about it.
//
// The `*` band loses its spaces when the expression mixes it with the `+`
// band, so that the operator the expression actually splits on is the one the
// eye lands on: `x + y*z`. Only the `+` band triggers that, not merely
// anything looser: `a == b * c` and `2 ^ 3 ^ 2 == 512` keep their spaces,
// which is both what gofmt does with the same shapes and how they are written
// in this tree.
//
// A division loses them whatever it sits among, because a quotient is written
// as one quantity everywhere else it is written at all: `1/3` is a number to
// read, `1 / 3` is a sum to work out. That takes the whole band with it,
// spaces being a claim about grouping: `a*b/c` divides the product, and
// `a * b/c` would say it multiplies the quotient.
PUREFUNC static int expression_spacing(ast_t *ast) {
    operator_bands_t bands = {};
    scan_operator_bands(ast, &bands);
    bool tighten = bands.has_div || (bands.has_add && bands.has_mul);
    return tighten ? TIGHTEN_MIXED : TIGHTEN_NONE;
}

// Whether this operand is part of the same expression as the operator above
// it, rather than a parenthesized one of its own: only then does it inherit
// that expression's spacing.
PUREFUNC static bool shares_expression(ast_t *operand_ast, ast_e outer_op, bool on_left) {
    if (!is_binop_case(operand_ast)) return false;
    return is_bare_operand(operand_ast, outer_op, on_left);
}

// One operator of an expression whose spacing has already been settled by
// mixes_operator_bands() at its outermost operator. An operand belonging to
// the same expression is rendered here too, so that it inherits that answer;
// anything else -- a parenthesized operand above all -- goes back through the
// ordinary path and is answered afresh.
static OptionalText_t format_binop_inline(ast_t *ast, Table_t comments, int tighten_from) {
    binary_operands_t operands = BINARY_OPERANDS(ast);
    const char *op = binop_info[ast->tag].operator;

    Text_t lhs = shares_expression(operands.lhs, ast->tag, true)
                     ? must(format_binop_inline(operands.lhs, comments, tighten_from))
                     : fmt_inline(operands.lhs, comments);
    Text_t rhs = shares_expression(operands.rhs, ast->tag, false)
                     ? must(format_binop_inline(operands.rhs, comments, tighten_from))
                     : fmt_inline(operands.rhs, comments);

    if (is_update_assignment(ast)) return Texts(lhs, " ", Text$from_str(op), " ", rhs);

    // See operand() above for which operands keep their parentheses.
    lhs = operand(lhs, operands.lhs, ast->tag, true, EMPTY_TEXT, tighten_from);
    rhs = operand(rhs, operands.rhs, ast->tag, false, EMPTY_TEXT, tighten_from);

    Text_t space = binop_join_spacing(lhs, ast->tag, rhs, tighten_from);
    return Texts(lhs, space, Text$from_str(op), space, rhs);
}

static Text_t format_binop(ast_t *ast, Table_t comments, Text_t indent, int64_t column, int tighten_from) {
    binary_operands_t operands = BINARY_OPERANDS(ast);
    const char *op = binop_info[ast->tag].operator;
    Text_t inner_indent = Texts(indent, single_indent);

    // The three gaps this expression is answerable for: before its left
    // operand, between the two, and after its right one. The outer two are
    // empty unless the source parenthesized it, since only then does its span
    // reach past its operands.
    const char *pos = ast->start;
    Text_t opening = comment_range(&pos, operands.lhs->start, inner_indent, comments);
    pos = operands.lhs->end;
    Text_t middle = comment_range(&pos, operands.rhs->start, inner_indent, comments);
    pos = operands.rhs->end;
    Text_t closing = comment_range(&pos, ast->end, inner_indent, comments);
    bool parenthesized = opening.length > 0 || closing.length > 0;
    Text_t operand_indent = parenthesized ? inner_indent : indent;
    // Parenthesized, the operands start on a line of their own one level in.
    int64_t lhs_column = parenthesized ? inner_indent.length : column;

    Text_t lhs = shares_expression(operands.lhs, ast->tag, true)
                     ? format_binop(operands.lhs, comments, operand_indent, lhs_column, tighten_from)
                     : fmt_at(operands.lhs, comments, operand_indent, lhs_column);
    // The operator and the spaces around it sit between the two operands.
    int64_t rhs_column =
        column_after(lhs_column, lhs) + (int64_t)strlen(op) + 2 * binop_spacing(ast->tag, tighten_from).length;
    Text_t rhs = shares_expression(operands.rhs, ast->tag, false)
                     ? format_binop(operands.rhs, comments, operand_indent, rhs_column, tighten_from)
                     : fmt_at(operands.rhs, comments, operand_indent, rhs_column);

    if (is_update_assignment(ast)) return Texts(lhs, " ", Text$from_str(op), " ", rhs);

    // See format_binop_inline() above for which operands keep their parentheses.
    lhs = operand(lhs, operands.lhs, ast->tag, true, operand_indent, tighten_from);
    rhs = operand(rhs, operands.rhs, ast->tag, false, operand_indent, tighten_from);

    Text_t space = binop_join_spacing(lhs, ast->tag, rhs, tighten_from);
    Text_t code = Texts(lhs, space, Text$from_str(op));
    if (middle.length > 0) {
        // A comment written by the operator stays by it. It can only follow
        // the operator, never precede it: `a # c` and then `+ b` on the next
        // line is not an expression the parser puts back together.
        code = Texts(code, " ", middle, "\n", inner_indent, rhs);
    } else {
        code = Texts(code, space, rhs);
    }
    if (closing.length > 0) code = Texts(code, " ", closing);
    if (!parenthesized) return code;
    if (opening.length > 0) code = Texts(opening, "\n", inner_indent, code);
    return Texts("(\n", inner_indent, code, "\n", indent, ")");
}

// A subscript is written compactly, `arr[i+1]` rather than `arr[i + 1]`: the
// brackets already say where it begins and ends, so the spaces inside them buy
// nothing. Anything that isn't a run of operators is written as it would be
// anywhere else, so `arr[foo(a + b)]` keeps the call's own spacing.
static OptionalText_t format_subscript_inline(ast_t *ast, Table_t comments) {
    if (is_binop_case(ast)) return format_binop_inline(ast, comments, TIGHTEN_ALL);
    return format_inline_code(ast, comments);
}

static Text_t format_subscript(ast_t *ast, Table_t comments, Text_t indent, int64_t column) {
    if (is_binop_case(ast)) return format_binop(ast, comments, indent, column, TIGHTEN_ALL);
    return format_code(ast, comments, indent);
}

// Comments written in a gap between the parts of a construct -- before an
// `else`, before a `case` -- have no line of their own to sit at the end of,
// so they take one at the construct's own level. Nothing else writes them out:
// a block scans between its statements, but these gaps are between the clauses
// of one statement, inside its span.
static Text_t gap_comments(const char **pos, const char *end, Text_t indent, Table_t comments) {
    Text_t found = comment_range(pos, end, indent, comments);
    return found.length > 0 ? Texts("\n", indent, found) : EMPTY_TEXT;
}

// A negation whose operand is a numeric literal or another negation always
// parenthesizes it: `-(1)`, `-(-1)`, `-(-(1))`. Written without the
// parentheses, `- 1` and `- -1` read back as a single negative literal rather
// than the negation of one, which is a different syntax tree.
PUREFUNC static bool negation_needs_parens(ast_t *operand) {
    return operand->tag == Int || operand->tag == Num || operand->tag == Negative;
}

// A comprehension without the parentheses that bound it.
static OptionalText_t inline_comprehension_body(ast_t *ast, Table_t comments) {
    DeclareMatch(comp, ast, Comprehension);
    // The `for` that follows ends the comprehension inside it, so a nested one
    // needs no parentheses either: `[x for x in y for y in ys]` is how the
    // parser reads that shape back.
    Text_t expr = comp->expr->tag == Comprehension ? must(inline_comprehension_body(comp->expr, comments))
                                                   : fmt_inline(comp->expr, comments);
    Text_t code = Texts(expr, " for ");
    for (ast_list_t *var = comp->vars; var; var = var->next) {
        code = Texts(code, fmt_inline(var->ast, comments));
        if (var->next) code = Texts(code, ", ");
    }
    if (comp->at) code = Texts(code, " at ", fmt_inline(comp->at, comments));
    code = Texts(code, " in ");
    for (ast_list_t *iter = comp->iters; iter; iter = iter->next) {
        code = Texts(code, fmt_inline(iter->ast, comments));
        if (iter->next) code = Texts(code, ", ");
    }
    if (comp->filter) code = Texts(code, " if ", fmt_inline(comp->filter, comments));
    return code;
}

// A comprehension written bare runs on: whatever follows it is read as another
// of its iterables, or as its filter. So it can only be written that way where
// the construct around it ends right after it -- as a container's last item,
// `[x*2 for x in xs]`, or as the whole of a reduction, `(+: x for x in xs)`.
//
// Anywhere else it needs parentheses of its own, and beside a later item in the
// same container it needs them badly: `[(a for a in xs), (b for b in xs)]` is a
// list of two generators, and written bare it reads back as one comprehension
// nested in another, with `b` swallowed into the outer one's iterables.

// A comprehension between delimiters. The parentheses are its own when it
// stands alone; a reduction supplies `(and: ` for the opening one instead, and
// the body between is written the same way on either side of that choice --
// which is what inline_comprehension_body() does for the one-line form.
static Text_t comprehension_at(ast_t *ast, Table_t comments, Text_t open, Text_t indent, int64_t column) {
    // The guard format_inline_code() puts in front of every one-line
    // rendering: a comment written anywhere inside has no line to sit on in
    // this form. The body is assembled from the parts, so nothing else asks
    // about the gaps between them -- and the gap before the `for` is one.
    if (!range_has_comment(ast->start, ast->end, comments)) {
        OptionalText_t inline_body = inline_comprehension_body(ast, comments);
        if (inline_body.tag != TEXT_NONE && column + open.length + inline_body.length + 1 <= MAX_WIDTH)
            return Texts(open, (Text_t)inline_body, ")");
    }

    DeclareMatch(comp, ast, Comprehension);
    // An expression that spans lines can't share the opening parenthesis's
    // line: constructs like `match` need their continuation lines at their
    // own starting column, which is one past `indent` there. Give it a line
    // of its own instead.
    Text_t inner_indent = Texts(indent, single_indent);
    Text_t block_expr = fmt(comp->expr, comments, inner_indent);
    bool block_layout = Text$has(block_expr, Text("\n"));
    Text_t body_indent = block_layout ? inner_indent : indent;

    // A comment written between the expression and its `for` belongs to
    // this gap and to nothing else; it goes at the end of the expression's
    // line, which the `for` then starts a new one after.
    const char *gap = comp->expr->end;
    Text_t before_for = comment_range(&gap, comp->vars ? comp->vars->ast->start : ast->end, body_indent, comments);

    // An expression on the line after the delimiter leaves nothing on the
    // delimiter's own line, which a reduction cannot have: its `+: ` needs an
    // expression to introduce. There, and only there, the comprehension keeps
    // parentheses of its own to put on that line.
    bool nested = block_layout && open.length > 1;

    Text_t code;
    if (block_layout) {
        code = Texts(open, nested ? Text("(") : EMPTY_TEXT, "\n", inner_indent, block_expr,
                     before_for.length > 0 ? Texts(" ", before_for) : EMPTY_TEXT, "\n", inner_indent, "for ");
    } else {
        code = Texts(open, fmt_at(comp->expr, comments, indent, column + (int64_t)open.length));
        if (before_for.length > 0) code = Texts(code, " ", before_for, "\n", indent, "for ");
        else if (code.length >= MAX_WIDTH) code = Texts(code, "\n", indent, "for ");
        else code = Texts(code, " for ");
    }

    for (ast_list_t *var = comp->vars; var; var = var->next) {
        code = Texts(code, fmt_at(var->ast, comments, body_indent, column_after(column, code)));
        if (var->next) code = Texts(code, ", ");
    }
    if (comp->at)
        code = Texts(code, " at ", bounded_at(comp->at, comments, body_indent, column_after(column, code) + 4));

    code = Texts(code, " in ");
    for (ast_list_t *iter = comp->iters; iter; iter = iter->next) {
        code = Texts(code, bounded_at(iter->ast, comments, body_indent, column_after(column, code)));
        if (iter->next) code = Texts(code, ", ");
    }

    if (comp->filter) {
        if (block_layout) code = Texts(code, "\n", inner_indent, "if ");
        else if (code.length >= MAX_WIDTH) code = Texts(code, "\n", indent, "if ");
        else code = Texts(code, " if ");
        code = Texts(code, bounded_at(comp->filter, comments, body_indent, column_after(column, code)));
    }
    // The closing parenthesis was missing entirely: a comprehension that
    // didn't fit on one line came out unparseable.
    return Texts(code, block_layout ? Texts("\n", indent, nested ? Text("))") : Text(")")) : Text(")"));
}

PUREFUNC static bool ends_where_it_does(ast_list_t *item) {
    return item->next == NULL && item->ast->tag == Comprehension;
}

OptionalText_t format_inline_code(ast_t *ast, Table_t comments) {
    if (range_has_comment(ast->start, ast->end, comments)) return NONE_TEXT;
    // A block literal stays a block. Its one-line form is a different thing to
    // read: the newlines come back as `\n` escapes, and the line it lands on is
    // as long as the whole text.
    if ((ast->tag == TextJoin || ast->tag == InlineCCode) && is_block_text(ast)) return NONE_TEXT;
    switch (ast->tag) {
    /*inline*/ case Unknown:
        fail("Invalid AST");
    /*inline*/ case Block: {
        ast_list_t *statements = Match(ast, Block)->statements;
        if (statements == NULL) return Text("pass");
        else if (statements->next == NULL) return fmt_inline(statements->ast, comments);
        else return NONE_TEXT;
    }
    /*inline*/ case StructDef:
    /*inline*/ case EnumDef:
    /*inline*/ case LangDef:
    /*inline*/ case FunctionDef:
    /*inline*/ case ConvertDef:
    /*inline*/ case DebugLog:
        return NONE_TEXT;
    /*inline*/ case Assert: {
        DeclareMatch(assert, ast, Assert);
        Text_t expr = must(bounded_inline(assert->expr, comments));
        if (!assert->message) return Texts("assert ", expr);
        Text_t message = fmt_inline(assert->message, comments);
        return Texts("assert ", expr, ", ", message);
    }
    /*inline*/ case Test:
        // A test is a label plus an indented body: never a single line.
        return NONE_TEXT;
    /*inline*/ case Defer:
        return Texts("defer ", fmt_inline(Match(ast, Defer)->body, comments));
    /*inline*/ case Lambda: {
        DeclareMatch(lambda, ast, Lambda);
        Text_t code = Texts("func(", format_inline_args(lambda->args, comments));
        if (lambda->ret_type)
            code = Texts(code, lambda->args ? Text(" -> ") : Text("-> "), format_type(lambda->ret_type));
        code = Texts(code, ") ", fmt_inline(lambda->body, comments));
        return Texts(code);
    }
    /*inline*/ case If: {
        DeclareMatch(if_, ast, If);

        Text_t if_condition = if_->condition->tag == Not
                                  ? Texts("unless ", fmt_inline(Match(if_->condition, Not)->value, comments))
                                  : Texts("if ", fmt_inline(if_->condition, comments));

        if (if_->postfix && if_->else_body == NULL) {
            return Texts(fmt_inline(if_->body, comments), " if ", if_condition);
        }

        if (if_->else_body == NULL && if_->condition->tag != Declare) {
            ast_t *stmt = unwrap_block(if_->body);
            if (!stmt) return Texts("pass ", if_condition);
            switch (stmt->tag) {
            case Return:
            case Continue:
            case Break: return Texts(fmt_inline(stmt, comments), " ", if_condition);
            default: break;
            }
        }

        Text_t code = Texts(if_condition, " then ", fmt_inline(if_->body, comments));
        if (if_->else_body) code = Texts(code, " else ", fmt_inline(if_->else_body, comments));
        return code;
    }
    /*inline*/ case Match: {
        DeclareMatch(match, ast, Match);
        Text_t code = Texts("match ", fmt_inline(match->subject, comments));
        for (match_clause_t *clause = match->clauses; clause; clause = clause->next) {
            code = Texts(code, " case ", fmt_inline(clause->pattern, comments));
            while (clause->next && clause->next->body == clause->body) {
                clause = clause->next;
                code = Texts(code, ", ", fmt_inline(clause->pattern, comments));
            }
            code = Texts(code, " then ", fmt_inline(clause->body, comments));
        }
        if (match->else_body) code = Texts(code, " else ", fmt_inline(match->else_body, comments));
        return code;
    }
    /*inline*/ case Repeat:
        return Texts("repeat ", fmt_inline(Match(ast, Repeat)->body, comments));
    /*inline*/ case While: {
        DeclareMatch(loop, ast, While);
        return Texts("while ", fmt_inline(loop->condition, comments), " do ", fmt_inline(loop->body, comments));
    }
    /*inline*/ case For: {
        DeclareMatch(loop, ast, For);
        Text_t code = Text("for ");
        for (ast_list_t *var = loop->vars; var; var = var->next) {
            code = Texts(code, fmt_inline(var->ast, comments));
            if (var->next) code = Texts(code, ", ");
        }
        if (loop->at) code = Texts(code, " at ", fmt_inline(loop->at, comments));
        code = Texts(code, " in ");
        for (ast_list_t *iter = loop->iters; iter; iter = iter->next) {
            code = Texts(code, fmt_inline(iter->ast, comments));
            if (iter->next) code = Texts(code, ", ");
        }
        code = Texts(code, " do ", fmt_inline(loop->body, comments));
        if (loop->empty) code = Texts(code, " else ", fmt_inline(loop->empty, comments));
        return code;
    }
    /*inline*/ case Comprehension:
        return Texts("(", must(inline_comprehension_body(ast, comments)), ")");
    /*inline*/ case List: {
        ast_list_t *items = Match(ast, List)->items;
        Text_t code = EMPTY_TEXT;
        for (ast_list_t *item = items; item; item = item->next) {
            code = Texts(code, ends_where_it_does(item) ? must(inline_comprehension_body(item->ast, comments))
                                                        : fmt_inline(item->ast, comments));
            if (item->next) code = Texts(code, ", ");
        }
        return Texts("[", code, "]");
    }
    /*inline*/ case Table: {
        DeclareMatch(table, ast, Table);
        Text_t code = EMPTY_TEXT;
        for (ast_list_t *entry = table->entries; entry; entry = entry->next) {
            code = Texts(code, ends_where_it_does(entry) ? must(inline_comprehension_body(entry->ast, comments))
                                                         : fmt_inline(entry->ast, comments));
            if (entry->next) code = Texts(code, ", ");
        }
        if (table->fallback) code = Texts(code, "; fallback=", fmt_inline(table->fallback, comments));
        if (table->default_value) code = Texts(code, "; default=", fmt_inline(table->default_value, comments));
        return Texts("{", code, "}");
    }
    /*inline*/ case TableEntry: {
        DeclareMatch(entry, ast, TableEntry);
        if (entry->value)
            return Texts(must(bounded_inline(entry->key, comments)), ": ",
                         must(bounded_inline(entry->value, comments)));
        else return Texts(fmt_inline(entry->key, comments));
    }
    /*inline*/ case Declare: {
        DeclareMatch(decl, ast, Declare);
        Text_t code = fmt_inline(decl->var, comments);
        if (decl->type) code = Texts(code, " : ", format_type(decl->type));
        if (decl->value) code = Texts(code, decl->type ? Text(" = ") : Text(" := "), fmt_inline(decl->value, comments));
        return code;
    }
    /*inline*/ case Assign: {
        DeclareMatch(assign, ast, Assign);
        Text_t code = EMPTY_TEXT;
        for (ast_list_t *target = assign->targets; target; target = target->next) {
            code = Texts(code, fmt_inline(target->ast, comments));
            if (target->next) code = Texts(code, ", ");
        }
        code = Texts(code, " = ");
        for (ast_list_t *value = assign->values; value; value = value->next) {
            code = Texts(code, fmt_inline(value->ast, comments));
            if (value->next) code = Texts(code, ", ");
        }
        return code;
    }
    /*inline*/ case Pass:
        return Text("pass");
    /*inline*/ case Return: {
        ast_t *value = Match(ast, Return)->value;
        return value ? Texts("return ", must(bounded_inline(value, comments))) : Text("return");
    }
    /*inline*/ case Not: {
        ast_t *val = Match(ast, Not)->value;
        return Texts("not ", must(termify_inline(val, comments)));
    }
    /*inline*/ case Negative: {
        ast_t *val = Match(ast, Negative)->value;
        if (negation_needs_parens(val)) return Texts("-(", fmt_inline(val, comments), ")");
        // An operand this `-` absorbs needs no parentheses to be read back as
        // part of it: `-x ^ 2` is already the negation of the power.
        if (is_binary_operation(val) && absorbs_rhs(Negative, expr_tightness(val)))
            return Texts("-", fmt_inline(val, comments));
        return Texts("-", must(termify_inline(val, comments)));
    }
    /*inline*/ case HeapAllocate: {
        ast_t *val = Match(ast, HeapAllocate)->value;
        if (has_nonoptional_suffix(val)) return Texts("@(", must(format_inline_code(val, comments)), ")");
        return Texts("@", must(termify_inline(val, comments)));
    }
    /*inline*/ case StackReference: {
        ast_t *val = Match(ast, StackReference)->value;
        if (has_nonoptional_suffix(val)) return Texts("&(", must(format_inline_code(val, comments)), ")");
        return Texts("&", must(termify_inline(val, comments)));
    }
    /*inline*/ case NonOptional: {
        ast_t *val = Match(ast, NonOptional)->value;
        return Texts(must(termify_inline(val, comments)), "!");
    }
    /*inline*/ case FieldAccess: {
        DeclareMatch(access, ast, FieldAccess);
        return Texts(must(dotted_inline(access->fielded, comments)), ".", Text$from_str(access->field));
    }
    /*inline*/ case Index: {
        DeclareMatch(index, ast, Index);
        Text_t indexed = must(termify_inline(index->indexed, comments));
        if (index->index) return Texts(indexed, "[", must(format_subscript_inline(index->index, comments)), "]");
        else return Texts(indexed, "[]");
    }
    /*inline*/ case TextJoin: {
        text_opts_t opts = choose_text_options(Match(ast, TextJoin)->children);
        Text_t ret = must(format_inline_text(opts, Match(ast, TextJoin)->children, comments));
        return decorate_text(ast, ret);
    }
    /*inline*/ case InlineCCode: {
        DeclareMatch(c_code, ast, InlineCCode);
        Text_t code = c_code->type_ast ? Texts("C_code:", format_type(c_code->type_ast)) : Text("C_code");
        text_opts_t opts = {.quote = Text("`"), .unquote = Text("`"), .interp = Text("@"), .verbatim = true};
        return Texts(code, must(format_inline_text(opts, Match(ast, InlineCCode)->chunks, comments)));
    }
    /*inline*/ case TextLiteral: { fail("Something went wrong, we shouldn't be formatting text literals directly"); }
    /*inline*/ case Path: {
        return Texts("(", Text$escaped(Text$from_str(Match(ast, Path)->path), false, Text("()")), ")");
    }
    /*inline*/ case Embed: { return Texts("_embed_ ", fmt_inline(Match(ast, Embed)->path, comments)); }
    /*inline*/ case Serialize: { return Texts("serialize(", fmt_inline(Match(ast, Serialize)->value, comments), ")"); }
    /*inline*/ case Deserialize: {
        DeclareMatch(deserialize, ast, Deserialize);
        return Texts("deserialize:", format_type(deserialize->type_ast), "(", fmt_inline(deserialize->value, comments),
                     ")");
    }
    /*inline*/ case Break: {
        const char *target = Match(ast, Break)->target;
        return target ? Texts("break ", Text$from_str(target)) : Text("break");
    }
    /*inline*/ case Continue: {
        const char *target = Match(ast, Continue)->target;
        return target ? Texts("continue ", Text$from_str(target)) : Text("continue");
    }
    /*inline*/ case Min:
    /*inline*/ case Max: {
        ast_t *lhs_ast = ast->tag == Min ? Match(ast, Min)->lhs : Match(ast, Max)->lhs;
        ast_t *rhs_ast = ast->tag == Min ? Match(ast, Min)->rhs : Match(ast, Max)->rhs;
        Text_t lhs = operand(fmt_inline(lhs_ast, comments), lhs_ast, ast->tag, true, EMPTY_TEXT, TIGHTEN_NONE);
        Text_t rhs = operand(fmt_inline(rhs_ast, comments), rhs_ast, ast->tag, false, EMPTY_TEXT, TIGHTEN_NONE);
        ast_t *key = ast->tag == Min ? Match(ast, Min)->key : Match(ast, Max)->key;
        // The keyed form (`a _min_.x b`) needs its spaces just as much as the
        // plain one; without them it ran together as `a_min_.xb`.
        Text_t op = key ? fmt_inline(key, comments) : (ast->tag == Min ? Text("_min_") : Text("_max_"));
        return Texts(lhs, " ", op, " ", rhs);
    }
    /*inline*/ case Reduction: {
        DeclareMatch(reduction, ast, Reduction);
        Text_t iter = reduction->iter->tag == Comprehension ? must(inline_comprehension_body(reduction->iter, comments))
                                                            : fmt_inline(reduction->iter, comments);
        Text_t op =
            reduction->key ? fmt_inline(reduction->key, comments) : Text$from_str(binop_info[reduction->op].operator);
        return Texts("(", op, ": ", iter, ")");
    }
    /*inline*/ case None:
        return Text("none");
    /*inline*/ case Bool:
        return Match(ast, Bool)->b ? Text("yes") : Text("no");
    /*inline*/ case Int: {
        // The literal as written, not the node's source text: a parenthesized
        // literal's span covers the parentheses, which are not part of it.
        const char *str = Match(ast, Int)->str;
        return str ? Text$from_str(str) : Int$value_as_text(Match(ast, Int)->i);
    }
    /*inline*/ case Num: {
        const char *str = Match(ast, Num)->str;
        return str ? Text$from_str(str) : Text$from_str(number_to_symbolic(Match(ast, Num)->n));
    }
    /*inline*/ case Var:
        return Text$from_str(Match(ast, Var)->name);
    /*inline*/ case FunctionCall: {
        DeclareMatch(call, ast, FunctionCall);
        // The function being called is a suffix's receiver like any other, so
        // it goes through termify_inline(): `(if c then f else g)(x)` printed
        // bare came back as `if c then f else g(x)`.
        return Texts(must(termify_inline(call->fn, comments)), "(", must(format_inline_args(call->args, comments)),
                     ")");
    }
    /*inline*/ case RecordLiteral: {
        DeclareMatch(record, ast, RecordLiteral);
        return Texts(fmt_inline(record->type, comments), "{", must(format_inline_args(record->args, comments)), "}");
    }
    /*inline*/ case MethodCall: {
        DeclareMatch(call, ast, MethodCall);
        // termify_inline(), the same as every other suffix takes its receiver
        // through and the same as the multi-line case below: a hand-rolled
        // list here went stale, and left `(if c then a else b).f()` and
        // `(@x).f()` printing as `if c then a else b.f()` and `@x.f()`.
        Text_t self = must(dotted_inline(call->self, comments));
        return Texts(self, ".", Text$from_str(call->name), "(", must(format_inline_args(call->args, comments)), ")");
    }
    /*inline*/ case BINOP_CASES:
        // The spacing is the whole expression's answer, so it is settled here,
        // at the operator the expression splits on, and handed down.
        return format_binop_inline(ast, comments, expression_spacing(ast));
    /*inline*/ case Use: {
        DeclareMatch(use, ast, Use);
        // `name := use ./module.tm` binds the module to a variable; dropping
        // that half left every reference to the module unresolvable.
        Text_t code = Texts("use ", use->path);
        return use->var ? Texts(fmt_inline(use->var, comments), " := ", code) : code;
    }
    /*inline*/ case ExplicitlyTyped:
        fail("Explicitly typed AST nodes are only meant to be used internally.");
    default: {
        fail("Formatting not implemented for: ", ast_to_sexp(ast));
    }
    }
}

Text_t format_code_at(ast_t *ast, Table_t comments, Text_t indent, int64_t column) {
    OptionalText_t inlined = format_inline_code(ast, comments);
    bool inlined_fits = (inlined.tag != TEXT_NONE && column + inlined.length <= MAX_WIDTH);

    switch (ast->tag) {
    /*multiline*/ case Unknown:
        fail("Invalid AST");
    /*multiline*/ case Block: {
        // A statement whose formatted form nests two levels deep needs a blank
        // line after it, so that the code following it isn't mistaken for part
        // of the inner body:
        Text_t double_indent = Texts("\n", indent, single_indent, single_indent);
        Text_t code = EMPTY_TEXT;
        const char *comment_pos = block_scan_start(ast);
        ast_list_t *prev = NULL;
        bool prev_was_double_indented = false;
        for (ast_list_t *stmt = Match(ast, Block)->statements; stmt; stmt = stmt->next) {
            Text_t comment_code = comment_range(&comment_pos, stmt->ast->start, indent, comments);
            int64_t target_newlines =
                prev == NULL ? 0
                             : 1 + MAX(prev_was_double_indented ? 1 : 0, suggested_blank_lines(prev->ast, stmt->ast));

            int64_t newlines = 0;
            for (int64_t i = code.length - 1; i >= 0; i--) {
                if (Text$get_grapheme(code, i) != '\n') break;
                newlines += 1;
            }
            for (; newlines < target_newlines; newlines++)
                code = Text$concat(code, Text("\n"));

            if (comment_code.length > 0) {
                if (code.length > 0 && !Text$ends_with(code, indent, NULL)) code = Text$concat(code, indent);
                code = Text$concat(code, comment_code, Text("\n"));
                // The gap between a comment and what it introduces is the
                // author's too. suggested_blank_lines() measures from one
                // statement to the next, so the comment standing between them
                // is inside what it measures rather than beside it.
                if (has_blank_line(comment_pos, stmt->ast->start)) code = Text$concat(code, Text("\n"));
            }

            if (code.length > 0 && !Text$ends_with(code, indent, NULL)) code = Text$concat(code, indent);
            Text_t stmt_code;
            if (stmt->ast->tag == Block) {
                stmt_code =
                    Texts("do\n", indent, single_indent, fmt(stmt->ast, comments, Texts(indent, single_indent)));
            } else {
                stmt_code = fmt(stmt->ast, comments, indent);
            }
            code = Text$concat(code, stmt_code);
            prev_was_double_indented = Text$has(stmt_code, double_indent);
            comment_pos = content_end(stmt->ast);
            const char *eol = comment_pos;
            while (eol < stmt->ast->file->text + stmt->ast->file->len && *eol != '\n')
                eol++;
            // A comment on the line this statement ends on. The cursor moves
            // past it either way, so that the next statement doesn't collect
            // it as one of its own, but a statement that ends inside an
            // indented block has already written it out on that block's last
            // line: this is the same line, seen from one level further out,
            // and claiming it again is what made `>> 1 # c` grow another
            // `# c` on every pass.
            Text_t line_comment = comment_range(&comment_pos, eol, indent, comments);
            if (line_comment.length > 0 && !ends_deeper_than(stmt_code, indent)) {
                code = Text$concat(code, Text(" "), line_comment);
            }
            prev = stmt;
        }

        Text_t comment_code = comment_range(&comment_pos, block_content_end(ast), indent, comments);
        if (comment_code.length > 0) {
            if (code.length > 0) code = Text$concat(code, Text("\n"), indent);
            code = Text$concat(code, comment_code);
        }
        return code;
    }
    /*multiline*/ case If: {
        DeclareMatch(if_, ast, If);
        Text_t code =
            if_->condition->tag == Not
                ? Texts("unless ", bounded_at(Match(if_->condition, Not)->value, comments, indent, column + 7))
                : Texts("if ", bounded_at(if_->condition, comments, indent, column + 3));

        Text_t body = fmt(if_->body, comments, Texts(indent, single_indent));
        if (if_->postfix && if_->else_body == NULL && !Text$has(body, Text("\n"))) {
            return Texts(body, " ", code);
        }

        code = Texts(code, header_comment(if_->body, comments), "\n", indent, single_indent, body);
        if (if_->else_body) {
            const char *gap = if_->body->end;
            code = Texts(code, gap_comments(&gap, if_->else_body->start, indent, comments));
            if (if_->else_body->tag != If) {
                code = Texts(code, "\n", indent, "else", format_body(if_->else_body, comments, indent));
            } else {
                code = Texts(code, "\n", indent, "else ", fmt_at(if_->else_body, comments, indent, indent.length + 5));
            }
        }
        return code;
    }
    /*multiline*/ case Match: {
        DeclareMatch(match, ast, Match);
        Text_t code = Texts("match ", bounded_at(match->subject, comments, indent, column + 6));
        // A comment after the subject or before a `case` sits in a gap of this
        // statement, which no block scans:
        const char *gap = match->subject->end;
        code = Texts(code, line_end_comment(&gap, ast->end, comments));
        for (match_clause_t *clause = match->clauses; clause; clause = clause->next) {
            code = Texts(code, gap_comments(&gap, clause->pattern->start, indent, comments));
            code = Texts(code, "\n", indent, "case ", fmt_at(clause->pattern, comments, indent, indent.length + 5));
            while (clause->next && clause->next->body == clause->body) {
                clause = clause->next;
                code = Texts(code, ", ", fmt_at(clause->pattern, comments, indent, column_after(column, code) + 2));
            }
            code = Texts(code, format_namespace(clause->body, comments, indent));
            gap = clause->body->end;
        }
        if (match->else_body) {
            code = Texts(code, gap_comments(&gap, match->else_body->start, indent, comments));
            code = Texts(code, "\n", indent, "else", format_namespace(match->else_body, comments, indent));
        }
        return code;
    }
    /*multiline*/ case Repeat: { return Texts("repeat", format_body(Match(ast, Repeat)->body, comments, indent)); }
    /*multiline*/ case While: {
        DeclareMatch(loop, ast, While);
        return Texts("while ", bounded_at(loop->condition, comments, indent, column + 6),
                     format_body(loop->body, comments, indent));
    }
    /*multiline*/ case For: {
        DeclareMatch(loop, ast, For);
        Text_t code = Text("for ");
        for (ast_list_t *var = loop->vars; var; var = var->next) {
            code = Texts(code, fmt_at(var->ast, comments, indent, column_after(column, code)));
            if (var->next) code = Texts(code, ", ");
        }
        if (loop->at)
            code = Texts(code, " at ", bounded_at(loop->at, comments, indent, column_after(column, code) + 4));
        code = Texts(code, " in ");
        for (ast_list_t *iter = loop->iters; iter; iter = iter->next) {
            code = Texts(code, bounded_at(iter->ast, comments, indent, column_after(column, code)));
            if (iter->next) code = Texts(code, ", ");
        }
        code = Texts(code, format_namespace(loop->body, comments, indent));
        if (loop->empty) code = Texts(code, "\n", indent, "else", format_namespace(loop->empty, comments, indent));
        return code;
    }
    /*multiline*/ case Comprehension:
        return comprehension_at(ast, comments, Text("("), indent, column);
    /*multiline*/ case FunctionDef: {
        DeclareMatch(func, ast, FunctionDef);
        Text_t code = Texts("func ", fmt_at(func->name, comments, indent, column + 5));
        code = Texts(code, format_signature(func->args, func->ret_type, func->cache, func->is_inline, func->body,
                                            comments, indent, column_after(column, code)));
        return Texts(code, format_body(func->body, comments, indent));
    }
    /*multiline*/ case Lambda: {
        if (inlined_fits) return inlined;
        DeclareMatch(lambda, ast, Lambda);
        Text_t code = Texts("func", format_signature(lambda->args, lambda->ret_type, NULL, false, lambda->body,
                                                     comments, indent, column + 4));
        return Texts(code, format_body(lambda->body, comments, indent));
    }
    /*multiline*/ case ConvertDef: {
        DeclareMatch(convert, ast, ConvertDef);
        Text_t code =
            Texts("convert", format_signature(convert->args, convert->ret_type, convert->cache, convert->is_inline,
                                              convert->body, comments, indent, column + 7));
        return Texts(code, format_body(convert->body, comments, indent));
    }
    /*multiline*/ case StructDef: {
        DeclareMatch(def, ast, StructDef);
        flag_list_t flags = {};
        add_flag(&flags, def->secret, Text("secret"));
        add_flag(&flags, def->packed_bools, Text("packed_bools"));
        add_flag(&flags, def->external, Text("external"));
        add_flag(&flags, def->opaque, Text("opaque"));
        Text_t code = Texts("struct ", Text$from_str(def->name));
        code = Texts(code, format_bracketed_args(def->fields, EMPTY_TEXT, flags, EMPTY_TEXT, comments, indent,
                                                 column_after(column, code), "{", "}"));
        // Comments inside the braces are emitted with their fields, so pick
        // up only what comes after the closing brace:
        const char *comment_pos = ast->start;
        for (arg_ast_t *field = def->fields; field; field = field->next)
            comment_pos = field->end;
        if (def->fields) comment_pos = after_close_delimiter(comment_pos, def->namespace->start, '}', comments);
        code = Texts(code, definition_comments(&comment_pos, def->namespace, indent, comments));
        return Texts(code, format_namespace(def->namespace, comments, indent));
    }
    /*multiline*/ case EnumDef: {
        DeclareMatch(def, ast, EnumDef);
        Text_t code = Texts("enum ", Text$from_str(def->name), "(");
        Text_t tags = format_tags_at(def->tags, comments, indent, column_after(column, code) + 1);
        code = Texts(code, tags, Text$has(tags, Text("\n")) ? Texts("\n", indent, ")") : Text(")"));
        // As in StructDef: comments inside a variant's braces are emitted with
        // its fields, so scanning resumes after the last fielded variant's
        // closing brace. Comments at paren level still belong to this
        // definition and are picked up below.
        const char *comment_pos = ast->start;
        bool any_fields = false;
        for (tag_ast_t *tag = def->tags; tag; tag = tag->next) {
            for (arg_ast_t *field = tag->fields; field; field = field->next) {
                comment_pos = field->end;
                any_fields = true;
            }
        }
        if (any_fields) comment_pos = after_close_delimiter(comment_pos, def->namespace->start, '}', comments);
        code = Texts(code, definition_comments(&comment_pos, def->namespace, indent, comments));
        return Texts(code, format_namespace(def->namespace, comments, indent));
    }
    /*multiline*/ case LangDef: {
        DeclareMatch(def, ast, LangDef);
        Text_t code = Texts("lang ", Text$from_str(def->name));
        const char *comment_pos = ast->start;
        code = Texts(code, definition_comments(&comment_pos, def->namespace, indent, comments));
        return Texts(code, format_namespace(def->namespace, comments, indent));
    }
    /*multiline*/ case Defer:
        return Texts("defer", format_namespace(Match(ast, Defer)->body, comments, indent));
    /*multiline*/ case List: {
        if (inlined_fits) return inlined;
        ast_list_t *items = Match(ast, List)->items;
        Text_t code = Text("[");
        const char *comment_pos = ast->start;
        ast_t *prev = NULL;
        for (ast_list_t *item = items; item; item = item->next) {
            Text_t item_comments =
                comment_range(&comment_pos, item->ast->start, Texts(indent, single_indent), comments);
            // On a line of its own: concatenated where the last item stopped,
            // it ran onto the back of that item's comma.
            if (item_comments.length > 0) add_line(&code, item_comments, Texts(indent, single_indent));
            Text_t item_text = bounded(item->ast, comments, Texts(indent, single_indent));
            // An item that trails off into an indented block (a lambda's body)
            // has no line of its own left to end with a comma: the comma would
            // read as part of that block, so the newline separates instead,
            // which is what format_args() does with the same shape.
            Text_t comma = ends_deeper_than(item_text, Texts(indent, single_indent)) ? EMPTY_TEXT : Text(",");
            if (Text$ends_with(code, Text(","), NULL) && prev
                && get_line_number(prev->file, prev->end) == get_line_number(item->ast->file, item->ast->start)) {
                if (!Text$has(item_text, Text("\n")) && trailing_line_len(code) + 1 + item_text.length + 1 <= MAX_WIDTH)
                    code = Texts(code, " ", item_text, comma);
                else code = Texts(code, "\n", indent, single_indent, item_text, comma);
            } else {
                add_line(&code, Texts(item_text, comma), Texts(indent, single_indent));
            }
            // Past the item, not merely up to where it started: what is
            // written inside it is the item's own to place, and scanning it
            // again here would write it a second time.
            comment_pos = item->ast->end;
            code =
                Texts(code, line_comment(&comment_pos, item->ast, item_text, Texts(indent, single_indent), comments));
            prev = item->ast;
        }
        // A comment left over at the end goes on a line of its own: appended
        // where the last item stopped, it ran onto the back of its comma.
        Text_t trailing = comment_range(&comment_pos, ast->end, Texts(indent, single_indent), comments);
        if (trailing.length > 0) add_line(&code, trailing, Texts(indent, single_indent));
        return Texts(code, "\n", indent, "]");
    }
    /*multiline*/ case Table: {
        if (inlined_fits) return inlined;
        DeclareMatch(table, ast, Table);
        Text_t code = Texts("{");
        const char *comment_pos = ast->start;
        for (ast_list_t *entry = table->entries; entry; entry = entry->next) {
            Text_t entry_comments =
                comment_range(&comment_pos, entry->ast->start, Texts(indent, single_indent), comments);
            if (entry_comments.length > 0) add_line(&code, entry_comments, Texts(indent, single_indent));

            Text_t entry_text = fmt(entry->ast, comments, Texts(indent, single_indent));
            // As with a list item above, an entry that ends inside an indented
            // block is separated by the newline rather than a comma.
            Text_t comma = ends_deeper_than(entry_text, Texts(indent, single_indent)) ? EMPTY_TEXT : Text(",");
            if (Text$ends_with(code, Text(","), NULL)) {
                if (!Text$has(entry_text, Text("\n"))
                    && trailing_line_len(code) + 1 + entry_text.length + 1 <= MAX_WIDTH)
                    code = Texts(code, " ", entry_text, comma);
                else code = Texts(code, "\n", indent, single_indent, entry_text, comma);
            } else {
                add_line(&code, Texts(entry_text, comma), Texts(indent, single_indent));
            }
            // As with a list item above: the entry's interior is its own.
            comment_pos = entry->ast->end;
            code =
                Texts(code, line_comment(&comment_pos, entry->ast, entry_text, Texts(indent, single_indent), comments));
        }
        // A comment left over at the end goes on a line of its own: appended
        // where the last item stopped, it ran onto the back of its comma.
        Text_t trailing = comment_range(&comment_pos, ast->end, Texts(indent, single_indent), comments);
        if (trailing.length > 0) add_line(&code, trailing, Texts(indent, single_indent));

        if (table->fallback)
            code =
                Texts(code, ";\n", indent, single_indent,
                      "fallback=", fmt_at(table->fallback, comments, indent, indent.length + single_indent.length + 9));

        if (table->default_value)
            code = Texts(code, ";\n", indent, single_indent, "default=",
                         fmt_at(table->default_value, comments, indent, indent.length + single_indent.length + 8));

        return Texts(code, "\n", indent, "}");
    }
    /*multiline*/ case TableEntry: {
        if (inlined_fits) return inlined;
        DeclareMatch(entry, ast, TableEntry);
        if (entry->value) {
            Text_t key = bounded_at(entry->key, comments, indent, column);
            return Texts(key, ": ", bounded_at(entry->value, comments, indent, column_after(column, key) + 2));
        } else return bounded_at(entry->key, comments, indent, column);
    }
    /*multiline*/ case Declare: {
        if (inlined_fits) return inlined;
        DeclareMatch(decl, ast, Declare);
        Text_t code = fmt_at(decl->var, comments, indent, column);
        if (decl->type) code = Texts(code, " : ", format_type(decl->type));
        if (decl->value) {
            Text_t assignment = decl->type ? Text(" = ") : Text(" := ");
            code = Texts(code, assignment,
                         fmt_at(decl->value, comments, indent, column_after(column, code) + assignment.length));
        }
        return code;
    }
    /*multiline*/ case Assign: {
        if (inlined_fits) return inlined;
        DeclareMatch(assign, ast, Assign);
        Text_t code = EMPTY_TEXT;
        for (ast_list_t *target = assign->targets; target; target = target->next) {
            code = Texts(code, fmt_at(target->ast, comments, indent, column_after(column, code)));
            if (target->next) code = Texts(code, ", ");
        }
        code = Texts(code, " = ");
        for (ast_list_t *value = assign->values; value; value = value->next) {
            code = Texts(code, fmt_at(value->ast, comments, indent, column_after(column, code)));
            if (value->next) code = Texts(code, ", ");
        }
        return code;
    }
    /*multiline*/ case Pass:
        return Text("pass");
    /*multiline*/ case Return: {
        if (inlined_fits) return inlined;
        ast_t *value = Match(ast, Return)->value;
        // `return` takes a plain expression, not a block-form `if`/`match`, so
        // one has to keep the parentheses it was written with.
        return value ? Texts("return ", bounded_at(value, comments, indent, column + 7)) : Text("return");
    }
    /*multiline*/ case Not: {
        if (inlined_fits) return inlined;
        ast_t *val = Match(ast, Not)->value;
        // A multi-clause `if`/`match` has to be parenthesized to stay an
        // operand: written bare, its later clauses land outside the `not`,
        // and `not if ...` doesn't parse at all.
        if (is_binary_operation(val) || val->tag == If || val->tag == Match)
            return Texts("not ", termify_at(val, comments, indent, column + 4));
        else return Texts("not ", fmt_at(val, comments, indent, column + 4));
    }
    /*multiline*/ case Negative: {
        if (inlined_fits) return inlined;
        ast_t *val = Match(ast, Negative)->value;
        // See the inline case above for which operands keep their parentheses.
        if (negation_needs_parens(val)) return Texts("-(", fmt_at(val, comments, indent, column + 2), ")");
        // An `if`/`match` keeps its parentheses here for the same reason it
        // does as a binary operand below: spread over several lines, its
        // later clauses would otherwise fall outside the `-`.
        if ((is_binary_operation(val) && !absorbs_rhs(Negative, expr_tightness(val))) || val->tag == If
            || val->tag == Match)
            return Texts("-", termify_at(val, comments, indent, column + 1));
        else return Texts("-", fmt_at(val, comments, indent, column + 1));
    }
    /*multiline*/ case HeapAllocate: {
        if (inlined_fits) return inlined;
        ast_t *val = Match(ast, HeapAllocate)->value;
        if (has_nonoptional_suffix(val)) return Texts("@(", fmt_at(val, comments, indent, column + 2), ")");
        return Texts("@", termify_at(val, comments, indent, column + 1));
    }
    /*multiline*/ case StackReference: {
        if (inlined_fits) return inlined;
        ast_t *val = Match(ast, StackReference)->value;
        if (has_nonoptional_suffix(val)) return Texts("&(", fmt_at(val, comments, indent, column + 2), ")");
        return Texts("&", termify_at(val, comments, indent, column + 1));
    }
    /*multiline*/ case NonOptional: {
        if (inlined_fits) return inlined;
        ast_t *val = Match(ast, NonOptional)->value;
        return Texts(termify_at(val, comments, indent, column), "!");
    }
    /*multiline*/ case FieldAccess: {
        if (inlined_fits) return inlined;
        DeclareMatch(access, ast, FieldAccess);
        return Texts(dotted_at(access->fielded, comments, indent, column), ".", Text$from_str(access->field));
    }
    /*multiline*/ case Index: {
        if (inlined_fits) return inlined;
        DeclareMatch(index, ast, Index);
        Text_t indexed = termify_at(index->indexed, comments, indent, column);
        if (index->index)
            return Texts(indexed, "[",
                         format_subscript(index->index, comments, indent, column_after(column, indexed) + 1), "]");
        else return Texts(indexed, "[]");
    }
    /*multiline*/ case TextJoin: {
        if (inlined_fits) return inlined;

        ast_list_t *children = Match(ast, TextJoin)->children;
        text_opts_t opts = choose_text_options(children);
        if (Text$equal_values(opts.quote, Text("`"))) {
            // Prefer double quotes over backticks, since a block's text needs
            // no escaping of either.
            opts.quote = Text("\"");
            opts.unquote = Text("\"");
        }
        Text_t ret = format_text(opts, children, comments, indent);
        return decorate_text(ast, ret);
    }
    /*multiline*/ case InlineCCode: {
        DeclareMatch(c_code, ast, InlineCCode);
        // `type` is filled in by the compiler, not the parser, so it is always
        // NULL here: testing it sent every inline C expression multiline, even
        // one that fits. Anything that must span lines can't be inlined anyway,
        // since format_inline_text() refuses a verbatim chunk holding a newline.
        if (inlined_fits) return inlined;
        Text_t code = c_code->type_ast ? Texts("C_code:", format_type(c_code->type_ast)) : Text("C_code");
        text_opts_t opts = {.quote = Text("`"), .unquote = Text("`"), .interp = Text("@"), .verbatim = true};
        return Texts(code, format_text(opts, Match(ast, InlineCCode)->chunks, comments, indent));
    }
    /*multiline*/ case TextLiteral: { fail("Something went wrong, we shouldn't be formatting text literals directly"); }
    /*multiline*/ case Path: {
        assert(inlined.length > 0);
        return inlined;
    }
    /*multiline*/ case Embed: {
        assert(inlined.length > 0);
        return inlined;
    }
    /*multiline*/ case Serialize: {
        if (inlined_fits) return inlined;
        return Texts("serialize(", fmt_at(Match(ast, Serialize)->value, comments, indent, column + 10), ")");
    }
    /*multiline*/ case Deserialize: {
        if (inlined_fits) return inlined;
        DeclareMatch(deserialize, ast, Deserialize);
        Text_t type = format_type(deserialize->type_ast);
        return Texts("deserialize:", type, "(", fmt_at(deserialize->value, comments, indent, column + 13 + type.length),
                     ")");
    }
    /*multiline*/ case Min:
    /*multiline*/ case Max: {
        if (inlined_fits) return inlined;
        ast_t *lhs_ast = ast->tag == Min ? Match(ast, Min)->lhs : Match(ast, Max)->lhs;
        ast_t *rhs_ast = ast->tag == Min ? Match(ast, Min)->rhs : Match(ast, Max)->rhs;
        Text_t lhs = operand(fmt_at(lhs_ast, comments, indent, column), lhs_ast, ast->tag, true, indent, TIGHTEN_NONE);
        ast_t *key = ast->tag == Min ? Match(ast, Min)->key : Match(ast, Max)->key;
        Text_t op = key ? fmt_at(key, comments, indent, column_after(column, lhs) + 1)
                        : (ast->tag == Min ? Text("_min_") : Text("_max_"));
        Text_t before_rhs = Texts(lhs, " ", op, " ");
        Text_t rhs = operand(fmt_at(rhs_ast, comments, indent, column_after(column, before_rhs)), rhs_ast, ast->tag,
                             false, indent, TIGHTEN_NONE);
        return Texts(before_rhs, rhs);
    }
    /*multiline*/ case Reduction: {
        if (inlined_fits) return inlined;
        DeclareMatch(reduction, ast, Reduction);
        Text_t open = reduction->key ? Texts("(", fmt_at(reduction->key, comments, indent, column + 1), ": ")
                                     : Texts("(", Text$from_str(binop_info[reduction->op].operator), ": ");
        // The iterable is nearly always the comprehension this reduces, and it
        // is written bare: `(and: (x for x in xs))` has one pair of parentheses
        // too many, and the pair it has puts the closing one an indent deeper
        // than the line it belongs to.
        if (reduction->iter->tag == Comprehension)
            return comprehension_at(reduction->iter, comments, open, indent, column);
        return Texts(open, fmt_at(reduction->iter, comments, indent, column + (int64_t)open.length), ")");
    }
    /*multiline*/ case Break:
    /*multiline*/ case Continue:
    /*multiline*/ case None:
    /*multiline*/ case Bool:
    /*multiline*/ case Int:
    /*multiline*/ case Num:
    /*multiline*/ case Var: {
        assert(inlined.tag != TEXT_NONE);
        return inlined;
    }
    /*multiline*/ case FunctionCall: {
        if (inlined_fits) return inlined;
        DeclareMatch(call, ast, FunctionCall);
        Text_t fn = termify_at(call->fn, comments, indent, column);
        return Texts(fn, format_fncall_at(call->args, comments, indent, column_after(column, fn)));
    }
    /*multiline*/ case RecordLiteral: {
        if (inlined_fits) return inlined;
        DeclareMatch(record, ast, RecordLiteral);
        Text_t type = fmt_at(record->type, comments, indent, column);
        return Texts(type, format_record_literal_at(record->args, comments, indent, column_after(column, type)));
    }
    /*multiline*/ case MethodCall: {
        if (inlined_fits) return inlined;
        DeclareMatch(call, ast, MethodCall);
        Text_t self = Texts(dotted_at(call->self, comments, indent, column), ".", Text$from_str(call->name));
        return Texts(self, format_fncall_at(call->args, comments, indent, column_after(column, self)));
    }
    /*multiline*/ case DebugLog: {
        DeclareMatch(debug, ast, DebugLog);
        Text_t code = Texts(">> ");
        for (ast_list_t *value = debug->values; value; value = value->next) {
            // Only a value with another after it has a comma to give back; the
            // last one ends the statement and needs no parentheses.
            int64_t at = column_after(column, code);
            Text_t expr =
                value->next ? bounded_at(value->ast, comments, indent, at) : fmt_at(value->ast, comments, indent, at);
            code = Texts(code, expr);
            if (value->next) code = Texts(code, ", ");
        }
        return code;
    }
    /*multiline*/ case Test: {
        DeclareMatch(test, ast, Test);
        Text_t code = Texts("test ", quoted_label(test->label), format_body(test->body, comments, indent));
        // At most one outcome clause, dedented back to the `test` keyword's own
        // indentation (that's where the parser looks for it):
        const char *clause = test->expected_compile_error ? "fails_compile" : (test->expected_failure ? "fails" : NULL);
        if (clause) {
            const char *expected = test->expected_compile_error ?: test->expected_failure;
            code = Texts(code, "\n", indent, Text$from_str(clause));
            // An empty expectation is a bare clause meaning "any failure will do":
            if (expected[0] != '\0') code = Texts(code, " ", quoted_label(expected));
        }
        return code;
    }
    /*multiline*/ case Assert: {
        DeclareMatch(assert, ast, Assert);
        Text_t expr = assert->message ? bounded_at(assert->expr, comments, indent, column + 7)
                                      : fmt_at(assert->expr, comments, indent, column + 7);
        if (!assert->message) return Texts("assert ", expr);
        Text_t code = Texts("assert ", expr, ", ");
        return Texts(code, fmt_at(assert->message, comments, indent, column_after(column, code)));
    }
    /*multiline*/ case BINOP_CASES: {
        if (inlined_fits) return inlined;
        return format_binop(ast, comments, indent, column, expression_spacing(ast));
    }
    /*multiline*/ case Use: {
        assert(inlined.length > 0);
        return inlined;
    }
    /*multiline*/ case ExplicitlyTyped:
        fail("Explicitly typed AST nodes are only meant to be used internally.");
    default: {
        if (inlined_fits) return inlined;
        fail("Formatting not implemented for: ", ast_to_sexp(ast));
    }
    }
}

Text_t format_file(const char *path) {
    file_t *file = load_file(path);
    if (!file) return EMPTY_TEXT;
    return format_source(file, NULL);
}

// Format an already-loaded file. `formatted` (when given) reports whether the
// source actually made it through: every early return here hands back the
// original text unchanged, which is the right thing for `tomo fmt` but is
// indistinguishable from a no-op reformat, and `tomo fmt --check` has to tell
// those apart.
public
Text_t format_source(file_t *file, bool *formatted) {
    if (formatted) *formatted = false;
    if (!file) return EMPTY_TEXT;

    // The error is taken as a value and reported here rather than from inside
    // the parser, because the unwind below is what stops the parser reporting
    // it itself, and an unformattable file is worth a diagnostic. It lives on
    // the heap because a modified automatic local of this frame would be
    // indeterminate after the longjmp:
    parse_error_t *parse_err = new (parse_error_t);
    jmp_buf on_err;
    if (setjmp(on_err) != 0) {
        if (parse_err->message) print_parse_error(*parse_err);
        return Text$from_str(file->text);
    }
    parse_ctx_t ctx = {
        .file = file,
        .on_err = &on_err,
        .error = parse_err,
        .comments = {},
    };

    const char *pos = file->text;
    if (match(&pos, "#!")) // shebang
        some_not(&pos, "\r\n");

    whitespace(&ctx, &pos);
    ast_t *ast = parse_file_body(&ctx, pos);
    if (!ast) return Text$from_str(file->text);
    pos = ast->end;
    whitespace(&ctx, &pos);
    if (pos < file->text + file->len && *pos != '\0') {
        return Text$from_str(file->text);
    }

    const char *fmt_pos = file->text;
    Text_t code = comment_range(&fmt_pos, ast->start, EMPTY_TEXT, ctx.comments);
    if (code.length > 0) code = Texts(code, "\n");
    // Special case: allow blank lines between comments and code at the top of
    // the file.
    for (const char *p = fmt_pos + 1; p < ast->start; p++) {
        if (*p == '\n') {
            code = Text$concat(code, Text("\n"));
            break;
        }
    }
    code = Texts(code, fmt(ast, ctx.comments, EMPTY_TEXT));
    fmt_pos = ast->end;
    code = Text$concat(code, comment_range(&fmt_pos, file->text + file->len, EMPTY_TEXT, ctx.comments));
    if (!Text$ends_with(code, Text("\n"), NULL)) {
        code = Text$concat(code, Text("\n"));
    }
    if (formatted) *formatted = true;
    return code;
}