flags
stdlib/extra/flags.hk: declarative command-line argument parsing.
A tool's argv walk is written once here and not once per tool. What it replaces, from tools/api-diff.hk before this module existed, is what every hand-rolled parser converges on: a fold carrying want_ref and want_format booleans through a five-deep if chain, which made "the next token is a value and no flag" state threaded by hand, and made an unknown flag a positional with no word about it.
The declaration is the documentation
A Spec is a literal: two lists of declarations, which reads down the page and answers both questions a CLI has from one declaration, how to parse and what --help prints. There is no second copy of the flag list to drift.
spec = flags.spec("api-diff", "Report a module's public-API delta", [ flags.valued("against", "a", "REF", "revision to compare against"), flags.switch("json", "", "emit the machine-readable envelope") ], [ flags.positional("file", "the .hk file to inspect") ])
A chained builder was the other candidate and is the worse fit for the language as it stands: a method chain cannot span lines (neither a leading nor a trailing . parses today), which would put every non-trivial declaration on one long line. A list literal spans lines natively.
match flags.parse(spec, args) Ok(p) -> run!(p.positional("file").unwrapor(""), p.value("against").unwrapor("HEAD"), p.given?("json")) Err(e) -> fail!(flags.render_error(e), flags.help(spec)) end
What it does and does not do
Long (--name) and short (-n) forms; a value attached (--name=v) or as the next token (--name v); repeated options collecting into a list; positionals by name; a bare -- ending option parsing so everything after it is a free argument, whatever it looks like.
Not a shell: no globbing, no -abc bundling of short flags, no abbreviation matching. Bundling and abbreviation both make an unknown flag ambiguous where it should be an error, and this module's stance is that an unknown flag is a mistake to report: UnknownFlag names the token for the message to quote.
Parsing is pure: argv arrives as a List<string> from main!, and nothing here touches the environment or the process.
Flag
struct Flag
long: string
short: string
takes_value: bool
repeated: bool
value_name: string
help: string
end
One declared option or flag.
short is "" when there is none. takes_value separates --verbose from --output PATH; repeated marks the ones that collect and never replace.
Positional
struct Positional
name: string
help: string
end
One declared positional, in the order it is expected.
Sub
struct Sub
name: string
about: string
flags: List<Flag>
positionals: List<Positional>
end
One subcommand: its own name, description, flags and positionals. Nesting stops here by design: a Sub has no subs of its own, which leaves a second level unrepresentable and not merely undocumented, and the help layout never has to answer a question the type cannot pose.
Spec
struct Spec
program: string
about: string
flags: List<Flag>
positionals: List<Positional>
subs: List<Sub>
end
What a program accepts: build it with spec, and attach subcommands with with_subs.
spec
def spec(program: string, about: string, flags: List<Flag>, positionals: List<Positional>) -> Spec
Declare a program: its name, its one-line description, the options it takes and the positionals it expects, in the order they are expected.
spec("t", "about", [], []).flags.length => 0
sub
def sub(name: string, about: string, flags: List<Flag>, positionals: List<Positional>) -> Sub
Declare one subcommand. Attach it with with_subs.
sub("build", "compile it", [], []).name => "build"
impl Spec
with_subs
def with_subs(self, subs: List<Sub>) -> Result<Spec, ArgError>
Attach subcommands, or say why they cannot be attached.
A subcommand's flags fold into the same Parsed maps as the parent's, so a long name declared on both sides would be one key with two meanings. That is rejected here and never at parse time: a spec is built once at startup from literals, which makes the mistake the author's and the same on every invocation, and discovering it per invocation would only mean finding it later.
spec("g", "", [], []).with_subs([sub("add", "stage it", [], [])]).map(|s: Spec| s.subs.length).unwrap_or(0) => 1
duplicatelong
def _duplicate_long(parent: List<Flag>, subs: List<Sub>) -> Option<string>
The first long name a subcommand declares that the parent already declares, or None. Two subcommands may share a name with each other, only one of them ever being in play, and the parent alone is therefore compared against.
_declares?
def _declares?(flags: List<Flag>, long: string) -> bool
switch
def switch(long: string, short: string, help: string) -> Flag
A boolean flag: present or absent, never valued. short may be "".
switch("verbose", "v", "say more").takes_value => false
valued
def valued(long: string, short: string, value_name: string, help: string) -> Flag
An option taking one value, spelled --long V or --long=V. A later occurrence replaces an earlier one.
valued("out", "o", "PATH", "where to write").takes_value => true
repeated
def repeated(long: string, short: string, value_name: string, help: string) -> Flag
An option that may be given more than once, collecting every value in order. Read it with values and never value.
repeated("include", "I", "DIR", "search here too").repeated => true
positional
def positional(name: string, help: string) -> Positional
A positional argument, matched in declaration order.
positional("file", "what to read").name => "file"
ArgError
type ArgError
UnknownFlag(string)
MissingValue(string)
MissingPositional(string)
BadValue(string, string)
UnknownSubcommand(string, List<string>)
DuplicateFlag(string)
PositionalWithSubcommands(string)
end
Why an argument list could not be parsed. Each names the offending token, which lets a message quote it in place of saying "bad arguments".
Parsed
struct Parsed
switches: List<string>
singles: Map<string, string>
multis: Map<string, List<string>>
named: Map<string, string>
rest: List<string>
sub: Option<string>
end
A successfully parsed command line.
Values are read by the flag's long name whichever spelling was used, which spares a caller remembering which form the user typed.
impl Parsed
given?
def given?(self, long: string) -> bool
Whether the boolean flag long was given.
Parsed(switches=["json"], singles=Map.empty(), multis=Map.empty(), named=Map.empty(), rest=[], sub=None).given?("json") => true
value
def value(self, long: string) -> Option<string>
The value of a single-valued option, or None when it was not given.
Parsed(switches=[], singles=Map.empty().insert("out", "f.txt"), multis=Map.empty(), named=Map.empty(), rest=[], sub=None).value("out") => Some("f.txt")
values
def values(self, long: string) -> List<string>
Every value given for a repeated option, in order; empty when it was not given at all.
Parsed(switches=[], singles=Map.empty(), multis=Map.empty(), named=Map.empty(), rest=[], sub=None).values("I").length => 0
positional
def positional(self, name: string) -> Option<string>
A declared positional by name.
Parsed(switches=[], singles=Map.empty(), multis=Map.empty(), named=Map.empty().insert("file", "a.hk"), rest=[], sub=None).positional("file") => Some("a.hk")
bad_value
def bad_value(long: string, token: string) -> ArgError
The ArgError a caller raises when a value parsed cleanly as text but not as what the flag means - --count squid. Parsing here is untyped by design: the spec says a flag takes a value and never what the value is, and the type belongs to the caller, as does this error.
render_error(bad_value("count", "squid")) => "--count: bad value `squid`"
render_error
def render_error(e: ArgError) -> string
A one-line message for an ArgError, suitable for stderr above the help.
render_error(UnknownFlag("--wat")) => "unknown flag `--wat`"
render_error(MissingValue("--out")) => "`--out` needs a value"
render_error(MissingPositional("file")) => "missing argument `file`"
help
def help(spec: Spec) -> string
The help text for spec: usage line, the positionals, then every flag with its spellings and one-line help. Generated from the declaration, which leaves it unable to drift from what is accepted.
help(spec("t", "about", [], [])).lines().get_or(0, "") => "t - about"
help(spec("t", "about", [], [])).contains?("usage: t") => true
help_sub
def help_sub(spec: Spec, name: string) -> Result<string, ArgError>
The help for one subcommand, rendered from its own declaration.
--help after a subcommand name is outside this module by design: flags reports what it saw and never decides what a flag means, and a caller that wants tool build --help therefore reads sub == Some("build") plus its own declared help switch and calls this.
help_sub(spec("t", "", [], []), "nope").unwrap_or("no such command") => "no such command"
subpage
def _sub_page(spec: Spec, s: Sub) -> string
_commands
def _commands(subs: List<Sub>) -> string
The commands: block of the index page, or "" when there are none. Each line is the name and its one-line about, in declaration order: the order the author chose, and no alphabetical one they did not.
_sections
def _sections(flags: List<Flag>, positionals: List<Positional>) -> string
The arguments: and options: blocks, shared by both levels: a subcommand's page is the same table over its own declarations.
usageflags
def _usage_flags(flags: List<Flag>) -> string
usagepositionals
def _usage_positionals(positionals: List<Positional>) -> string
_spellings
def _spellings(f: Flag) -> string
--long, -s VALUE - how a flag is spelled, for the help table.
_pad
def _pad(s: string) -> string
Left-pad a help-table column to a fixed width, which lines the descriptions up.
_lookup
def _lookup(spec: Spec, name: string, long_form: bool) -> Option<Flag>
Find the declared flag a token names, by long or short spelling.
_empty
def _empty() -> Parsed
An empty result, for a caller that wants a default and no match.
_empty().rest.length => 0
parse
def parse(spec: Spec, args: List<string>) -> Result<Parsed, ArgError>
Parse args against spec.
Everything after a bare -- is a free argument, and any positional beyond those declared joins rest and is no error: a tool that takes a variable number of files wants them, and one that does not can check rest.
parse(spec("t", "", [], []), []).unwrap_or(_empty()).rest.length => 0
_step
def _step(spec: Spec, args: List<string>, i: int, free: bool, acc: Parsed) -> Result<Parsed, ArgError>
One token at a time. free latches once -- is seen, after which nothing is interpreted. Recursive and no fold: a valued option consumes the token after it, which a fold cannot express without the want_value state flag this module exists to remove.
_bare
def _bare(spec: Spec, args: List<string>, i: int, tok: string, acc: Parsed) -> Result<Parsed, ArgError>
A bare word: the subcommand, when the spec declares any, otherwise the next declared positional.
This is where the global-flag rule sits, the walk being the only place that can enforce it: parent flags come ahead of the subcommand word (prog --verbose build x). That rule parses without lookahead, it is what git and cargo do, and it is what makes the parent/subcommand flag clash a build-time question (with_subs) and no per-invocation one. After the word only the subcommand's own flags are declared, and a parent flag written there therefore reports as unknown and never means something unannounced.
asspec
def _as_spec(parent: Spec, s: Sub) -> Spec
The subcommand's own declaration, as the spec the rest of the line parses against. subs is emptied, which is what stops a second level: the next bare word is the subcommand's positional and no second command.
_select
def _select(acc: Parsed, name: string) -> Parsed
_long
def _long(spec: Spec, args: List<string>, i: int, tok: string, acc: Parsed) -> Result<Parsed, ArgError>
--name, --name=value, or --name value.
_short
def _short(spec: Spec, args: List<string>, i: int, tok: string, acc: Parsed) -> Result<Parsed, ArgError>
-n or -n value. No bundling: -abc is looked up whole and reported as unknown and never guessed at.
_valued
def _valued(spec: Spec, args: List<string>, i: int, tok: string, f: Flag, acc: Parsed) -> Result<Parsed, ArgError>
Shared tail of both spellings: take the next token as the value when the flag needs one, otherwise record its presence.
afterfirst_eq
def _after_first_eq(body: string) -> string
Everything after the first =, which lets a value contain one.
_record
def _record(acc: Parsed, f: Flag, v: string) -> Parsed
turnon
def _turn_on(acc: Parsed, long: string) -> Parsed
pushrest
def _push_rest(acc: Parsed, tok: string) -> Parsed
_unfilled?
def _unfilled?(acc: Parsed, name: string) -> bool
Whether a declared positional has not been given a value yet. A match and no prop read: Option has no emptiness predicate, and asking for one inside a closure in a stdlib module slips past the checker and only surfaces as an internal lowering error.
placepositional
def _place_positional(spec: Spec, acc: Parsed, tok: string) -> Parsed
Fill the next declared positional that has no value yet; once they are all filled, extra bare tokens become free arguments.
_finish
def _finish(spec: Spec, acc: Parsed) -> Result<Parsed, ArgError>
Every declared positional must have been supplied.
_demo
def _demo() -> Spec
_parsed
def _parsed(args: List<string>) -> Parsed
_err
def _err(args: List<string>) -> string
_multi
def _multi() -> Spec
A program with subcommands: parent flags, then one of two commands, each with its own flags and positionals.
multiparsed
def _multi_parsed(args: List<string>) -> Parsed
multierr
def _multi_err(args: List<string>) -> string