hanki

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