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
44 changes: 36 additions & 8 deletions cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"os"
"reflect"
"runtime/debug"
"slices"
"strings"
)

Expand Down Expand Up @@ -82,6 +83,7 @@ type commandNode struct {
name string
desc string
long string
aliases []string
handler Runnable
children []Node
opts cmdOptions
Expand All @@ -95,6 +97,7 @@ type groupNode struct {
name string
desc string
long string
aliases []string
children []Node
}

Expand All @@ -108,6 +111,7 @@ type CmdOption func(*cmdOptions)
type cmdOptions struct {
argsFunc ArgsFunc
long string
aliases []string
}

// WithArgs sets an args validation function on a command.
Expand All @@ -121,6 +125,14 @@ func WithLong(text string) CmdOption {
return func(o *cmdOptions) { o.long = text }
}

// Aliases sets alternate names a command or group also answers to.
// Aliases are matched during routing and completion traversal, but are not
// listed alongside canonical names in help output or completion suggestions.
// Useful for keeping old command names working after a rename.
func Aliases(names ...string) CmdOption {
return func(o *cmdOptions) { o.aliases = append(o.aliases, names...) }
}

// parseNodeOpts processes the variadic opts accepted by Command and Group,
// separating Node children from CmdOption configurers.
func parseNodeOpts(kind, name string, opts []any) ([]Node, cmdOptions) {
Expand All @@ -144,15 +156,15 @@ func parseNodeOpts(kind, name string, opts []any) ([]Node, cmdOptions) {
// type at runtime.
func Command(name, desc string, handler Runnable, opts ...any) Node {
children, o := parseNodeOpts("Command", name, opts)
return &commandNode{name: name, desc: desc, long: o.long, handler: handler, children: children, opts: o}
return &commandNode{name: name, desc: desc, long: o.long, aliases: o.aliases, handler: handler, children: children, opts: o}
}

// Group creates a group node that only prints help when invoked directly.
// Each element of opts may be a Node (child subcommand) or a CmdOption
// (e.g. WithLong); they are distinguished by type at runtime.
func Group(name, desc string, opts ...any) Node {
children, o := parseNodeOpts("Group", name, opts)
return &groupNode{name: name, desc: desc, long: o.long, children: children}
return &groupNode{name: name, desc: desc, long: o.long, aliases: o.aliases, children: children}
}

// AppOption configures an App.
Expand Down Expand Up @@ -398,10 +410,11 @@ func routeArgsWithPath(
}

for _, child := range children {
if child.nodeName() == name {
fullPath := name
if nodeMatches(child, name) {
canonical := child.nodeName()
fullPath := canonical
if prefix != "" {
fullPath = prefix + " " + name
fullPath = prefix + " " + canonical
}
rest := make([]string, 0, len(args)-1)
rest = append(rest, args[:i]...)
Expand All @@ -427,11 +440,26 @@ func routeArgsWithPath(
return nil, args, prefix
}

// nodeMatches reports whether name is the node's canonical name or one of
// its aliases.
func nodeMatches(n Node, name string) bool {
if n.nodeName() == name {
return true
}
switch v := n.(type) {
case *commandNode:
return slices.Contains(v.aliases, name)
case *groupNode:
return slices.Contains(v.aliases, name)
}
return false
}

func (a *App) executeNode(ctx context.Context, node Node, args []string, path string) (int, error) {
switch n := node.(type) {
case *groupNode:
// Groups print help when invoked directly (no matching subcommand)
printGroupHelp(a.output, a.name, path, n.desc, n.long, n.children)
printGroupHelp(a.output, a.name, path, n.desc, n.long, n.aliases, n.children)
return 0, nil

case *commandNode:
Expand All @@ -446,7 +474,7 @@ func (a *App) executeCommand(ctx context.Context, node *commandNode, args []stri
// Check for --help before doing anything else
for _, arg := range args {
if arg == "--help" || arg == "-h" {
printCommandHelp(a.output, a.name, path, node.desc, node.long, handler, node.children, a.globals)
printCommandHelp(a.output, a.name, path, node.desc, node.long, node.aliases, handler, node.children, a.globals)
return 0, nil
}
if arg == "--" {
Expand Down Expand Up @@ -483,7 +511,7 @@ func (a *App) executeCommand(ctx context.Context, node *commandNode, args []stri
posArgs, err := fs.Parse(args)
if err != nil {
fmt.Fprintf(a.output, "error: %s\n\n", err)
printCommandHelp(a.output, a.name, path, node.desc, node.long, handler, node.children, a.globals)
printCommandHelp(a.output, a.name, path, node.desc, node.long, node.aliases, handler, node.children, a.globals)
return 1, nil
}

Expand Down
89 changes: 89 additions & 0 deletions cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1509,3 +1509,92 @@ func TestGlobals_NoGlobalsStillWorks(t *testing.T) {
assert.Equal(t, 0, code)
assert.Equal(t, "hi", cmd.Message)
}

// --- alias tests ---

func TestAliases_CommandRouting(t *testing.T) {
tests := []struct {
name string
args []string
}{
{"canonical name", []string{"parent", "add-step"}},
{"alias name", []string{"parent", "addStep"}},
{"second alias", []string{"parent", "add_step"}},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var called bool
sub := &captureArgsCmd{inner: func(_ []string) { called = true }}
var buf bytes.Buffer
app := cli.New("app", "test", cli.WithOutput(&buf))
app.AddCommand(cli.Command("parent", "parent", &noopCmd{},
cli.Command("add-step", "add a step", sub, cli.Aliases("addStep", "add_step")),
))

code := app.Run(context.Background(), tt.args)
assert.Equal(t, 0, code, "output: %s", buf.String())
assert.True(t, called)
})
}
}

func TestAliases_GroupRouting(t *testing.T) {
var called bool
sub := &captureArgsCmd{inner: func(_ []string) { called = true }}
var buf bytes.Buffer
app := cli.New("app", "test", cli.WithOutput(&buf))
app.AddCommand(cli.Group("managed-enrichments", "enrichments", cli.Aliases("managedenrichments"),
cli.Command("list", "list", sub),
))

code := app.Run(context.Background(), []string{"managedenrichments", "list"})
assert.Equal(t, 0, code, "output: %s", buf.String())
assert.True(t, called)
}

func TestAliases_HiddenFromParentHelp(t *testing.T) {
var buf bytes.Buffer
app := cli.New("app", "test", cli.WithOutput(&buf))
app.AddCommand(cli.Group("parent", "parent commands",
cli.Command("add-step", "add a step", &noopCmd{}, cli.Aliases("addStep")),
))

code := app.Run(context.Background(), []string{"parent"})
assert.Equal(t, 0, code)
assert.Contains(t, buf.String(), "add-step")
assert.NotContains(t, buf.String(), "addStep")
}

func TestAliases_ShownInCommandHelp(t *testing.T) {
var buf bytes.Buffer
app := cli.New("app", "test", cli.WithOutput(&buf))
app.AddCommand(cli.Command("add-step", "add a step", &noopCmd{}, cli.Aliases("addStep")))

code := app.Run(context.Background(), []string{"add-step", "--help"})
assert.Equal(t, 0, code)
assert.Contains(t, buf.String(), "Aliases: addStep")
}

func TestAliases_HelpViaAliasShowsCanonicalPath(t *testing.T) {
var buf bytes.Buffer
app := cli.New("app", "test", cli.WithOutput(&buf))
app.AddCommand(cli.Group("parent", "parent commands",
cli.Command("add-step", "add a step", &noopCmd{}, cli.Aliases("addStep")),
))

code := app.Run(context.Background(), []string{"parent", "addStep", "--help"})
assert.Equal(t, 0, code)
assert.Contains(t, buf.String(), "app parent add-step")
}

func TestAliases_FlagsParseViaAlias(t *testing.T) {
cmd := &echoCmd{}
var buf bytes.Buffer
app := cli.New("app", "test", cli.WithOutput(&buf))
app.AddCommand(cli.Command("echo-msg", "echo", cmd, cli.Aliases("echoMsg")))

code := app.Run(context.Background(), []string{"echoMsg", "--message", "hi"})
assert.Equal(t, 0, code, "output: %s", buf.String())
assert.Equal(t, "hi", cmd.Message)
}
2 changes: 1 addition & 1 deletion cli/complete.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ func (a *App) computeCompletions(
}
found := false
for _, child := range children {
if child.nodeName() == arg {
if nodeMatches(child, arg) {
currentNode = child
consumed = i + 1
switch n := child.(type) {
Expand Down
27 changes: 27 additions & 0 deletions cli/complete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,30 @@ func TestCompleteOutputFormat(t *testing.T) {
)
}
}

func TestComplete_Aliases(t *testing.T) {
newAliasApp := func(buf *bytes.Buffer) *cli.App {
app := cli.New("testapp", "A test application", cli.WithOutput(buf))
app.AddCommand(cli.Group("pipelines", "pipeline commands",
cli.Command("add-step", "add a step", &serveHandler{}, cli.Aliases("addStep")),
))
return app
}

t.Run("aliases not suggested", func(t *testing.T) {
var buf bytes.Buffer
app := newAliasApp(&buf)
code := app.Run(context.Background(), []string{"__complete", "pipelines", ""})
assert.Equal(t, 0, code)
assert.Contains(t, buf.String(), "add-step")
assert.NotContains(t, buf.String(), "addStep")
})

t.Run("flag completion works through alias", func(t *testing.T) {
var buf bytes.Buffer
app := newAliasApp(&buf)
code := app.Run(context.Background(), []string{"__complete", "pipelines", "addStep", "--"})
assert.Equal(t, 0, code)
assert.Contains(t, buf.String(), "--addr")
})
}
17 changes: 15 additions & 2 deletions cli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ func printAppHelp(w io.Writer, appName, desc string, children []Node, version, d
}
}

// printAliases prints an "Aliases:" line when a node has alternate names.
func printAliases(w io.Writer, aliases []string) {
if len(aliases) > 0 {
fmt.Fprintf(w, "Aliases: %s\n\n", strings.Join(aliases, ", "))
}
}

func printLongText(w io.Writer, long string) {
for _, line := range strings.Split(long, "\n") {
if line == "" {
Expand All @@ -54,13 +61,15 @@ func printLongText(w io.Writer, long string) {
fmt.Fprintln(w)
}

func printGroupHelp(w io.Writer, appName, path, desc, long string, children []Node) {
func printGroupHelp(w io.Writer, appName, path, desc, long string, aliases []string, children []Node) {
if desc != "" {
fmt.Fprintf(w, "%s %s - %s\n\n", appName, path, desc)
} else {
fmt.Fprintf(w, "%s %s\n\n", appName, path)
}

printAliases(w, aliases)

if long != "" {
printLongText(w, long)
}
Expand All @@ -82,13 +91,17 @@ func printGroupHelp(w io.Writer, appName, path, desc, long string, children []No
fmt.Fprintf(w, "\nUse \"%s %s <command> --help\" for more information.\n", appName, path)
}

func printCommandHelp(w io.Writer, appName, path, desc, long string, handler Runnable, children []Node, globals any) {
func printCommandHelp(
w io.Writer, appName, path, desc, long string, aliases []string, handler Runnable, children []Node, globals any,
) {
if desc != "" {
fmt.Fprintf(w, "%s %s - %s\n\n", appName, path, desc)
} else {
fmt.Fprintf(w, "%s %s\n\n", appName, path)
}

printAliases(w, aliases)

if long != "" {
printLongText(w, long)
}
Expand Down
Loading