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
4 changes: 3 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 40 additions & 5 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,21 @@ a derive macro:
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.
**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
rather than by a feature flag:

| a CLI that… | carries | mise-sized binary |
| ------------------------------ | ---------------- | ----------------: |
| only binds | the parse tables | 2.60 MB |
| applies the post-binding rules | `+ Meta` | 2.82 MB |
| prints help | `+ HelpText` | 2.82 MB |

None of them has an init function. That is what Rust gets from putting the cold
half behind a feature flag, except nobody has to remember the flag — which is also
why help text is a third table rather than more fields on `Meta`: folding them
together would make every CLI that applies a rule carry every help string in the
spec.

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
Expand All @@ -118,6 +129,25 @@ var (
)
```

## Help

`argv.UsageLine` renders the line a page prints after `Usage: `, from the parse
tables and `HelpText`:

```go
argv.UsageLine([]string{"mise"}, mise.Root, mise.HelpText)
// mise [FLAGS] [TASK] <SUBCOMMAND>
```

**All 211 of mise's usage lines match usage-lib's byte for byte**, which is the
test that keeps it honest. usage-lib builds the line from a spec through a
template over a runtime model; this builds it from static tables. Reimplemented
rules drift, so both are run over mise's real spec and compared — the same check
`benches/gate/tests/help.rs` makes for usage-argv, against the same reference.

Two implementations checked against one oracle beats two checked against each
other.

## Conformance

The [corpus](../corpus) is the definition of correct, and it is plain JSON so that
Expand Down Expand Up @@ -153,7 +183,12 @@ claim is measured at real scale rather than against a fixture with four flags:
- **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.
- **The help pages themselves.** The usage line is done and the table behind it
carries the text; `-h` and `--help` still need laying out, which on the Rust
side is most of `argv/src/help.rs`.
- **Errors worth reading.** `Error()` returns `unknown flag: --wat`, which names
the problem and helps nobody fix it. usage-argv renders these through miette
with the offending token underlined.
- **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
(`Collecting`, `PendingArg`, `FlagsInScope`, `CommandStart`) are already here.
227 changes: 227 additions & 0 deletions go/argv/help.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
package argv

import "strings"

// What a help page prints.
//
// A third table, separate from [Meta] rather than folded into it, because Go's
// linker drops an unreferenced package-level symbol whole. One table would mean
// a CLI that applies the post-binding rules also carries every help string in
// the spec — mise's run to several hundred kilobytes. Three tables let a program
// pay for what it uses: binding alone carries neither, adding the rules carries
// [Metadata], and printing help carries this.
//
// Indexed by key like the others, so an entry's three halves are joined by
// identity rather than by position in three lists that could drift.

// Help is what a page needs to say about one command, flag or argument.
type Help struct {
// Key matches the entry this describes in the parse tables.
Key uint64
// Hide keeps an entry out of help without keeping it out of the parse. A
// hidden flag still binds; help simply does not invite anyone to type it.
Hide bool
// Demanded is `required` and undefaulted, which is what decides whether the
// usage line angles an entry or brackets it.
//
// Precomputed rather than read from [Meta], so that rendering a page does not
// drag the post-binding table in with it — which would undo the whole reason
// these are separate.
Demanded bool
// Repeatable is the spec's `var` on a flag: the `…` in `--tag… <t>`, meaning
// the flag may be given again, not that one occurrence takes several values.
Repeatable bool
// ValueName is what a flag's value is called. Empty for a flag that takes
// none.
ValueName string
// ValueDemanded is the same required-and-undefaulted test as [Help.Demanded],
// applied to the flag's *value* rather than to the flag.
//
// The two are independent, and usage-lib writes both: `<--v <n>>` is a
// required flag whose value must be given, and `<--jobs [n]>` is a required
// flag whose value has a default. Angling the value unconditionally — which
// is what usage-argv does — is invisible until a spec has a flag whose value
// is optional or defaulted, and mise has none.
ValueDemanded bool
// Short is the one-line help, and Long the fuller text `--help` prefers.
Short string
Long string
// Heading groups an entry into a section of the page. Presentational only.
Heading string
}

// HelpTable is the cold help table, indexed by key: entry `Key` sits at
// `HelpTable[Key-1]`.
type HelpTable []Help

// Lookup returns the help for a key, or nil if the table has none.
func (h HelpTable) Lookup(key uint64) *Help {
if key == 0 || key > uint64(len(h)) {
return nil
}
entry := &h[key-1]
if entry.Key != key {
// Out of step with the parse tables. Reporting nothing makes a caller's
// own test fail, where searching would quietly describe the wrong entry.
return nil
}
return entry
}

// inlineLimit is how many entries a usage line spells out before collapsing them
// into `[FLAGS]` or `[ARGS]…`. Two, as usage-lib has it.
const inlineLimit = 2

// UsageLine renders the line a page prints after `Usage: `.
//
// `path` is the command as invoked, binary first: `[]string{"mise", "use"}`.
//
// Hidden entries are absent from the line as they are from the sections — help
// describes what a user is invited to type.
func UsageLine(path []string, cmd *Command, help HelpTable) string {
var out strings.Builder
out.WriteString(strings.Join(path, " "))

visibleFlags := make([]*Flag, 0, len(cmd.Flags))
demandedFlag := false
for _, f := range cmd.Flags {
h := help.Lookup(f.Key)
if h != nil && h.Hide {
continue
}
visibleFlags = append(visibleFlags, f)
if h != nil && h.Demanded {
demandedFlag = true
}
}
if n := len(visibleFlags); n > 0 {
if n <= inlineLimit {
for _, f := range visibleFlags {
h := help.Lookup(f.Key)
// A required flag is angled like a required argument: the brackets
// are what say whether leaving it out is allowed.
open, close := "[", "]"
if h != nil && h.Demanded {
open, close = "<", ">"
}
out.WriteString(" " + open + flagUsage(f, h) + close)
}
} else if demandedFlag {
out.WriteString(" <FLAGS>")
} else {
out.WriteString(" [FLAGS]")
}
}

visibleArgs := make([]*Arg, 0, len(cmd.Args))
demandedArg := false
for _, a := range cmd.Args {
h := help.Lookup(a.Key)
if h != nil && h.Hide {
continue
}
visibleArgs = append(visibleArgs, a)
if h != nil && h.Demanded {
demandedArg = true
}
}
if n := len(visibleArgs); n > 0 {
if n <= inlineLimit {
for _, a := range visibleArgs {
out.WriteString(" " + argUsage(a, help.Lookup(a.Key)))
}
} else if demandedArg {
out.WriteString(" <ARGS>…")
} else {
out.WriteString(" [ARGS]…")
}
}

if len(cmd.Subcommands) > 0 {
out.WriteString(" <SUBCOMMAND>")
}
return out.String()
}

// flagUsage is how one flag appears in the usage line: `-f --force`, plus its
// value if it takes one.
func flagUsage(f *Flag, h *Help) string {
var out strings.Builder

long, short := "", byte(0)
if len(f.Longs) > 0 {
long = f.Longs[0]
}
if len(f.Shorts) > 0 {
short = f.Shorts[0]
}

// The declared name, when it is not the one the forms would imply. A flag
// called `verbose` reachable only as `-v` has to say so, or help would name
// something the spec does not.
implied := false
switch {
case long != "":
implied = long == f.Name
case short != 0:
implied = string(short) == f.Name
}
if !implied {
out.WriteString(f.Name + ":")
}
if short != 0 {
if out.Len() > 0 {
out.WriteByte(' ')
}
out.WriteString("-" + string(short))
}
if long != "" {
if out.Len() > 0 {
out.WriteByte(' ')
}
out.WriteString("--" + long)
}

// A repeatable flag, which is the spec's `var` — not one occurrence taking
// several values, which is the value's own business below.
if h != nil && h.Repeatable {
out.WriteString("…")
}
if f.TakesValue {
name := f.Name
if h != nil && h.ValueName != "" {
name = h.ValueName
}
open, close := "[", "]"
if h != nil && h.ValueDemanded {
open, close = "<", ">"
}
out.WriteString(" " + open + name + close)
if f.Variadic {
out.WriteString("…")
}
}
return out.String()
}

// argUsage is how one positional appears in the usage line.
func argUsage(a *Arg, h *Help) string {
open, close := "[", "]"
if h != nil && h.Demanded {
open, close = "<", ">"
}
var out strings.Builder
// An argument that only takes what follows a `--` shows the separator, because
// typing the value without it does not reach this argument at all — and the
// brackets go outside it, as usage-lib writes it: `[-- COMMAND]…`, one
// optional thing rather than a literal `--` followed by an optional word.
if a.DoubleDash == DoubleDashRequired {
out.WriteString(open + "-- " + a.Name + close)
} else {
out.WriteString(open + a.Name + close)
}
if a.Var {
out.WriteString("…")
}
return out.String()
}
6 changes: 5 additions & 1 deletion go/conformance/conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -452,9 +452,13 @@ func show(p *Parsed) string {
}

// lower turns a vector's KDL spec into the JSON the tables are built from.
func runUsage(usageBin string, args ...string) ([]byte, error) {
return exec.Command(usageBin, args...).Output()
}

func lower(t *testing.T, usageBin, kdl string) *spec.Spec {
t.Helper()
out, err := exec.Command(usageBin, "generate", "json", "--spec", kdl).Output()
out, err := runUsage(usageBin, "generate", "json", "--spec", kdl)
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
t.Fatalf("lowering the spec failed: %v\n%s", err, ee.Stderr)
Expand Down
Loading
Loading