Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 28 additions & 13 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,15 @@ its error inline; a bound value is a slice of the argv string rather than a copy
`TestParseAllocatesNothing` measures this with `testing.AllocsPerRun`, on the
failure paths as well as the success ones. A mise-sized binding runs in 57 ns.

**Binding only.** The parser answers one question — which token becomes which flag
or argument — and reports each occurrence as an event. Everything that needs to
know a value's _type_ (`required`, `choices`, `env` fallback, defaults, `var_min`,
`overrides`) belongs to the layer that owns the target struct, exactly as it does
in Rust. That is why the corpus's post-binding vectors are skipped here rather
than failed.
**Binding stays separate from judging.** The parser answers one question — which
token becomes which flag or argument — and reports each occurrence as an event.
The rules that need a value's declared type live in `post.go`, reading a second,
cold table the parser never touches: `required`, `choices`, the `env`-then-
`default` fallback, `var_min`, `var_max`. They are pure functions over what
binding produced rather than a framework, because the caller is the one that knows
how it accumulated — generated code assigns to a field, a harness with no target
type appends to a slice, and inventing a value model here would force both through
it.

## Using it

Expand Down Expand Up @@ -114,10 +117,14 @@ an implementation in any language can run it. `go/conformance` runs all of it:
mise run test:go
```

All 122 binding vectors pass; the 30 post-binding ones are skipped with the reason
recorded. The count is worth watching rather than just quoting: it was 101 when
this module landed, and grew when the corpus imported the argv questions clap's
suite answers — which the Go parser then answered without a change. A vector's spec is KDL, and this module deliberately has no KDL parser —
**145 of 152 vectors pass.** The seven left are relationships _between_ flags —
`conflicts`, `overrides`, `required_unless` — which need a resolution step turning
a name into the entry it refers to. They are listed by id in `notYet` with a
reason, and the count is asserted, so that set cannot quietly grow.

The number is worth watching rather than quoting: 101 when this module landed, 122
once the corpus imported the argv questions clap's suite answers (which the Go
parser answered without a change), and 145 once the post-binding rules arrived. A vector's spec is KDL, and this module deliberately has no KDL parser —
`usage generate json` does the lowering, which is why the suite needs the CLI
built. That split is the same one an adopter gets: tables are generated once at
build time by a maintainer who has the usage CLI, and the shipped binary never
Expand All @@ -131,9 +138,17 @@ claim is measured at real scale rather than against a fixture with four flags:

## What is missing

- **The typed layer.** Binding produces events; something has to turn them into a
struct with `int` and `time.Duration` fields, and apply the post-binding rules.
This is where the corpus's skipped post-binding vectors get answered.
- **Relationships between flags.** `conflicts`, `overrides`, `required_if` and
`required_unless` — the seven corpus vectors still unanswered. Unlike everything
in `post.go` they are not a property of one entry: a name has to be resolved to
the entry it refers to first.
- **The cold table in generated code.** `usage generate go` emits the parse tables
but not the `Meta` ones yet, so the post-binding rules are reachable today from a
spec lowered at run time rather than from a generated package. Proving them
against the corpus came first, the way the binder did before the generator.
- **Typed values.** Binding collects text. Something still has to turn `"8"` into
an `int` and `"1m"` into a `time.Duration`, and report the ones that will not
convert.
- **Help and errors.** A cold table of help text, and rendering worth reading.
- **Completions.** The Rust side serves these from the parser's own scope rules so
that what is offered and what is accepted cannot disagree; the hooks for it
Expand Down
40 changes: 40 additions & 0 deletions go/argv/argv.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,22 @@ const (
CodeHelp
// CodeVersion means --version or -V was given. Not a failure either.
CodeVersion

// The rest are raised after the parse, by [Check], because they need to know
// a value's declared type or its final count. They share this type so a caller
// has one error to handle rather than two.

// CodeMissingRequiredFlag means a required flag never appeared.
CodeMissingRequiredFlag
// CodeMissingRequiredArg means a required argument was never filled.
CodeMissingRequiredArg
// CodeInvalidChoice means a value was given that is not among the declared
// choices.
CodeInvalidChoice
// CodeVarTooFew means fewer values than var_min.
CodeVarTooFew
// CodeVarTooMany means more occurrences than a repeatable flag's var_max.
CodeVarTooMany
)

var codeNames = [...]string{
Expand All @@ -252,6 +268,11 @@ var codeNames = [...]string{
CodeTooDeep: "too_deep",
CodeHelp: "help",
CodeVersion: "version",
CodeMissingRequiredFlag: "missing_required_flag",
CodeMissingRequiredArg: "missing_required_arg",
CodeInvalidChoice: "invalid_choice",
CodeVarTooFew: "var_too_few",
CodeVarTooMany: "var_too_many",
}

// String gives the code the corpus spells it with.
Expand Down Expand Up @@ -290,6 +311,17 @@ type Error struct {
Cmd *Command
// Long distinguishes --help from -h, which print different amounts.
Long bool

// Name is the flag or argument the post-binding rules rejected, as the spec
// spells it.
Name string
// Choices carries the declared list for CodeInvalidChoice, rather than the
// offending value: the value is the caller's to render, and it has it.
Choices []string
// Bound and Got are the declared limit and what was actually counted, for the
// two var codes.
Bound uint32
Got int
}

func (e *Error) Error() string {
Expand All @@ -308,6 +340,14 @@ func (e *Error) Error() string {
return "help requested"
case CodeVersion:
return "version requested"
case CodeMissingRequiredFlag, CodeMissingRequiredArg:
return "missing required: " + e.Name
case CodeInvalidChoice:
return "invalid value for " + e.Name
case CodeVarTooFew:
return "too few values for " + e.Name
case CodeVarTooMany:
return "too many occurrences of " + e.Name
}
return "parse error"
}
Expand Down
202 changes: 202 additions & 0 deletions go/argv/post.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package argv

// The rules that are decided once the last token has been read.
//
// Binding says which token becomes which flag or argument. These say whether
// what landed is acceptable, and fill in what the command line left empty — and
// every one of them needs to know something about a value that no single token
// can tell you. `required` needs to know nothing was given anywhere, `var_min`
// needs the final count, `choices` needs the declared list. So they live here,
// off the hot path, reading a second table the parser never touches.
//
// Deliberately not a framework. These are pure functions over what binding
// produced, because the caller is the one that knows how it accumulated: a
// generated struct assigns to a field, and a harness with no target type appends
// to a slice. Inventing a value model here would force both of them through it.
//
// The split is the same one usage-argv makes in Rust, and the reason is the same:
// a successful parse never reaches this file, so nothing here is in front of the
// parser.

// Meta is the cold half of a flag or argument's declaration.
//
// Everything binding deliberately does not know. A generated table holds one of
// these per entry, indexed by [Meta.Key] — see [Metadata] — and a program that
// never applies the rules never touches them.
type Meta struct {
// Key matches the [Flag.Key] or [Arg.Key] this describes, so the two tables
// cannot drift apart on identity even though they are separate data.
Key uint64
// Name is what the spec calls it, for the error.
Name string
// Flag distinguishes a missing flag from a missing argument, which the
// grammar reports as different classes.
Flag bool
// Required means it must end up with a value, from anywhere.
Required bool
// Choices is the exact set of values allowed. Matching is case-sensitive:
// case-insensitive matching would have to be declared rather than assumed.
Choices []string
// Default fills in when neither the command line nor the environment did.
Default []string
// Env names an environment variable to fall back to. Empty means none.
Env string
// VarMin is the fewest values a variadic may end up with. Zero means no
// bound. It is a check rather than a limit, because nothing about a single
// word tells you a variadic will end up short.
VarMin uint32
// VarMax is the most times a repeatable flag may be given. Zero means no
// bound.
//
// Occurrences, not values. A variadic's per-occurrence bound is a limit that
// binding applies, and lives on [Flag.VarMax] and [Arg.VarMax] instead — a
// value bound here would fail an invocation that never broke it.
VarMax uint32
}

// Metadata is the cold table, indexed by key.
//
// Keys are dense from 1, so entry `Key` sits at `Metadata[Key-1]` and a lookup is
// an index rather than a map — which also keeps the table static data the linker
// can lay out, where a Go map would need building at init.
type Metadata []Meta

// Lookup returns the metadata for a key, or nil if the table has none.
func (m Metadata) Lookup(key uint64) *Meta {
if key == 0 || key > uint64(len(m)) {
return nil
}
entry := &m[key-1]
if entry.Key != key {
// A table built by hand, or one that got out of step with the parse
// tables. Searching would paper over it; reporting nothing makes the
// caller's own test fail instead.
return nil
}
return entry
}

// Source says where a value came from, which callers need because the rules
// distinguish them: an `overrides` loser is not refilled from the environment,
// and a value that arrived from `env` is still checked against `choices`.
type Source uint8

const (
// FromArgv means the command line supplied it.
FromArgv Source = iota
// FromEnv means the environment did.
FromEnv
// FromDefault means neither did, and the declaration filled it in.
FromDefault
// Unset means nothing supplied it.
Unset
)

// Fill applies the fallbacks: command line, then environment, then default.
//
// `given` is what binding produced, and nil means the command line said nothing —
// which is distinct from a flag given an empty value, since `--jobs=` binds the
// empty string and that is a value.
//
// `lookupEnv` is passed in rather than read from the process, so that a caller
// testing a parse is not testing the machine it runs on. [LookupEnv] wraps
// os.LookupEnv for callers that do want the process environment.
//
// 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.
func Fill(m *Meta, given []string, lookupEnv func(string) (string, bool)) ([]string, Source) {
if given != nil {
return given, FromArgv
}
if m == nil {
return nil, Unset
}
if m.Env != "" && lookupEnv != nil {
if v, ok := lookupEnv(m.Env); ok {
// One token. The grammar never re-splits a value on whitespace —
// quoting is the shell's job and there was no shell here at all.
return []string{v}, FromEnv
}
}
if len(m.Default) > 0 {
return m.Default, FromDefault
}
return nil, Unset
}

// Check applies the rules that judge what ended up bound.
//
// `values` is the result of [Fill], and `occurrences` is how many times a
// repeatable flag was given — which is not `len(values)`, since one occurrence of
// a variadic can bring several. Pass 0 for an argument.
//
// The first failure is returned; a caller wanting all of them should call this
// per entry, which it is doing anyway.
func Check(m *Meta, values []string, occurrences int) *Error {
if m == nil {
return nil
}

// `occurrences` is what makes a value-less flag work here: it has no values to
// count, so "was it given" is the only question, and the answer is that it was
// seen at least once.
if m.Required && len(values) == 0 && occurrences == 0 {
// Two classes, because the grammar reports them separately: what is
// missing reads differently for a flag nobody typed than for an argument
// the command needed.
code := CodeMissingRequiredArg
if m.Flag {
code = CodeMissingRequiredFlag
}
return &Error{Code: code, Name: m.Name}
}

if len(m.Choices) > 0 {
// Every value, not just the first: a variadic can be given a good value
// and a bad one, and so can a repeatable flag across occurrences.
for _, v := range values {
if !contains(m.Choices, v) {
return &Error{Code: CodeInvalidChoice, Name: m.Name, Choices: m.Choices}
}
}
}

// Only where something was given. An absent optional variadic has not broken
// its minimum; it simply is not there, and reporting `var_too_few` for it
// would make every bounded variadic effectively required.
if m.VarMin > 0 && len(values) > 0 && uint32(len(values)) < m.VarMin {
return &Error{Code: CodeVarTooFew, Name: m.Name, Bound: m.VarMin, Got: len(values)}
}

if m.VarMax > 0 && occurrences > int(m.VarMax) {
return &Error{Code: CodeVarTooMany, Name: m.Name, Bound: m.VarMax, Got: occurrences}
}

return nil
}

// EnvTruth reports whether an environment value sets a flag that holds no value.
//
// A flag with no value has nowhere to put the text, so the variable has to be
// read as a yes or a no — and `EX_VERBOSE=0` meaning "verbose" would be a trap.
//
// An allow-list rather than a not-falsy test, matching usage-lib exactly, which
// means `yes`, `on` and `TrUe` are all false. That is worth knowing rather than
// discovering: the corpus pins `1`, `true`, `false` and `0`, and the rest of the
// list is here so the two implementations cannot drift on the cases it does not.
func EnvTruth(value string) bool {
switch value {
case "1", "true", "True", "TRUE":
return true
}
return false
}

func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
Loading
Loading