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
20 changes: 10 additions & 10 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,16 @@ a derive macro:
//go:generate usage generate go -f mycli.usage.kdl -o tables.go
```

The generated file exports `Root` to pass to `argv.New`, and a key constant per
command, flag and argument. Dispatch on those rather than on `Name`: it costs no
string comparison, and a flag renamed in the spec then fails to compile instead of
The generated file exports `Root` to pass to `argv.New`, `Meta` for the rules
decided after the last token, and a key constant per command, flag and argument.

`Meta` costs nothing if you do not use it: Go's linker drops an unreferenced
package-level table entirely, so a CLI that only binds does not carry it. mise's
is 217 KB when something does reference it. That is the same split Rust gets from
a feature flag, without needing one.

Dispatch on the key constants rather than on `Name`: it costs no string
comparison, and a flag renamed in the spec then fails to compile instead of
silently never matching.

Writing tables by hand is supported too, and is what
Expand All @@ -111,9 +118,6 @@ var (
)
```

Dispatch on `Key` rather than `Name` in generated code: it is what the field
identifiers are for, and it costs no string comparison.

## Conformance

The [corpus](../corpus) is the definition of correct, and it is plain JSON so that
Expand Down Expand Up @@ -146,10 +150,6 @@ claim is measured at real scale rather than against a fixture with four flags:

## What is missing

- **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.
Expand Down
207 changes: 207 additions & 0 deletions go/internal/shadow/mise/meta_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
package mise

import (
"testing"

"github.com/jdx/usage/go/argv"
)

// The generated cold table, driving the rules it exists for.
//
// The unit tests in `argv` prove the rules against tables written by hand; the
// corpus proves them against tables built from a spec at run time. Neither
// exercises the emitter's half, which is where a field can be dropped, misnamed,
// or filed under the wrong key without anything noticing. These are the join.

// resolve runs a parse and applies the rules to one named entry, returning what
// it ended up with.
func resolve(t *testing.T, name string, args []string,
environ map[string]string) (values []string, source argv.Source, err *argv.Error) {
t.Helper()

// A value-less flag that was given has no values, and nil would read as "the
// command line said nothing" — so it is recorded as an empty slice, which is
// the distinction Fill draws. Getting this wrong makes a typed boolean fall
// through to env and default.
given := map[uint64][]string{}
occurrences := map[uint64]int{}
path := []*argv.Command{Root}

p := argv.New(Root, args)
for p.Next() {
ev := p.Event()
switch ev.Kind {
case argv.KindCommand:
path = append(path, ev.Command)
case argv.KindFlag:
occurrences[ev.Flag.Key]++
if ev.HasValue {
given[ev.Flag.Key] = append(given[ev.Flag.Key], ev.Value)
} else if given[ev.Flag.Key] == nil {
given[ev.Flag.Key] = []string{}
}
case argv.KindArg:
occurrences[ev.Arg.Key]++
given[ev.Arg.Key] = append(given[ev.Arg.Key], ev.Value)
}
}
if e := p.Err(); e != nil {
t.Fatalf("binding failed: %v", e)
}

lookup := func(k string) (string, bool) { v, ok := environ[k]; return v, ok }

for _, cmd := range path {
for _, f := range cmd.Flags {
v, src := argv.Fill(Meta.Lookup(f.Key), given[f.Key], lookup)
if e := argv.Check(Meta.Lookup(f.Key), v, occurrences[f.Key]); e != nil && err == nil {
err = e
}
if f.Name == name {
values, source = v, src
}
}
for _, a := range cmd.Args {
v, src := argv.Fill(Meta.Lookup(a.Key), given[a.Key], lookup)
if e := argv.Check(Meta.Lookup(a.Key), v, 0); e != nil && err == nil {
err = e
}
if a.Name == name {
values, source = v, src
}
}
}
return values, source, err
}

// mise declares `--manager` on `bootstrap packages import` with both a default
// and the single choice that default names, which makes it the one entry that
// exercises the whole cold table at once.
func TestAGeneratedDefaultFills(t *testing.T) {
values, source, err := resolve(t, "manager",
[]string{"bootstrap", "packages", "import"}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if source != argv.FromDefault {
t.Errorf("want the default, got %v", source)
}
if len(values) != 1 || values[0] != "brew" {
t.Errorf("want [brew], got %q", values)
}
}

// The choices came from a `choices` block on the flag's *value*, which is a level
// of nesting the emitter has to read through.
func TestGeneratedChoicesAreEnforced(t *testing.T) {
if _, _, err := resolve(t, "log-level", []string{"--log-level", "debug"}, nil); err != nil {
t.Fatalf("a declared choice should be accepted: %v", err)
}
_, _, err := resolve(t, "log-level", []string{"--log-level", "chatty"}, nil)
if err == nil {
t.Fatal("a value outside the choices should be refused")
}
if err.Code != argv.CodeInvalidChoice || err.Name != "log-level" {
t.Errorf("want invalid_choice for log-level, got %q for %q", err.Code, err.Name)
}
}

// The command line beats the default, which is the ordering the whole fallback
// rests on.
func TestTheCommandLineBeatsAGeneratedDefault(t *testing.T) {
values, source, err := resolve(t, "manager",
[]string{"bootstrap", "packages", "import", "--manager", "brew"}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if source != argv.FromArgv || len(values) != 1 || values[0] != "brew" {
t.Errorf("want brew from argv, got %q from %v", values, source)
}
}

// The two tables are separate data joined only by key, and the emitter writes
// them in two passes. If those ever disagree, every rule reads the wrong
// declaration — so it is checked across all 989 entries rather than sampled.
func TestEveryEntryHasMetadataDescribingItself(t *testing.T) {
var checked int
var walk func(*argv.Command)
walk = func(c *argv.Command) {
// A command takes a key and has no cold half, so its slot is empty and
// Lookup should report nothing rather than a neighbour's entry.
if m := Meta.Lookup(c.Key); m != nil {
t.Errorf("command %q has metadata %+v", c.Name, m)
}
for _, f := range c.Flags {
m := Meta.Lookup(f.Key)
if m == nil {
t.Errorf("flag %q of %q has no metadata", f.Name, c.Name)
continue
}
if m.Name != f.Name || !m.Flag {
t.Errorf("flag %q of %q got metadata for %q (flag=%v)",
f.Name, c.Name, m.Name, m.Flag)
}
checked++
}
for _, a := range c.Args {
m := Meta.Lookup(a.Key)
if m == nil {
t.Errorf("arg %q of %q has no metadata", a.Name, c.Name)
continue
}
if m.Name != a.Name || m.Flag {
t.Errorf("arg %q of %q got metadata for %q (flag=%v)",
a.Name, c.Name, m.Name, m.Flag)
}
checked++
}
for _, sub := range c.Subcommands {
walk(sub)
}
}
walk(Root)

if checked < 800 {
t.Errorf("only %d entries checked: the tables are probably truncated", checked)
}
}

// Every key a relationship points at must exist, or the rule silently does
// nothing. The emitter drops names it cannot resolve, so this is where a spec
// that names a flag which does not exist would show up.
func TestRelationshipsPointAtRealEntries(t *testing.T) {
for i := range Meta {
m := &Meta[i]
for _, group := range [][]uint64{
m.Conflicts, m.Overrides, m.RequiredUnless, m.RequiredIf,
} {
for _, key := range group {
if Meta.Lookup(key) == nil {
t.Errorf("%q points at key %d, which is not an entry", m.Name, key)
}
}
}
}
}

// A value-less flag typed on the command line must not fall through to the
// fallbacks. mise's `--quiet` is one, and the distinction is invisible unless
// something asks: `Fill` reads a nil `given` as "the command line said nothing",
// so a helper that only records values reports a typed boolean as unset.
func TestATypedBooleanCountsAsGiven(t *testing.T) {
_, source, err := resolve(t, "quiet", []string{"--quiet"}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if source != argv.FromArgv {
t.Errorf("a typed boolean should come from argv, got %v", source)
}

_, source, err = resolve(t, "quiet", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if source != argv.Unset {
t.Errorf("untyped, with nothing declared to fill it, should be unset: %v", source)
}
}
Loading