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.

Both implementations answer every vector

usage-lib and usage-argv agree with every vector today. That is a measurement, checked on every run rather than asserted here — see Where the reference implementation differs.

Seeing it for yourself

usage explain answers this page for one command line: which token bound to which flag or argument, and where every value that no token supplied came from. The example below is examples/explain.usage.kdl, and its output is checked against the real thing by a snapshot test.

sh
usage explain -f examples/explain.usage.kdl \
    -e MYCLI_COLOR=never -e MYCLI_PROFILE=prod \
    -- mycli -j8 --env=prod build a -- --raw
text
mycli -j8 --env=prod build a -- --raw
command  mycli build

tokens
  [0]  mycli       program
  [1]  -j8         flag -j, value of jobs = "8", attached
  [2]  --env=prod  flag --env, value of env = "prod", attached
  [3]  build       subcommand build
  [4]  a           arg target = "a"
  [5]  --          separator
  [6]  --raw       arg extra = "--raw"

values
  flag  --jobs       8      argv [1]
  flag  --env        prod   argv [2]
  flag  --color      never  env MYCLI_COLOR
  flag  --profile    prod   env MYCLI_PROFILE
  flag  --strict     true   default_if --profile when="prod"
  arg   <target>     a      argv [4]
  arg   [-- extra]…  --raw  argv [6]

shadowed
  flag  --jobs   default 1     lost to argv [1]
  flag  --color  default auto  lost to env MYCLI_COLOR

Three tables because no one of them is enough. tokens cannot show a value that came from nowhere in argv; values cannot show a token that bound to nothing; and shadowed answers the question neither does — which declaration would have supplied a value, and what beat it.

The reference implementation is the one answering, so the report is what the grammar below says rather than what any particular reading of it assumes. It exits 0 even when the explained command line does not parse — that being the case a report is most wanted for. Pass --format json for the same facts as data.

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.

When the spec declares multicall, argv[0]'s basename is itself a word: a symlink ls -> busybox is parsed as if the first token were ls. The dispatcher names (name and bin) are skipped, so busybox ls still has two words after the program name. Path components and a trailing .exe are stripped so /usr/bin/ls and ls.exe select the same applet.

A token is flag-like when it begins with - and is longer than one character. Negative numbers normally remain values, but an exact two-byte spelling such as -0 is a flag when that digit short is explicitly declared. This lets fd keep -0 / --print0 without turning undeclared -1, -2.5, or -1e5 into flags.

A number here means digits, at most one ., and optionally an exponent — e or E, an optional + or -, then at least one digit. So -1, -2.5, -1e5, and -1.5e-3 are values. It is deliberately narrower than what a float parser accepts: -inf and -NaN parse as floats but are far likelier to be misspelled flags than numbers somebody meant to pass. -1x and -1e are not numbers either, and so name flags that do not exist.

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). If nothing matches, see unrecognized flags.
  4. Otherwise the token is a word: it selects a subcommand if one matches; otherwise it is forwarded as an external subcommand if the command declares external_subcommand; otherwise it is 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. A prefix that matches nothing is then an unrecognized flag.

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 in one token.

A value attached to a flag that takes none is dropped: --force=yes sets force and nothing else. Handing the leftover to the positionals would re-split one token into two and fill an argument the caller never typed a word for.

A detached value must not be flag-like, unless the flag declares allow_hyphen_values. --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, and a lone - is a value too, so --file - reaches the flag. A -- is flag-like, so it stays the separator rather than becoming the value of whatever flag precedes it.

A flag declared allow_hyphen_values takes the following token whatever it looks like, including -- and tokens that name other flags. The attached form is then no longer the only way to pass a dash-prefixed value. A variadic occurrence still stops collecting at a later flag-like token, so a second occurrence of the same flag is not eaten as a value.

A flag declared require_equals accepts only the attached form: --inspect=9229 binds and --inspect 9229 is a missing value. A short's attached form (-i9229, -i=9229) still binds.

A boolean switch declared bool_value additionally accepts the exact attached long forms --flag=true and --flag=false. It never takes a detached value: --flag false binds the flag and leaves false to the positionals. A negated spelling applies the attached value to that spelling, so --no-flag=false binds the flag true.

A flag declared default_missing binds that string when it is given with no value: --color is always if the spec says default_missing="always". An attached or (unless require_equals) detached value still wins. A following flag-like token is not taken as the value, so --color --verbose colours with the missing default and still sets verbose. Combined with require_equals, a following word is a positional rather than the value.

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

A flag may declare several long names; the extra ones are aliases, and every one of them binds under the flag's name. A flag given more than once keeps the last value, unless it is var or count, which is what those declare instead. A repeat is a correction — usually a wrapper appending to a command line it did not write — so the last word on the subject is the one that counts.

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 its var_max is reached, or the command line ends. So --include a b gives it both, while --include a --force gives it only a. The attached form settles where the first value came from and nothing more, so --include=a b collects both as well.

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 ways to end the run are -- and a var_max, which stops the flag and leaves the following words to whatever comes next.

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.

A token containing an unrecognized letter is not a bundle at all, so none of its letters are applied: -az does not set -a on the way to discovering that z names nothing. What happens to the token instead is described under unrecognized flags.

-h is recognized, and so is -V on the root of a CLI that declares a version. A parser supplies both rather than a spec declaring them, and they are letters like any other: -vh is a bundle, and it asks for help. A spec that declares the letter itself keeps it — -h is supplied only where nothing else claims it, so a CLI whose -h means --host reads -vhlocal as its own.

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

Unrecognized flags

A flag-like token that names no flag in scope becomes a word, and is offered to the positional arguments like any other. With nothing left to hold it, that is an unexpected_arg — the same error an extra word produces, rather than a special one about flags.

This is where the grammar parts company with every comparable parser. clap, argparse, commander, oclif v2+, and POSIX getopt all reject the token. They are right for what they do, which is parse their own argv, where a dash-word can only be a flag or a typo. A usage spec is also used to parse command lines whose flags it does not own:

  • a shell script run through usage exec, forwarding options to a tool it wraps
  • a task's arguments, where the task script is the authority on what it accepts
  • a completion, asked about a line that is still half-typed

In all three, a token the spec has not heard of is far more likely to be data in transit than a mistake, and refusing it would break the wrapper for everyone who did not enumerate the flags of the program behind it.

The cost is real and worth stating plainly: a misspelled --hekp becomes an argument instead of an error, and whether it does depends on whether a positional is free to take it. A CLI that owns all of its flags can have the stricter reading by asking:

kdl
unknown_flags "error"     // for the whole CLI
cmd "exec" unknown_flags="value"   // except here, which forwards a command line

Unlike effect, this is inherited: the nearest enclosing command that states a preference wins, then the spec, then value. It describes how a command line is read rather than what a command does, and a CLI that forwards options tends to forward them at every level.

Even when refusing, a lone - and a negative number stay values — neither is a misspelled flag, and without the second --offset -1 could not be written. oclif made exactly this mistake when it switched to refusing unknown flags, and had to add the number case back afterward.

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_max is a limit rather than a check: a variadic stops once it is full and the next argument takes the rest, which is what makes [a]… [b] fillable at all. An argument after an unbounded variadic still can never be filled. clap's num_args behaves this way, and specs are commonly generated from clap commands.
  • var_min is a check, since nothing about a word tells you a variadic will end up short. It is enforced once the last token has been read.
  • 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 name outranks an alias. The word is matched against every subcommand's name first; only if none answers is it matched against their aliases. So declaration order never decides which command a word selects — reordering cmd blocks cannot change what a command line means — and no command's own name can be shadowed by another command's alias.

That precedence only ever comes up in a spec that should not exist: one command's alias equal to another's name leaves one of the two unreachable whichever way it is resolved. usage lint reports it as duplicate-subcommand, and a derive rejects it at compile time. The rule is stated because a parser handed a spec it did not validate still has to answer, and every implementation should answer the same way.

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.

External subcommands

A command may declare external_subcommand. An unmatched word that names no subcommand is then the name of an external command, and every token after it is forwarded with it — including flags. Known subcommands still win. A default_subcommand still catches first. A flag-like token on the parent is still an unknown flag, not a forwarded name: ex git --help forwards git --help; ex --wat errors.

This is clap's allow_external_subcommands. It is not unknown_flags=value, which is the reading a wrapper uses when hyphen-taking tokens should bind as values of this command.

kdl
unknown_flags "error"
external_subcommand #true
cmd "install"

The property is per command, so a nested cmd can forward while the root still owns its flags:

kdl
cmd "exec" external_subcommand=#true {
  cmd "install"
}

Multicall

A spec may declare multicall. argv[0]'s basename is then a word: a symlink ls -> busybox is parsed as if the first token were ls. The dispatcher names (name and bin) are skipped, so busybox ls still selects ls. Path components and a trailing .exe are stripped, so /usr/bin/ls and ls.exe select the same applet.

An unknown applet is an unmatched word: it errors, or is forwarded if the root declares external_subcommand. Invoking the dispatcher with no further words stays at the root.

This is clap's Command::multicall. Corpus vectors that care about argv[0] carry it as argv0; everyone else is the spec's bin.

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.

A variable's value is one value, however much whitespace or however many commas it contains. It is not a command line, and is never split into several.

For a flag that holds no value there is nothing to bind, so the variable's text is read as a boolean: 1, true, True, and TRUE set it, and anything else — 0 and false included — leaves it false. Presence cannot be the test, since a parent process that exports EX_VERBOSE=false is saying no. Note that this is narrower than clap's falsey parser, where any value that is not falsey is true, so yes sets the flag there and not here.

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.

Warnings

A declaration may still work and still be something a CLI wants to stop people using. deprecated on a flag or a command says so, and deprecated_env says it about one of the variables a value may arrive through. Help and completions describe those already; this is what a parse says to the person who just used one.

A parse reports rather than prints. A parser that wrote to stderr could not be used by anything with an opinion about output, and mise queues its deprecations until its logging is up — the same rule configuration resolution follows. What prints is the CLI's own process entry point, not the parse.

Each warning has a kind, because the wording is for a person and the kind is what a program acts on:

kindwhat happened
deprecated-flaga flag whose declaration says not to use it any more was given
deprecated-commanda selected command's declaration says not to use it any more
deprecated-enva value arrived through a deprecated_env alias

What a parse reports is a set, not a sequence. Every deprecation a command line used is reported once, and the arrangement is a quality-of-implementation matter: two implementations agree on which warnings there are without having to agree on the order they come out in. A caller that cares about arrangement sorts by kind.

Three rules decide whether there is anything to say.

The command line and the environment count; a default does not. A flag supplied by a variable used the deprecated declaration as much as a typed word did, and both are things the user arranged. A declared default is nobody's request, so a deprecated flag with a default does not warn on a command line that never mentioned it.

Every deprecated command on the selected path reports, not only the last one: a deprecated group whose child is fine was still the way in. The root is not one of them — it is the program that is running, not something the command line selected. Neither is a command a view promoted, nor any command the view routes through: under aubr those words are the program's identity, so an implementation that reaches them by rewriting argv reports no more than one that reads a spec whose root is already the promoted command.

deprecated_warn_at is an author saying not yet. A warning is withheld until the CLI's own version reaches that release. deprecated_remove_at never withholds anything and is never an error: removing a declaration is the author's job, not the parser's, and until then the milestone is something the message mentions.

Versions compare as dotted integers, left to right:

caseresult
no deprecated_warn_atwarn — it is deprecated now
the CLI declares no versionwarn
either version is not readable as onewarn
unequal segment countsmissing segments are 0, so 2026.12 = 2026.12.0
a -suffixsorts before the same numbers without one
a +suffixignored

Semver and calver both work under that rule. Every uncertain case warns on purpose: noise is recoverable and silence is not, so a version string an implementation cannot read produces a warning rather than hiding one.

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 occurrences than a repeatable flag's var_max
conflicting_flagstwo flags declared to conflict were both given

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, and mise run test:go runs them against usage-go, which is a second implementation of this page in another language — the thing the corpus was made to make possible.

Each vector says which layer of a parser it is a question for. Most are binding — which token becomes which flag or argument — and a parser that reads argv is expected to answer all of those. The rest are post-binding: required, choices, env fallback, defaults, var_min, overrides, and conflicts are decided once the last token has been read, and need to know a value's type, so a binding-only parser leaves them to the layer that owns the target struct.

That is declared per vector rather than worked out from the spec, because an implementation should be told which vectors apply to it. usage-lib answers every vector; usage-argv answers the binding ones.

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 it does not: usage-lib agrees with every vector. The list is empty for the first time, and the five entries it used to hold were what writing the grammar down was for. Each was a real defect that only a second reading found:

  • --jobs=--force bound force and left jobs unset, because an attached value went back on the token queue and was read a second time as a flag of its own.
  • ex --jobs -- x gave jobs the word after the separator, so the command line quietly meant ex --jobs=x and the -- was gone.
  • --include a b gave a variadic flag argument only a and called b unexpected, though the flag reference documents --include <pattern>... as the form that collects.
  • The same gap made a var_max on such a flag unreachable.
  • double_dash="automatic" was declared and serialized but never enforced.

An empty list is a state, not a promise. The next rule written here will very likely land before the parser does, and the label is how that gets said out loud rather than discovered by whoever writes the next implementation.

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.
  • 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