Skip to content

Commit

Permalink
Merge pull request #150 from jbenet/commands
Browse files Browse the repository at this point in the history
Command Interface
  • Loading branch information
jbenet committed Oct 22, 2014
2 parents 8d7850e + 4303dcc commit c4af76c
Show file tree
Hide file tree
Showing 8 changed files with 755 additions and 0 deletions.
79 changes: 79 additions & 0 deletions commands/cli/parse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package cli

import (
"fmt"
"strings"

"github.com/jbenet/go-ipfs/commands"
)

// Parse parses the input commandline string (cmd, flags, and args).
// returns the corresponding command Request object.
func Parse(input []string, root *commands.Command) (commands.Request, error) {
path, input := parsePath(input, root)
opts, args, err := parseOptions(input)
if err != nil {
return nil, err
}

return commands.NewRequest(path, opts, args, nil), nil
}

// parsePath gets the command path from the command line input
func parsePath(input []string, root *commands.Command) ([]string, []string) {
cmd := root
i := 0

for _, blob := range input {
if strings.HasPrefix(blob, "-") {
break
}

cmd := cmd.Subcommand(blob)
if cmd == nil {
break
}

i++
}

return input[:i], input[i:]
}

// parseOptions parses the raw string values of the given options
// returns the parsed options as strings, along with the CLI args
func parseOptions(input []string) (map[string]interface{}, []string, error) {
opts := make(map[string]interface{})
args := []string{}

for i := 0; i < len(input); i++ {
blob := input[i]

if strings.HasPrefix(blob, "-") {
name := blob[1:]
value := ""

// support single and double dash
if strings.HasPrefix(name, "-") {
name = name[1:]
}

if strings.Contains(name, "=") {
split := strings.SplitN(name, "=", 2)
name = split[0]
value = split[1]
}

if _, ok := opts[name]; ok {
return nil, nil, fmt.Errorf("Duplicate values for option '%s'", name)
}

opts[name] = value

} else {
args = append(args, blob)
}
}

return opts, args, nil
}
47 changes: 47 additions & 0 deletions commands/cli/parse_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package cli

import (
//"fmt"
"testing"

"github.com/jbenet/go-ipfs/commands"
)

func TestOptionParsing(t *testing.T) {
cmd := &commands.Command{
Options: []commands.Option{
commands.Option{Names: []string{"b"}, Type: commands.String},
},
Subcommands: map[string]*commands.Command{
"test": &commands.Command{},
},
}

opts, input, err := parseOptions([]string{"--beep", "-boop=lol", "test2", "-c", "beep", "--foo=5"})
/*for k, v := range opts {
fmt.Printf("%s: %s\n", k, v)
}
fmt.Printf("%s\n", input)*/
if err != nil {
t.Error("Should have passed")
}
if len(opts) != 4 || opts["beep"] != "" || opts["boop"] != "lol" || opts["c"] != "" || opts["foo"] != "5" {
t.Error("Returned options were defferent than expected: %v", opts)
}
if len(input) != 2 || input[0] != "test2" || input[1] != "beep" {
t.Error("Returned input was different than expected: %v", input)
}

_, _, err = parseOptions([]string{"-beep=1", "-boop=2", "-beep=3"})
if err == nil {
t.Error("Should have failed (duplicate option name)")
}

path, args := parsePath([]string{"test", "beep", "boop"}, cmd)
if len(path) != 1 || path[0] != "test" {
t.Error("Returned path was defferent than expected: %v", path)
}
if len(args) != 2 || args[0] != "beep" || args[1] != "boop" {
t.Error("Returned args were different than expected: %v", args)
}
}
122 changes: 122 additions & 0 deletions commands/command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package commands

import (
"errors"
"fmt"
"strings"

u "github.com/jbenet/go-ipfs/util"
)

var log = u.Logger("command")

// Function is the type of function that Commands use.
// It reads from the Request, and writes results to the Response.
type Function func(Request, Response)

// Command is a runnable command, with input arguments and options (flags).
// It can also have Subcommands, to group units of work into sets.
type Command struct {
Help string
Options []Option
Run Function
Subcommands map[string]*Command
}

// ErrNotCallable signals a command that cannot be called.
var ErrNotCallable = errors.New("This command can't be called directly. Try one of its subcommands.")

// Call invokes the command for the given Request
func (c *Command) Call(req Request) Response {
res := NewResponse(req)

cmds, err := c.Resolve(req.Path())
if err != nil {
res.SetError(err, ErrClient)
return res
}
cmd := cmds[len(cmds)-1]

if cmd.Run == nil {
res.SetError(ErrNotCallable, ErrClient)
return res
}

options, err := c.GetOptions(req.Path())
if err != nil {
res.SetError(err, ErrClient)
return res
}

err = req.ConvertOptions(options)
if err != nil {
res.SetError(err, ErrClient)
return res
}

cmd.Run(req, res)

return res
}

// Resolve gets the subcommands at the given path
func (c *Command) Resolve(path []string) ([]*Command, error) {
cmds := make([]*Command, len(path)+1)
cmds[0] = c

cmd := c
for i, name := range path {
cmd = cmd.Subcommand(name)

if cmd == nil {
pathS := strings.Join(path[0:i], "/")
return nil, fmt.Errorf("Undefined command: '%s'", pathS)
}

cmds[i+1] = cmd
}

return cmds, nil
}

// Get resolves and returns the Command addressed by path
func (c *Command) Get(path []string) (*Command, error) {
cmds, err := c.Resolve(path)
if err != nil {
return nil, err
}
return cmds[len(cmds)-1], nil
}

// GetOptions gets the options in the given path of commands
func (c *Command) GetOptions(path []string) (map[string]Option, error) {
options := make([]Option, len(c.Options))

cmds, err := c.Resolve(path)
if err != nil {
return nil, err
}
cmds = append(cmds, globalCommand)

for _, cmd := range cmds {
options = append(options, cmd.Options...)
}

optionsMap := make(map[string]Option)
for _, opt := range options {
for _, name := range opt.Names {
if _, found := optionsMap[name]; found {
return nil, fmt.Errorf("Option name '%s' used multiple times", name)
}

optionsMap[name] = opt
}
}

return optionsMap, nil
}

// Subcommand returns the subcommand with the given id
func (c *Command) Subcommand(id string) *Command {
return c.Subcommands[id]
}
146 changes: 146 additions & 0 deletions commands/command_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package commands

import "testing"

func TestOptionValidation(t *testing.T) {
cmd := Command{
Options: []Option{
Option{[]string{"b", "beep"}, Int},
Option{[]string{"B", "boop"}, String},
},
Run: func(req Request, res Response) {},
}

req := NewEmptyRequest()
req.SetOption("foo", 5)
res := cmd.Call(req)
if res.Error() == nil {
t.Error("Should have failed (unrecognized option)")
}

req = NewEmptyRequest()
req.SetOption("beep", 5)
req.SetOption("b", 10)
res = cmd.Call(req)
if res.Error() == nil {
t.Error("Should have failed (duplicate options)")
}

req = NewEmptyRequest()
req.SetOption("beep", "foo")
res = cmd.Call(req)
if res.Error() == nil {
t.Error("Should have failed (incorrect type)")
}

req = NewEmptyRequest()
req.SetOption("beep", 5)
res = cmd.Call(req)
if res.Error() != nil {
t.Error(res.Error(), "Should have passed")
}

req = NewEmptyRequest()
req.SetOption("beep", 5)
req.SetOption("boop", "test")
res = cmd.Call(req)
if res.Error() != nil {
t.Error("Should have passed")
}

req = NewEmptyRequest()
req.SetOption("b", 5)
req.SetOption("B", "test")
res = cmd.Call(req)
if res.Error() != nil {
t.Error("Should have passed")
}

req = NewEmptyRequest()
req.SetOption(EncShort, "json")
res = cmd.Call(req)
if res.Error() != nil {
t.Error("Should have passed")
}

req = NewEmptyRequest()
req.SetOption("b", "100")
res = cmd.Call(req)
if res.Error() != nil {
t.Error("Should have passed")
}

req = NewEmptyRequest()
req.SetOption("b", ":)")
res = cmd.Call(req)
if res.Error == nil {
t.Error(res.Error, "Should have failed (string value not convertible to int)")
}
}

func TestRegistration(t *testing.T) {
noop := func(req Request, res Response) {}

cmdA := &Command{
Options: []Option{
Option{[]string{"beep"}, Int},
},
Run: noop,
}

cmdB := &Command{
Options: []Option{
Option{[]string{"beep"}, Int},
},
Run: noop,
Subcommands: map[string]*Command{
"a": cmdA,
},
}

cmdC := &Command{
Options: []Option{
Option{[]string{"encoding"}, String},
},
Run: noop,
}

res := cmdB.Call(NewRequest([]string{"a"}, nil, nil, nil))
if res.Error() == nil {
t.Error("Should have failed (option name collision)")
}

res = cmdC.Call(NewEmptyRequest())
if res.Error() == nil {
t.Error("Should have failed (option name collision with global options)")
}
}

func TestResolving(t *testing.T) {
cmdC := &Command{}
cmdB := &Command{
Subcommands: map[string]*Command{
"c": cmdC,
},
}
cmdB2 := &Command{}
cmdA := &Command{
Subcommands: map[string]*Command{
"b": cmdB,
"B": cmdB2,
},
}
cmd := &Command{
Subcommands: map[string]*Command{
"a": cmdA,
},
}

cmds, err := cmd.Resolve([]string{"a", "b", "c"})
if err != nil {
t.Error(err)
}
if len(cmds) != 4 || cmds[0] != cmd || cmds[1] != cmdA || cmds[2] != cmdB || cmds[3] != cmdC {
t.Error("Returned command path is different than expected", cmds)
}
}
Loading

0 comments on commit c4af76c

Please sign in to comment.