Add command package

This commit is contained in:
Sung 2023-03-11 12:22:35 +11:00
commit dc2501af50
15 changed files with 520 additions and 61 deletions

274
pkg/cli/command/command.go Normal file
View file

@ -0,0 +1,274 @@
package command
import (
"os"
"strings"
"github.com/dnote/dnote/pkg/cli/log"
"github.com/pkg/errors"
flag "github.com/spf13/pflag"
)
type Command struct {
// Name is the name of the command. This is used to find the subcommand
// and is case sensitive.
Name string
// flags is a FlagSet which can parse and hold flags and their values.
flags *flag.FlagSet
// args is a list of arments
args []string
// RunE is a function that contains the logic for the command
RunE func(cmd *Command, args []string) error
// commands is the list of subcommands that this command has.
commands []*Command
// Parent is a pointer to the parent command, if any, of which the command
// is a subcommand.
Parent *Command
Use string
Short string
SilenceErrors bool
SilenceUsage bool
Example string
Aliases []string
PreRunE func(cmd *Command, args []string) error
Deprecated string
Long string
Run func(cmd *Command, args []string)
}
// Flags returns a flag set for the command. If not initialized yet, it initializes
// one and returns the result.
func (c *Command) Flags() *flag.FlagSet {
if c.flags == nil {
c.flags = flag.NewFlagSet(c.Name, flag.ContinueOnError)
}
return c.flags
}
// ParseFlags parses the given slice of arguments using the flag set of the command.
func (c *Command) ParseFlags(args []string) error {
err := c.Flags().Parse(args)
if err != nil {
return errors.Wrap(err, "Error parsing flags")
}
return nil
}
// Root returns the root command of the given command.
func (c *Command) Root() *Command {
if c.Parent != nil {
return c.Parent.Root()
}
return c
}
// flagHasDefaultOption checks whether the given flag has a default value
// in the given flag set.
func flagHasDefaultOption(flag *flag.Flag, flagSet *flag.FlagSet) bool {
if flag == nil {
return false
}
return flag.NoOptDefVal != ""
}
// isFlagWithAdditionalArg checks whether the given string represents a flag
// which is followed by an argument. For instance, suppose that there is no
// default value for a flag "--flag". Then, given "--flag", this function returns
// true because "--flag" must be followed by an argument. Similar logic applies to
// a short-hand flag using a single dash. (e.g. "-f")
func isFlagWithAdditionalArg(flagStr string, flagSet *flag.FlagSet) bool {
// --flag arg
if strings.HasPrefix(flagStr, "--") && !strings.Contains(flagStr, "=") {
flagKey := flagStr[2:]
flag := flagSet.Lookup(flagKey)
return !flagHasDefaultOption(flag, flagSet)
}
// -f arg
if strings.HasPrefix(flagStr, "-") && !strings.Contains(flagStr, "=") && len(flagStr) == 2 {
flagKey := flagStr[1:]
flag := flagSet.ShorthandLookup(flagKey)
return !flagHasDefaultOption(flag, flagSet)
}
return false
}
// filterFlags removes flags and their values from the given arguments. It returns
// a filtered slice which contains only non-flag arguments.
func filterFlags(args []string, c *Command) []string {
ret := []string{}
flags := c.Flags()
idx := 0
for idx < len(args) {
currentArg := args[idx]
// "--" signifies the end of command line flags
if currentArg == "--" {
ret = append(ret, args[idx+1:]...)
break
}
if isFlagWithAdditionalArg(currentArg, flags) {
idx = idx + 2
continue
}
if currentArg != "" && !strings.HasPrefix(currentArg, "-") {
ret = append(ret, currentArg)
}
idx = idx + 1
}
return ret
}
// setArgs sets the arguments for the command. It is useful while writing tests.
func (c *Command) setArgs(args []string) {
c.args = args
}
// Args returns the argument for the command. By default, os.Args[1:] is used.
func (c *Command) Args() []string {
args := c.args
if c.args == nil {
args = os.Args[1:]
}
return args
}
// Execute runs the root command. It is meant to be called on the root command.
func (c *Command) Execute() error {
// Call Execute on the root command
if c.Parent != nil {
return c.Root().Execute()
}
args := c.Args()
log.Debug("root command received arguments: %s\n", args)
cmd, flags := c.findSubCommand(args)
if cmd == nil {
// not found. show suggestion
return nil
}
cmd.execute(flags)
return nil
}
// execute runs the command.
func (c *Command) execute(args []string) error {
log.Debug("command '%s' called with arguments: %s\n", c.Name, args)
if err := c.ParseFlags(args); err != nil {
return err
}
nonFlagArgs := c.Flags().Args()
log.Debug("command '%s' called with non-flag arguments: %s\n", c.Name, nonFlagArgs)
if err := c.RunE(c, nonFlagArgs); err != nil {
return err
}
return nil
}
// hasAlias checks whether the command has the given alias.
func (c *Command) hasAlias(targetAlias string) bool {
for _, alias := range c.Aliases {
if alias == targetAlias {
return true
}
}
return false
}
// findSubCommand finds and returns an appropriate subcommand to be called, based
// on the given slice of arguments. It also returns a slice of arguments with which
// the subcommand should be called.
func (c *Command) findSubCommand(args []string) (*Command, []string) {
nonFlagArgs := filterFlags(args, c)
log.Debug("non-flag arguments: %s\n", nonFlagArgs)
subCommand := nonFlagArgs[0]
log.Debug("sub-command: '%s'\n", subCommand)
for _, cmd := range c.commands {
if cmd.Name == subCommand || cmd.hasAlias(subCommand) {
return cmd, c.argsWithout(args, subCommand)
}
}
return nil, []string{}
}
// argsWithout removes the fist non-flag occurrence of the given key in the
// given slice of arguments. It does not modify the given slice.
func (c *Command) argsWithout(args []string, key string) []string {
flags := c.Flags()
idx := -1
pos := 0
for pos < len(args) {
currentArg := args[pos]
// "--" signifies the end of command line flags
if currentArg == "--" {
break
}
if isFlagWithAdditionalArg(currentArg, flags) {
pos = pos + 2
continue
}
if !strings.HasPrefix(currentArg, "-") {
if currentArg == key {
idx = pos
break
}
}
pos = pos + 1
}
if idx == -1 {
return args
}
ret := []string{}
ret = append(ret, args[:pos]...)
ret = append(ret, args[pos+1:]...)
return ret
}
// AddCommand adds the given command as a subcommand.
func (c *Command) AddCommand(cmd *Command) {
cmd.Parent = c
c.commands = append(c.commands, cmd)
}

View file

@ -0,0 +1,187 @@
package command
import (
"fmt"
"testing"
"github.com/dnote/dnote/pkg/assert"
)
func TestAddCommand(t *testing.T) {
cmd := Command{Name: "root command"}
assert.Equal(t, len(cmd.commands), 0, "Commands length mismatch")
subCommand1 := Command{Name: "foo"}
cmd.AddCommand(&subCommand1)
assert.Equal(t, subCommand1.Parent, &cmd, "subCommand1 Parent mismatch")
assert.Equal(t, len(cmd.commands), 1, "Commands length mismatch")
assert.Equal(t, cmd.commands[0], &subCommand1, "commands[0] mismatch")
subCommand2 := Command{Name: "bar"}
cmd.AddCommand(&subCommand2)
assert.Equal(t, len(cmd.commands), 2, "Commands length mismatch")
assert.Equal(t, subCommand2.Parent, &cmd, "subCommand2 Parent mismatch")
assert.Equal(t, cmd.commands[0], &subCommand1, "commands[0] mismatch")
assert.Equal(t, cmd.commands[1], &subCommand2, "commands[1] mismatch")
}
func TestHasAlias(t *testing.T) {
cmd := Command{
Name: "foo",
Aliases: []string{"f", "bar"},
}
assert.Equal(t, cmd.hasAlias("f"), true, "Command should have 'f' alias")
assert.Equal(t, cmd.hasAlias("F"), false, "Command should not have 'F' alias")
assert.Equal(t, cmd.hasAlias("bar"), true, "Command should have 'bar' alias")
assert.Equal(t, cmd.hasAlias("BAR"), false, "Command should have 'BAR' alias")
assert.Equal(t, cmd.hasAlias("baz"), false, "Command should not have 'baz' alias")
assert.Equal(t, cmd.hasAlias(""), false, "Command should not have an empty alias")
}
func TestHasAlias_withoutAlias(t *testing.T) {
cmd := Command{
Name: "foo",
}
assert.Equal(t, cmd.hasAlias("f"), false, "Command should not have any alias")
assert.Equal(t, cmd.hasAlias(""), false, "Command should not have any alias")
}
func TestCommandRoot(t *testing.T) {
subCommand2 := Command{
Name: "baz",
}
subCommand1 := Command{
Name: "bar",
commands: []*Command{
&subCommand2,
},
}
cmd := Command{
Name: "foo",
commands: []*Command{
&subCommand1,
},
}
subCommand1.Parent = &cmd
subCommand2.Parent = &subCommand1
assert.Equal(t, cmd.Root(), &cmd, "Command should already be a root")
assert.Equal(t, subCommand1.Root(), &cmd, "subCommand1 root mismatch")
assert.Equal(t, subCommand2.Root(), &cmd, "subCommand2 root mismatch")
}
func TestFindSubcommand(t *testing.T) {
subCommand1 := Command{
Name: "bar",
Aliases: []string{"quz"},
}
subCommand2 := Command{
Name: "baz",
}
cmd := Command{
Name: "foo",
commands: []*Command{
&subCommand1,
&subCommand2,
},
}
subcommandResult1, subcommandArgResult1 := cmd.findSubCommand([]string{"bar"})
assert.Equal(t, subcommandResult1, &subCommand1, "Subcommand 'bar' mismatch")
assert.DeepEqual(t, subcommandArgResult1, []string{}, "Subcommand arg for 'bar' mismatch")
subcommandResult2, subcommandArgResult2 := cmd.findSubCommand([]string{"baz", "echo"})
assert.Equal(t, subcommandResult2, &subCommand2, "Subcommand 'baz' mismatch")
assert.DeepEqual(t, subcommandArgResult2, []string{"echo"}, "Subcommand arg for 'baz' mismatch")
// Should match an alias
subcommandResult3, subcommandArgResult3 := cmd.findSubCommand([]string{"quz"})
assert.Equal(t, subcommandResult3, &subCommand1, "Subcommand 'quz' mismatch")
assert.DeepEqual(t, subcommandArgResult3, []string{}, "Subcommand arg for 'quz' mismatch")
// Should not match if not exists
subcommandResult4, subcommandArgResult4 := cmd.findSubCommand([]string{"qux"})
assert.Equal(t, subcommandResult4, (*Command)(nil), "Subcommand 'qux' mismatch")
assert.DeepEqual(t, subcommandArgResult4, []string{}, "Subcommand arg for 'qux' mismatch")
// Should not match itself
subcommandResult5, subcommandArgResult5 := subCommand1.findSubCommand([]string{"bar"})
assert.Equal(t, subcommandResult5, (*Command)(nil), "Subcommand 'bar' should not exist on 'bar'")
assert.DeepEqual(t, subcommandArgResult5, []string{}, "Subcommand arg for 'bar' should be empty when there is no match")
}
func executeCommand(root *Command, args ...string) error {
return root.Execute()
}
func TestFilterFlags(t *testing.T) {
testCases := []struct {
arguments []string
expected []string
}{
{
arguments: []string{"a", "b", "c"},
expected: []string{"a", "b", "c"},
},
{
arguments: []string{"-b", "cmd"},
expected: []string{"cmd"},
},
{
arguments: []string{"foo", "-b", "--str"},
expected: []string{"foo"},
},
{
arguments: []string{"foo", "-b", "baz"},
expected: []string{"foo", "baz"},
},
{
arguments: []string{"foo", "-i", "123"},
expected: []string{"foo"},
},
{
arguments: []string{"-b", "bar", "-i", "1", "a"},
expected: []string{"bar", "a"},
},
{
arguments: []string{"a", "-b", "bar", "-i", "1"},
expected: []string{"a", "bar"},
},
{
arguments: []string{"--foo", "b", "-baz"},
expected: []string{},
},
{
arguments: []string{"--s=hello", "foo"},
expected: []string{"foo"},
},
{
arguments: []string{"-shello", "foo"},
expected: []string{"foo"},
},
{
arguments: []string{"-shello", "foo", "-i1"},
expected: []string{"foo"},
},
{
arguments: []string{"-si", "foo"},
expected: []string{"foo"},
},
}
cmd := Command{}
cmd.Flags().StringP("str", "s", "", "")
cmd.Flags().IntP("int", "i", 1, "")
cmd.Flags().BoolP("bool", "b", false, "")
// Default 'bool' to "true"
cmd.Flags().Lookup("bool").NoOptDefVal = "true"
for idx, testCase := range testCases {
got := filterFlags(testCase.arguments, &cmd)
assert.DeepEqual(t, got, testCase.expected, fmt.Sprintf("result mismatch for test case %d", idx))
}
}