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

Add word-wrap support, with wrap length provided by the user #1119

Merged
merged 1 commit into from Jun 18, 2022
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
74 changes: 74 additions & 0 deletions help.go
Expand Up @@ -64,6 +64,11 @@ var HelpPrinter helpPrinter = printHelp
// HelpPrinterCustom is a function that writes the help output. It is used as
// the default implementation of HelpPrinter, and may be called directly if
// the ExtraInfo field is set on an App.
//
// In the default implementation, if the customFuncs argument contains a
// "wrapAt" key, which is a function which takes no arguments and returns
// an int, this int value will be used to produce a "wrap" function used
// by the default template to wrap long lines.
var HelpPrinterCustom helpPrinterCustom = printHelpCustom

// VersionPrinter prints the version for the App
Expand Down Expand Up @@ -286,12 +291,29 @@ func ShowCommandCompletions(ctx *Context, command string) {
// The customFuncs map will be combined with a default template.FuncMap to
// allow using arbitrary functions in template rendering.
func printHelpCustom(out io.Writer, templ string, data interface{}, customFuncs map[string]interface{}) {

const maxLineLength = 10000

funcMap := template.FuncMap{
"join": strings.Join,
"indent": indent,
"nindent": nindent,
"trim": strings.TrimSpace,
"wrap": func(input string, offset int) string { return wrap(input, offset, maxLineLength) },
"offset": offset,
}

if customFuncs["wrapAt"] != nil {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is better to define a type for this function signature.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not quite sure what you mean- are you suggesting that we export a WrapAtFunc type alias, and then only use customFuncs["wrapAt"] if it is created as a WrapAtFunc explicitly?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gentle ping on this :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes correct

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This requires some boilerplate for the caller, but I'm not sure how it makes things better. How does this help? I couldn't find any examples of this pattern in a quick web search, but I'm not sure what to call this so maybe I'm missing something obvious.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fwiw I'm happy to defer resolution of this to a later PR, so I'm going to move forward with merging the current work 👍🏼

if wa, ok := customFuncs["wrapAt"]; ok {
if waf, ok := wa.(func() int); ok {
wrapAt := waf()
customFuncs["wrap"] = func(input string, offset int) string {
return wrap(input, offset, wrapAt)
}
}
}
}

for key, value := range customFuncs {
funcMap[key] = value
}
Expand Down Expand Up @@ -402,3 +424,55 @@ func indent(spaces int, v string) string {
func nindent(spaces int, v string) string {
return "\n" + indent(spaces, v)
}

func wrap(input string, offset int, wrapAt int) string {
var sb strings.Builder

lines := strings.Split(input, "\n")

padding := strings.Repeat(" ", offset)

for i, line := range lines {
if i != 0 {
sb.WriteString(padding)
}

sb.WriteString(wrapLine(line, offset, wrapAt, padding))

if i != len(lines)-1 {
sb.WriteString("\n")
}
}

return sb.String()
}

func wrapLine(input string, offset int, wrapAt int, padding string) string {
if wrapAt <= offset || len(input) <= wrapAt-offset {
return input
}

lineWidth := wrapAt - offset
words := strings.Fields(input)
if len(words) == 0 {
return input
}

wrapped := words[0]
spaceLeft := lineWidth - len(wrapped)
for _, word := range words[1:] {
if len(word)+1 > spaceLeft {
wrapped += "\n" + padding + word
spaceLeft = lineWidth - len(word)
} else {
wrapped += " " + word
spaceLeft -= 1 + len(word)
}
}

return wrapped
}

func offset(input string, fixed int) int {
rliebz marked this conversation as resolved.
Show resolved Hide resolved
return len(input) + fixed
}
222 changes: 222 additions & 0 deletions help_test.go
Expand Up @@ -1124,3 +1124,225 @@ func TestDefaultCompleteWithFlags(t *testing.T) {
})
}
}

func TestWrappedHelp(t *testing.T) {

// Reset HelpPrinter after this test.
defer func(old helpPrinter) {
HelpPrinter = old
}(HelpPrinter)

output := new(bytes.Buffer)
app := &App{
Writer: output,
Flags: []Flag{
&BoolFlag{Name: "foo",
Aliases: []string{"h"},
Usage: "here's a really long help text line, let's see where it wraps. blah blah blah and so on.",
},
},
Usage: "here's a sample App.Usage string long enough that it should be wrapped in this test",
UsageText: "i'm not sure how App.UsageText differs from App.Usage, but this should also be wrapped in this test",
// TODO: figure out how to make ArgsUsage appear in the help text, and test that
Description: `here's a sample App.Description string long enough that it should be wrapped in this test

with a newline
and an indented line`,
Copyright: `Here's a sample copyright text string long enough that it should be wrapped.
Including newlines.
And also indented lines.


And then another long line. Blah blah blah does anybody ever read these things?`,
}

c := NewContext(app, nil, nil)

HelpPrinter = func(w io.Writer, templ string, data interface{}) {
funcMap := map[string]interface{}{
"wrapAt": func() int {
return 30
},
}

HelpPrinterCustom(w, templ, data, funcMap)
}

_ = ShowAppHelp(c)

expected := `NAME:
- here's a sample
App.Usage string long
enough that it should be
wrapped in this test

USAGE:
i'm not sure how
App.UsageText differs from
App.Usage, but this should
also be wrapped in this
test

DESCRIPTION:
here's a sample
App.Description string long
enough that it should be
wrapped in this test

with a newline
and an indented line

GLOBAL OPTIONS:
--foo, -h here's a
really long help text
line, let's see where it
wraps. blah blah blah
and so on. (default:
false)

COPYRIGHT:
Here's a sample copyright
text string long enough
that it should be wrapped.
Including newlines.
And also indented lines.


And then another long line.
Blah blah blah does anybody
ever read these things?
`

if output.String() != expected {
t.Errorf("Unexpected wrapping, got:\n%s\nexpected: %s",
output.String(), expected)
}
}

func TestWrappedCommandHelp(t *testing.T) {

// Reset HelpPrinter after this test.
defer func(old helpPrinter) {
HelpPrinter = old
}(HelpPrinter)

output := new(bytes.Buffer)
app := &App{
Writer: output,
Commands: []*Command{
{
Name: "add",
Aliases: []string{"a"},
Usage: "add a task to the list",
UsageText: "this is an even longer way of describing adding a task to the list",
Description: "and a description long enough to wrap in this test case",
Action: func(c *Context) error {
return nil
},
},
},
}

c := NewContext(app, nil, nil)

HelpPrinter = func(w io.Writer, templ string, data interface{}) {
funcMap := map[string]interface{}{
"wrapAt": func() int {
return 30
},
}

HelpPrinterCustom(w, templ, data, funcMap)
}

_ = ShowCommandHelp(c, "add")

expected := `NAME:
- add a task to the list

USAGE:
this is an even longer way
of describing adding a task
to the list

DESCRIPTION:
and a description long
enough to wrap in this test
case
`

if output.String() != expected {
t.Errorf("Unexpected wrapping, got:\n%s\nexpected: %s",
output.String(), expected)
}
}

func TestWrappedSubcommandHelp(t *testing.T) {

// Reset HelpPrinter after this test.
defer func(old helpPrinter) {
HelpPrinter = old
}(HelpPrinter)

output := new(bytes.Buffer)
app := &App{
Name: "cli.test",
Writer: output,
Commands: []*Command{
{
Name: "bar",
Aliases: []string{"a"},
Usage: "add a task to the list",
UsageText: "this is an even longer way of describing adding a task to the list",
Description: "and a description long enough to wrap in this test case",
Action: func(c *Context) error {
return nil
},
Subcommands: []*Command{
{
Name: "grok",
Usage: "remove an existing template",
UsageText: "longer usage text goes here, la la la, hopefully this is long enough to wrap even more",
Action: func(c *Context) error {
return nil
},
},
},
},
},
}

HelpPrinter = func(w io.Writer, templ string, data interface{}) {
funcMap := map[string]interface{}{
"wrapAt": func() int {
return 30
},
}

HelpPrinterCustom(w, templ, data, funcMap)
}

_ = app.Run([]string{"foo", "bar", "grok", "--help"})

expected := `NAME:
cli.test bar grok - remove
an
existing
template

USAGE:
longer usage text goes
here, la la la, hopefully
this is long enough to wrap
even more

OPTIONS:
--help, -h show help (default: false)

`

if output.String() != expected {
t.Errorf("Unexpected wrapping, got:\n%s\nexpected: %s",
output.String(), expected)
}
}
20 changes: 10 additions & 10 deletions template.go
Expand Up @@ -4,16 +4,16 @@ package cli
// cli.go uses text/template to render templates. You can
// render custom help text by setting this variable.
var AppHelpTemplate = `NAME:
{{.Name}}{{if .Usage}} - {{.Usage}}{{end}}
{{$v := offset .Name 6}}{{wrap .Name 3}}{{if .Usage}} - {{wrap .Usage $v}}{{end}}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we're wrapping most of the fields in the template, but not all of the fields. This PR also omits wrapping for command help and sub-command help.

Is there a reason NOT to expand wrapping to all sections of all templates?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly because I don't know all the variations that need to be tested. I suspect there will be some fields that aren't worth trying to wrap because they're only a problem in unusual cases, but command and sub-command help are worth supporting.

I will try to expand this a bit.


USAGE:
{{if .UsageText}}{{.UsageText | nindent 3 | trim}}{{else}}{{.HelpName}} {{if .VisibleFlags}}[global options]{{end}}{{if .Commands}} command [command options]{{end}} {{if .ArgsUsage}}{{.ArgsUsage}}{{else}}[arguments...]{{end}}{{end}}{{if .Version}}{{if not .HideVersion}}
{{if .UsageText}}{{wrap .UsageText 3}}{{else}}{{.HelpName}} {{if .VisibleFlags}}[global options]{{end}}{{if .Commands}} command [command options]{{end}} {{if .ArgsUsage}}{{.ArgsUsage}}{{else}}[arguments...]{{end}}{{end}}{{if .Version}}{{if not .HideVersion}}

VERSION:
{{.Version}}{{end}}{{end}}{{if .Description}}

DESCRIPTION:
{{.Description | nindent 3 | trim}}{{end}}{{if len .Authors}}
{{wrap .Description 3}}{{end}}{{if len .Authors}}

AUTHOR{{with $length := len .Authors}}{{if ne 1 $length}}S{{end}}{{end}}:
{{range $index, $author := .Authors}}{{if $index}}
Expand All @@ -31,26 +31,26 @@ GLOBAL OPTIONS:{{range .VisibleFlagCategories}}

GLOBAL OPTIONS:
{{range $index, $option := .VisibleFlags}}{{if $index}}
{{end}}{{$option}}{{end}}{{end}}{{end}}{{if .Copyright}}
{{end}}{{wrap $option.String 6}}{{end}}{{end}}{{end}}{{if .Copyright}}

COPYRIGHT:
{{.Copyright}}{{end}}
{{wrap .Copyright 3}}{{end}}
`

// CommandHelpTemplate is the text template for the command help topic.
// cli.go uses text/template to render templates. You can
// render custom help text by setting this variable.
var CommandHelpTemplate = `NAME:
{{.HelpName}} - {{.Usage}}
{{$v := offset .HelpName 6}}{{wrap .HelpName 3}}{{if .Usage}} - {{wrap .Usage $v}}{{end}}

USAGE:
{{if .UsageText}}{{.UsageText | nindent 3 | trim}}{{else}}{{.HelpName}}{{if .VisibleFlags}} [command options]{{end}} {{if .ArgsUsage}}{{.ArgsUsage}}{{else}}[arguments...]{{end}}{{end}}{{if .Category}}
{{if .UsageText}}{{wrap .UsageText 3}}{{else}}{{.HelpName}}{{if .VisibleFlags}} [command options]{{end}} {{if .ArgsUsage}}{{.ArgsUsage}}{{else}}[arguments...]{{end}}{{end}}{{if .Category}}

CATEGORY:
{{.Category}}{{end}}{{if .Description}}

DESCRIPTION:
{{.Description | nindent 3 | trim}}{{end}}{{if .VisibleFlagCategories}}
{{wrap .Description 3}}{{end}}{{if .VisibleFlagCategories}}

OPTIONS:{{range .VisibleFlagCategories}}
{{if .Name}}{{.Name}}
Expand All @@ -69,10 +69,10 @@ var SubcommandHelpTemplate = `NAME:
{{.HelpName}} - {{.Usage}}

USAGE:
{{if .UsageText}}{{.UsageText | nindent 3 | trim}}{{else}}{{.HelpName}} command{{if .VisibleFlags}} [command options]{{end}} {{if .ArgsUsage}}{{.ArgsUsage}}{{else}}[arguments...]{{end}}{{end}}{{if .Description}}
{{if .UsageText}}{{wrap .UsageText 3}}{{else}}{{.HelpName}} command{{if .VisibleFlags}} [command options]{{end}} {{if .ArgsUsage}}{{.ArgsUsage}}{{else}}[arguments...]{{end}}{{end}}{{if .Description}}

DESCRIPTION:
{{.Description | nindent 3 | trim}}{{end}}
{{wrap .Description 3}}{{end}}

COMMANDS:{{range .VisibleCategories}}{{if .Name}}
{{.Name}}:{{range .VisibleCommands}}
Expand Down