llm
Download
919 lines 44 KiBCModified 2026-08-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919
// Command-line argument parsing

#include <fcntl.h>
#include <gc.h>
#include <math.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#include <sys/param.h>
#include <time.h>

#include "../config.h"
#include "bool.h"
#include "byte.h"
#include "c_string.h"
#include "cli.h"
#include "floats.h"
#include "integers.h"
#include "metamethods.h"
#include "num.h"
#include "number.h"
#include "optionals.h"
#include "path.h"
#include "print.h"
#include "stdlib.h"
#include "table.h"
#include "text.h"
#include "util.h"

// The palette, or an all-empty one when color is off:
public
cli_style_t tomo_cli_style(void) {
    if (USE_COLOR)
        return (cli_style_t){
            .bold = "\x1b[1m",
            .dim = "\x1b[2m",
            .italic = "\x1b[3m",
            .reset = "\x1b[m",
            .heading = "\x1b[4;1m",
            .usage = "\x1b[93;4;1m",
            .flag = "\x1b[93;1m",
            .value = "\x1b[1;94m",
            .command = "\x1b[1;32m",
            .error = "\x1b[91;1m",
        };
    return (cli_style_t){
        .bold = "",
        .dim = "",
        .italic = "",
        .reset = "",
        .heading = "",
        .usage = "",
        .flag = "",
        .value = "",
        .command = "",
        .error = "",
    };
}

// Whether `arg` starts the way a negative number does: `-1`, `-.5`, `-1e5`,
// `-0x1f` do; `-x`, `-inf`, `-` and `` do not. Whether the whole token is a
// well-formed number is a question for the number parsers.
static bool starts_like_negative_number(const char *arg) {
    if (arg[0] != '-') return false;
    const char *number = arg + 1;
    if (*number == '.') number += 1; // the sole non-digit a number may open with
    return *number >= '0' && *number <= '9';
}

// Parse the value half of a boolean flag (`--flag=X`, `-f=X`) into `*out`,
// returning whether it was valid. `optional` additionally accepts "none",
// which is a value in its own right, distinct from both yes and no, and
// from the NONE_BOOL that Bool$parse returns to report a bad value.
static bool parse_boolean_flag_value(const char *value, bool optional, void *out) {
    if (optional && streq(value, "none")) {
        *(OptionalBool_t *)out = NONE_BOOL;
        return true;
    }
    OptionalBool_t parsed = Bool$parse(Text$from_str(value), NULL);
    *(OptionalBool_t *)out = parsed;
    return parsed != NONE_BOOL;
}

// `dest` is a Bool_t, or an OptionalBool_t when `optional`, both one byte.
// A bad value writes NONE_BOOL into either, but only after `optional` has been
// checked, so the write is always immediately followed by print_err exiting.
static bool pop_boolean_cli_flag(List_t *args, char short_flag, const char *flag, void *dest, bool optional) {
    const char *no_flag = String("no-", flag);
    for (int64_t i = 0; i < (int64_t)args->length; i++) {
        const char *arg = *(const char **)(args->data + i * args->stride);
        if (arg[0] == '-' && arg[1] == '-') {
            if (arg[2] == '\0') {
                // Case: -- (end of flags and beginning of positional args)
                break;
            } else if (streq(arg + 2, flag)) {
                // Case: --flag
                *(Bool_t *)dest = true;
                List$remove_at(args, I(i + 1), I(1), sizeof(const char *));
                return true;
            } else if (streq(arg + 2, no_flag)) {
                // Case: --no-flag
                *(Bool_t *)dest = false;
                List$remove_at(args, I(i + 1), I(1), sizeof(const char *));
                return true;
            } else if (starts_with(arg + 2, flag) && arg[2 + strlen(flag)] == '=') {
                // Case: --flag=yes|no|true|false|on|off|0|1 (or none, if optional)
                if (!parse_boolean_flag_value(arg + 2 + strlen(flag) + 1, optional, dest))
                    print_err("Invalid boolean value for flag ", flag, ": ", arg);
                List$remove_at(args, I(i + 1), I(1), sizeof(const char *));
                return true;
            }
            // Short flags are letters, so a token that opens like a number is
            // one, not a cluster to search for this flag's letter in. Without
            // this, a `-e` flag claims the `e` inside `-1e5`.
        } else if (short_flag && arg[0] == '-' && arg[1] != '-' && !starts_like_negative_number(arg)
                   && strchr(arg + 1, short_flag)) {
            const char *loc = strchr(arg + 1, short_flag);
            if (loc[1] == '=') {
                // Case: -f=yes|no|true|false|on|off|1|0 (or none, if optional)
                if (!parse_boolean_flag_value(loc + 2, optional, dest)) {
                    char short_str[2] = {short_flag, '\0'};
                    print_err("Invalid boolean value for flag -", short_str, ": ", arg);
                }
                if (loc > arg + 1) {
                    // Case: -abcdef=... -> -abcde
                    char *remainder = String(string_slice(arg, (size_t)(loc - arg)));
                    if unlikely (args->data_refcount > 0) List$compact(args, sizeof(const char *));
                    *(const char **)(args->data + i * args->stride) = remainder;
                } else {
                    // Case: -f=... -> pop flag entirely
                    List$remove_at(args, I(i + 1), I(1), sizeof(const char *));
                }
                return true;
            } else {
                // Case: -...f...
                *(Bool_t *)dest = true;
                if (strlen(arg) == 2) {
                    // Case: -f -> pop flag entirely
                    List$remove_at(args, I(i + 1), I(1), sizeof(const char *));
                } else {
                    // Case: -abcdefgh... -> -abcdegh...
                    char *remainder =
                        String(string_slice(arg, (size_t)(loc - arg)), string_slice(loc + 1, strlen(loc + 1)));
                    if unlikely (args->data_refcount > 0) List$compact(args, sizeof(const char *));
                    *(const char **)(args->data + i * args->stride) = remainder;
                }
                return true;
            }
        }
    }
    return false;
}

public
void tomo_parse_arg_list(List_t args, cli_help_info_t info, int spec_len, cli_arg_t spec[spec_len]) {
    for (int i = 0; i < spec_len; i++) {
        spec[i].populated = pop_cli_flag(&args, spec[i].short_flag, spec[i].name, spec[i].dest, spec[i].type);
    }

    bool show_help = false;
    if (pop_boolean_cli_flag(&args, info.help_short, "help", &show_help, false) && show_help) {
        print(info.help);
        exit(0);
    }
    List_t before_double_dash = args, after_double_dash = EMPTY_LIST;
    for (int i = 0; i < (int64_t)args.length; i++) {
        const char *arg = *(const char **)(args.data + i * args.stride);
        if (streq(arg, "--")) {
            before_double_dash = List$slice(args, I(1), I(i));
            after_double_dash = List$slice(args, I(i + 2), I(-1));
            break;
        }
    }

    for (int i = 0; i < spec_len && before_double_dash.length > 0; i++) {
        if (info.strict_positionals && !spec[i].positional) continue;
        if (!spec[i].populated) {
            spec[i].populated =
                pop_cli_positional(&before_double_dash, spec[i].name, spec[i].dest, spec[i].type, false);
        }
    }

    for (int i = 0; i < spec_len && after_double_dash.length > 0; i++) {
        if (info.strict_positionals && !spec[i].positional) continue;
        if (!spec[i].populated) {
            spec[i].populated = pop_cli_positional(&after_double_dash, spec[i].name, spec[i].dest, spec[i].type, true);
        }
    }

    for (int i = 0; i < spec_len; i++) {
        if (!spec[i].populated && spec[i].required)
            print_err("Missing required ", spec[i].positional ? "argument" : "flag", ": ", spec[i].name, "\n",
                      info.usage, info.command_hint);
    }

    List_t remaining_args = List$concat(before_double_dash, after_double_dash, sizeof(const char *));
    if (remaining_args.length > 0) {
        // Show the usage too: leftovers usually mean an earlier word was
        // consumed as something the user didn't intend (a mistyped command
        // name swallowed as a positional argument, say).
        print_err("Unknown flag values: ", CString$join(" ", remaining_args), "\n", info.usage, info.command_hint);
    }
}

// Whether this is a list or table, possibly behind an optional or pointer
// wrapper, which doesn't change how the contents parse. Such an argument takes
// many values, so `--flag=a,b,c` splits on commas, and the container decides
// for itself where its values stop.
static bool holds_many_values(const TypeInfo_t *type) {
    while (type->tag == OptionalInfo || type->tag == PointerInfo)
        type = type->tag == OptionalInfo ? type->OptionalInfo.type : type->PointerInfo.pointed;
    return type->tag == ListInfo || type->tag == TableInfo;
}

// The runtime counterparts of the compiler's is_int_type() and
// is_numeric_type() (src/types.h). Those take a `type_t`, the compiler's own
// representation of a type, which a compiled program never has, since it
// carries TypeInfo_t instead, so the same questions get asked here of that. Keep the
// two in step: these cover exactly the types those do.
static bool is_int_info(const TypeInfo_t *type) {
    return type == &Int$info || type == &Int64$info || type == &Int32$info || type == &Int16$info || type == &Int8$info
           || type == &Byte$info;
}

static bool is_numeric_info(const TypeInfo_t *type) {
    return is_int_info(type) || type == &Num$info || type == &Float64$info || type == &Float32$info;
}

// The value placeholder shown for a flag in usage/help text, derived from its
// parsing type (mirroring the compiler's generated help in src/compile/cli.c):
static Text_t flag_value_options(const char *metavar, const TypeInfo_t *type) {
    if (metavar) return Text$from_str(metavar);
    if (type == &Bool$info) return Text("yes|no");
    if (type == &Path$info) return Text("path");
    if (is_numeric_info(type)) return Text("N");
    if (type == &CString$info || type->tag == TextInfo) return Text("text");
    if (type->tag == OptionalInfo) return flag_value_options(NULL, type->OptionalInfo.type);
    if (type->tag == PointerInfo) return flag_value_options(NULL, type->PointerInfo.pointed);
    if (type->tag == ListInfo) {
        Text_t item = flag_value_options(NULL, type->ListInfo.item);
        return Texts(item, "1 ", item, "2...");
    }
    if (type->tag == TableInfo) {
        Text_t key = flag_value_options(NULL, type->TableInfo.key);
        if (type->TableInfo.value->size == 0) return Texts(key, "1 ", key, "2...");
        Text_t value = flag_value_options(NULL, type->TableInfo.value);
        return Texts(key, "1:", value, "1 ", key, "2:", value, "2...");
    }
    if (type->tag == EnumInfo) {
        Text_t options = EMPTY_TEXT;
        for (int t = 0; t < type->EnumInfo.num_tags; t++) {
            if (t > 0) options = Texts(options, "|");
            options = Texts(options, type->EnumInfo.tags[t].name);
        }
        return options;
    }
    return Text("value");
}

static bool is_bool_arg(const cli_arg_t *arg) {
    const TypeInfo_t *type = arg->type;
    while (type->tag == OptionalInfo)
        type = type->OptionalInfo.type;
    return type == &Bool$info;
}

// A positional argument's display name: its metavar (or name), with "..."
// appended for lists:
static Text_t positional_display(const cli_arg_t *arg) {
    Text_t name = Text$from_str(arg->metavar ? arg->metavar : arg->name);
    if (holds_many_values(arg->type)) name = Texts(name, "...");
    return name;
}

// Generate a one-line "Usage: <prefix> [flags] positionals" from an arg spec:
public
Text_t tomo_generate_usage(Text_t prefix, int spec_len, cli_arg_t spec[spec_len]) {
    cli_style_t style = tomo_cli_style();
    Text_t usage = Texts(style.usage, "Usage:", style.reset, " ", prefix);
    for (int i = 0; i < spec_len; i++) { // Flags first...
        if (spec[i].positional) continue;
        Text_t flag = Texts(style.bold, "--", spec[i].name, style.reset);
        if (spec[i].short_flag)
            flag = Texts(flag, "|", style.bold, "-", Text$from_strn((char[]){spec[i].short_flag}, 1), style.reset);
        if (!is_bool_arg(&spec[i])) flag = Texts(flag, " ", flag_value_options(spec[i].metavar, spec[i].type));
        usage = Texts(usage, " ", spec[i].required ? flag : Texts("[", flag, "]"));
    }
    for (int i = 0; i < spec_len; i++) { // ...then positionals
        if (!spec[i].positional) continue;
        Text_t name = Texts(style.bold, positional_display(&spec[i]), style.reset);
        usage = Texts(usage, " ", spec[i].required ? name : Texts("[", name, "]"));
    }
    return usage;
}

// One "  --flag|-f value  description" line of help text for an arg:
static Text_t arg_help_line(const cli_arg_t *arg) {
    cli_style_t style = tomo_cli_style();
    Text_t line;
    if (arg->positional) {
        line = Texts("  ", style.bold, positional_display(arg), style.reset);
    } else {
        Text_t flags = Texts(style.flag, "--", arg->name, style.reset);
        if (arg->short_flag)
            // reset before dim: the comma is dim on its own, not dim-on-top-of
            // the flag color it follows
            flags = Texts(style.flag, "-", Text$from_strn((char[]){arg->short_flag}, 1), style.reset, style.dim, ",",
                          style.reset, " ", flags);
        if (is_bool_arg(arg)) flags = Texts(flags, "|", style.flag, "--no-", arg->name, style.reset);
        line = Texts("  ", flags);
        if (!is_bool_arg(arg))
            line = Texts(line, " ", style.value, flag_value_options(arg->metavar, arg->type), style.reset);
    }
    if (arg->description) line = Texts(line, " ", style.italic, arg->description, style.reset);
    if (arg->default_text) line = Texts(line, " ", style.dim, "(default:", arg->default_text, ")", style.reset);
    return Texts(line, "\n");
}

// The "Commands:" listing of a command's children, names column-aligned:
static Text_t commands_listing(cli_command_t *command) {
    int64_t width = 0;
    for (int i = 0; i < command->num_children; i++) {
        int64_t w = Text$from_str(command->children[i]->name).length;
        if (w > width) width = w;
    }
    cli_style_t style = tomo_cli_style();
    Text_t listing = Texts("\n", style.heading, "Commands:", style.reset, "\n");
    for (int i = 0; i < command->num_children; i++) {
        cli_command_t *child = command->children[i];
        Text_t name = Text$from_str(child->name);
        listing = Texts(listing, "  ", style.command, name, style.reset);
        // A pure namespace has no summary of its own to show:
        const char *summary = child->summary ? child->summary : (child->num_children > 0 ? "<command> ..." : NULL);
        if (summary) {
            for (int64_t pad = name.length; pad < width; pad++)
                listing = Texts(listing, " ");
            listing = Texts(listing, "  ", style.italic, summary, style.reset);
        }
        listing = Texts(listing, "\n");
    }
    return listing;
}

// Fill in a command's usage/help (and its children's, recursively). `prefix`
// is how the parent is invoked, so each command can name itself in full.
// Anything set explicitly by the program is left alone.
static void materialize_command(cli_spec_t *cli, cli_command_t *command, Text_t prefix) {
    cli_style_t style = tomo_cli_style();
    // The full invocation of this command, e.g. "git submodule init":
    Text_t invocation = command->name ? Texts(prefix, " ", command->name) : prefix;

    if (command->usage.length == 0) {
        Text_t subcommand_form = Texts(" ", style.bold, "<command>", style.reset, " ...");
        if (command->handler) command->usage = tomo_generate_usage(invocation, command->spec_len, command->spec);
        else command->usage = Texts(style.usage, "Usage:", style.reset, " ", invocation, subcommand_form);
        if (command->handler && command->num_children > 0)
            // The spaces go outside the style, or they get underlined too:
            command->usage =
                Texts(command->usage, "\n   ", style.usage, "or:", style.reset, " ", invocation, subcommand_form);
    }

    if (command->help.length == 0) {
        Text_t help = Texts(style.bold, invocation, style.reset);
        if (command->summary) help = Texts(help, ": ", command->summary);
        help = Texts(help, "\n\n", command->usage);
        if (command->alias)
            help = Texts(help, "\n", style.usage, "Alias:", style.reset, " ", prefix, " ", style.command,
                         command->alias, style.reset);
        if (command->description) help = Texts(help, "\n\n", command->description);

        Text_t args_text = EMPTY_TEXT, flags_text = EMPTY_TEXT;
        for (int i = 0; i < command->spec_len; i++) {
            if (command->spec[i].positional) args_text = Texts(args_text, arg_help_line(&command->spec[i]));
            else flags_text = Texts(flags_text, arg_help_line(&command->spec[i]));
        }
        if (args_text.length > 0)
            help = Texts(help, "\n\n", style.heading, "Arguments:", style.reset, "\n",
                         Text$trim(args_text, Text("\n"), false, true));
        if (flags_text.length > 0)
            help = Texts(help, "\n\n", style.heading, "Flags:", style.reset, "\n",
                         Text$trim(flags_text, Text("\n"), false, true));
        // Trimmed like the sections above, so each section is separated from
        // the next by exactly one blank line:
        if (command->num_children > 0)
            help = Texts(help, "\n", Text$trim(commands_listing(command), Text("\n"), false, true));
        // Global flags apply at every level, so document them everywhere:
        if (cli->global_len > 0) {
            Text_t globals = EMPTY_TEXT;
            for (int i = 0; i < cli->global_len; i++)
                globals = Texts(globals, arg_help_line(&cli->global_spec[i]));
            help = Texts(help, "\n\n", style.heading, "Global flags", style.reset,
                         " (valid anywhere on the command line):\n", Text$trim(globals, Text("\n"), false, true));
        }
        command->help = help;
    }

    for (int i = 0; i < command->num_children; i++)
        materialize_command(cli, command->children[i], invocation);
}

static void materialize_help_text(const char *prog, cli_spec_t *cli) {
    cli->root.name = NULL; // the root is the program itself
    if (cli->root.summary == NULL) cli->root.summary = cli->summary;
    if (cli->root.description == NULL) cli->root.description = cli->description;
    materialize_command(cli, &cli->root, Text$from_str(prog));
}

static cli_command_t *find_child(cli_command_t *command, const char *name) {
    for (int i = 0; i < command->num_children; i++) {
        cli_command_t *child = command->children[i];
        if (streq(name, child->name) || (child->alias && streq(name, child->alias))) return child;
    }
    return NULL;
}

// The closest of a command's subcommand names to `word`, if any is close
// enough to be worth suggesting:
static OptionalText_t nearest_command(cli_command_t *command, const char *word) {
    List_t names = EMPTY_LIST;
    for (int i = 0; i < command->num_children; i++) {
        Text_t name = Text$from_str(command->children[i]->name);
        List$insert(&names, &name, I(0), sizeof(Text_t));
        // An alias is a real name for the command, so a typo of it should
        // suggest the alias itself rather than the name it stands for:
        if (command->children[i]->alias) {
            Text_t alias = Text$from_str(command->children[i]->alias);
            List$insert(&names, &alias, I(0), sizeof(Text_t));
        }
    }
    return Text$nearest(Text$from_str(word), names, NUMBER_SMALL(3, 5) /* 0.6 */);
}

// The short alias to use for an automatic --help/--version flag, or 0 when the
// command (or a global flag) already claims that letter for something else:
// `-v` is far more often a program's own "verbose" than "version", and the
// automatic flags are popped before the command's own spec is parsed, so
// without this they would shadow it.
static char unclaimed_short_flag(cli_spec_t *cli, cli_command_t *command, char flag) {
    if (!flag) return 0;
    for (int i = 0; i < command->spec_len; i++)
        if (command->spec[i].short_flag == flag) return 0;
    for (int i = 0; i < cli->global_len; i++)
        if (cli->global_spec[i].short_flag == flag) return 0;
    return flag;
}

// Same, for the long name: a program that declares its own `help` or `version`
// argument means that one, so the automatic flag has to step aside entirely
// rather than answer (and exit) before the command's spec is ever parsed.
static bool claims_long_flag(cli_spec_t *cli, cli_command_t *command, const char *name) {
    for (int i = 0; i < command->spec_len; i++)
        if (!command->spec[i].positional && streq(command->spec[i].name, name)) return true;
    for (int i = 0; i < cli->global_len; i++)
        if (streq(cli->global_spec[i].name, name)) return true;
    return false;
}

// Report a first word that names neither a child command nor anything this
// command can do, suggesting the closest command name:
static int unrecognized_command(cli_command_t *command, const char *word) {
    OptionalText_t nearest = nearest_command(command, word);
    cli_style_t style = tomo_cli_style();
    fprint(stderr, style.error, "Unrecognized command: ", word, style.reset,
           nearest.tag == TEXT_NONE ? EMPTY_TEXT : Texts("\nDid you mean ", style.bold, nearest, style.reset, "?"),
           "\n", command->help);
    return 1;
}

// Walk the command tree: descend into the longest matching command path, then
// parse the remaining arguments against whatever command we landed on.
static int dispatch_into(cli_spec_t *cli, cli_command_t *command, List_t head, List_t extra_args) {
    const char *word = head.length > 0 ? *(const char **)head.data : NULL;
    if (word) {
        cli_command_t *child = find_child(command, word);
        if (child) {
            List$remove_at(&head, I(1), I(1), sizeof(const char *)); // drop the command name itself
            return dispatch_into(cli, child, head, extra_args);
        }
    }

    if (!command->handler) {
        // A namespace with nothing to run itself:
        if (word) return unrecognized_command(command, word);
        fprint(stderr, command->help);
        return 1;
    }

    // If this command has subcommands and the first word resembles one, say so
    // when parsing fails. `tomo bulid file.tm` parses "bulid" as the file to
    // run and then chokes on the leftover "file.tm"; the useful thing to say
    // is that `bulid` looks like a typo for `build`. Held back until the parse
    // actually fails, so `tomo tests` still runs a directory called `tests`
    // even though it's one edit from the `test` command.
    Text_t command_hint = EMPTY_TEXT;
    cli_style_t style = tomo_cli_style();
    if (word && command->num_children > 0) {
        OptionalText_t nearest = nearest_command(command, word);
        if (nearest.tag != TEXT_NONE)
            command_hint = Texts("\n", style.bold, word, style.reset, " isn't a command. Did you mean ", style.bold,
                                 nearest, style.reset, "?");
    }

    cli_help_info_t info = {.usage = command->usage,
                            .help = command->help,
                            .help_short = 'h',
                            .strict_positionals = cli->strict_positionals,
                            .command_hint = command_hint};
    tomo_parse_arg_list(head, info, command->spec_len, command->spec);
    return command->handler(command, extra_args);
}

public
int tomo_dispatch_command(int argc, char *argv[], cli_spec_t *cli) {
    materialize_help_text(cli->name ? cli->name : argv[0], cli);

    List_t args = EMPTY_LIST;
    for (int i = 1; i < argc; i++) {
        List$insert(&args, &argv[i], I(0), sizeof(const char *));
    }

    // Split at the first bare "--": everything after it is passed through to
    // the handler raw (e.g. the arguments for the program `run` executes).
    // Programs that don't relay arguments leave "--" alone, so the argument
    // parser can give it its usual "the rest are values" meaning:
    List_t head = args, extra_args = EMPTY_LIST;
    if (cli->passthrough_after_double_dash) {
        for (int64_t i = 0; i < (int64_t)args.length; i++) {
            const char *arg = *(const char **)(args.data + i * args.stride);
            if (streq(arg, "--")) {
                head = List$slice(args, I(1), I(i));
                extra_args = List$slice(args, I(i + 2), I(-1));
                break;
            }
        }
    }

    // Global flags are valid anywhere on the command line (before or after
    // the command name), so they all get popped before dispatching. This is
    // why command-specific flag names must not collide with global ones: the
    // global pop would steal them.
    for (int i = 0; i < cli->global_len; i++) {
        cli_arg_t *flag = &cli->global_spec[i];
        flag->populated = pop_cli_flag(&head, flag->short_flag, flag->name, flag->dest, flag->type);
    }

    // --help/--version are answered for whichever command was named, so find
    // it (without consuming anything) before looking for them:
    cli_command_t *named = &cli->root;
    for (int64_t i = 0; i < (int64_t)head.length; i++) {
        cli_command_t *child = find_child(named, *(const char **)(head.data + i * head.stride));
        if (!child) break;
        named = child;
    }

    if (!claims_long_flag(cli, named, "help")) {
        bool show_help = false;
        if (pop_boolean_cli_flag(&head, unclaimed_short_flag(cli, named, 'h'), "help", &show_help, false)
            && show_help) {
            print(named->help);
            return 0;
        }
    }
    if (cli->version && !claims_long_flag(cli, named, "version")) {
        bool show_version = false;
        if (pop_boolean_cli_flag(&head, unclaimed_short_flag(cli, named, cli->version_short), "version", &show_version,
                                 false)
            && show_version) {
            print(cli->version);
            return 0;
        }
    }

    if (cli->after_globals) cli->after_globals();

    return dispatch_into(cli, &cli->root, head, extra_args);
}

// Whether or not a CLI arg resembles a number
static bool resembles_number(const char *arg, const TypeInfo_t *type) {
    switch (type->tag) {
    case OptionalInfo: return resembles_number(arg, type->OptionalInfo.type);
    case PointerInfo: return resembles_number(arg, type->PointerInfo.pointed);
    // A struct's values are parsed field by field, so only the first field can
    // land on the leading token:
    case StructInfo: return type->StructInfo.num_fields > 0 && resembles_number(arg, type->StructInfo.fields[0].type);
    default: break;
    }
    if (!is_numeric_info(type)) return false;
    // A number was plainly meant, even if it turns out to be a bad one: let
    // `-1abc` reach the parse error that names it, as `1abc` would.
    if (starts_like_negative_number(arg)) return true;
    // Otherwise only a float can still be a number, and Float64$parse
    // is what knows those: `-inf`, `-INFINITY`.
    return !is_int_info(type) && !isnan(Float64$parse(Text$from_str(arg), NULL));
}

// Whether a dash-leading argument should be read as a flag rather than as a
// value of `type`. A negative number given to a numeric argument is a value:
// otherwise `--count -1` and `--count=-1` would both be errors, leaving no way
// to pass a negative number except after a bare "--".
static bool is_flag_not_value(const char *arg, const TypeInfo_t *type) {
    if (arg[0] != '-') return false;
    // A wrapped container defers to the container, which breaks out of its own
    // loop on a flag. This has to come first: `[Int]?` and `[Int]` would
    // otherwise disagree about where `--nums --other` stops.
    if ((type->tag == OptionalInfo || type->tag == PointerInfo) && holds_many_values(type)) return false;
    if (arg[1] == '-') return true; // no number starts with `--`
    return !resembles_number(arg, type);
}

static List_t parse_arg_list(List_t args, const char *flag, void *dest, const TypeInfo_t *type, bool allow_dashes) {
    if (type->tag == ListInfo) {
        void *item = type->ListInfo.item->size ? GC_MALLOC((size_t)type->ListInfo.item->size) : NULL;
        while (args.length > 0) {
            const char *arg = *(const char **)args.data;
            if (!allow_dashes && is_flag_not_value(arg, type->ListInfo.item)) break;
            args = parse_arg_list(args, flag, item, type->ListInfo.item, allow_dashes);
            List$insert(dest, item, I(0), type->ListInfo.item->size);
        }
        return args;
    } else if (type->tag == TableInfo) {
        // Arguments take the form key:value
        void *key = type->TableInfo.key->size ? GC_MALLOC((size_t)type->TableInfo.key->size) : NULL;
        void *value = type->TableInfo.value->size ? GC_MALLOC((size_t)type->TableInfo.value->size) : NULL;
        bool is_set = type->TableInfo.value->size == 0;
        while (args.length > 0) {
            const char *arg = *(const char **)args.data;
            // A key:value token carries two values, so the flag-or-value
            // question, and the key parse below, are about the key alone.
            const char *colon = is_set ? NULL : strchr(arg, ':');
            if (!is_set && !colon) break;
            const char *key_str = colon ? String(string_slice(arg, (size_t)(colon - arg))) : arg;
            if (!allow_dashes && is_flag_not_value(key_str, type->TableInfo.key)) break;
            if (is_set) {
                List_t key_arg = List(arg);
                (void)parse_arg_list(key_arg, flag, key, type->TableInfo.key, allow_dashes);
                Table$set(dest, key, NULL, type);
                args = List$from(args, I(2));
            } else {
                List_t key_arg = List(key_str);
                (void)parse_arg_list(key_arg, flag, key, type->TableInfo.key, allow_dashes);
                List_t value_arg = List(colon + 1);
                (void)parse_arg_list(value_arg, flag, value, type->TableInfo.value, allow_dashes);
                Table$set(dest, key, value, type);
                args = List$from(args, I(2));
            }
        }
        return args;
    } else if (type->tag == StructInfo) {
        for (int i = 0; i < type->StructInfo.num_fields; i++) {
            const TypeInfo_t *field_type = type->StructInfo.fields[i].type;
            if (field_type->align > 0 && (size_t)dest % (size_t)field_type->align > 0)
                dest += (size_t)field_type->align - ((size_t)dest % (size_t)field_type->align);
            args = parse_arg_list(args, String(flag, ".", type->StructInfo.fields[i].name), dest, field_type,
                                  allow_dashes);
            dest += field_type->size;
        }
        return args;
    }

    if (args.length == 0) print_err("No value provided for flag: ", flag);

    const char *arg = *(const char **)args.data;

    if (!allow_dashes) {
        if ((type->tag == TextInfo || type == &CString$info) && arg[0] == '\\' && arg[1] == '-') {
            arg = arg + 1;
        } else if (is_flag_not_value(arg, type)) {
            print_err("Not a valid flag: ", arg);
        }
    }

    if (type->tag == OptionalInfo) {
        const TypeInfo_t *nonnull = type->OptionalInfo.type;
        if (streq(arg, "none")) {
            set_none(dest, type);
            return List$from(args, I(2));
        } else {
            // A list or table parses by appending into whatever is already
            // there, so an argument still holding `none` (data == NULL) has to
            // become empty first. This is also what makes a flag given with no
            // values at all (`--files --other`) an empty list rather than none.
            if (nonnull->tag == ListInfo && ((List_t *)dest)->data == NULL) *(List_t *)dest = (List_t)EMPTY_LIST;
            else if (nonnull->tag == TableInfo && ((Table_t *)dest)->entries.data == NULL)
                *(Table_t *)dest = (Table_t)EMPTY_TABLE;

            args = parse_arg_list(args, flag, dest, nonnull, allow_dashes);
            // For optional types where the value itself is enough:
            if (nonnull == &Int$info || nonnull == &Path$info || nonnull == &Num$info || nonnull == &Float64$info
                || nonnull == &Float32$info || nonnull == &Bool$info || nonnull->tag == TextInfo
                || nonnull->tag == EnumInfo || nonnull->tag == ListInfo || nonnull->tag == TableInfo)
                return args;
            // For optional types where there's a 'has_value' boolean:
            else if (nonnull == &Int64$info) ((OptionalInt64_t *)dest)->has_value = true;
            else if (nonnull == &Int32$info) ((OptionalInt32_t *)dest)->has_value = true;
            else if (nonnull == &Int16$info) ((OptionalInt16_t *)dest)->has_value = true;
            else if (nonnull == &Int8$info) ((OptionalInt8_t *)dest)->has_value = true;
            else if (nonnull == &Byte$info) ((OptionalByte_t *)dest)->has_value = true;
            else if (nonnull->tag == StructInfo) *(bool *)(dest + nonnull->size) = true;
            else print_err("Unsupported type: ", generic_as_text(NULL, USE_COLOR, nonnull));
            return args;
        }
    }

    List_t rest_of_args = List$from(args, I(2));

    if (type == &CString$info) {
        *(const char **)dest = arg;
    } else if (type == &Int$info) {
        OptionalInt_t parsed = Int$from_str(arg);
        if (parsed.small == 0) print_err("Could not parse argument for ", flag, ": ", arg);
        *(Int_t *)dest = parsed;
    } else if (type == &Int64$info) {
        OptionalInt64_t parsed = Int64$parse(Text$from_str(arg), NONE_INT, NULL);
        if (!parsed.has_value) print_err("Could not parse argument for ", flag, ": ", arg);
        *(Int64_t *)dest = parsed.value;
    } else if (type == &Int32$info) {
        OptionalInt32_t parsed = Int32$parse(Text$from_str(arg), NONE_INT, NULL);
        if (!parsed.has_value) print_err("Could not parse argument for ", flag, ": ", arg);
        *(Int32_t *)dest = parsed.value;
    } else if (type == &Int16$info) {
        OptionalInt16_t parsed = Int16$parse(Text$from_str(arg), NONE_INT, NULL);
        if (!parsed.has_value) print_err("Could not parse argument for ", flag, ": ", arg);
        *(Int16_t *)dest = parsed.value;
    } else if (type == &Int8$info) {
        OptionalInt8_t parsed = Int8$parse(Text$from_str(arg), NONE_INT, NULL);
        if (!parsed.has_value) print_err("Could not parse argument for ", flag, ": ", arg);
        *(Int8_t *)dest = parsed.value;
    } else if (type == &Byte$info) {
        OptionalByte_t parsed = Byte$parse(Text$from_str(arg), NONE_INT, NULL);
        if (!parsed.has_value) print_err("Could not parse argument for ", flag, ": ", arg);
        *(Byte_t *)dest = parsed.value;
    } else if (type == &Bool$info) {
        OptionalBool_t parsed = Bool$parse(Text$from_str(arg), NULL);
        if (parsed == NONE_BOOL) print_err("Could not parse argument for ", flag, ": ", arg);
        *(Bool_t *)dest = parsed;
    } else if (type == &Num$info) {
        OptionalNum_t parsed = Num$parse(Text$from_str(arg));
        if (parsed.bits == NONE_NUM.bits) print_err("Could not parse argument for ", flag, ": ", arg);
        *(Num_t *)dest = parsed;
    } else if (type == &Float64$info) {
        OptionalFloat64_t parsed = Float64$parse(Text$from_str(arg), NULL);
        if (isnan(parsed)) print_err("Could not parse argument for ", flag, ": ", arg);
        *(Float64_t *)dest = parsed;
    } else if (type == &Float32$info) {
        OptionalFloat32_t parsed = Float32$parse(Text$from_str(arg), NULL);
        if (isnan(parsed)) print_err("Could not parse argument for ", flag, ": ", arg);
        *(Float32_t *)dest = parsed;
    } else if (type->tag == PointerInfo) {
        // For pointers, we can just allocate memory for the value and then parse the value
        void *value = GC_MALLOC((size_t)type->PointerInfo.pointed->size);
        args = parse_arg_list(args, flag, value, type->PointerInfo.pointed, allow_dashes);
        *(void **)dest = value;
        return args;
    } else if (type == &Path$info) {
        *(Path_t *)dest = Path$from_str(arg);
    } else if (type->tag == TextInfo) {
        *(Text_t *)dest = Text$from_str(arg);
    } else if (type->tag == EnumInfo) {
        List_t tag_names = EMPTY_LIST;
        for (int t = 0; t < type->EnumInfo.num_tags; t++) {
            NamedType_t named = type->EnumInfo.tags[t];
            Text_t name_text = Text$from_str(named.name);
            List$insert(&tag_names, &name_text, I(0), sizeof(name_text));
            size_t len = strlen(named.name);
            if (strncmp(arg, named.name, len) == 0 && (arg[len] == '\0' || arg[len] == ':')) {
                *(int32_t *)dest = (t + 1);

                // Simple tag (no associated data):
                if (!named.type || (named.type->tag == StructInfo && named.type->StructInfo.num_fields == 0))
                    return rest_of_args;

                dest += sizeof(int32_t);

                if (named.type->align > 0 && (size_t)dest % (size_t)named.type->align > 0)
                    dest += (size_t)named.type->align - ((size_t)dest % (size_t)named.type->align);

                return parse_arg_list(rest_of_args, String(flag, ".", named.name), dest, named.type, allow_dashes);
            }
        }
        print_err("Invalid enum name for ", type->EnumInfo.name, ": ", arg,
                  "\nValid names are: ", Text$join(Text(", "), tag_names));
    } else {
        Text_t t = generic_as_text(NULL, false, type);
        print_err("Unsupported type for argument parsing: ", t);
    }
    return rest_of_args;
}

bool pop_cli_flag(List_t *args, char short_flag, const char *flag, void *dest, const TypeInfo_t *type) {
    // Booleans support `--flag`, `--no-flag`, and `--flag=<val>`, where `<val>`
    // can be `yes`, `no`, or `none` for optionals, plus any of the other ways to
    // parse a boolean.
    if (type == &Bool$info) return pop_boolean_cli_flag(args, short_flag, flag, dest, false);
    if (type->tag == OptionalInfo && type->OptionalInfo.type == &Bool$info)
        return pop_boolean_cli_flag(args, short_flag, flag, dest, true);

    for (int64_t i = 0; i < (int64_t)args->length; i++) {
        const char *arg = *(const char **)(args->data + i * args->stride);
        if (arg[0] == '-' && arg[1] == '-') {
            if (arg[2] == '\0') {
                // Case: -- (end of flags and beginning of positional args)
                break;
            } else if (streq(arg + 2, flag)) {
                // Case: --flag values...
                if (i + 1 >= (int64_t)args->length) print_err("No value provided for flag: ", flag);
                List_t values = List$slice(*args, I(i + 2), I(-1));
                List_t remaining_args = parse_arg_list(values, flag, dest, type, false);
                *args = List$concat(List$to(*args, I(i)), remaining_args, sizeof(const char *));
                return true;
            } else if (starts_with(arg + 2, flag) && arg[2 + strlen(flag)] == '=') {
                // Case: --flag=...
                const char *arg_value = arg + 2 + strlen(flag) + 1;
                List_t values;
                if (holds_many_values(type)) {
                    // For lists and tables, --flag=a,b,c or --flag=a:1,b:2,c:3
                    List_t texts = Text$split(Text$from_str(arg_value), Text(","));
                    values = EMPTY_LIST;
                    for (int64_t j = 0; j < (int64_t)texts.length; j++)
                        List$insert_value(&values, Text$as_c_string(*(Text_t *)(texts.data + j * texts.stride)), I(0),
                                          sizeof(const char *));
                } else {
                    values = List(arg_value);
                }
                (void)parse_arg_list(values, flag, dest, type, false);
                // The value is inside this token, so only the token is
                // consumed; everything after it is still to be parsed:
                List$remove_at(args, I(i + 1), I(1), sizeof(const char *));
                return true;
            }
        } else if (short_flag && arg[0] == '-' && arg[1] != '-' && !starts_like_negative_number(arg)
                   && strchr(arg + 1, short_flag)) {
            const char *loc = strchr(arg + 1, short_flag);
            char short_str[2] = {short_flag, '\0'};
            if (loc[1] == '=') {
                // Case: -f=...
                const char *arg_value = loc + 2;
                List_t values;
                if (holds_many_values(type)) {
                    // For lists and tables, -f=a,b,c or -f=a:1,b:2,c:3
                    List_t texts = Text$split(Text$from_str(arg_value), Text(","));
                    values = EMPTY_LIST;
                    for (int64_t j = 0; j < (int64_t)texts.length; j++)
                        List$insert_value(&values, Text$as_c_string(*(Text_t *)(texts.data + j * texts.stride)), I(0),
                                          sizeof(const char *));
                } else {
                    // Case: -f=value
                    values = List(arg_value);
                }
                values = parse_arg_list(values, flag, dest, type, false);

                if (loc > arg + 1) {
                    // Case: -abcdef=... -> -abcde
                    char *remainder = String(string_slice(arg, (size_t)(loc - arg)));
                    if unlikely (args->data_refcount > 0) List$compact(args, sizeof(const char *));
                    *(const char **)(args->data + i * args->stride) = remainder;
                } else {
                    // Case: -f=... -> pop flag entirely
                    List$remove_at(args, I(i + 1), I(1), sizeof(const char *));
                }
                return true;
            } else if (loc[1] == '\0') {
                // Case: -...f value...
                if (i + 1 >= (int64_t)args->length) print_err("No value provided for flag: -", short_str);
                List_t values = List$slice(*args, I(i + 2), I(-1));
                List_t remaining_values = parse_arg_list(values, flag, dest, type, false);
                if (loc == arg + 1) {
                    // Case: -f values...
                    *args = List$concat(List$to(*args, I(i)), remaining_values, sizeof(const char *));
                } else {
                    // Case: -abcdef values... -> -abcde
                    char *remainder = String(string_slice(arg, (size_t)(loc - arg)));
                    if unlikely (args->data_refcount > 0) List$compact(args, sizeof(const char *));
                    *args = List$concat(List$to(*args, I(i)),
                                        List$concat(List(remainder), remaining_values, sizeof(const char *)),
                                        sizeof(const char *));
                }
                return true;
            } else {
                // Case: -...fVALUE (e.g. -O3)
                const char *arg_value = loc + 1;
                List_t values;
                if (holds_many_values(type)) {
                    // For lists and tables, -fa,b,c or -fa:1,b:2,c:3
                    List_t texts = Text$split(Text$from_str(arg_value), Text(","));
                    values = EMPTY_LIST;
                    for (int64_t j = 0; j < (int64_t)texts.length; j++)
                        List$insert_value(&values, Text$as_c_string(*(Text_t *)(texts.data + j * texts.stride)), I(0),
                                          sizeof(const char *));
                } else {
                    // Case: -fVALUE
                    values = List(arg_value);
                }
                (void)parse_arg_list(values, flag, dest, type, false);
                if (loc > arg + 1) {
                    // Case: -abcdefVALUE -> -abcde;
                    // NOTE: adding a semicolon means that `-ab1 2` won't parse as b=1, then a=2
                    char *remainder = String(string_slice(arg, (size_t)(loc - arg)), ";");
                    if unlikely (args->data_refcount > 0) List$compact(args, sizeof(const char *));
                    *(const char **)(args->data + i * args->stride) = remainder;
                } else {
                    // Case: -fVALUE -> pop flag entirely
                    List$remove_at(args, I(i + 1), I(1), sizeof(const char *));
                }
                return true;
            }
        }
    }
    return false;
}

bool pop_cli_positional(List_t *args, const char *flag, void *dest, const TypeInfo_t *type, bool allow_dashes) {
    if (args->length == 0) {
        print_err("No value provided for flag: ", flag);
        return false;
    }
    *args = parse_arg_list(*args, flag, dest, type, allow_dashes);
    return true;
}