Skip to content

Commit 09e9322

Browse files
authored
Allow linker to perform deadcode elimination for programs using kingpin (#365)
This was largely inspired by spf13/cobra#1956. The usage rendering relied on text/template which in turn relied on reflect.MethodByName. As a result, Go's deadcode elimination is prevented from running, and thus means any downstream consumers of kingpin also cannot take advantage of deadcode elimination. This changes the usage rendering of kingpin such that text/template is not used by default. The existing templates were converted into pure Go functions, and an extensive test suite was added to ensure equality with the legacy templates. Default applications now use the pure Go equivalent of kingpin.DefaultUsageTemplate. The existing UsageTemplate and UsageFuncs APIs remain intact to preserve compatibility. Any use of either API will result in text/template rendering usage and preventing deadcode elimination. The basic app used in TestDeadCodeElimination was tested before and after this change. The usage text remained the same, but the binary size shrunk from ~5MB to ~2MB.
1 parent f060154 commit 09e9322

6 files changed

Lines changed: 1090 additions & 174 deletions

File tree

app.go

Lines changed: 70 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"os"
77
"regexp"
88
"strings"
9-
"text/template"
109
)
1110

1211
var (
@@ -28,17 +27,20 @@ type Application struct {
2827
Name string
2928
Help string
3029

31-
author string
32-
version string
33-
errorWriter io.Writer // Destination for errors.
34-
usageWriter io.Writer // Destination for usage
35-
usageTemplate string
36-
usageFuncs template.FuncMap
37-
validator ApplicationValidator
38-
terminate func(status int) // See Terminate()
39-
noInterspersed bool // can flags be interspersed with args (or must they come first)
40-
defaultEnvars bool
41-
completion bool
30+
author string
31+
version string
32+
errorWriter io.Writer // Destination for errors.
33+
usageWriter io.Writer // Destination for usage
34+
hiddenHelpWriter io.Writer // Desitination for hidden help commands.
35+
usageTemplate string
36+
usageFuncs map[string]interface{}
37+
templateRenderer func(a *Application, context *ParseContext, indent int, tmpl string) error
38+
usageRenderer UsageRenderer
39+
validator ApplicationValidator
40+
terminate func(status int) // See Terminate()
41+
noInterspersed bool // can flags be interspersed with args (or must they come first)
42+
defaultEnvars bool
43+
completion bool
4244

4345
// Help flag. Exposed for user customisation.
4446
HelpFlag *FlagClause
@@ -51,12 +53,12 @@ type Application struct {
5153
// New creates a new Kingpin application instance.
5254
func New(name, help string) *Application {
5355
a := &Application{
54-
Name: name,
55-
Help: help,
56-
errorWriter: os.Stderr, // Left for backwards compatibility purposes.
57-
usageWriter: os.Stderr,
58-
usageTemplate: DefaultUsageTemplate,
59-
terminate: os.Exit,
56+
Name: name,
57+
Help: help,
58+
errorWriter: os.Stderr, // Left for backwards compatibility purposes.
59+
usageWriter: os.Stderr,
60+
hiddenHelpWriter: os.Stdout,
61+
terminate: os.Exit,
6062
}
6163
a.flagGroup = newFlagGroup()
6264
a.argGroup = newArgGroup()
@@ -73,45 +75,57 @@ func New(name, help string) *Application {
7375
return a
7476
}
7577

78+
// renderHiddenFlag renders usage for hidden help flags (--help-long, --help-man, etc.).
79+
// A custom UsageRenderer does NOT override these — they are distinct output formats
80+
// (man pages, completion scripts) that should always produce their advertised output.
81+
// UsageFuncs overrides ARE applied, since they provide template helper functions that
82+
// may be needed by the template path.
83+
func (a *Application) renderHiddenFlag(c *ParseContext, renderer UsageRenderer, tmpl string) error {
84+
if a.usageFuncs != nil && a.templateRenderer != nil {
85+
return a.templateRenderer(a, c, 2, tmpl)
86+
}
87+
return a.usageForContextWithUsageRenderer(c, 2, renderer)
88+
}
89+
7690
func (a *Application) generateLongHelp(c *ParseContext) error {
77-
a.Writer(os.Stdout)
78-
if err := a.UsageForContextWithTemplate(c, 2, LongHelpTemplate); err != nil {
91+
a.Writer(a.hiddenHelpWriter)
92+
if err := a.renderHiddenFlag(c, RenderLongHelp, LongHelpTemplate); err != nil {
7993
return err
8094
}
8195
a.terminate(0)
8296
return nil
8397
}
8498

8599
func (a *Application) generateManPage(c *ParseContext) error {
86-
a.Writer(os.Stdout)
87-
if err := a.UsageForContextWithTemplate(c, 2, ManPageTemplate); err != nil {
100+
a.Writer(a.hiddenHelpWriter)
101+
if err := a.renderHiddenFlag(c, RenderManPage, ManPageTemplate); err != nil {
88102
return err
89103
}
90104
a.terminate(0)
91105
return nil
92106
}
93107

94108
func (a *Application) generateBashCompletionScript(c *ParseContext) error {
95-
a.Writer(os.Stdout)
96-
if err := a.UsageForContextWithTemplate(c, 2, BashCompletionTemplate); err != nil {
109+
a.Writer(a.hiddenHelpWriter)
110+
if err := a.renderHiddenFlag(c, RenderBashCompletion, BashCompletionTemplate); err != nil {
97111
return err
98112
}
99113
a.terminate(0)
100114
return nil
101115
}
102116

103117
func (a *Application) generateZSHCompletionScript(c *ParseContext) error {
104-
a.Writer(os.Stdout)
105-
if err := a.UsageForContextWithTemplate(c, 2, ZshCompletionTemplate); err != nil {
118+
a.Writer(a.hiddenHelpWriter)
119+
if err := a.renderHiddenFlag(c, RenderZshCompletion, ZshCompletionTemplate); err != nil {
106120
return err
107121
}
108122
a.terminate(0)
109123
return nil
110124
}
111125

112126
func (a *Application) generateFishCompletionScript(c *ParseContext) error {
113-
a.Writer(os.Stdout)
114-
if err := a.UsageForContextWithTemplate(c, 2, FishCompletionTemplate); err != nil {
127+
a.Writer(a.hiddenHelpWriter)
128+
if err := a.renderHiddenFlag(c, RenderFishCompletion, FishCompletionTemplate); err != nil {
115129
return err
116130
}
117131
a.terminate(0)
@@ -152,22 +166,47 @@ func (a *Application) ErrorWriter(w io.Writer) *Application {
152166
return a
153167
}
154168

155-
// UsageWriter sets the io.Writer to use for errors.
169+
// UsageWriter sets the io.Writer to use for usage.
156170
func (a *Application) UsageWriter(w io.Writer) *Application {
157171
a.usageWriter = w
158172
return a
159173
}
160174

175+
// HiddenHelpWriter sets the io.Writer to use for usage of hidden help commands.
176+
func (a *Application) HiddenHelpWriter(w io.Writer) *Application {
177+
a.hiddenHelpWriter = w
178+
return a
179+
}
180+
161181
// UsageTemplate specifies the text template to use when displaying usage
162182
// information. The default is UsageTemplate.
183+
//
184+
// Note: calling this method causes text/template to be linked into the binary,
185+
// which prevents dead code elimination of reflect.MethodByName. Programs that
186+
// want smaller binaries should use UsageRenderer instead.
163187
func (a *Application) UsageTemplate(template string) *Application {
164188
a.usageTemplate = template
189+
a.templateRenderer = templateRenderFunc
165190
return a
166191
}
167192

168-
// UsageFuncs adds extra functions that can be used in the usage template.
169-
func (a *Application) UsageFuncs(funcs template.FuncMap) *Application {
193+
// UsageFuncs adds extra functions that can be used in the usage template
194+
//
195+
// Note: calling this method causes text/template to be linked into the binary,
196+
// which prevents dead code elimination of reflect.MethodByName. Programs that
197+
// want smaller binaries should use UsageRenderer instead..
198+
func (a *Application) UsageFuncs(funcs map[string]interface{}) *Application {
170199
a.usageFuncs = funcs
200+
a.templateRenderer = templateRenderFunc
201+
return a
202+
}
203+
204+
// UsageRenderer registers a custom UsageRenderer for the primary --help output.
205+
// It does not affect hidden help flags (--help-long, --help-man, completion scripts),
206+
// which always use their built-in renderers or the template path if UsageFuncs is set.
207+
// For backward compatibility, UsageTemplate takes precedence over UsageRenderer.
208+
func (a *Application) UsageRenderer(fn UsageRenderer) *Application {
209+
a.usageRenderer = fn
171210
return a
172211
}
173212

dce_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package kingpin
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"path/filepath"
7+
"runtime"
8+
"testing"
9+
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// TestDeadCodeElimination verifies that programs using kingpin's default
14+
// UsageRenderer do not link in reflect.MethodByName, which is the key
15+
// indicator that dead code elimination is working.
16+
func TestDeadCodeElimination(t *testing.T) {
17+
if runtime.GOOS == "windows" {
18+
t.Skip("go tool nm not reliable on Windows")
19+
}
20+
21+
dir := t.TempDir()
22+
filename := filepath.Join(dir, "main.go")
23+
err := os.WriteFile(filename, []byte(`package main
24+
25+
import (
26+
"os"
27+
28+
"github.com/alecthomas/kingpin/v2"
29+
)
30+
31+
func main() {
32+
app := kingpin.New("test", "A test app.")
33+
app.UsageRenderer(kingpin.RenderDefault)
34+
app.Flag("verbose", "Enable verbose mode.").Bool()
35+
app.Command("sub", "A subcommand.")
36+
app.Parse(os.Args[1:])
37+
}
38+
`), 0o600)
39+
require.NoError(t, err)
40+
41+
binPath := filepath.Join(dir, "test_binary")
42+
buf, err := exec.Command("go", "build", "-trimpath", "-o", binPath, filename).CombinedOutput()
43+
require.NoError(t, err, "go build failed: %s", buf)
44+
45+
buf, err = exec.Command("go", "tool", "nm", binPath).CombinedOutput()
46+
require.NoError(t, err, "go tool nm failed: %s", buf)
47+
48+
require.NotContains(t, string(buf), "MethodByName", "text/template was not eliminated by dead code elimination")
49+
}

0 commit comments

Comments
 (0)