mirror of
https://github.com/coder/coder.git
synced 2025-07-06 15:41:45 +00:00
This change will improve over CLI performance and "snappiness" as well as substantially reduce our test times. Preliminary benchmarks show `coder server --help` times cut from 300ms to 120ms on my dogfood instance. The inefficiency of lipgloss disproportionately impacts our system, as all help text for every command is generated whenever any command is invoked. The `pretty` API could clean up a lot of the code (e.g., by replacing complex string concatenations with Printf), but this commit is too expansive as is so that work will be done in a follow up.
321 lines
8.0 KiB
Go
321 lines
8.0 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/v2/cli/clibase"
|
|
"github.com/coder/coder/v2/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.Bold(s)
|
|
},
|
|
"isEnterprise": func(opt clibase.Option) bool {
|
|
return opt.Annotations.IsSet("enterprise")
|
|
},
|
|
"isDeprecated": func(opt clibase.Option) bool {
|
|
return len(opt.UseInstead) > 0
|
|
},
|
|
"useInstead": func(opt clibase.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()
|
|
},
|
|
"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 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
|
|
}
|
|
if len(inv.Args) > 0 && !usageWantsArgRe.MatchString(inv.Command.Use) {
|
|
_, _ = fmt.Fprintf(inv.Stderr, "---\nerror: unknown subcommand %q\n", inv.Args[0])
|
|
}
|
|
return nil
|
|
}
|
|
}
|