Skip to content
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
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ jobs:
run: make lint

- run: make vet
- run: make test GFLAGS='--tags urfave_cli_no_template'
- run: make test
- run: make check-binary-size

Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,27 @@ command line tools in Go featuring:
See the hosted documentation website at <https://cli.urfave.org>. Contents of
this website are built from the [`./docs`](./docs) directory.

## Build tags

You can use the following build tags:

### `urfave_cli_no_template`

When set, the default help and fish completion are rendered without using
`text/template`, which makes the Go linker disable dead code elimination (see
[Binary Size](https://cli.urfave.org/v3/binary-size/#urfave_cli_no_template)).
The output is the same, but help templates can not be customized:

- `RootCommandHelpTemplate`, `CommandHelpTemplate`, `SubcommandHelpTemplate`
and `FishCompletionTemplate` are constants;
- if the default help printer is used with a custom template (such as
`CustomRootCommandHelpTemplate` or `CustomHelpTemplate`), it panics;
- custom template functions other than `wrap` and `wrapAt` are ignored.

Replacing `cli.HelpPrinter` works as usual, except it receives `"root"`,
`"command"` or `"subcommand"` as `templ` instead of the default template text
(a custom template is passed as is).

## Support

Check the [Q&A discussions]. If you don't find answer to your question, [create
Expand Down
35 changes: 35 additions & 0 deletions dce_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package cli

import (
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// reflectMethodSymbols builds examples/example-cli with the given tags,
// and returns the number of reachable symbols using reflect.Method or
// reflect.MethodByName with a non-constant argument. If there are any,
// the linker's dead code elimination of exported methods is disabled.
func reflectMethodSymbols(t *testing.T, tags string) int {
t.Helper()
out := filepath.Join(t.TempDir(), "example-cli")
cmd := exec.Command("go", "build", "-tags", tags, "-ldflags=-dumpdep", "-o", out, "./examples/example-cli")
deps, err := cmd.CombinedOutput()
require.NoError(t, err, string(deps))
return strings.Count(string(deps), "<ReflectMethod>")
}

// Using text/template (or anything else calling reflect.Method or
// reflect.MethodByName with a non-constant argument) disables linker's
// dead code elimination of exported methods. Make sure this does not
// happen with urfave_cli_no_template tag.
func TestNoTemplateTagDCE(t *testing.T) {
// Sanity check: without the tag, DCE is disabled by text/template.
assert.NotZero(t, reflectMethodSymbols(t, ""))

assert.Zero(t, reflectMethodSymbols(t, "urfave_cli_no_template"))
}
2 changes: 1 addition & 1 deletion docs/migrate-v2-to-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ These tags are **not available in v3** because they are no longer necessary:
- **Shell completion** uses `embed.FS` for its scripts, with no external dependencies.
- **Altsrc** has been extracted to a separate module, `github.com/urfave/cli-altsrc/v3` (see below).

Binary size in v3 is managed through architectural simplification rather than build tags. v3's minimal dependency tree (only `github.com/stretchr/testify` in tests) means the core library contributes very little to your final binary.
Binary size in v3 is managed through architectural simplification rather than build tags (the only exception is the `urfave_cli_no_template` tag, see [Binary Size](v3/binary-size.md#urfave_cli_no_template)). v3's minimal dependency tree (only `github.com/stretchr/testify` in tests) means the core library contributes very little to your final binary.

### Order of precedence of envvars, filepaths, altsrc now depends on the order in which they are defined

Expand Down
23 changes: 23 additions & 0 deletions docs/v3/binary-size.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ Reflection and templates can contribute noticeably to binary size because additi
of the packages worth investigating when analyzing binary size. These numbers
show their contribution to the analyzed binary, but do not by themselves
prove that all of this code comes from a single feature.

See [`urfave_cli_no_template`](#urfave_cli_no_template) for a way to avoid
linking `text/template`.

## Hiding Built-in Help

Expand Down Expand Up @@ -152,6 +155,26 @@ configurations or changes to the library — would be required.

## Current v3 Build Tags

### `urfave_cli_no_template`

Using `text/template` makes the Go linker disable dead code elimination of
exported methods for the whole program, since templates can call arbitrary
methods by name (see [golang/go#72895](https://github.com/golang/go/issues/72895)).
For larger programs, this can noticeably increase the binary size.

With the `urfave_cli_no_template` build tag, the default help and fish
completion are rendered without `text/template`, producing the same output,
and dead code elimination is no longer disabled:

```sh-session
go build -tags urfave_cli_no_template -o myapp ./cmd/myapp
```

Note that help templates can not be customized with this tag; see
[README](https://github.com/urfave/cli#urfave_cli_no_template) for details.

### Other tags

The v3 module does not currently define build tags such as
`urfave_cli_no_docs`, `urfave_cli_no_completion`, or `urfave_cli_minimal`.
Documentation generation lives outside the core module in
Expand Down
39 changes: 39 additions & 0 deletions examples_template_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//go:build !urfave_cli_no_template

package cli_test

import (
"context"
"fmt"
"os"

"github.com/urfave/cli/v3"
)

func ExampleCommand_Suggest() {
cmd := &cli.Command{
Name: "greet",
ErrWriter: os.Stdout,
Suggest: true,
HideHelp: false,
HideHelpCommand: true,
CustomRootCommandHelpTemplate: "(this space intentionally left blank)\n",
Flags: []cli.Flag{
&cli.StringFlag{Name: "name", Value: "squirrel", Usage: "a name to say"},
},
Action: func(_ context.Context, cmd *cli.Command) error {
fmt.Printf("Hello %v\n", cmd.String("name"))
return nil
},
}

if cmd.Run(context.Background(), []string{"greet", "--nema", "chipmunk"}) == nil {
fmt.Println("Expected error")
}
// Output:
// Incorrect Usage: flag provided but not defined: -nema
//
// Did you mean "--name"?
//
// (this space intentionally left blank)
}
28 changes: 0 additions & 28 deletions examples_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -540,34 +540,6 @@ func ExampleBoolWithInverseFlag() {
// flags: 2
}

func ExampleCommand_Suggest() {
cmd := &cli.Command{
Name: "greet",
ErrWriter: os.Stdout,
Suggest: true,
HideHelp: false,
HideHelpCommand: true,
CustomRootCommandHelpTemplate: "(this space intentionally left blank)\n",
Flags: []cli.Flag{
&cli.StringFlag{Name: "name", Value: "squirrel", Usage: "a name to say"},
},
Action: func(_ context.Context, cmd *cli.Command) error {
fmt.Printf("Hello %v\n", cmd.String("name"))
return nil
},
}

if cmd.Run(context.Background(), []string{"greet", "--nema", "chipmunk"}) == nil {
fmt.Println("Expected error")
}
// Output:
// Incorrect Usage: flag provided but not defined: -nema
//
// Did you mean "--name"?
//
// (this space intentionally left blank)
}

func ExampleCommand_Suggest_command() {
cmd := &cli.Command{
ErrWriter: os.Stdout,
Expand Down
9 changes: 1 addition & 8 deletions fish.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"fmt"
"io"
"strings"
"text/template"
)

// ToFishCompletion creates a fish completion string for the `*Command`
Expand All @@ -25,12 +24,6 @@ type fishCommandCompletionTemplate struct {
}

func (cmd *Command) writeFishCompletionTemplate(w io.Writer) error {
const name = "cli"
t, err := template.New(name).Parse(FishCompletionTemplate)
if err != nil {
return err
}

// Add global flags
completions := prepareFishFlags(cmd.Name, cmd)

Expand All @@ -57,7 +50,7 @@ func (cmd *Command) writeFishCompletionTemplate(w io.Writer) error {
toplevelCommandNames = append(toplevelCommandNames, child.Names()...)
}

return t.ExecuteTemplate(w, name, &fishCommandCompletionTemplate{
return renderFishCompletion(w, &fishCommandCompletionTemplate{
Command: cmd,
Completions: completions,
AllCommands: toplevelCommandNames,
Expand Down
17 changes: 17 additions & 0 deletions fish_template_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//go:build !urfave_cli_no_template

package cli

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestFishCompletionTemplateError(t *testing.T) {
defer func(old string) { FishCompletionTemplate = old }(FishCompletionTemplate)
FishCompletionTemplate = "{{something"

_, err := buildExtendedTestCommand().ToFishCompletion()
assert.Error(t, err)
}
10 changes: 0 additions & 10 deletions fish_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,6 @@ func TestFishCompletion(t *testing.T) {
})
cmd.setupCommandGraph()

oldTemplate := FishCompletionTemplate
defer func() { FishCompletionTemplate = oldTemplate }()
FishCompletionTemplate = "{{something"

// test error case
_, err1 := cmd.ToFishCompletion()
assert.Error(t, err1)

// reset the template
FishCompletionTemplate = oldTemplate
// When
res, err := cmd.ToFishCompletion()

Expand Down
95 changes: 0 additions & 95 deletions help.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import (
"os"
"slices"
"strings"
"text/tabwriter"
"text/template"
"unicode/utf8"
)

Expand Down Expand Up @@ -378,99 +376,6 @@ func handleTemplateError(err error) {
}
}

// DefaultPrintHelpCustom is the default implementation of HelpPrinterCustom.
//
// The customFuncs map will be combined with a default template.FuncMap to
// allow using arbitrary functions in template rendering.
func DefaultPrintHelpCustom(out io.Writer, templ string, data any, customFuncs map[string]any) {
const maxLineLength = 10000

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

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

for key, value := range customFuncs {
funcMap[key] = value
}

w := tabwriter.NewWriter(out, 1, 8, 2, ' ', 0)
t := template.Must(template.New("help").Funcs(funcMap).Parse(templ))

if _, err := t.New("helpNameTemplate").Parse(helpNameTemplate); err != nil {
handleTemplateError(err)
}

if _, err := t.New("argsTemplate").Parse(argsTemplate); err != nil {
handleTemplateError(err)
}

if _, err := t.New("usageTemplate").Parse(usageTemplate); err != nil {
handleTemplateError(err)
}

if _, err := t.New("descriptionTemplate").Parse(descriptionTemplate); err != nil {
handleTemplateError(err)
}

if _, err := t.New("visibleCommandTemplate").Parse(visibleCommandTemplate); err != nil {
handleTemplateError(err)
}

if _, err := t.New("copyrightTemplate").Parse(copyrightTemplate); err != nil {
handleTemplateError(err)
}

if _, err := t.New("versionTemplate").Parse(versionTemplate); err != nil {
handleTemplateError(err)
}

if _, err := t.New("visibleFlagCategoryTemplate").Parse(visibleFlagCategoryTemplate); err != nil {
handleTemplateError(err)
}

if _, err := t.New("visibleFlagTemplate").Parse(visibleFlagTemplate); err != nil {
handleTemplateError(err)
}

if _, err := t.New("visiblePersistentFlagTemplate").Parse(visiblePersistentFlagTemplate); err != nil {
handleTemplateError(err)
}

if _, err := t.New("visibleGlobalFlagCategoryTemplate").Parse(strings.ReplaceAll(visibleFlagCategoryTemplate, "OPTIONS", "GLOBAL OPTIONS")); err != nil {
handleTemplateError(err)
}

if _, err := t.New("authorsTemplate").Parse(authorsTemplate); err != nil {
handleTemplateError(err)
}

if _, err := t.New("visibleCommandCategoryTemplate").Parse(visibleCommandCategoryTemplate); err != nil {
handleTemplateError(err)
}

tracef("executing template")
handleTemplateError(t.Execute(w, data))

_ = w.Flush()
}

// DefaultPrintHelp is the default implementation of HelpPrinter.
func DefaultPrintHelp(out io.Writer, templ string, data any) {
HelpPrinterCustom(out, templ, data, nil)
Expand Down
Loading
Loading