Skip to content
Draft
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
77 changes: 73 additions & 4 deletions cli/helptext.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,15 @@ type helpFields struct {
Tagline string
Arguments string
Options string
GlobalOptions string
Synopsis string
Subcommands string
ExperimentalSubcommands string
DeprecatedSubcommands string
RemovedSubcommands string
Description string
MoreHelp bool
GlobalOptionsHint string
}

// TrimNewlines removes extra newlines from fields. This makes aligning
Expand All @@ -60,6 +62,7 @@ func (f *helpFields) TrimNewlines() {
f.Tagline = strings.Trim(f.Tagline, "\n")
f.Arguments = strings.Trim(f.Arguments, "\n")
f.Options = strings.Trim(f.Options, "\n")
f.GlobalOptions = strings.Trim(f.GlobalOptions, "\n")
f.Synopsis = strings.Trim(f.Synopsis, "\n")
f.Subcommands = strings.Trim(f.Subcommands, "\n")
f.ExperimentalSubcommands = strings.Trim(f.ExperimentalSubcommands, "\n")
Expand All @@ -81,6 +84,7 @@ func (f *helpFields) IndentAll() {
f.Usage = indent(f.Usage)
f.Arguments = indent(f.Arguments)
f.Options = indent(f.Options)
f.GlobalOptions = indent(f.GlobalOptions)
f.Synopsis = indent(f.Synopsis)
f.Subcommands = indent(f.Subcommands)
f.DeprecatedSubcommands = indent(f.DeprecatedSubcommands)
Expand All @@ -105,6 +109,10 @@ const longHelpFormat = `{{if .Warning}}WARNING: {{.Warning}}

{{.Options}}

{{end}}{{if .GlobalOptions}}GLOBAL OPTIONS

{{.GlobalOptions}}

{{end}}{{if .Description}}DESCRIPTION

{{.Description}}
Expand Down Expand Up @@ -134,6 +142,8 @@ const shortHelpFormat = `{{if .Warning}}WARNING: {{.Warning}}
{{.Synopsis}}
{{end}}{{if .Description}}
{{.Description}}
{{end}}{{if .GlobalOptionsHint}}
{{.GlobalOptionsHint}}
{{end}}{{if .Subcommands}}
SUBCOMMANDS
{{.Subcommands}}
Expand Down Expand Up @@ -193,6 +203,45 @@ func HandleHelp(appName string, req *cmds.Request, out io.Writer) error {
}
}

// collectGlobalOptions gathers options from ancestor commands that are not
// already defined on the leaf command. Returns nil when cmd is the root.
func collectGlobalOptions(root *cmds.Command, path []string, cmd *cmds.Command) []cmds.Option {
if cmd == root || len(path) == 0 {
return nil
}

chain, err := root.Resolve(path)
if err != nil {
return nil
}

// Build a set of option names on the leaf command for dedup.
leafNames := make(map[string]struct{})
for _, opt := range cmd.Options {
for _, n := range opt.Names() {
leafNames[n] = struct{}{}
}
}

// Collect options from all ancestors (everything except the last element).
seen := make(map[string]struct{})
var global []cmds.Option
for _, ancestor := range chain[:len(chain)-1] {
for _, opt := range ancestor.Options {
primary := opt.Name()
if _, ok := leafNames[primary]; ok {
continue
}
if _, ok := seen[primary]; ok {
continue
}
seen[primary] = struct{}{}
global = append(global, opt)
}
}
return global
}

// LongHelp writes a formatted CLI helptext string to a Writer for the given command
func LongHelp(rootName string, root *cmds.Command, path []string, out io.Writer) error {
cmd, err := root.Get(path)
Expand Down Expand Up @@ -243,8 +292,15 @@ func LongHelp(rootName string, root *cmds.Command, path []string, out io.Writer)
fields.DeprecatedSubcommands = strings.Join(subcommandText(width, cmd, rootName, path, cmds.Deprecated), "\n")
fields.RemovedSubcommands = strings.Join(subcommandText(width, cmd, rootName, path, cmds.Removed), "\n")
}

globalOpts := collectGlobalOptions(root, path, cmd)
if len(globalOpts) > 0 {
// Build a temporary command to pass to optionText for rendering.
fields.GlobalOptions = strings.Join(optionText(width, &cmds.Command{Options: globalOpts}), "\n")
}

if len(fields.Synopsis) == 0 {
fields.Synopsis = generateSynopsis(width, cmd, pathStr)
fields.Synopsis = generateSynopsis(width, cmd, pathStr, globalOpts)
}

// trim the extra newlines (see TrimNewlines doc)
Expand Down Expand Up @@ -298,8 +354,15 @@ func ShortHelp(rootName string, root *cmds.Command, path []string, out io.Writer
fields.DeprecatedSubcommands = strings.Join(subcommandText(width, cmd, rootName, path, cmds.Deprecated), "\n")
fields.RemovedSubcommands = strings.Join(subcommandText(width, cmd, rootName, path, cmds.Removed), "\n")
}

globalOpts := collectGlobalOptions(root, path, cmd)

if len(fields.Synopsis) == 0 {
fields.Synopsis = generateSynopsis(width, cmd, pathStr)
fields.Synopsis = generateSynopsis(width, cmd, pathStr, globalOpts)
}

if len(globalOpts) > 0 {
fields.GlobalOptionsHint = fmt.Sprintf("%sUse '%s --help' for global options.", indentStr, rootName)
}

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

func generateSynopsis(width int, cmd *cmds.Command, path string) string {
func generateSynopsis(width int, cmd *cmds.Command, path string, globalOpts []cmds.Option) string {
res := path
currentLineLength := len(res)
appendText := func(text string) {
Expand All @@ -322,7 +385,13 @@ func generateSynopsis(width int, cmd *cmds.Command, path string) string {
currentLineLength += len(text) + 1
res += " " + text
}
for _, opt := range cmd.Options {

allOpts := cmd.Options
if len(globalOpts) > 0 {
allOpts = append(append([]cmds.Option{}, cmd.Options...), globalOpts...)
}

for _, opt := range allOpts {
valopt, ok := cmd.Helptext.SynopsisOptionsValues[opt.Name()]
if !ok {
valopt = opt.Name()
Expand Down
147 changes: 146 additions & 1 deletion cli/helptext_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cli

import (
"bytes"
"strings"
"testing"

Expand All @@ -24,7 +25,7 @@ func TestSynopsisGenerator(t *testing.T) {
},
}
terminalWidth := 100
syn := generateSynopsis(terminalWidth, command, "cmd")
syn := generateSynopsis(terminalWidth, command, "cmd", nil)
t.Logf("Synopsis is: %s", syn)
if !strings.HasPrefix(syn, "cmd ") {
t.Fatal("Synopsis should start with command name")
Expand All @@ -48,3 +49,147 @@ func TestSynopsisGenerator(t *testing.T) {
t.Fatal("Synopsis should contain options finalizer")
}
}

func newTestRootAndSub() (root *cmds.Command, sub *cmds.Command) {
sub = &cmds.Command{
Helptext: cmds.HelpText{
Tagline: "Add a file",
},
Options: []cmds.Option{
cmds.BoolOption("pin", "p", "Pin the file"),
},
Arguments: []cmds.Argument{
cmds.StringArg("file", true, false, "File to add"),
},
}
root = &cmds.Command{
Helptext: cmds.HelpText{
Tagline: "Global root",
},
Options: []cmds.Option{
cmds.StringOption("encoding", "enc", "Output encoding"),
cmds.StringOption("timeout", "Max time"),
},
Subcommands: map[string]*cmds.Command{
"add": sub,
},
}
return root, sub
}

func TestLongHelpGlobalOptions(t *testing.T) {
root, _ := newTestRootAndSub()

var buf bytes.Buffer
if err := LongHelp("ipfs", root, []string{"add"}, &buf); err != nil {
t.Fatal(err)
}
out := buf.String()
t.Log(out)

if !strings.Contains(out, "OPTIONS") {
t.Fatal("long help should contain OPTIONS section")
}
if !strings.Contains(out, "--pin") {
t.Fatal("long help OPTIONS should contain subcommand option --pin")
}
if !strings.Contains(out, "GLOBAL OPTIONS") {
t.Fatal("long help should contain GLOBAL OPTIONS section")
}
if !strings.Contains(out, "--encoding") {
t.Fatal("GLOBAL OPTIONS should contain --encoding")
}
if !strings.Contains(out, "--timeout") {
t.Fatal("GLOBAL OPTIONS should contain --timeout")
}
}

func TestLongHelpRootNoGlobalSection(t *testing.T) {
root, _ := newTestRootAndSub()

var buf bytes.Buffer
if err := LongHelp("ipfs", root, nil, &buf); err != nil {
t.Fatal(err)
}
out := buf.String()

if strings.Contains(out, "GLOBAL OPTIONS") {
t.Fatal("root long help should NOT contain GLOBAL OPTIONS section")
}
}

func TestShortHelpGlobalOptionsHint(t *testing.T) {
root, _ := newTestRootAndSub()

var buf bytes.Buffer
if err := ShortHelp("ipfs", root, []string{"add"}, &buf); err != nil {
t.Fatal(err)
}
out := buf.String()
t.Log(out)

if !strings.Contains(out, "Use 'ipfs --help' for global options.") {
t.Fatal("short help should contain global options hint")
}
}

func TestShortHelpRootNoHint(t *testing.T) {
root, _ := newTestRootAndSub()

var buf bytes.Buffer
if err := ShortHelp("ipfs", root, nil, &buf); err != nil {
t.Fatal(err)
}
out := buf.String()

if strings.Contains(out, "global options") {
t.Fatal("root short help should NOT contain global options hint")
}
}

func TestSynopsisIncludesGlobalOptions(t *testing.T) {
root, sub := newTestRootAndSub()
globalOpts := collectGlobalOptions(root, []string{"add"}, sub)

syn := generateSynopsis(120, sub, "ipfs add", globalOpts)
t.Log(syn)

if !strings.Contains(syn, "--pin") {
t.Fatal("synopsis should contain subcommand option --pin")
}
if !strings.Contains(syn, "--encoding") {
t.Fatal("synopsis should contain global option --encoding")
}
if !strings.Contains(syn, "--timeout") {
t.Fatal("synopsis should contain global option --timeout")
}
}

func TestCollectGlobalOptionsDedup(t *testing.T) {
// If the subcommand redefines an option name from the root, it should
// not appear in the global options list.
sub := &cmds.Command{
Options: []cmds.Option{
cmds.StringOption("encoding", "Override encoding"),
},
}
root := &cmds.Command{
Options: []cmds.Option{
cmds.StringOption("encoding", "enc", "Output encoding"),
cmds.StringOption("timeout", "Max time"),
},
Subcommands: map[string]*cmds.Command{
"sub": sub,
},
}

globalOpts := collectGlobalOptions(root, []string{"sub"}, sub)
for _, opt := range globalOpts {
if opt.Name() == "encoding" {
t.Fatal("global options should not include option already on leaf command")
}
}
if len(globalOpts) != 1 || globalOpts[0].Name() != "timeout" {
t.Fatalf("expected only timeout in global opts, got %v", globalOpts)
}
}
Loading