diff --git a/README.md b/README.md index 8d41afb..773443d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Go Reference](https://pkg.go.dev/badge/github.com/coder/serpent.svg)](https://pkg.go.dev/github.com/coder/serpent) -`serpent` is a CLI configuration framework based on [cobra](https://github.com/spf13/cobra) and used by [coder/coder](https://github.com/coder/coder). +`serpent` is a Go CLI configuration framework based on [cobra](https://github.com/spf13/cobra) and used by [coder/coder](https://github.com/coder/coder). It's designed for large-scale CLIs with dozens of commands and hundreds of options. If you're building a small, self-contained tool, go with cobra. diff --git a/cmd.go b/cmd.go index e0d1d20..8d855c0 100644 --- a/cmd.go +++ b/cmd.go @@ -421,7 +421,7 @@ func (inv *Invocation) run(state *runState) error { if inv.Command.Handler == nil || errors.Is(state.flagParseErr, pflag.ErrHelp) { if inv.Command.HelpHandler == nil { - return xerrors.Errorf("no handler or help for command %s", inv.Command.FullName()) + return defaultHelpFn()(inv) } return inv.Command.HelpHandler(inv) } diff --git a/go.mod b/go.mod index 5ac2c8a..060ce3c 100644 --- a/go.mod +++ b/go.mod @@ -5,26 +5,35 @@ go 1.21.4 require ( cdr.dev/slog v1.6.2-0.20240126064726-20367d4aede6 github.com/coder/coder/v2 v2.8.3 + github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 github.com/hashicorp/go-multierror v1.1.1 github.com/mitchellh/go-wordwrap v1.0.1 + github.com/muesli/termenv v0.15.2 github.com/pion/udp v0.1.4 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.8.4 + golang.org/x/crypto v0.19.0 golang.org/x/exp v0.0.0-20240213143201-ec583247a57a golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect github.com/pion/transport/v2 v2.0.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.4 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect go.opentelemetry.io/otel v1.19.0 // indirect go.opentelemetry.io/otel/trace v1.19.0 // indirect golang.org/x/net v0.21.0 // indirect + golang.org/x/sys v0.17.0 // indirect golang.org/x/term v0.17.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index ba9133c..bffd90c 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/charmbracelet/lipgloss v0.8.0 h1:IS00fk4XAHcf8uZKc3eHeMUTCxUH6NkaTrdy github.com/charmbracelet/lipgloss v0.8.0/go.mod h1:p4eYUZZJ/0oXTuCQKFF8mqyKCz0ja6y+7DniDDw5KKU= github.com/coder/coder/v2 v2.8.3 h1:DpdiCfKhyKd6hwJKOeHf6rdYd2+petdI76qDCl9CsCs= github.com/coder/coder/v2 v2.8.3/go.mod h1:sH8OtYWiRq/dDYt/65T8tX1IXFOUDT81RtgMrJWf6HU= +github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= +github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -62,6 +64,7 @@ github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsK github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= @@ -88,6 +91,8 @@ go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1 go.opentelemetry.io/otel/trace v1.19.0/go.mod h1:mfaSyvGyEJEI0nyV2I4qhNQnbBOUUmYZpYojqMnX2vo= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/exp v0.0.0-20240213143201-ec583247a57a h1:HinSgX1tJRX3KsL//Gxynpw5CTOAIPhgL4W8PNiIpVE= golang.org/x/exp v0.0.0-20240213143201-ec583247a57a/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -106,6 +111,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/help.go b/help.go new file mode 100644 index 0000000..087a6db --- /dev/null +++ b/help.go @@ -0,0 +1,352 @@ +package serpent + +import ( + "bufio" + _ "embed" + "flag" + "fmt" + "os" + "regexp" + "sort" + "strings" + "sync" + "text/tabwriter" + "text/template" + + "github.com/mitchellh/go-wordwrap" + "github.com/muesli/termenv" + "golang.org/x/crypto/ssh/terminal" + "golang.org/x/xerrors" + + "github.com/coder/pretty" +) + +//go:embed help.tpl +var helpTemplateRaw string + +type optionGroup struct { + Name string + Description string + Options OptionSet +} + +func ttyWidth() int { + width, _, err := terminal.GetSize(0) + if err != nil { + return 80 + } + return width +} + +// wrapTTY wraps a string to the width of the terminal, or 80 no terminal +// is detected. +func wrapTTY(s string) string { + return wordwrap.WrapString(s, uint(ttyWidth())) +} + +var ( + helpColorProfile termenv.Profile + helpColorOnce sync.Once +) + +// Color returns a color for the given string. +func helpColor(s string) termenv.Color { + helpColorOnce.Do(func() { + helpColorProfile = termenv.NewOutput(os.Stdout).ColorProfile() + if flag.Lookup("test.v") != nil { + // Use a consistent colorless profile in tests so that results + // are deterministic. + helpColorProfile = termenv.Ascii + } + }) + return helpColorProfile.Color(s) +} + +var defaultHelpTemplate = func() *template.Template { + var ( + optionFg = pretty.FgColor( + helpColor("#04A777"), + ) + headerFg = pretty.FgColor( + helpColor("#337CA0"), + ) + ) + return template.Must( + template.New("usage").Funcs( + template.FuncMap{ + "wrapTTY": func(s string) string { + return wrapTTY(s) + }, + "trimNewline": func(s string) string { + return strings.TrimSuffix(s, "\n") + }, + "keyword": func(s string) string { + txt := pretty.String(s) + optionFg.Format(txt) + return txt.String() + }, + "prettyHeader": func(s string) string { + s = strings.ToUpper(s) + txt := pretty.String(s, ":") + headerFg.Format(txt) + return txt.String() + }, + "typeHelper": func(opt *Option) string { + switch v := opt.Value.(type) { + case *Enum: + return strings.Join(v.Choices, "|") + default: + return v.Type() + } + }, + "joinStrings": func(s []string) string { + return strings.Join(s, ", ") + }, + "indent": func(body string, spaces int) string { + twidth := ttyWidth() + + spacing := strings.Repeat(" ", spaces) + + wrapLim := twidth - len(spacing) + body = wordwrap.WrapString(body, uint(wrapLim)) + + sc := bufio.NewScanner(strings.NewReader(body)) + + var sb strings.Builder + for sc.Scan() { + // Remove existing indent, if any. + // line = strings.TrimSpace(line) + // Use spaces so we can easily calculate wrapping. + _, _ = sb.WriteString(spacing) + _, _ = sb.Write(sc.Bytes()) + _, _ = sb.WriteString("\n") + } + return sb.String() + }, + "rootCommandName": func(cmd *Cmd) string { + return strings.Split(cmd.FullName(), " ")[0] + }, + "formatSubcommand": func(cmd *Cmd) string { + // Minimize padding by finding the longest neighboring name. + maxNameLength := len(cmd.Name()) + if parent := cmd.Parent; parent != nil { + for _, c := range parent.Children { + if len(c.Name()) > maxNameLength { + maxNameLength = len(c.Name()) + } + } + } + + var sb strings.Builder + _, _ = fmt.Fprintf( + &sb, "%s%s%s", + strings.Repeat(" ", 4), cmd.Name(), strings.Repeat(" ", maxNameLength-len(cmd.Name())+4), + ) + + // This is the point at which indentation begins if there's a + // next line. + descStart := sb.Len() + + twidth := ttyWidth() + + for i, line := range strings.Split( + wordwrap.WrapString(cmd.Short, uint(twidth-descStart)), "\n", + ) { + if i > 0 { + _, _ = sb.WriteString(strings.Repeat(" ", descStart)) + } + _, _ = sb.WriteString(line) + _, _ = sb.WriteString("\n") + } + + return sb.String() + }, + "envName": func(opt Option) string { + if opt.Env == "" { + return "" + } + return opt.Env + }, + "flagName": func(opt Option) string { + return opt.Flag + }, + + "isDeprecated": func(opt Option) bool { + return len(opt.UseInstead) > 0 + }, + "useInstead": func(opt Option) string { + var sb strings.Builder + for i, s := range opt.UseInstead { + if i > 0 { + if i == len(opt.UseInstead)-1 { + _, _ = sb.WriteString(" and ") + } else { + _, _ = sb.WriteString(", ") + } + } + if s.Flag != "" { + _, _ = sb.WriteString("--") + _, _ = sb.WriteString(s.Flag) + } else if s.FlagShorthand != "" { + _, _ = sb.WriteString("-") + _, _ = sb.WriteString(s.FlagShorthand) + } else if s.Env != "" { + _, _ = sb.WriteString("$") + _, _ = sb.WriteString(s.Env) + } else { + _, _ = sb.WriteString(s.Name) + } + } + return sb.String() + }, + "formatGroupDescription": func(s string) string { + s = strings.ReplaceAll(s, "\n", "") + s = s + "\n" + s = wrapTTY(s) + return s + }, + "visibleChildren": func(cmd *Cmd) []*Cmd { + return filterSlice(cmd.Children, func(c *Cmd) bool { + return !c.Hidden + }) + }, + "optionGroups": func(cmd *Cmd) []optionGroup { + groups := []optionGroup{{ + // Default group. + Name: "", + Description: "", + }} + + // Sort options lexicographically. + sort.Slice(cmd.Options, func(i, j int) bool { + return cmd.Options[i].Name < cmd.Options[j].Name + }) + + optionLoop: + for _, opt := range cmd.Options { + if opt.Hidden { + continue + } + + if len(opt.Group.Ancestry()) == 0 { + // Just add option to default group. + groups[0].Options = append(groups[0].Options, opt) + continue + } + + groupName := opt.Group.FullName() + + for i, foundGroup := range groups { + if foundGroup.Name != groupName { + continue + } + groups[i].Options = append(groups[i].Options, opt) + continue optionLoop + } + + groups = append(groups, optionGroup{ + Name: groupName, + Description: opt.Group.Description, + Options: OptionSet{opt}, + }) + } + sort.Slice(groups, func(i, j int) bool { + // Sort groups lexicographically. + return groups[i].Name < groups[j].Name + }) + + return filterSlice(groups, func(g optionGroup) bool { + return len(g.Options) > 0 + }) + }, + }, + ).Parse(helpTemplateRaw), + ) +}() + +func filterSlice[T any](s []T, f func(T) bool) []T { + var r []T + for _, v := range s { + if f(v) { + r = append(r, v) + } + } + return r +} + +// newLineLimiter makes working with Go templates more bearable. Without this, +// modifying the template is a slow toil of counting newlines and constantly +// checking that a change to one command's help doesn't break another. +type newlineLimiter struct { + // w is not an interface since we call WriteRune byte-wise, + // and the devirtualization overhead is significant. + w *bufio.Writer + limit int + + newLineCounter int +} + +// isSpace is a based on unicode.IsSpace, but only checks ASCII characters. +func isSpace(b byte) bool { + switch b { + case '\t', '\n', '\v', '\f', '\r', ' ', 0x85, 0xA0: + return true + } + return false +} + +func (lm *newlineLimiter) Write(p []byte) (int, error) { + for _, b := range p { + switch { + case b == '\r': + // Carriage returns can sneak into `help.tpl` when `git clone` + // is configured to automatically convert line endings. + continue + case b == '\n': + lm.newLineCounter++ + if lm.newLineCounter > lm.limit { + continue + } + case !isSpace(b): + lm.newLineCounter = 0 + } + err := lm.w.WriteByte(b) + if err != nil { + return 0, err + } + } + return len(p), nil +} + +var usageWantsArgRe = regexp.MustCompile(`<.*>`) + +// defaultHelpFn returns a function that generates usage (help) +// output for a given command. +func defaultHelpFn() HandlerFunc { + return func(inv *Invocation) error { + // We use stdout for help and not stderr since there's no straightforward + // way to distinguish between a user error and a help request. + // + // We buffer writes to stdout because the newlineLimiter writes one + // rune at a time. + outBuf := bufio.NewWriter(inv.Stdout) + out := newlineLimiter{w: outBuf, limit: 2} + tabwriter := tabwriter.NewWriter(&out, 0, 0, 2, ' ', 0) + err := defaultHelpTemplate.Execute(tabwriter, inv.Command) + if err != nil { + return xerrors.Errorf("execute template: %w", err) + } + err = tabwriter.Flush() + if err != nil { + return err + } + err = outBuf.Flush() + if err != nil { + return err + } + if len(inv.Args) > 0 && !usageWantsArgRe.MatchString(inv.Command.Use) { + _, _ = fmt.Fprintf(inv.Stderr, "---\nerror: unknown subcommand %q\n", inv.Args[0]) + } + return nil + } +} diff --git a/help.tpl b/help.tpl new file mode 100644 index 0000000..bcda2d6 --- /dev/null +++ b/help.tpl @@ -0,0 +1,54 @@ +{{- /* Heavily inspired by the Go toolchain and fd */ -}} +{{prettyHeader "Usage"}} +{{indent .FullUsage 2}} + + +{{ with .Short }} +{{- indent . 2 | wrapTTY }} +{{"\n"}} +{{- end}} + +{{ with .Aliases }} +{{" Aliases: "}} {{- joinStrings .}} +{{- end }} + +{{- with .Long}} +{{"\n"}} +{{- indent . 2}} +{{ "\n" }} +{{- end }} +{{ with visibleChildren . }} +{{- range $index, $child := . }} +{{- if eq $index 0 }} +{{ prettyHeader "Subcommands"}} +{{- end }} + {{- "\n" }} + {{- formatSubcommand . | trimNewline }} +{{- end }} +{{- "\n" }} +{{- end }} +{{- range $index, $group := optionGroups . }} +{{ with $group.Name }} {{- print $group.Name " Options" | prettyHeader }} {{ else -}} {{ prettyHeader "Options"}}{{- end -}} +{{- with $group.Description }} +{{ formatGroupDescription . }} +{{- else }} +{{- end }} + {{- range $index, $option := $group.Options }} + {{- if not (eq $option.FlagShorthand "") }}{{- print "\n "}} {{ keyword "-"}}{{keyword $option.FlagShorthand }}{{", "}} + {{- else }}{{- print "\n " -}} + {{- end }} + {{- with flagName $option }}{{keyword "--"}}{{ keyword . }}{{ end }} {{- with typeHelper $option }} {{ . }}{{ end }} + {{- with envName $option }}, {{ print "$" . | keyword }}{{ end }} + {{- with $option.Default }} (default: {{ . }}){{ end }} + {{- with $option.Description }} + {{- $desc := $option.Description }} +{{ indent $desc 10 }} +{{- if isDeprecated $option }}{{ indent (printf "DEPRECATED: Use %s instead." (useInstead $option)) 10 }}{{ end }} + {{- end -}} + {{- end }} +{{- end }} +{{- if .Parent }} +——— +Run `{{ rootCommandName . }} --help` for a list of global options. +{{- else }} +{{- end }}