diff --git a/cli/cli.go b/cli/cli.go index d9716af..fb7fa5c 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -12,6 +12,7 @@ import ( "os" "reflect" "runtime/debug" + "slices" "strings" ) @@ -82,6 +83,7 @@ type commandNode struct { name string desc string long string + aliases []string handler Runnable children []Node opts cmdOptions @@ -95,6 +97,7 @@ type groupNode struct { name string desc string long string + aliases []string children []Node } @@ -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. @@ -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) { @@ -144,7 +156,7 @@ 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. @@ -152,7 +164,7 @@ func Command(name, desc string, handler Runnable, opts ...any) Node { // (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. @@ -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]...) @@ -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: @@ -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 == "--" { @@ -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 } diff --git a/cli/cli_test.go b/cli/cli_test.go index 1eecf11..86b0c2e 100644 --- a/cli/cli_test.go +++ b/cli/cli_test.go @@ -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) +} diff --git a/cli/complete.go b/cli/complete.go index 753862b..69e8cf6 100644 --- a/cli/complete.go +++ b/cli/complete.go @@ -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) { diff --git a/cli/complete_test.go b/cli/complete_test.go index 04c49b9..130e79c 100644 --- a/cli/complete_test.go +++ b/cli/complete_test.go @@ -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") + }) +} diff --git a/cli/help.go b/cli/help.go index c646c79..32aa61a 100644 --- a/cli/help.go +++ b/cli/help.go @@ -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 == "" { @@ -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) } @@ -82,13 +91,17 @@ func printGroupHelp(w io.Writer, appName, path, desc, long string, children []No fmt.Fprintf(w, "\nUse \"%s %s --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) }