coder/cli/help.go

367 lines
9.2 KiB
Go

package cli
import (
"bufio"
_ "embed"
"fmt"
"os"
"regexp"
"slices"
"sort"
"strings"
"text/tabwriter"
"text/template"
"github.com/mitchellh/go-wordwrap"
"golang.org/x/crypto/ssh/terminal"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
//go:embed help.tpl
var helpTemplateRaw string
type optionGroup struct {
Name string
Description string
Options serpent.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 usageTemplate = func() *template.Template {
var (
optionFg = pretty.FgColor(
cliui.Color("#04A777"),
)
headerFg = pretty.FgColor(
cliui.Color("#337CA0"),
)
)
return template.Must(
template.New("usage").Funcs(
template.FuncMap{
"version": func() string {
return buildinfo.Version()
},
"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 *serpent.Option) string {
switch v := opt.Value.(type) {
case *serpent.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()
},
"formatSubcommand": func(cmd *serpent.Command) 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 serpent.Option) string {
if opt.Env == "" {
return ""
}
return opt.Env
},
"flagName": func(opt serpent.Option) string {
return opt.Flag
},
"isEnterprise": func(opt serpent.Option) bool {
return opt.Annotations.IsSet("enterprise")
},
"isDeprecated": func(opt serpent.Option) bool {
return len(opt.UseInstead) > 0
},
"useInstead": func(opt serpent.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 *serpent.Command) []*serpent.Command {
return filterSlice(cmd.Children, func(c *serpent.Command) bool {
return !c.Hidden
})
},
"optionGroups": func(cmd *serpent.Command) []optionGroup {
groups := []optionGroup{{
// Default group.
Name: "",
Description: "",
}}
enterpriseGroup := optionGroup{
Name: "Enterprise",
Description: `These options are only available in the Enterprise Edition.`,
}
// 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
}
// Enterprise options are always grouped separately.
if opt.Annotations.IsSet("enterprise") {
enterpriseGroup.Options = append(enterpriseGroup.Options, opt)
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: serpent.OptionSet{opt},
})
}
sort.Slice(groups, func(i, j int) bool {
// Sort groups lexicographically.
return groups[i].Name < groups[j].Name
})
// Always show enterprise group last.
groups = append(groups, enterpriseGroup)
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(`<.*>`)
// helpFn returns a function that generates usage (help)
// output for a given command.
func helpFn() serpent.HandlerFunc {
return func(inv *serpent.Invocation) error {
// Check for invalid subcommands before printing help.
if len(inv.Args) > 0 && !usageWantsArgRe.MatchString(inv.Command.Use) {
_, _ = fmt.Fprintf(inv.Stderr, "---\nerror: unrecognized subcommand %q\n", inv.Args[0])
}
if len(inv.Args) > 0 {
// Return an error so that exit status is non-zero when
// a subcommand is not found.
err := xerrors.Errorf("unrecognized subcommand %q", strings.Join(inv.Args, " "))
if slices.Contains(os.Args, "--help") {
// Subcommand error is not wrapped in RunCommandErr if command
// is invoked with --help with no HelpHandler
return &serpent.RunCommandError{
Cmd: inv.Command,
Err: err,
}
}
return err
}
// 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 := usageTemplate.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
}
return nil
}
}