Skip to content

Commit

Permalink
Merge pull request #14 from jxskiss/feat_cmd_options
Browse files Browse the repository at this point in the history
New options to customize help
  • Loading branch information
jxskiss authored Aug 2, 2023
2 parents ea4a6b0 + 3b924ca commit 3117df6
Show file tree
Hide file tree
Showing 8 changed files with 190 additions and 24 deletions.
28 changes: 22 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@ func main() {
// except that when flag "--mcli-show-hidden" is given.
mcli.AddHiden("secret-cmd", secretCmd, "An secret command won't be showed in help")

// Enable shell auto-completion, see `program completion -h` for help.
mcli.AddCompletion()
// Enable shell auto-completion, see `program completion -h` for help.
mcli.AddCompletion()

mcli.Run()
}
Expand Down Expand Up @@ -156,6 +156,8 @@ Also, there are some sophisticated examples:

## API

Use the default App:

- `SetGlobalFlags` sets global flags, global flags are available to all commands.
- `Add` adds a command.
- `AddRoot` adds a root command. A root command is executed when no sub command is specified.
Expand All @@ -169,18 +171,32 @@ Also, there are some sophisticated examples:
- `Parse` parses the command line for flags and arguments.
- `Run` runs the program, it will parse the command line, search for a registered command and run it.
- `PrintHelp` prints usage doc of the current command to stderr.

Create a new App instance:

- `NewApp` creates a new cli applcation instance.

### Custom parsing options
### Custom options

App:

- `App.Options` specifies optional options for an application.

CmdOpt:

- `WithLongDesc` specifies a long description of a command, which will be showed in the command's help.
- `WithExamples` specifies examples for a command. Examples will be showed after flags in the command's help.

ParseOpt:

- `WithArgs` tells `Parse` to parse from the given args, instead of parsing from the command line arguments.
- `WithErrorHandling` tells `Parse` to use the given ErrorHandling.
By default, it exits the program when an error happens.
By default, the program exits when an error happens.
- `WithName` specifies the command name to use when printing usage doc.
- `DisableGlobalFlags` tells `Parse` to don't parse and print global flags in help.
- `ReplaceUsage` tells `Parse` to use a custom usage function instead of the default.
- `WithFooter` adds a footer message after the default help.
- `App.Options` specifies optional options for an application.
- `WithFooter` adds a footer message after the default help,
this option overrides the App's setting `Options.HelpFooter` for this parsing call.

## Tag syntax

Expand Down
22 changes: 16 additions & 6 deletions cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,13 @@ type Options struct {
KeepCommandOrder bool

// AllowPosixSTMO enables using the posix-style single token to specify
// multiple boolean options. e.g. -abc is equivalent to -a -b -c.
// multiple boolean options. e.g. `-abc` is equivalent to `-a -b -c`.
AllowPosixSTMO bool

// HelpFooter optionally adds a footer message to help output.
// If Parse is called with option `WithFooter`, the option function's
// output overrides this setting.
HelpFooter string
}

// NewApp creates a new cli application instance.
Expand Down Expand Up @@ -308,21 +313,23 @@ func (p *App) printUsage() {

// Add adds a command.
// f must be a function of signature `func()` or `func(*Context)`, else it panics.
func (p *App) Add(name string, f interface{}, description string) {
func (p *App) Add(name string, f interface{}, description string, opts ...CmdOpt) {
ff := p.validateFunc(f)
p.addCommand(&Command{
Name: name,
Description: description,
f: ff,
opts: newCmdOptions(opts...),
})
}

// AddRoot adds a root command.
// When no sub command specified, a root command will be executed.
func (p *App) AddRoot(f interface{}) {
func (p *App) AddRoot(f interface{}, opts ...CmdOpt) {
ff := p.validateFunc(f)
p.rootCmd = &Command{
f: ff,
opts: newCmdOptions(opts...),
isRoot: true,
}
}
Expand All @@ -342,7 +349,7 @@ func (p *App) validateFunc(f interface{}) func() {
}

// AddAlias adds an alias name for a command.
func (p *App) AddAlias(aliasName, target string) {
func (p *App) AddAlias(aliasName, target string, opts ...CmdOpt) {
cmd := p.cmdMap[target]
if cmd == nil {
panic(fmt.Sprintf("alias command target %q does not exist", target))
Expand All @@ -354,6 +361,7 @@ func (p *App) AddAlias(aliasName, target string) {
Description: desc,
AliasOf: target,
f: cmd.f,
opts: newCmdOptions(opts...),
})
}

Expand All @@ -362,13 +370,14 @@ func (p *App) AddAlias(aliasName, target string) {
//
// A hidden command won't be showed in help, except that when a special flag
// "--mcli-show-hidden" is provided.
func (p *App) AddHidden(name string, f interface{}, description string) {
func (p *App) AddHidden(name string, f interface{}, description string, opts ...CmdOpt) {
ff := p.validateFunc(f)
p.addCommand(&Command{
Name: name,
Description: description,
Hidden: true,
f: ff,
opts: newCmdOptions(opts...),
})
}

Expand All @@ -377,11 +386,12 @@ func (p *App) AddHidden(name string, f interface{}, description string) {
// It's not required to add group before adding sub commands, but user
// can use this function to add a description to a group, which will be
// showed in help.
func (p *App) AddGroup(name string, description string) {
func (p *App) AddGroup(name string, description string, opts ...CmdOpt) {
p.addCommand(&Command{
Name: name,
Description: description,
f: p.groupCmd,
opts: newCmdOptions(opts...),
isGroup: true,
})
}
Expand Down
39 changes: 39 additions & 0 deletions cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ func dummyCmd() {
PrintHelp()
}

func (p *App) dummyCmd_flagContinueOnError() {
p.parseArgs(nil, WithErrorHandling(flag.ContinueOnError))
p.printUsage()
}

func dummyCmdWithContext(ctx *Context) {
ctx.Parse(nil)
ctx.PrintHelp()
Expand Down Expand Up @@ -814,3 +819,37 @@ Line 3 in Description.`
got4 := buf.String()
assert.NotContains(t, got4, app4.Description)
}

func TestAppOptions(t *testing.T) {
t.Run("HelpFooter", func(t *testing.T) {
app := NewApp()
app.HelpFooter = `
LEARN MORE:
Use 'program help <command> <subcommand>' for more information of a command.
`
cmd2 := func(ctx *Context) {
ctx.Parse(nil, WithErrorHandling(flag.ContinueOnError),
WithFooter(func() string {
return "Footer from parsing option."
}))
ctx.PrintHelp()
}
app.Add("cmd1", app.dummyCmd_flagContinueOnError, "test cmd1")
app.Add("cmd2", cmd2, "test cmd2")

var buf bytes.Buffer
app.Run("cmd1", "-h")
app.getFlagSet().SetOutput(&buf)
app.printUsage()
got1 := buf.String()
assert.Contains(t, got1, "LEARN MORE:\n Use 'program help <command> <subcommand>' for more information of a command.\n\n")

buf.Reset()
app.Run("cmd2", "-h")
app.getFlagSet().SetOutput(&buf)
app.printUsage()
got2 := buf.String()
assert.NotContains(t, got2, "LEARN MORE")
assert.Contains(t, got2, "Footer from parsing option.\n\n")
})
}
3 changes: 2 additions & 1 deletion command.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ type Command struct {

AliasOf string

f func()
f func()
opts cmdOptions

idx int
level int
Expand Down
20 changes: 10 additions & 10 deletions default.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,37 +17,37 @@ func SetGlobalFlags(v interface{}) {

// Add adds a command.
// f must be a function of signature `func()` or `func(*Context)`, else it panics.
func Add(name string, f interface{}, description string) {
defaultApp.Add(name, f, description)
func Add(name string, f interface{}, description string, opts ...CmdOpt) {
defaultApp.Add(name, f, description, opts...)
}

// AddRoot adds a root command processor.
// When no sub command specified, a root command will be executed.
func AddRoot(f interface{}) {
defaultApp.AddRoot(f)
func AddRoot(f interface{}, opts ...CmdOpt) {
defaultApp.AddRoot(f, opts...)
}

// AddAlias adds an alias name for a command.
func AddAlias(aliasName, target string) {
defaultApp.AddAlias(aliasName, target)
func AddAlias(aliasName, target string, opts ...CmdOpt) {
defaultApp.AddAlias(aliasName, target, opts...)
}

// AddHidden adds a hidden command.
// f must be a function of signature `func()` or `func(*Context)`, else it panics.
//
// A hidden command won't be showed in help, except that when a special flag
// "--mcli-show-hidden" is provided.
func AddHidden(name string, f interface{}, description string) {
defaultApp.AddHidden(name, f, description)
func AddHidden(name string, f interface{}, description string, opts ...CmdOpt) {
defaultApp.AddHidden(name, f, description, opts...)
}

// AddGroup adds a group explicitly.
// A group is a common prefix for some commands.
// It's not required to add group before adding sub commands, but user
// can use this function to add a description to a group, which will be
// showed in help.
func AddGroup(name string, description string) {
defaultApp.AddGroup(name, description)
func AddGroup(name string, description string, opts ...CmdOpt) {
defaultApp.AddGroup(name, description, opts...)
}

// AddHelp enables the "help" command to print help about any command.
Expand Down
44 changes: 43 additions & 1 deletion option.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package mcli

import "flag"
import (
"flag"
"strings"
)

func newParseOptions(opts ...ParseOpt) *parseOptions {
out := &parseOptions{
Expand Down Expand Up @@ -72,8 +75,47 @@ func ReplaceUsage(f func() string) ParseOpt {

// WithFooter specifies a function to generate extra help text to print
// after the default help.
// If this option is provided, the option function's output overrides
// the App's optional help-footer setting.
func WithFooter(f func() string) ParseOpt {
return ParseOpt{f: func(options *parseOptions) {
options.helpFooter = f
}}
}

func newCmdOptions(opts ...CmdOpt) cmdOptions {
return *(new(cmdOptions).apply(opts...))
}

type cmdOptions struct {
longDesc string
examples string
}

func (p *cmdOptions) apply(opts ...CmdOpt) *cmdOptions {
for _, o := range opts {
o.f(p)
}
return p
}

// CmdOpt specifies options to customize the behavior of a Command.
type CmdOpt struct {
f func(*cmdOptions)
}

// WithLongDesc specifies a long description of a command,
// which will be showed in the command's help.
func WithLongDesc(long string) CmdOpt {
return CmdOpt{f: func(options *cmdOptions) {
options.longDesc = strings.TrimSpace(long)
}}
}

// WithExamples specifies examples for a command.
// Examples will be showed after flags in the command's help.
func WithExamples(examples string) CmdOpt {
return CmdOpt{f: func(options *cmdOptions) {
options.examples = strings.TrimSpace(examples)
}}
}
38 changes: 38 additions & 0 deletions option_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,41 @@ func TestWithFooter(t *testing.T) {
assert.Contains(t, got, "--args-b")
assert.Contains(t, got, "test with footer custom footer text\nanother line")
}

func TestWithLongDesc(t *testing.T) {
app := NewApp()
app.Add("cmd1", app.dummyCmd_flagContinueOnError, "test cmd1", WithLongDesc(`
Adding an issue to projects requires authorization with the "project" scope.
To authorize, run "gh auth refresh -s project".`))

app.Run("cmd1", "-h")

var buf bytes.Buffer
fs := app.getFlagSet()
fs.SetOutput(&buf)
fs.Usage()

got := buf.String()
assert.Contains(t, got, "test cmd1\n\nAdding an issue to projects requires authorization with the \"project\" scope.\nTo authorize, run \"gh auth refresh -s project\".\n\n")
}

func TestWithExamples(t *testing.T) {
app := NewApp()
app.Add("cmd1", app.dummyCmd_flagContinueOnError, "test cmd1", WithExamples(`
$ gh issue create --title "I found a bug" --body "Nothing works"
$ gh issue create --label "bug,help wanted"
$ gh issue create --label bug --label "help wanted"
$ gh issue create --assignee monalisa,hubot
$ gh issue create --assignee "@me"
$ gh issue create --project "Roadmap"`))

app.Run("cmd1", "-h")

var buf bytes.Buffer
fs := app.getFlagSet()
fs.SetOutput(&buf)
fs.Usage()

got := buf.String()
assert.Contains(t, got, "EXAMPLES:\n $ gh issue create --title \"I found a bug\" --body \"Nothing works\"\n $ gh issue create --label \"bug,help wanted\"\n $ gh")
}
Loading

0 comments on commit 3117df6

Please sign in to comment.