Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

improve help text on narrow terminals #140

Merged
merged 8 commits into from
May 14, 2019
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
187 changes: 120 additions & 67 deletions cli/helptext.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,23 @@ import (
"errors"
"fmt"
"io"
"os"
"sort"
"strings"
"text/template"

"github.com/ipfs/go-ipfs-cmds"
cmds "github.com/ipfs/go-ipfs-cmds"
"golang.org/x/crypto/ssh/terminal"
)

const (
requiredArg = "<%v>"
optionalArg = "[<%v>]"
variadicArg = "%v..."
shortFlag = "-%v"
longFlag = "--%v"
optionType = "(%v)"
defaultTerminalWidth = 80
requiredArg = "<%v>"
optionalArg = "[<%v>]"
variadicArg = "%v..."
shortFlag = "-%v"
longFlag = "--%v"
optionType = "(%v)"

whitespace = "\r\n\t "

Expand All @@ -28,7 +31,6 @@ type helpFields struct {
Indent string
Usage string
Path string
ArgUsage string
Tagline string
Arguments string
Options string
Expand All @@ -48,7 +50,7 @@ type helpFields struct {
// `
func (f *helpFields) TrimNewlines() {
f.Path = strings.Trim(f.Path, "\n")
f.ArgUsage = strings.Trim(f.ArgUsage, "\n")
f.Usage = strings.Trim(f.Usage, "\n")
f.Tagline = strings.Trim(f.Tagline, "\n")
f.Arguments = strings.Trim(f.Arguments, "\n")
f.Options = strings.Trim(f.Options, "\n")
Expand All @@ -66,17 +68,16 @@ func (f *helpFields) IndentAll() {
return indentString(s, indentStr)
}

f.Usage = indent(f.Usage)
f.Arguments = indent(f.Arguments)
f.Options = indent(f.Options)
f.Synopsis = indent(f.Synopsis)
f.Subcommands = indent(f.Subcommands)
f.Description = indent(f.Description)
}

const usageFormat = "{{if .Usage}}{{.Usage}}{{else}}{{.Path}}{{if .ArgUsage}} {{.ArgUsage}}{{end}} - {{.Tagline}}{{end}}"

const longHelpFormat = `USAGE
{{.Indent}}{{template "usage" .}}
{{.Usage}}

{{if .Synopsis}}SYNOPSIS
{{.Synopsis}}
Expand All @@ -96,11 +97,12 @@ const longHelpFormat = `USAGE
{{end}}{{if .Subcommands}}SUBCOMMANDS
{{.Subcommands}}

{{.Indent}}Use '{{.Path}} <subcmd> --help' for more information about each command.
{{.Indent}}For more information about each command, use:
{{.Indent}}'{{.Path}} <subcmd> --help'
{{end}}
`
const shortHelpFormat = `USAGE
{{.Indent}}{{template "usage" .}}
{{.Usage}}
{{if .Synopsis}}
{{.Synopsis}}
{{end}}{{if .Description}}
Expand All @@ -109,18 +111,30 @@ const shortHelpFormat = `USAGE
SUBCOMMANDS
{{.Subcommands}}
{{end}}{{if .MoreHelp}}
Use '{{.Path}} --help' for more information about this command.
{{.Indent}}For more information about each command, use:
{{.Indent}}'{{.Path}} <subcmd> --help'
{{end}}
`

var usageTemplate *template.Template
var longHelpTemplate *template.Template
var shortHelpTemplate *template.Template

func getTerminalWidth(out io.Writer) int {
file, ok := out.(*os.File)
if ok {
if terminal.IsTerminal(int(file.Fd())) {
width, _, err := terminal.GetSize(int(file.Fd()))
if err == nil {
return width
}
}
}
return defaultTerminalWidth
}

func init() {
usageTemplate = template.Must(template.New("usage").Parse(usageFormat))
longHelpTemplate = template.Must(usageTemplate.New("longHelp").Parse(longHelpFormat))
shortHelpTemplate = template.Must(usageTemplate.New("shortHelp").Parse(shortHelpFormat))
longHelpTemplate = template.Must(template.New("longHelp").Parse(longHelpFormat))
shortHelpTemplate = template.Must(template.New("shortHelp").Parse(shortHelpFormat))
}

var ErrNoHelpRequested = errors.New("no help requested")
Expand Down Expand Up @@ -154,7 +168,6 @@ func LongHelp(rootName string, root *cmds.Command, path []string, out io.Writer)
fields := helpFields{
Indent: indentStr,
Path: pathStr,
ArgUsage: usageText(cmd),
Tagline: cmd.Helptext.Tagline,
Arguments: cmd.Helptext.Arguments,
Options: cmd.Helptext.Options,
Expand All @@ -165,22 +178,29 @@ func LongHelp(rootName string, root *cmds.Command, path []string, out io.Writer)
MoreHelp: (cmd != root),
}

width := getTerminalWidth(out) - len(indentStr)

if len(cmd.Helptext.LongDescription) > 0 {
fields.Description = cmd.Helptext.LongDescription
}

// autogen fields that are empty
if len(cmd.Helptext.Usage) > 0 {
fields.Usage = cmd.Helptext.Usage
} else {
fields.Usage = commandUsageText(width, cmd, rootName, path)
}
if len(fields.Arguments) == 0 {
fields.Arguments = strings.Join(argumentText(cmd), "\n")
fields.Arguments = strings.Join(argumentText(width, cmd), "\n")
}
if len(fields.Options) == 0 {
fields.Options = strings.Join(optionText(cmd), "\n")
fields.Options = strings.Join(optionText(width, cmd), "\n")
}
if len(fields.Subcommands) == 0 {
fields.Subcommands = strings.Join(subcommandText(cmd, rootName, path), "\n")
fields.Subcommands = strings.Join(subcommandText(width, cmd, rootName, path), "\n")
}
if len(fields.Synopsis) == 0 {
fields.Synopsis = generateSynopsis(cmd, pathStr)
fields.Synopsis = generateSynopsis(width, cmd, pathStr)
}

// trim the extra newlines (see TrimNewlines doc)
Expand Down Expand Up @@ -212,21 +232,26 @@ func ShortHelp(rootName string, root *cmds.Command, path []string, out io.Writer
fields := helpFields{
Indent: indentStr,
Path: pathStr,
ArgUsage: usageText(cmd),
Tagline: cmd.Helptext.Tagline,
Synopsis: cmd.Helptext.Synopsis,
Description: cmd.Helptext.ShortDescription,
Subcommands: cmd.Helptext.Subcommands,
Usage: cmd.Helptext.Usage,
MoreHelp: (cmd != root),
}

width := getTerminalWidth(out) - len(indentStr)

// autogen fields that are empty
if len(cmd.Helptext.Usage) > 0 {
fields.Usage = cmd.Helptext.Usage
} else {
fields.Usage = commandUsageText(width, cmd, rootName, path)
}
if len(fields.Subcommands) == 0 {
fields.Subcommands = strings.Join(subcommandText(cmd, rootName, path), "\n")
fields.Subcommands = strings.Join(subcommandText(width, cmd, rootName, path), "\n")
}
if len(fields.Synopsis) == 0 {
fields.Synopsis = generateSynopsis(cmd, pathStr)
fields.Synopsis = generateSynopsis(width, cmd, pathStr)
}

// trim the extra newlines (see TrimNewlines doc)
Expand All @@ -238,8 +263,17 @@ func ShortHelp(rootName string, root *cmds.Command, path []string, out io.Writer
return shortHelpTemplate.Execute(out, fields)
}

func generateSynopsis(cmd *cmds.Command, path string) string {
func generateSynopsis(width int, cmd *cmds.Command, path string) string {
res := path
currentLineLength := len(res)
appendText := func(text string) {
if currentLineLength+len(text)+1 > width {
res += "\n" + strings.Repeat(" ", len(path))
currentLineLength = len(path)
}
currentLineLength += len(text) + 1
res += " " + text
}
for _, opt := range cmd.Options {
valopt, ok := cmd.Helptext.SynopsisOptionsValues[opt.Name()]
if !ok {
Expand Down Expand Up @@ -267,10 +301,10 @@ func generateSynopsis(cmd *cmds.Command, path string) string {
}
}
}
res = fmt.Sprintf("%s [%s]", res, sopt)
appendText("[" + sopt + "]")
}
if len(cmd.Arguments) > 0 {
res = fmt.Sprintf("%s [--]", res)
appendText("[--]")
}
for _, arg := range cmd.Arguments {
sarg := fmt.Sprintf("<%s>", arg.Name)
Expand All @@ -281,25 +315,52 @@ func generateSynopsis(cmd *cmds.Command, path string) string {
if !arg.Required {
sarg = fmt.Sprintf("[%s]", sarg)
}
res = fmt.Sprintf("%s %s", res, sarg)
appendText(sarg)
}
return strings.Trim(res, " ")
}

func argumentText(cmd *cmds.Command) []string {
func argumentText(width int, cmd *cmds.Command) []string {
lines := make([]string, len(cmd.Arguments))

for i, arg := range cmd.Arguments {
lines[i] = argUsageText(arg)
}
lines = align(lines)
for i, arg := range cmd.Arguments {
lines[i] += " - " + arg.Description
lines[i] += " - "
lines[i] = appendWrapped(lines[i], arg.Description, width)
}

return lines
}

func appendWrapped(prefix, text string, width int) string {
offset := len(prefix)
bWidth := width - offset

text = strings.Trim(text, whitespace)
// Minimum help-text width is 30 characters.
if bWidth < 30 {
prefix += text
return prefix
}

for len(text) > bWidth {
idx := strings.LastIndexAny(text[:bWidth], whitespace)
if idx < 0 {
idx = strings.IndexAny(text, whitespace)
}
if idx < 0 {
break
}
prefix += text[:idx] + "\n" + strings.Repeat(" ", offset)
text = strings.TrimLeft(text[idx:], whitespace)
}
prefix += text
return prefix
}

func optionFlag(flag string) string {
if len(flag) == 1 {
return fmt.Sprintf(shortFlag, flag)
Expand All @@ -308,7 +369,7 @@ func optionFlag(flag string) string {
}
}

func optionText(cmd ...*cmds.Command) []string {
func optionText(width int, cmd ...*cmds.Command) []string {
// get a slice of the options we want to list out
options := make([]cmds.Option, 0)
for _, c := range cmd {
Expand All @@ -317,53 +378,33 @@ func optionText(cmd ...*cmds.Command) []string {
}
}

// add option names to output (with each name aligned)
lines := make([]string, 0)
j := 0
for {
done := true
i := 0
for _, opt := range options {
if len(lines) < i+1 {
lines = append(lines, "")
}

names := sortByLength(opt.Names())
if len(names) >= j+1 {
lines[i] += optionFlag(names[j])
}
if len(names) > j+1 {
lines[i] += ", "
done = false
}

i++
}

if done {
break
// add option names to output
lines := make([]string, len(options))
for i, opt := range options {
flags := sortByLength(opt.Names())
for j, f := range flags {
flags[j] = optionFlag(f)
}

lines = align(lines)
j++
lines[i] = strings.Join(flags, ", ")
}
lines = align(lines)

// add option types to output
for i, opt := range options {
lines[i] += " " + fmt.Sprintf("%v", opt.Type())
lines[i] += " " + fmt.Sprintf("%v", opt.Type())
}
lines = align(lines)

// add option descriptions to output
for i, opt := range options {
lines[i] += " - " + opt.Description()
lines[i] += " - "
lines[i] = appendWrapped(lines[i], opt.Description(), width)
}

return lines
}

func subcommandText(cmd *cmds.Command, rootName string, path []string) []string {
func subcommandText(width int, cmd *cmds.Command, rootName string, path []string) []string {
prefix := fmt.Sprintf("%v %v", rootName, strings.Join(path, " "))
if len(path) > 0 {
prefix += " "
Expand Down Expand Up @@ -392,12 +433,24 @@ func subcommandText(cmd *cmds.Command, rootName string, path []string) []string

lines = align(lines)
for i, sub := range subcmds {
lines[i] += " - " + sub.Helptext.Tagline
lines[i] += " - "
lines[i] = appendWrapped(lines[i], sub.Helptext.Tagline, width)
}

return lines
}

func commandUsageText(width int, cmd *cmds.Command, rootName string, path []string) string {
text := fmt.Sprintf("%v %v", rootName, strings.Join(path, " "))
argUsage := usageText(cmd)
if len(argUsage) > 0 {
text += " " + argUsage
}
text += " - "
text = appendWrapped(text, cmd.Helptext.Tagline, width)
return text
}

func usageText(cmd *cmds.Command) string {
s := ""
for i, arg := range cmd.Arguments {
Expand Down
5 changes: 3 additions & 2 deletions cli/helptext_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import (
"strings"
"testing"

"github.com/ipfs/go-ipfs-cmds"
cmds "github.com/ipfs/go-ipfs-cmds"
)

func TestSynopsisGenerator(t *testing.T) {
Expand All @@ -22,7 +22,8 @@ func TestSynopsisGenerator(t *testing.T) {
},
},
}
syn := generateSynopsis(command, "cmd")
terminalWidth := 100
syn := generateSynopsis(terminalWidth, command, "cmd")
t.Logf("Synopsis is: %s", syn)
if !strings.HasPrefix(syn, "cmd ") {
t.Fatal("Synopsis should start with command name")
Expand Down
Loading