Skip to content

The argv grammar

A usage spec says what a CLI accepts. This page says how a command line is matched against it: which token binds to which flag or argument, when a word selects a subcommand, and what counts as an error.

It exists because that behavior was previously defined only by usage-lib's implementation. A second implementation — in Rust, in Go, in a shell completion — had no way to know whether it agreed, and no way to prove it. The grammar here is normative, and the conformance corpus makes it executable.

Being written down changes nothing on its own

usage-lib does not implement every rule below, and the differences are recorded per case in the corpus rather than smoothed over. See Where the reference implementation differs.

Terms

A token is one element of argv, after the shell has finished with it. The grammar never re-splits a token on whitespace: quoting is the shell's job and is already done.

A command line is the tokens after the program name. mycli install -f x has three.

A token is flag-like when it begins with -, is longer than one character, and is not a negative number (- followed by a digit). So --force, -f, and -abc are flag-like; -, -1, and -2.5 are not.

Reading a command line

Tokens are read once, left to right. There is no backtracking, no reordering, and no second pass: what a token binds to is decided when it is read, from the command in scope at that moment. This is what makes the grammar implementable as a single loop, and it is also why a -- or a subcommand word changes the meaning of everything after it but nothing before it.

At each token, in order:

  1. If flag interpretation has stopped (a -- was consumed), the token is a value.
  2. If the token is exactly --, flag interpretation stops. The token is consumed and is not itself a value.
  3. If the token is flag-like, it is matched as a flag (long or short). No match is an error.
  4. Otherwise the token is a word: it selects a subcommand if one matches, and is otherwise offered to the command's positional arguments.

Long flags

A token beginning with -- is a long flag. The name is the text up to the first =, or the whole token if there is none.

Names match exactly. --for does not match --force. Abbreviation inference is deliberately absent: it makes adding a flag a breaking change for anyone who typed a prefix that was unique until the new flag arrived.

For a flag that takes a value, the value comes from one of two forms:

formvalue
--jobs=8the text after the first =, so --set=a=b is a=b
--jobs 8the following token

--jobs= binds the empty string. Present-but-empty is a distinct state from absent, and the attached form is the only way to express it.

A detached value must not be flag-like. --jobs --force is a missing value, not a jobs of "--force", because the overwhelmingly likely reading is that the value was forgotten. To pass a value that begins with a dash, attach it: --jobs=--force. The negative-number exception means --offset -1 still works.

A flag needing a value that ends the command line is an error.

Flags that take several values

A flag whose argument is variadic (--include <pattern>...) keeps taking values from one occurrence: it consumes following tokens until one is flag-like, or a -- arrives, or the command line ends. So --include a b gives it both, while --include a --force gives it only a.

This is greedy, and a command that declares both a variadic flag and positionals will find the flag eating them. That is inherent to the feature rather than a quirk of this grammar; the way to end the run explicitly is --.

A flag declared var without a variadic argument is the other shape: it takes one value per occurrence and may be repeated, so --include a --include b collects two. The distinction matters — --include a b gives a repeatable flag only a, leaving b to a positional, while a variadic one takes both.

Short flags

A token beginning with a single - is one or more short flags. Letters are read left to right.

A letter whose flag takes no value simply sets it, and reading continues with the next letter — so -ab sets both a and b.

A letter whose flag takes a value ends the token. Its value is:

formvalue
-j8the rest of the token
-j=8the rest of the token after one leading =
-j 8the following token

So -aj8 sets a and gives jobs the value 8. The attached form is what makes -C/tmp and -Edev work.

One = immediately after the letter is a separator, matching the long form. Only one: -j==8 is a value of =8.

An unrecognized letter fails the whole token, including any letters before it. A partially applied bundle would be worse than a rejected one.

- alone is not a flag. It is a value, conventionally meaning stdin.

Positional arguments

A word that does not select a subcommand is offered to the command's arguments in declaration order. Each argument takes one word, except a variadic (var=#true, or a trailing ...), which collects every word still available.

  • A word offered when no argument can hold it is an error, not silently dropped.
  • var_min and var_max are enforced.
  • An unfilled required argument is an error. An unfilled optional one is absent.

Flags and words may interleave freely: ex from -f to fills the same arguments as ex -f from to. A flag between two words does not affect which argument each word fills.

Subcommands

A word is matched against the subcommands of the command in scope, by name and by alias. A match descends: parsing continues against the subcommand, and the selected path records the canonical name even when an alias was typed.

A subcommand name wins over a positional value. A CLI that declares both cannot receive a positional whose text equals one of its subcommand names — mise documents exactly this hazard for tasks that share a name with a command.

Only the descent position routes. Once a word has been consumed by a positional argument, a later word matching a subcommand name is just a value. ex other install does not run install.

Flag scope

A flag belongs to the command that declares it and may appear anywhere that command is in scope, which includes before one of its own subcommand words: ex --quiet install works for a root --quiet.

A flag declared global=#true is additionally inherited by every command beneath it, so it may appear after any subcommand word at any depth.

Scope only ever runs downward. A flag declared on a subcommand is not accepted before that subcommand is reached, global or not. A subcommand may redeclare a name it would otherwise inherit, and below that point its own declaration is the one that binds — mise does this deliberately, redeclaring several root globals on run with different shorts.

The -- separator

A bare -- stops flag interpretation. Every token after it is a value, however flag-like it looks. The separator is consumed and is not itself a value.

Only the first -- is a separator. A later one is an ordinary value, since flag interpretation has already stopped — which is what lets a CLI forward a command line that itself contains --.

An argument may say more about its relationship to the separator, with double_dash:

modemeaning
optionalthe default: values may appear on either side
requiredvalues are accepted only after a --; a word before it is an error
preservethe separator is kept as a value instead of being consumed
automaticonce the argument takes a value, behave as if -- had been given

Values not from argv

When the command line does not supply a value, it is taken from the environment if the flag or argument declares env, and otherwise from its default. In short: command line, then environment, then default.

An environment variable set to the empty string is set. Treating empty as unset would make EX_JOBS= mean something no other empty value in the grammar means.

None of this changes which token binds where. It only fills what the command line left empty, which is why it is described last: an implementation can do all of it after the single pass is over.

Errors

The grammar distinguishes these classes of failure. Wording is not specified — diagnostics are a quality-of-implementation concern and should be much better than these names — but the class is, so a strict parser and a lenient one can be told apart mechanically.

codewhen
unknown_flaga flag-like token matched no flag in scope
missing_flag_valuea flag needing a value did not get one
missing_required_flaga required flag never appeared
missing_required_arga required argument was never filled
unexpected_argmore words than the command can hold
invalid_choicea value outside the declared choices
arg_requires_double_dasha double_dash="required" argument got a value too early
var_too_fewfewer values than var_min
var_too_manymore values than var_max

Choices match exactly; case-insensitive matching would have to be declared rather than assumed.

The conformance corpus

corpus/ holds the executable form of this page: JSON vectors pairing a spec and a command line with the expected result.

json
{
  "id": "long-value-attached",
  "doc": "`--flag=value` binds the text after the first `=`.",
  "spec": "name \"ex\"\nbin \"ex\"\nflag \"--jobs <n>\"\n",
  "argv": ["--jobs=8"],
  "expect": { "ok": { "flags": { "jobs": "8" } } }
}

Bindings are keyed by the name the spec gives each flag and argument, never by the token that set them, so -j, --jobs, and EX_JOBS all land under jobs. Values are recorded as strings: the grammar decides which token binds where, not what it means, so turning "8" into a number is the caller's business. Failures record only the error code.

Vectors that set env carry their own environment. The harness never reads the process environment, so no vector's result can depend on the machine running it.

Any implementation in any language can run these. In this repository, cargo test -p usage-conformance runs them against both usage-lib and usage-argv.

usage-argv answers 63 of the 87 vectors. The other 24 turn on something decided after the last token is read — required, choices, env fallback, defaults, var_min and var_max, overrides — which needs to know a value's type and so belongs to the layer above a binding parser. Those are reported as out of scope rather than as failures, and the count is asserted so the exempt set cannot quietly grow.

Where the reference implementation differs

Each vector records whether usage-lib agrees with it, as a measurement rather than an assumption, and conformance/tests/reference.rs fails if a label is wrong in either direction. A recorded divergence that gets fixed shows up as a test failure telling you to delete the label, so the list cannot rot.

Today usage-lib diverges on 16 of 87 vectors, from four causes:

Unrecognized flags fall through to positionals. ex --wat binds --wat to an argument if one is free, and reports unexpected_arg if not. This accounts for most of the divergences, including the ones where the grammar and usage-lib agree a flag is out of scope but disagree about which error to report.

A flag missing its value is dropped silently. ex --jobs parses successfully with nothing bound, so a forgotten value looks like a working command. ex --jobs --force likewise binds force and leaves jobs unset.

= is kept in attached short values. -j=8 binds =8 rather than 8.

Repeated -- is eaten. A second separator is dropped rather than kept as a value, so a forwarded command line containing -- is altered in transit.

Three smaller gaps: --jobs= binds nothing rather than the empty string; a flag with a variadic argument (--include <pattern>..., which the flag reference documents) rejects its second value; and double_dash="automatic" is not enforced, which the arg reference already says outright.

Where the grammar and usage-lib disagree, the grammar is the intent and the divergence is a bug to fix or a decision to revisit — not a description of settled behavior. Each one is a small, self-contained change to lib/src/parse.rs, and the corpus is how a fix gets verified.

Not yet covered

  • Restart tokens. restart_token (mise's :::) makes one command line describe several invocations, which the vector format cannot express: expect holds a single result. Supporting it needs a multi-invocation shape, and until then usage-lib's behavior — rewind, and let the last invocation's bindings stand — is untested here.
  • Mounts. mount resolves a sub-spec by running a command during the parse. That makes a vector depend on an external process, so it needs a stubbing mechanism first.
  • Completion parsing. parse_partial deliberately accepts incomplete input to drive completions. It is a different contract with different expectations, and deserves its own corpus.
MIT LicenseCopyright © 2026jdx.dev