Skip to content

Commit 68e33b8

Browse files
committed
chore: do not call os exit on usage to allow for resource cleaup
Calling os.Exit from a library can be problematic for some users, especially when cleanup is necessary. Whenever usage is rendered, return error ErrShowUsage and let the caller handle it appropriately. Add tests.
1 parent 1090edc commit 68e33b8

File tree

2 files changed

+114
-37
lines changed

2 files changed

+114
-37
lines changed

execute.go

Lines changed: 20 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ var ErrEnvironmentBindFailure = errors.New("cmder: failed to update flag from en
6060
//
6161
// If the command also implements [FlagInitializer], InitializeFlags() will be invoked to register additional
6262
// command-line flags. Each command/subcommand is given a unique [flag.FlagSet]. Help flags ('-h', '--help') are
63-
// configured automatically and must not be set by the application.
63+
// configured automatically if not defined and will instruct Execute to render command usage.
6464
//
6565
// Execute parses getopt-style (GNU/POSIX) command-line arguments with the help of package [getopt]. To use the standard
6666
// [flag] syntax instead, see [WithNativeFlags]. Flags and arguments cannot be interspersed by default. You can change
@@ -70,12 +70,13 @@ var ErrEnvironmentBindFailure = errors.New("cmder: failed to update flag from en
7070
//
7171
// # Usage and Help Texts
7272
//
73-
// Whenever the user provides the '-h' or '--help' flag at the command line, [Execute] will display command usage and
74-
// exit. The format of the help text can be adjusted with [WithUsageTemplate]. By default, usage information will
75-
// be written to stderr, but this can be adjusted by setting [WithUsageOutput].
73+
// Whenever the user provides the '-h' or '--help' flag at the command line and the command doesn't register custom help
74+
// flags, Execute will display command usage and return [ErrShowUsage]. The format of the help text can be adjusted with
75+
// [WithUsageTemplate]. By default, usage information will be written to stderr, but this can be adjusted by setting
76+
// [WithUsageOutput].
7677
//
77-
// If a command's [Run] routine returns [ErrShowUsage] (or an error wrapping [ErrShowUsage]), [Execute] will render
78-
// help text and exit with status 2.
78+
// If a command's Run routine returns [ErrShowUsage] (or an error wrapping [ErrShowUsage]), Execute will render
79+
// help text and return the error.
7980
func Execute(ctx context.Context, cmd Command, op ...ExecuteOption) error {
8081
// do some checks
8182
if cmd == nil {
@@ -98,11 +99,6 @@ func Execute(ctx context.Context, cmd Command, op ...ExecuteOption) error {
9899
return err
99100
}
100101

101-
// if help was requested, display and exit
102-
if cmd, ok := helpRequested(stack); ok {
103-
return usage(*cmd, ops)
104-
}
105-
106102
return execute(ctx, stack, ops)
107103
}
108104

@@ -157,13 +153,16 @@ type command struct {
157153
func (c command) onInit(ctx context.Context, ops *ExecuteOptions) error {
158154
var err error
159155

156+
if c.showHelp {
157+
return errors.Join(ErrShowUsage, usage(c, ops))
158+
}
159+
160160
if cmd, ok := c.Command.(Initializer); ok {
161161
err = cmd.Initialize(ctx, c.args)
162162
}
163163

164164
if errors.Is(err, ErrShowUsage) {
165-
_ = usage(c, ops)
166-
os.Exit(2)
165+
return errors.Join(err, usage(c, ops))
167166
}
168167

169168
return err
@@ -173,8 +172,7 @@ func (c command) onInit(ctx context.Context, ops *ExecuteOptions) error {
173172
func (c command) run(ctx context.Context, ops *ExecuteOptions) error {
174173
err := c.Run(ctx, c.args)
175174
if errors.Is(err, ErrShowUsage) {
176-
_ = usage(c, ops)
177-
os.Exit(2)
175+
return errors.Join(err, usage(c, ops))
178176
}
179177

180178
return err
@@ -189,8 +187,7 @@ func (c command) onDestroy(ctx context.Context, ops *ExecuteOptions) error {
189187
}
190188

191189
if errors.Is(err, ErrShowUsage) {
192-
_ = usage(c, ops)
193-
os.Exit(2)
190+
return errors.Join(err, usage(c, ops))
194191
}
195192

196193
return err
@@ -212,14 +209,16 @@ func buildCallStack(cmd Command, ops *ExecuteOptions) ([]command, error) {
212209
fs: flag.NewFlagSet(cmd.Name(), flag.ContinueOnError),
213210
}
214211

215-
// add help flags
216-
this.fs.BoolVar(&this.showHelp, "h", false, "show command help and usage information")
217-
this.fs.BoolVar(&this.showHelp, "help", false, "show command help and usage information")
218-
219212
if c, ok := cmd.(FlagInitializer); ok {
220213
c.InitializeFlags(this.fs)
221214
}
222215

216+
// add help flags
217+
if this.fs.Lookup("h") == nil && this.fs.Lookup("help") == nil {
218+
this.fs.BoolVar(&this.showHelp, "h", false, "show command help and usage information")
219+
this.fs.BoolVar(&this.showHelp, "help", false, "show command help and usage information")
220+
}
221+
223222
// bind environment variables
224223
if ops.bindEnv {
225224
if err := bindEnvironmentFlags(stack, this, ops); err != nil {
@@ -330,19 +329,3 @@ func formatEnvvar(flagpath []string) string {
330329

331330
return strings.Join(flagpath, "_")
332331
}
333-
334-
// helpRequested traverses the command stack and returns whether help text was requested with '-h' or '--help' flags,
335-
// returning the leaf command from stack and true.
336-
func helpRequested(stack []command) (*command, bool) {
337-
if len(stack) == 0 {
338-
return nil, false
339-
}
340-
341-
for _, cmd := range stack {
342-
if cmd.showHelp {
343-
return &stack[len(stack)-1], true
344-
}
345-
}
346-
347-
return nil, false
348-
}

execute_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@ package cmder
22

33
import (
44
"context"
5+
"errors"
56
"flag"
7+
"net/http"
68
"testing"
9+
"time"
10+
11+
"github.com/brandon1024/cmder/getopt"
712
)
813

914
func TestExecute(t *testing.T) {
@@ -86,4 +91,93 @@ func TestExecute(t *testing.T) {
8691
assert(t, match([]string{"000", "--l2f1", "25", "111", "--", "--l2f0=255"}, result))
8792
})
8893
})
94+
95+
t.Run("native flags", func(t *testing.T) {
96+
var (
97+
addr string
98+
readTimeout time.Duration
99+
writeTimeout time.Duration
100+
maxHeaderBytes int
101+
maxBodySize int64
102+
basicAuth string
103+
noAuth bool
104+
)
105+
106+
var args []string
107+
108+
cmd := &BaseCommand{
109+
CommandName: "native-flags",
110+
InitFlagsFunc: func(fs *flag.FlagSet) {
111+
fs.StringVar(&addr, "http.bind-addr", ":8080", "bind address for the web server")
112+
fs.DurationVar(&readTimeout, "http.read-timeout", time.Duration(0), "read timeout for requests")
113+
fs.DurationVar(&writeTimeout, "http.write-timeout", time.Duration(0), "write timeout for responses")
114+
fs.IntVar(&maxHeaderBytes, "http.max-header-size", http.DefaultMaxHeaderBytes, "max permitted size of the headers in a request")
115+
fs.Int64Var(&maxBodySize, "http.max-body-size", 1<<26, "max permitted size of the headers in a request")
116+
fs.StringVar(&basicAuth, "http.auth-basic", "", "basic auth credentials (in format user:pass)")
117+
fs.BoolVar(&noAuth, "http.no-auth", false, "disable basic auth")
118+
119+
getopt.Alias(fs, "http.bind-addr", "a")
120+
getopt.Alias(fs, "http.read-timeout", "r")
121+
getopt.Alias(fs, "http.write-timeout", "w")
122+
getopt.Alias(fs, "http.max-header-size", "h")
123+
getopt.Alias(fs, "http.max-body-size", "b")
124+
getopt.Alias(fs, "http.auth-basic", "C")
125+
getopt.Alias(fs, "http.no-auth", "E")
126+
},
127+
RunFunc: func(ctx context.Context, a []string) error {
128+
args = a
129+
return nil
130+
},
131+
}
132+
133+
t.Run("should correctly parse flags in standard flag libs format", func(t *testing.T) {
134+
err := Execute(t.Context(), cmd, WithNativeFlags(), WithArgs([]string{
135+
"-http.bind-addr", "0.0.0.0:8000",
136+
"--http.read-timeout", "10s",
137+
"-http.write-timeout=5s",
138+
"-h", "8096",
139+
"-b=65536",
140+
"-http.auth-basic", "U:P",
141+
"--",
142+
"-http.no-auth", "true",
143+
}))
144+
145+
assert(t, nilerr(err))
146+
assert(t, eq("0.0.0.0:8000", addr))
147+
assert(t, eq(10*time.Second, readTimeout))
148+
assert(t, eq(5*time.Second, writeTimeout))
149+
assert(t, eq(8096, maxHeaderBytes))
150+
assert(t, eq(65536, maxBodySize))
151+
assert(t, eq("U:P", basicAuth))
152+
assert(t, eq(false, noAuth))
153+
assert(t, match([]string{"-http.no-auth", "true"}, args))
154+
})
155+
})
156+
157+
t.Run("help flags", func(t *testing.T) {
158+
t.Run("should not register help flags if defined by command", func(t *testing.T) {
159+
var showHelp bool
160+
161+
cmd := &BaseCommand{
162+
CommandName: "help-cmd",
163+
InitFlagsFunc: func(fs *flag.FlagSet) {
164+
fs.BoolVar(&showHelp, "h", showHelp, "show help")
165+
fs.BoolVar(&showHelp, "help", showHelp, "show help")
166+
},
167+
}
168+
169+
err := Execute(t.Context(), cmd, WithArgs([]string{"--help"}))
170+
assert(t, nilerr(err))
171+
assert(t, eq(true, showHelp))
172+
})
173+
174+
t.Run("should return ErrShowUsage if help flags not defined", func(t *testing.T) {
175+
cmd := &BaseCommand{
176+
CommandName: "help-cmd",
177+
}
178+
179+
err := Execute(t.Context(), cmd, WithArgs([]string{"--help"}))
180+
assert(t, eq(true, errors.Is(err, ErrShowUsage)))
181+
})
182+
})
89183
}

0 commit comments

Comments
 (0)