Skip to content
Merged
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
24 changes: 14 additions & 10 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ how it accumulated — generated code assigns to a field, a harness with no targ
type appends to a slice, and inventing a value model here would force both through
it.

The four rules that compare one entry against another — `conflicts`, `overrides`,
`required_if`, `required_unless` — live in `relationships.go`, because a name in
the declaration has to be resolved to the entry it refers to before any of them
can be checked. `overrides` is the odd one out and is applied first: it asks which
of two flags came _last_, which only the arriving tokens know.

## Using it

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

**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.
**All 154 vectors pass** — every one the corpus has, binding and post-binding
alike. The suite asserts that nothing was skipped, so that stays a measurement
rather than a claim.

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 —
once the corpus imported the argv questions clap's suite answers, 145 once the
post-binding rules arrived, 152 with the relationships between flags, and 154 as
the corpus kept growing underneath. Every one of those increases was answered
without a change to the Go side, which is the argument for a shared corpus in one
line. 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 @@ -138,10 +146,6 @@ claim is measured at real scale rather than against a fixture with four flags:

## What is missing

- **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
Expand Down
9 changes: 9 additions & 0 deletions go/argv/argv.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,8 @@ const (
CodeVarTooFew
// CodeVarTooMany means more occurrences than a repeatable flag's var_max.
CodeVarTooMany
// CodeConflictingFlags means two flags declared to conflict were both given.
CodeConflictingFlags
)

var codeNames = [...]string{
Expand All @@ -273,6 +275,7 @@ var codeNames = [...]string{
CodeInvalidChoice: "invalid_choice",
CodeVarTooFew: "var_too_few",
CodeVarTooMany: "var_too_many",
CodeConflictingFlags: "conflicting_flags",
}

// String gives the code the corpus spells it with.
Expand Down Expand Up @@ -322,6 +325,10 @@ type Error struct {
// two var codes.
Bound uint32
Got int
// Other is the flag [Name] cannot be given with, for CodeConflictingFlags.
// Both are carried because either alone reads as a puzzle: which flag is
// unwelcome depends on what else was given.
Other string
}

func (e *Error) Error() string {
Expand All @@ -348,6 +355,8 @@ func (e *Error) Error() string {
return "too few values for " + e.Name
case CodeVarTooMany:
return "too many occurrences of " + e.Name
case CodeConflictingFlags:
return e.Name + " cannot be given with " + e.Other
}
return "parse error"
}
Expand Down
27 changes: 27 additions & 0 deletions go/argv/post.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,23 @@ type Meta struct {
// 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

// The four that need a second entry to answer, all resolved to keys rather
// than left as the names the spec writes. Resolution happens where the whole
// command is visible — the generator, or the table builder — so that nothing
// downstream has to search by name, and a declaration naming a flag that does
// not exist is caught there rather than silently doing nothing.
// See relationships.go.

// Conflicts names entries this one cannot be given alongside.
Conflicts []uint64
// Overrides names entries this one is mutually exclusive with, resolved by
// whichever was given last rather than reported as a mistake.
Overrides []uint64
// RequiredUnless makes this required when none of them is present.
RequiredUnless []uint64
// RequiredIf makes this required when any of them is present.
RequiredIf []uint64
}

// Metadata is the cold table, indexed by key.
Expand Down Expand Up @@ -92,6 +109,16 @@ const (
Unset
)

// Given reports whether a source counts as the flag having been *given*, which
// is the question the rules comparing two entries ask.
//
// A default does not count, and that is the whole reason this exists. usage-lib
// and clap both treat `env` as a value source and a default as a fallback: with
// `--file` defaulted and only `--stdin` typed, a declared conflict between them
// does not fire. Counting the default would make a defaulted flag conflict with
// every partner anyone types, which is a CLI nobody can use.
func (s Source) Given() bool { return s == FromArgv || s == FromEnv }

// Fill applies the fallbacks: command line, then environment, then default.
//
// `given` is what binding produced, and nil means the command line said nothing —
Expand Down
15 changes: 15 additions & 0 deletions go/argv/post_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,18 @@ func TestPostBindingIsOffTheHotPath(t *testing.T) {
t.Errorf("want 0 allocations, got %v", n)
}
}

// TestSourceGiven pins the asymmetry the relationship rules depend on.
//
// The command line and the environment count as the user having said something;
// a default does not. Counting a default would make a defaulted flag conflict
// with every partner anyone types, and usage-lib agrees: with `--file` defaulted
// and only `--stdin` given, a declared conflict between them does not fire.
func TestSourceGiven(t *testing.T) {
if !FromArgv.Given() || !FromEnv.Given() {
t.Error("argv and env are both the user supplying a value")
}
if FromDefault.Given() || Unset.Given() {
t.Error("a default is a fallback, not something the user said")
}
}
169 changes: 169 additions & 0 deletions go/argv/relationships.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package argv

// The rules that compare one entry against another.
//
// Everything in post.go judges an entry on its own: is it there, is its value
// allowed, are there enough of them. These four need a second entry to answer at
// all, which is why they are separate — a name in the declaration has to be
// resolved to the entry it refers to before any of it can be checked, and that
// resolution happens where the whole command is visible rather than here.
//
// `overrides` is the odd one. The other three are decided once the last token has
// been read; this one asks which of two flags came *last*, which only the
// arriving tokens know. So it is applied first, on the order binding reports, and
// what it removes is removed before anything else looks.

// ApplyOverrides decides last-one-wins between flags declared to override each
// other, returning the keys that lost.
//
// `order` gives the position of each key's last occurrence on the command line.
// A key absent from it was not typed, and cannot win or lose: the declaration is
// about which of two *given* flags survives.
//
// A loser must be treated as absent by everything downstream, and in particular
// must not be refilled from `env` or `default`. Filling it afterwards would leave
// both flags standing and undo the last-one-wins the user asked for by typing the
// second one.
//
// The relationship is symmetric however it was declared. `--file overrides
// --stdin` establishes the pair; it does not mean `--file` always wins. The
// corpus pins that directly: with `--file` declaring it and `--stdin` typed last,
// `--file` is the one that loses.
func ApplyOverrides(meta Metadata, order map[uint64]int) map[uint64]bool {
if len(order) < 2 {
return nil
}
var lost map[uint64]bool
drop := func(key uint64) {
if lost == nil {
lost = map[uint64]bool{}
}
lost[key] = true
}

for key, at := range order {
m := meta.Lookup(key)
if m == nil {
continue
}
for _, other := range m.Overrides {
otherAt, given := order[other]
if !given {
continue
}
// Equal positions cannot happen — two flags cannot share a token — but
// were it ever to, dropping neither is the safer answer than dropping
// both and losing the value entirely.
if otherAt < at {
drop(other)
} else if at < otherAt {
drop(key)
}
}
}
return lost
}

// CheckRelationships verifies the rules that read one entry's state to judge
// another, once every entry's final state is known.
//
// `entries` is every key in scope, so each declaration is visited once, and
// `sourceOf` reports where each entry's value came from — the whole [Source]
// rather than a yes or no, because the rules need both readings of it.
//
// As a *partner*, only the command line and the environment count. `conflicts`
// asks whether a flag has a value rather than how it got one, so an environment
// variable counts on both sides and the corpus pins the one-sided and
// neither-side-typed cases. A default does not count: it is a fallback rather
// than something the user said, and counting it would make a defaulted flag
// conflict with every partner anyone types.
//
// As the entry *being judged*, a default does count — it has a value, so it is
// not missing. usage-lib agrees on both halves, and a caller that collapsed
// `sourceOf` into one predicate would get one of them wrong whichever way it
// chose.
//
// A key removed by [ApplyOverrides] should not appear in `entries` at all: it
// lost, so it is out of the running rather than merely absent.
Comment thread
cursor[bot] marked this conversation as resolved.
func CheckRelationships(meta Metadata, entries []uint64, sourceOf func(uint64) Source) *Error {
given := func(key uint64) bool { return sourceOf(key).Given() }

for _, key := range entries {
m := meta.Lookup(key)
if m == nil {
continue
}

if given(key) {
for _, other := range m.Conflicts {
if given(other) {
// Both names, because either alone reads as a puzzle: which flag
// is unwelcome depends entirely on what else was given.
return &Error{
Code: CodeConflictingFlags,
Name: m.Name,
Other: nameOf(meta, other),
}
}
}
continue
}

// Not given — but a default still fills it, and an entry that has a value
// is not missing whatever supplied it. That is the asymmetry: a default
// counts for the entry being judged and not for the partners judging it.
// usage-lib draws it in exactly the same place, which is worth spelling
// out because getting it backwards is silent either way:
//
// --file defaulted, required_unless="--stdin", nothing typed → fine
// --stdin defaulted, --file required_unless="--stdin" → --file missing
if sourceOf(key) != Unset {
continue
}

// Both are skipped where the entry is already `Required`, since that has
// been answered by Check and reporting it twice helps nobody.
if m.Required {
continue
}

// Required unless one of these is present. With none of them present the
// requirement stands.
if len(m.RequiredUnless) > 0 && !anySet(m.RequiredUnless, given) {
return missingRequired(m)
}

// Required because one of these is present.
if len(m.RequiredIf) > 0 && anySet(m.RequiredIf, given) {
return missingRequired(m)
}
}
return nil
}

func missingRequired(m *Meta) *Error {
code := CodeMissingRequiredArg
if m.Flag {
code = CodeMissingRequiredFlag
}
return &Error{Code: code, Name: m.Name}
}

func anySet(keys []uint64, isSet func(uint64) bool) bool {
for _, k := range keys {
if isSet(k) {
return true
}
}
return false
}

// nameOf renders the other side of a relationship, falling back to nothing rather
// than to a number: an error naming `key 7` is worse than one naming only the
// flag the reader already knows about.
func nameOf(meta Metadata, key uint64) string {
if m := meta.Lookup(key); m != nil {
return m.Name
}
return ""
}
Loading