From 02c1754043f8abdb9eb39c018c91b1f10095756c Mon Sep 17 00:00:00 2001 From: Mostyn Bramley-Moore Date: Thu, 14 May 2020 00:07:56 +0200 Subject: [PATCH] Add word-wrap support, with wrap length provided by the user We could try to automatically detect the terminal width and wrap at that point, but this would increase the binary footprint for all users even if not using this feature. Instead, we can allow users to specify their preferred line length limit (if any), and those who want to bear the cost of checking the terminal size can do so if they wish. This also makes the feature more testable. Original patch by Sascha Grunert --- app.go | 2 + app_test.go | 2 +- help.go | 83 ++++++++++++++++++++++++++++++++----- help_test.go | 115 +++++++++++++++++++++++++++++++++++++++++---------- template.go | 10 ++--- 5 files changed, 174 insertions(+), 38 deletions(-) diff --git a/app.go b/app.go index 59bf68df95..4b49e9d374 100644 --- a/app.go +++ b/app.go @@ -74,6 +74,8 @@ type App struct { Copyright string // Writer writer to write output to Writer io.Writer + // If greater than zero, wrap help text lines at this length (experimental). + WrapAt int // ErrWriter writes error output ErrWriter io.Writer // ExitErrHandler processes any error encountered while running an App before diff --git a/app_test.go b/app_test.go index 6c95faa612..e024572ecb 100644 --- a/app_test.go +++ b/app_test.go @@ -1197,7 +1197,7 @@ func TestAppHelpPrinter(t *testing.T) { }() var wasCalled = false - HelpPrinter = func(w io.Writer, template string, data interface{}) { + HelpPrinter = func(w io.Writer, template string, data interface{}, wrapAt int) { wasCalled = true } diff --git a/help.go b/help.go index c1e974a481..d2507217c5 100644 --- a/help.go +++ b/help.go @@ -42,10 +42,10 @@ var helpSubcommand = &Command{ } // Prints help for the App or Command -type helpPrinter func(w io.Writer, templ string, data interface{}) +type helpPrinter func(w io.Writer, templ string, data interface{}, wrapAt int) // Prints help for the App or Command with custom template function. -type helpPrinterCustom func(w io.Writer, templ string, data interface{}, customFunc map[string]interface{}) +type helpPrinterCustom func(w io.Writer, templ string, data interface{}, customFunc map[string]interface{}, wrapAt int) // HelpPrinter is a function that writes the help output. If not set explicitly, // this calls HelpPrinterCustom using only the default template functions. @@ -78,7 +78,7 @@ func ShowAppHelp(c *Context) error { } if c.App.ExtraInfo == nil { - HelpPrinter(c.App.Writer, template, c.App) + HelpPrinter(c.App.Writer, template, c.App, c.App.WrapAt) return nil } @@ -87,7 +87,7 @@ func ShowAppHelp(c *Context) error { "ExtraInfo": c.App.ExtraInfo, } } - HelpPrinterCustom(c.App.Writer, template, c.App, customAppData()) + HelpPrinterCustom(c.App.Writer, template, c.App, customAppData(), c.App.WrapAt) return nil } @@ -189,7 +189,7 @@ func ShowCommandHelpAndExit(c *Context, command string, code int) { func ShowCommandHelp(ctx *Context, command string) error { // show the subcommand help for a command with subcommands if command == "" { - HelpPrinter(ctx.App.Writer, SubcommandHelpTemplate, ctx.App) + HelpPrinter(ctx.App.Writer, SubcommandHelpTemplate, ctx.App, ctx.App.WrapAt) return nil } @@ -200,7 +200,7 @@ func ShowCommandHelp(ctx *Context, command string) error { templ = CommandHelpTemplate } - HelpPrinter(ctx.App.Writer, templ, c) + HelpPrinter(ctx.App.Writer, templ, c, ctx.App.WrapAt) return nil } @@ -261,9 +261,22 @@ 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{}) { +func printHelpCustom(out io.Writer, templ string, data interface{}, customFuncs map[string]interface{}, wrapAt int) { + + wrapFunc := func(input string, offset int) string { + return input + } + + if wrapAt > 0 { + wrapFunc = func(input string, offset int) string { + return wrap(input, offset, wrapAt) + } + } + funcMap := template.FuncMap{ - "join": strings.Join, + "join": strings.Join, + "wrap": wrapFunc, + "offset": offset, } for key, value := range customFuncs { funcMap[key] = value @@ -284,8 +297,8 @@ func printHelpCustom(out io.Writer, templ string, data interface{}, customFuncs _ = w.Flush() } -func printHelp(out io.Writer, templ string, data interface{}) { - HelpPrinterCustom(out, templ, data, nil) +func printHelp(out io.Writer, templ string, data interface{}, wrapAt int) { + HelpPrinterCustom(out, templ, data, nil, wrapAt) } func checkVersion(c *Context) bool { @@ -366,3 +379,53 @@ func checkCommandCompletions(c *Context, name string) bool { ShowCommandCompletions(c, name) return true } + +func wrap(input string, offset int, wrapAt int) string { + var sb strings.Builder + + lines := strings.Split(input, "\n") + + for i, line := range lines { + if i != 0 { + sb.WriteString(strings.Repeat(" ", offset)) + } + + sb.WriteString(wrapLine(line, offset, wrapAt)) + + if i != len(lines)-1 { + sb.WriteString("\n") + } + } + + return sb.String() +} + +func wrapLine(input string, offset int, wrapAt int) string { + if wrapAt <= offset || len(input) <= wrapAt-offset { + return input + } + + lineWidth := wrapAt - offset + words := strings.Fields(strings.TrimSpace(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" + strings.Repeat(" ", offset) + word + spaceLeft = lineWidth - len(word) + } else { + wrapped += " " + word + spaceLeft -= 1 + len(word) + } + } + + return wrapped +} + +func offset(input string, fixed int) int { + return len(input) + fixed +} diff --git a/help_test.go b/help_test.go index 5f292b77e5..23d4a017dc 100644 --- a/help_test.go +++ b/help_test.go @@ -227,7 +227,7 @@ func TestShowCommandHelp_HelpPrinter(t *testing.T) { { name: "no-command", template: "", - printer: func(w io.Writer, templ string, data interface{}) { + printer: func(w io.Writer, templ string, data interface{}, wrapAt int) { fmt.Fprint(w, "yo") }, command: "", @@ -237,7 +237,7 @@ func TestShowCommandHelp_HelpPrinter(t *testing.T) { { name: "standard-command", template: "", - printer: func(w io.Writer, templ string, data interface{}) { + printer: func(w io.Writer, templ string, data interface{}, wrapAt int) { fmt.Fprint(w, "yo") }, command: "my-command", @@ -247,10 +247,10 @@ func TestShowCommandHelp_HelpPrinter(t *testing.T) { { name: "custom-template-command", template: "{{doublecho .Name}}", - printer: func(w io.Writer, templ string, data interface{}) { + printer: func(w io.Writer, templ string, data interface{}, wrapAt int) { // Pass a custom function to ensure it gets used fm := map[string]interface{}{"doublecho": doublecho} - HelpPrinterCustom(w, templ, data, fm) + HelpPrinterCustom(w, templ, data, fm, wrapAt) }, command: "my-command", wantTemplate: "{{doublecho .Name}}", @@ -263,12 +263,12 @@ func TestShowCommandHelp_HelpPrinter(t *testing.T) { defer func(old helpPrinter) { HelpPrinter = old }(HelpPrinter) - HelpPrinter = func(w io.Writer, templ string, data interface{}) { + HelpPrinter = func(w io.Writer, templ string, data interface{}, wrapAt int) { if templ != tt.wantTemplate { t.Errorf("want template:\n%s\ngot template:\n%s", tt.wantTemplate, templ) } - tt.printer(w, templ, data) + tt.printer(w, templ, data, wrapAt) } var buf bytes.Buffer @@ -312,7 +312,7 @@ func TestShowCommandHelp_HelpPrinterCustom(t *testing.T) { { name: "no-command", template: "", - printer: func(w io.Writer, templ string, data interface{}, fm map[string]interface{}) { + printer: func(w io.Writer, templ string, data interface{}, fm map[string]interface{}, wrapAt int) { fmt.Fprint(w, "yo") }, command: "", @@ -322,7 +322,7 @@ func TestShowCommandHelp_HelpPrinterCustom(t *testing.T) { { name: "standard-command", template: "", - printer: func(w io.Writer, templ string, data interface{}, fm map[string]interface{}) { + printer: func(w io.Writer, templ string, data interface{}, fm map[string]interface{}, wrapAt int) { fmt.Fprint(w, "yo") }, command: "my-command", @@ -332,10 +332,10 @@ func TestShowCommandHelp_HelpPrinterCustom(t *testing.T) { { name: "custom-template-command", template: "{{doublecho .Name}}", - printer: func(w io.Writer, templ string, data interface{}, _ map[string]interface{}) { + printer: func(w io.Writer, templ string, data interface{}, _ map[string]interface{}, wrapAt int) { // Pass a custom function to ensure it gets used fm := map[string]interface{}{"doublecho": doublecho} - printHelpCustom(w, templ, data, fm) + printHelpCustom(w, templ, data, fm, wrapAt) }, command: "my-command", wantTemplate: "{{doublecho .Name}}", @@ -348,7 +348,7 @@ func TestShowCommandHelp_HelpPrinterCustom(t *testing.T) { defer func(old helpPrinterCustom) { HelpPrinterCustom = old }(HelpPrinterCustom) - HelpPrinterCustom = func(w io.Writer, templ string, data interface{}, fm map[string]interface{}) { + HelpPrinterCustom = func(w io.Writer, templ string, data interface{}, fm map[string]interface{}, wrapAt int) { if fm != nil { t.Error("unexpected function map passed") } @@ -357,7 +357,7 @@ func TestShowCommandHelp_HelpPrinterCustom(t *testing.T) { t.Errorf("want template:\n%s\ngot template:\n%s", tt.wantTemplate, templ) } - tt.printer(w, templ, data, fm) + tt.printer(w, templ, data, fm, wrapAt) } var buf bytes.Buffer @@ -566,7 +566,7 @@ func TestShowAppHelp_HelpPrinter(t *testing.T) { { name: "standard-command", template: "", - printer: func(w io.Writer, templ string, data interface{}) { + printer: func(w io.Writer, templ string, data interface{}, wrapAt int) { fmt.Fprint(w, "yo") }, wantTemplate: AppHelpTemplate, @@ -575,10 +575,10 @@ func TestShowAppHelp_HelpPrinter(t *testing.T) { { name: "custom-template-command", template: "{{doublecho .Name}}", - printer: func(w io.Writer, templ string, data interface{}) { + printer: func(w io.Writer, templ string, data interface{}, wrapAt int) { // Pass a custom function to ensure it gets used fm := map[string]interface{}{"doublecho": doublecho} - printHelpCustom(w, templ, data, fm) + printHelpCustom(w, templ, data, fm, wrapAt) }, wantTemplate: "{{doublecho .Name}}", wantOutput: "my-app my-app", @@ -590,12 +590,12 @@ func TestShowAppHelp_HelpPrinter(t *testing.T) { defer func(old helpPrinter) { HelpPrinter = old }(HelpPrinter) - HelpPrinter = func(w io.Writer, templ string, data interface{}) { + HelpPrinter = func(w io.Writer, templ string, data interface{}, wrapAt int) { if templ != tt.wantTemplate { t.Errorf("want template:\n%s\ngot template:\n%s", tt.wantTemplate, templ) } - tt.printer(w, templ, data) + tt.printer(w, templ, data, wrapAt) } var buf bytes.Buffer @@ -633,7 +633,7 @@ func TestShowAppHelp_HelpPrinterCustom(t *testing.T) { { name: "standard-command", template: "", - printer: func(w io.Writer, templ string, data interface{}, fm map[string]interface{}) { + printer: func(w io.Writer, templ string, data interface{}, fm map[string]interface{}, wrapAt int) { fmt.Fprint(w, "yo") }, wantTemplate: AppHelpTemplate, @@ -642,10 +642,10 @@ func TestShowAppHelp_HelpPrinterCustom(t *testing.T) { { name: "custom-template-command", template: "{{doublecho .Name}}", - printer: func(w io.Writer, templ string, data interface{}, _ map[string]interface{}) { + printer: func(w io.Writer, templ string, data interface{}, _ map[string]interface{}, wrapAt int) { // Pass a custom function to ensure it gets used fm := map[string]interface{}{"doublecho": doublecho} - printHelpCustom(w, templ, data, fm) + printHelpCustom(w, templ, data, fm, wrapAt) }, wantTemplate: "{{doublecho .Name}}", wantOutput: "my-app my-app", @@ -657,7 +657,7 @@ func TestShowAppHelp_HelpPrinterCustom(t *testing.T) { defer func(old helpPrinterCustom) { HelpPrinterCustom = old }(HelpPrinterCustom) - HelpPrinterCustom = func(w io.Writer, templ string, data interface{}, fm map[string]interface{}) { + HelpPrinterCustom = func(w io.Writer, templ string, data interface{}, fm map[string]interface{}, wrapAt int) { if fm != nil { t.Error("unexpected function map passed") } @@ -666,7 +666,7 @@ func TestShowAppHelp_HelpPrinterCustom(t *testing.T) { t.Errorf("want template:\n%s\ngot template:\n%s", tt.wantTemplate, templ) } - tt.printer(w, templ, data, fm) + tt.printer(w, templ, data, fm, wrapAt) } var buf bytes.Buffer @@ -907,3 +907,74 @@ func TestHideHelpCommand_WithSubcommands(t *testing.T) { t.Errorf("Run returned unexpected error: %v", err) } } + +func TestWrappedHelp(t *testing.T) { + output := new(bytes.Buffer) + app := &App{ + Writer: output, + WrapAt: 30, + 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", + Copyright: `Here's a sample copyright text string long enough that it should be wrapped. +Including newlines. + + +And then another long line. Blah blah blah does anybody ever read these things?`, + } + + c := NewContext(app, nil, nil) + + _ = 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 + +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 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", + expected, output.String()) + } +} diff --git a/template.go b/template.go index aee3e0494f..8c97e1accb 100644 --- a/template.go +++ b/template.go @@ -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}} USAGE: - {{if .UsageText}}{{.UsageText}}{{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}}{{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}} @@ -26,10 +26,10 @@ COMMANDS:{{range .VisibleCategories}}{{if .Name}} GLOBAL OPTIONS: {{range $index, $option := .VisibleFlags}}{{if $index}} - {{end}}{{$option}}{{end}}{{end}}{{if .Copyright}} + {{end}}{{wrap $option.String 6}}{{end}}{{end}}{{if .Copyright}} COPYRIGHT: - {{.Copyright}}{{end}} + {{wrap .Copyright 3}}{{end}} ` // CommandHelpTemplate is the text template for the command help topic.