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
23 changes: 21 additions & 2 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,27 @@ 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`, `Meta` for the rules
decided after the last token, and a key constant per command, flag and argument.
The generated file exports `Parse`, a struct per command, `Root` and the two cold
tables, and a key constant per entry:

```go
cli, err := mycli.Parse(os.Args[1:])
if err != nil {
fmt.Fprint(os.Stderr, argv.Render(err.(*argv.Error), path, chain, mycli.HelpText))
os.Exit(2)
}
if cli.Run != nil {
fmt.Println(cli.Run.Task, cli.Run.Args)
}
```

`Parse` binds, applies the post-binding rules, and fills the structs — a missing
required flag or a value outside its choices comes back rather than reaching your
code, and `env` and `default` values reach the fields.

Fields are `string`, `bool` and `[]string`, because that is what a spec knows: it
says what a value is _called_ and never what type it is. Turning `"8"` into an
`int` is what the conversions above are for.

**Three tables, and you pay for the ones you use.** Go's linker drops an
unreferenced package-level table entirely, so the split is enforced by the linker
Expand Down
9 changes: 9 additions & 0 deletions go/argv/post.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package argv

import "os"

// The rules that are decided once the last token has been read.
//
// Binding says which token becomes which flag or argument. These say whether
Expand Down Expand Up @@ -238,3 +240,10 @@ func contains(list []string, s string) bool {
}
return false
}

// LookupEnv reads the process environment, for callers that want it.
//
// [Fill] takes the lookup as a parameter rather than reading the environment
// itself, so that a test of a parse is not a test of the machine it runs on.
// Generated code passes this, because a real CLI does want the real environment.
func LookupEnv(name string) (string, bool) { return os.LookupEnv(name) }
71 changes: 71 additions & 0 deletions go/internal/shadow/mise/meta_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,3 +273,74 @@ func TestAGeneratedPageReadsAsAPage(t *testing.T) {
}
}
}

// The generated front door, on mise's real command lines.
//
// `Parse` is what an author actually calls, and it is generated from the same
// tables the rest of the suite checks — so this is the join between them: the
// structs exist, the right one is filled, and the rules still run.
func TestGeneratedParseFillsTheStructs(t *testing.T) {
cli, err := Parse([]string{"use", "-g", "node@20"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cli.Use == nil {
t.Fatal("`use` should be selected")
}
if !cli.Use.Global {
t.Error("-g should set Global")
}
if got := cli.Use.ToolVersion; len(got) != 1 || got[0] != "node@20" {
t.Errorf("want [node@20], got %q", got)
}
// A command nobody ran is nil, which is how a caller tells which was chosen.
if cli.Config != nil {
t.Error("`config` was not on the command line")
}
}

// The shape that made the Rust derive's validation wrong, through the front door.
func TestGeneratedParseSplitsArgsAcrossASeparator(t *testing.T) {
cli, err := Parse([]string{"tasks", "run", "build", "extra", "--", "--verbose"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
run := cli.Tasks.Run
if run == nil {
t.Fatal("`tasks run` should be selected")
}
if run.Task != "build" {
t.Errorf("want build, got %q", run.Task)
}
if len(run.Args) != 1 || run.Args[0] != "extra" {
t.Errorf("want [extra] before the separator, got %q", run.Args)
}
if len(run.ArgsLast) != 1 || run.ArgsLast[0] != "--verbose" {
t.Errorf("want [--verbose] after it, got %q", run.ArgsLast)
}
}

// A default reaches the field. mise's `bootstrap packages import --manager`
// defaults to `brew`, and a front door that enforces a default and hands back the
// zero value is worse than one with no defaults at all.
func TestGeneratedParseAppliesADefault(t *testing.T) {
cli, err := Parse([]string{"bootstrap", "packages", "import"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := cli.Bootstrap.Packages.Import.Manager; got != "brew" {
t.Errorf("want the declared default, got %q", got)
}
}

// And the rules still run: a value outside the choices comes back rather than
// reaching the struct.
func TestGeneratedParseEnforcesChoices(t *testing.T) {
_, err := Parse([]string{"--log-level", "chatty"})
if err == nil {
t.Fatal("a value outside the choices should be refused")
}
if e, ok := err.(*argv.Error); !ok || e.Code != argv.CodeInvalidChoice {
t.Errorf("want invalid_choice, got %v", err)
}
}
Loading