mirror of https://github.com/coder/coder.git
293 lines
7.2 KiB
Go
293 lines
7.2 KiB
Go
package cli
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
_ "embed"
|
|
"fmt"
|
|
"io"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"text/template"
|
|
"unicode"
|
|
|
|
"github.com/mitchellh/go-wordwrap"
|
|
"golang.org/x/crypto/ssh/terminal"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/cli/clibase"
|
|
"github.com/coder/coder/cli/cliui"
|
|
)
|
|
|
|
//go:embed help.tpl
|
|
var helpTemplateRaw string
|
|
|
|
type optionGroup struct {
|
|
Name string
|
|
Description string
|
|
Options clibase.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 = 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")
|
|
},
|
|
"typeHelper": func(opt *clibase.Option) string {
|
|
switch v := opt.Value.(type) {
|
|
case *clibase.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)
|
|
|
|
body = wordwrap.WrapString(body, uint(twidth-len(spacing)))
|
|
|
|
var sb strings.Builder
|
|
for _, line := range strings.Split(body, "\n") {
|
|
// Remove existing indent, if any.
|
|
line = strings.TrimSpace(line)
|
|
// Use spaces so we can easily calculate wrapping.
|
|
_, _ = sb.WriteString(spacing)
|
|
_, _ = sb.WriteString(line)
|
|
_, _ = sb.WriteString("\n")
|
|
}
|
|
return sb.String()
|
|
},
|
|
"formatSubcommand": func(cmd *clibase.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 clibase.Option) string {
|
|
if opt.Env == "" {
|
|
return ""
|
|
}
|
|
return opt.Env
|
|
},
|
|
"flagName": func(opt clibase.Option) string {
|
|
return opt.Flag
|
|
},
|
|
"prettyHeader": func(s string) string {
|
|
return cliui.Styles.Bold.Render(s)
|
|
},
|
|
"isEnterprise": func(opt clibase.Option) bool {
|
|
return opt.Annotations.IsSet("enterprise")
|
|
},
|
|
"isDeprecated": func(opt clibase.Option) bool {
|
|
return len(opt.UseInstead) > 0
|
|
},
|
|
"formatLong": func(long string) string {
|
|
// We intentionally don't wrap here because it would misformat
|
|
// examples, where the new line would start without the prior
|
|
// line's indentation.
|
|
return strings.TrimSpace(long)
|
|
},
|
|
"formatGroupDescription": func(s string) string {
|
|
s = strings.ReplaceAll(s, "\n", "")
|
|
s = s + "\n"
|
|
s = wrapTTY(s)
|
|
return s
|
|
},
|
|
"visibleChildren": func(cmd *clibase.Cmd) []*clibase.Cmd {
|
|
return filterSlice(cmd.Children, func(c *clibase.Cmd) bool {
|
|
return !c.Hidden
|
|
})
|
|
},
|
|
"optionGroups": func(cmd *clibase.Cmd) []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: clibase.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 clobber break another.
|
|
type newlineLimiter struct {
|
|
w io.Writer
|
|
limit int
|
|
|
|
newLineCounter int
|
|
}
|
|
|
|
func (lm *newlineLimiter) Write(p []byte) (int, error) {
|
|
rd := bytes.NewReader(p)
|
|
for r, n, _ := rd.ReadRune(); n > 0; r, n, _ = rd.ReadRune() {
|
|
switch {
|
|
case r == '\r':
|
|
// Carriage returns can sneak into `help.tpl` when `git clone`
|
|
// is configured to automatically convert line endings.
|
|
continue
|
|
case r == '\n':
|
|
lm.newLineCounter++
|
|
if lm.newLineCounter > lm.limit {
|
|
continue
|
|
}
|
|
case !unicode.IsSpace(r):
|
|
lm.newLineCounter = 0
|
|
}
|
|
_, err := lm.w.Write([]byte(string(r)))
|
|
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() clibase.HandlerFunc {
|
|
return func(inv *clibase.Invocation) error {
|
|
// We buffer writes to stderr because the newlineLimiter writes one
|
|
// rune at a time.
|
|
stderrBuf := bufio.NewWriter(inv.Stderr)
|
|
out := newlineLimiter{w: stderrBuf, 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 = stderrBuf.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
|
|
}
|
|
}
|