From 9de57bdcf5246827e9b1a57c905203e2edf6edf4 Mon Sep 17 00:00:00 2001 From: Eyal Posener Date: Wed, 10 May 2017 07:28:43 +0300 Subject: Enable completion and executable be the same command Fixes #6 --- cmd/cmd.go | 129 ++++++++++++++++++++----------- complete.go | 94 +++++++++++++++++++++++ complete_test.go | 201 +++++++++++++++++++++++++++++++++++++++++++++++++ example/self/main.go | 51 +++++++++++++ gocomplete/complete.go | 2 +- match/match_test.go | 166 +++++++++++++++++++--------------------- readme.md | 9 ++- run.go | 75 ------------------ run_test.go | 201 ------------------------------------------------- 9 files changed, 518 insertions(+), 410 deletions(-) create mode 100644 complete.go create mode 100644 complete_test.go create mode 100644 example/self/main.go delete mode 100644 run.go delete mode 100644 run_test.go diff --git a/cmd/cmd.go b/cmd/cmd.go index d0b341c..f796ec8 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -11,81 +11,122 @@ import ( "github.com/posener/complete/cmd/install" ) +// CLI for command line +type CLI struct { + Name string + + install bool + uninstall bool + yes bool +} + +const ( + defaultInstallName = "install" + defaultUninstallName = "uninstall" +) + // Run is used when running complete in command line mode. // this is used when the complete is not completing words, but to // install it or uninstall it. -func Run(cmd string) { - c := parseFlags(cmd) - err := c.validate() +func (f *CLI) Run() bool { + + // add flags and parse them in case they were not added and parsed + // by the main program + f.AddFlags(nil, "", "") + flag.Parse() + + err := f.validate() if err != nil { os.Stderr.WriteString(err.Error() + "\n") os.Exit(1) } - if !c.yes && !prompt(c.action(), cmd) { - fmt.Println("Cancelling...") - os.Exit(2) - } - fmt.Println(c.action() + "ing...") - if c.install { - err = install.Install(cmd) - } else { - err = install.Uninstall(cmd) + + switch { + case f.install: + f.prompt() + err = install.Install(f.Name) + case f.uninstall: + f.prompt() + err = install.Uninstall(f.Name) + default: + // non of the action flags matched, + // returning false should make the real program execute + return false } + if err != nil { - fmt.Printf("%s failed! %s\n", c.action(), err) + fmt.Printf("%s failed! %s\n", f.action(), err) os.Exit(3) } fmt.Println("Done!") + return true } // prompt use for approval -func prompt(action, cmd string) bool { - fmt.Printf("%s completion for %s? ", action, cmd) +// exit if approval was not given +func (f *CLI) prompt() { + defer fmt.Println(f.action() + "ing...") + if f.yes { + return + } + fmt.Printf("%s completion for %s? ", f.action(), f.Name) var answer string fmt.Scanln(&answer) switch strings.ToLower(answer) { case "y", "yes": - return true + return default: - return false + fmt.Println("Cancelling...") + os.Exit(1) } } -// config for command line -type config struct { - install bool - uninstall bool - yes bool -} +// AddFlags adds the CLI flags to the flag set. +// If flags is nil, the default command line flags will be taken. +// Pass non-empty strings as installName and uninstallName to override the default +// flag names. +func (f *CLI) AddFlags(flags *flag.FlagSet, installName, uninstallName string) { + if flags == nil { + flags = flag.CommandLine + } -// create a config from command line arguments -func parseFlags(cmd string) config { - var c config - flag.BoolVar(&c.install, "install", false, - fmt.Sprintf("Install completion for %s command", cmd)) - flag.BoolVar(&c.uninstall, "uninstall", false, - fmt.Sprintf("Uninstall completion for %s command", cmd)) - flag.BoolVar(&c.yes, "y", false, "Don't prompt user for typing 'yes'") - flag.Parse() - return c -} + if installName == "" { + installName = defaultInstallName + } + if uninstallName == "" { + uninstallName = defaultUninstallName + } -// validate the config -func (c config) validate() error { - if c.install && c.uninstall { - return errors.New("Install and uninstall are exclusive") + if flags.Lookup(installName) == nil { + flags.BoolVar(&f.install, installName, false, + fmt.Sprintf("Install completion for %s command", f.Name)) + } + if flags.Lookup(uninstallName) == nil { + flags.BoolVar(&f.uninstall, uninstallName, false, + fmt.Sprintf("Uninstall completion for %s command", f.Name)) } - if !c.install && !c.uninstall { - return errors.New("Must specify -install or -uninstall") + if flags.Lookup("y") == nil { + flags.BoolVar(&f.yes, "y", false, "Don't prompt user for typing 'yes'") + } +} + +// validate the CLI +func (f *CLI) validate() error { + if f.install && f.uninstall { + return errors.New("Install and uninstall are mutually exclusive") } return nil } -// action name according to the config values. -func (c config) action() string { - if c.install { +// action name according to the CLI values. +func (f *CLI) action() string { + switch { + case f.install: return "Install" + case f.uninstall: + return "Uninstall" + default: + return "unknown" } - return "Uninstall" } diff --git a/complete.go b/complete.go new file mode 100644 index 0000000..925c9a2 --- /dev/null +++ b/complete.go @@ -0,0 +1,94 @@ +// Package complete provides a tool for bash writing bash completion in go. +// +// Writing bash completion scripts is a hard work. This package provides an easy way +// to create bash completion scripts for any command, and also an easy way to install/uninstall +// the completion of the command. +package complete + +import ( + "fmt" + "os" + "strings" + + "github.com/posener/complete/cmd" +) + +const ( + envComplete = "COMP_LINE" + envDebug = "COMP_DEBUG" +) + +// Complete structs define completion for a command with CLI options +type Complete struct { + Command Command + cmd.CLI +} + +// New creates a new complete command. +// name is the name of command we want to auto complete. +// IMPORTANT: it must be the same name - if the auto complete +// completes the 'go' command, name must be equal to "go". +// command is the struct of the command completion. +func New(name string, command Command) *Complete { + return &Complete{ + Command: command, + CLI: cmd.CLI{Name: name}, + } +} + +// Run get a command, get the typed arguments from environment +// variable, and print out the complete options +// returns success if the completion ran or if the cli matched +// any of the given flags, false otherwise +func (c *Complete) Run() bool { + args, ok := getLine() + if !ok { + // make sure flags parsed, + // in case they were not added in the main program + return c.CLI.Run() + } + Log("Completing args: %s", args) + + options := complete(c.Command, args) + + Log("Completion: %s", options) + output(options) + return true +} + +// complete get a command an command line arguments and returns +// matching completion options +func complete(c Command, args []string) (matching []string) { + options, _ := c.options(args[:len(args)-1]) + + // choose only matching options + l := last(args) + for _, option := range options { + if option.Match(l) { + matching = append(matching, option.String()) + } + } + return +} + +func getLine() ([]string, bool) { + line := os.Getenv(envComplete) + if line == "" { + return nil, false + } + return strings.Split(line, " "), true +} + +func last(args []string) (last string) { + if len(args) > 0 { + last = args[len(args)-1] + } + return +} + +func output(options []string) { + // stdout of program defines the complete options + for _, option := range options { + fmt.Println(option) + } +} diff --git a/complete_test.go b/complete_test.go new file mode 100644 index 0000000..147a361 --- /dev/null +++ b/complete_test.go @@ -0,0 +1,201 @@ +package complete + +import ( + "os" + "sort" + "testing" +) + +func TestCompleter_Complete(t *testing.T) { + t.Parallel() + initTests() + + c := Command{ + Sub: map[string]Command{ + "sub1": { + Flags: map[string]Predicate{ + "-flag1": PredictAnything, + "-flag2": PredictNothing, + }, + }, + "sub2": { + Flags: map[string]Predicate{ + "-flag2": PredictNothing, + "-flag3": PredictSet("opt1", "opt2", "opt12"), + }, + Args: Predicate(PredictDirs).Or(PredictFiles("*.md")), + }, + }, + Flags: map[string]Predicate{ + "-h": PredictNothing, + "-global1": PredictAnything, + "-o": PredictFiles("*.txt"), + }, + } + + allGlobals := []string{} + for sub := range c.Sub { + allGlobals = append(allGlobals, sub) + } + for flag := range c.Flags { + allGlobals = append(allGlobals, flag) + } + + testTXTFiles := []string{"./a.txt", "./b.txt", "./c.txt"} + + tests := []struct { + args string + want []string + }{ + { + args: "", + want: allGlobals, + }, + { + args: "-", + want: []string{"-h", "-global1", "-o"}, + }, + { + args: "-h ", + want: allGlobals, + }, + { + args: "-global1 ", // global1 is known follow flag + want: []string{}, + }, + { + args: "sub", + want: []string{"sub1", "sub2"}, + }, + { + args: "sub1", + want: []string{"sub1"}, + }, + { + args: "sub2", + want: []string{"sub2"}, + }, + { + args: "sub1 ", + want: []string{"-flag1", "-flag2", "-h", "-global1", "-o"}, + }, + { + args: "sub2 ", + want: []string{"./", "./dir", "./readme.md", "-flag2", "-flag3", "-h", "-global1", "-o"}, + }, + { + args: "sub2 ./", + want: []string{"./", "./readme.md", "./dir"}, + }, + { + args: "sub2 re", + want: []string{"./readme.md"}, + }, + { + args: "sub2 -flag2 ", + want: []string{"./", "./dir", "./readme.md", "-flag2", "-flag3", "-h", "-global1", "-o"}, + }, + { + args: "sub1 -fl", + want: []string{"-flag1", "-flag2"}, + }, + { + args: "sub1 -flag1", + want: []string{"-flag1"}, + }, + { + args: "sub1 -flag1 ", + want: []string{}, // flag1 is unknown follow flag + }, + { + args: "sub1 -flag2 ", + want: []string{"-flag1", "-flag2", "-h", "-global1", "-o"}, + }, + { + args: "-no-such-flag", + want: []string{}, + }, + { + args: "-no-such-flag ", + want: allGlobals, + }, + { + args: "no-such-command", + want: []string{}, + }, + { + args: "no-such-command ", + want: allGlobals, + }, + { + args: "-o ", + want: testTXTFiles, + }, + { + args: "-o ./no-su", + want: []string{}, + }, + { + args: "-o ./", + want: testTXTFiles, + }, + { + args: "-o ", + want: testTXTFiles, + }, + { + args: "-o ./read", + want: []string{}, + }, + { + args: "-o ./readme.md", + want: []string{}, + }, + { + args: "-o ./readme.md ", + want: allGlobals, + }, + { + args: "-o sub2 -flag3 ", + want: []string{"opt1", "opt2", "opt12"}, + }, + { + args: "-o sub2 -flag3 opt1", + want: []string{"opt1", "opt12"}, + }, + { + args: "-o sub2 -flag3 opt", + want: []string{"opt1", "opt2", "opt12"}, + }, + } + + for _, tt := range tests { + t.Run(tt.args, func(t *testing.T) { + + tt.args = "cmd " + tt.args + os.Setenv(envComplete, tt.args) + args, _ := getLine() + + got := complete(c, args) + + sort.Strings(tt.want) + sort.Strings(got) + + if !equalSlices(got, tt.want) { + t.Errorf("failed '%s'\ngot = %s\nwant: %s", t.Name(), got, tt.want) + } + }) + } +} + +func equalSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/example/self/main.go b/example/self/main.go new file mode 100644 index 0000000..068a0ac --- /dev/null +++ b/example/self/main.go @@ -0,0 +1,51 @@ +// Package self +// a program that complete itself +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/posener/complete" +) + +func main() { + + // add a variable to the program + var name string + flag.StringVar(&name, "name", "", "Give your name") + + // create the complete command + cmp := complete.New( + "self", + complete.Command{Flags: complete.Flags{"name": complete.PredictAnything}}, + ) + + // AddFlags adds the completion flags to the program flags, + // in case of using non-default flag set, it is possible to pass + // it as an argument. + // it is possible to set custom flags name + // so when one will type 'self -h', he will see '-complete' to install the + // completion and -uncomplete to uninstall it. + cmp.AddFlags(nil, "complete", "uncomplete") + + // parse the flags - both the program's flags and the completion flags + flag.Parse() + + // run the completion, in case that the completion was invoked + // and ran as a completion script or handled a flag that passed + // as argument, the Run method will return true, + // in that case, our program have nothing to do and should return. + if cmp.Run() { + return + } + + // if the completion did not do anything, we can run our program logic here. + if name == "" { + fmt.Println("Your name is missing") + os.Exit(1) + } + + fmt.Println("Hi,", name) +} diff --git a/gocomplete/complete.go b/gocomplete/complete.go index 1f94553..ac5f5ed 100644 --- a/gocomplete/complete.go +++ b/gocomplete/complete.go @@ -185,5 +185,5 @@ func main() { }, } - complete.Run("go", gogo) + complete.New("go", gogo).Run() } diff --git a/match/match_test.go b/match/match_test.go index f9afd46..ae1ffea 100644 --- a/match/match_test.go +++ b/match/match_test.go @@ -15,100 +15,92 @@ func TestMatch(t *testing.T) { panic(err) } - tests := []struct { - m Matcher + type matcherTest struct { prefix string want bool + } + + tests := []struct { + m Matcher + tests []matcherTest }{ { - m: Prefix("abcd"), - prefix: "", - want: true, - }, - { - m: Prefix("abcd"), - prefix: "ab", - want: true, - }, - { - m: Prefix("abcd"), - prefix: "ac", - want: false, - }, - { - m: Prefix(""), - prefix: "ac", - want: false, - }, - { - m: Prefix(""), - prefix: "", - want: true, - }, - { - m: File("file.txt"), - prefix: "", - want: true, - }, - { - m: File("./file.txt"), - prefix: "", - want: true, - }, - { - m: File("./file.txt"), - prefix: "f", - want: true, - }, - { - m: File("./file.txt"), - prefix: "file.", - want: true, - }, - { - m: File("./file.txt"), - prefix: "./f", - want: true, - }, - { - m: File("./file.txt"), - prefix: "other.txt", - want: false, - }, - { - m: File("./file.txt"), - prefix: "/file.txt", - want: false, - }, - { - m: File("/file.txt"), - prefix: "file.txt", - want: false, - }, - { - m: File("/file.txt"), - prefix: "./file.txt", - want: false, - }, - { - m: File("/file.txt"), - prefix: "/file.txt", - want: true, - }, - { - m: File("/file.txt"), - prefix: "/fil", - want: true, + m: Prefix("abcd"), + tests: []matcherTest{ + {prefix: "", want: true}, + {prefix: "ab", want: true}, + {prefix: "ac", want: false}, + }, + }, + { + m: Prefix(""), + tests: []matcherTest{ + {prefix: "ac", want: false}, + {prefix: "", want: true}, + }, + }, + { + m: File("file.txt"), + tests: []matcherTest{ + {prefix: "", want: true}, + {prefix: "f", want: true}, + {prefix: "./f", want: true}, + {prefix: "file.", want: true}, + {prefix: "./file.", want: true}, + {prefix: "file.txt", want: true}, + {prefix: "./file.txt", want: true}, + {prefix: "other.txt", want: false}, + {prefix: "/other.txt", want: false}, + {prefix: "/file.txt", want: false}, + {prefix: "/fil", want: false}, + {prefix: "/file.txt2", want: false}, + }, + }, + { + m: File("./file.txt"), + tests: []matcherTest{ + {prefix: "", want: true}, + {prefix: "f", want: true}, + {prefix: "./f", want: true}, + {prefix: "file.", want: true}, + {prefix: "./file.", want: true}, + {prefix: "file.txt", want: true}, + {prefix: "./file.txt", want: true}, + {prefix: "other.txt", want: false}, + {prefix: "/other.txt", want: false}, + {prefix: "/file.txt", want: false}, + {prefix: "/fil", want: false}, + {prefix: "/file.txt2", want: false}, + }, + }, + { + m: File("/file.txt"), + tests: []matcherTest{ + {prefix: "", want: false}, + {prefix: "f", want: false}, + {prefix: "./f", want: false}, + {prefix: "file.", want: false}, + {prefix: "./file.", want: false}, + {prefix: "file.txt", want: false}, + {prefix: "./file.txt", want: false}, + {prefix: "other.txt", want: false}, + {prefix: "/other.txt", want: false}, + {prefix: "/file.txt", want: true}, + {prefix: "/fil", want: true}, + {prefix: "/file.txt2", want: false}, + }, }, } for _, tt := range tests { - name := tt.m.String() + "/" + tt.prefix - t.Run(name, func(t *testing.T) { - got := tt.m.Match(tt.prefix) - if got != tt.want { - t.Errorf("Failed %s: got = %t, want: %t", name, got, tt.want) - } - }) + for _, ttt := range tt.tests { + name := "matcher:" + tt.m.String() + "/prefix:" + ttt.prefix + t.Run(name, func(t *testing.T) { + got := tt.m.Match(ttt.prefix) + if got != ttt.want { + t.Errorf("Failed %s: got = %t, want: %t", name, got, ttt.want) + } + }) + } } } diff --git a/readme.md b/readme.md index 81e0864..0010098 100644 --- a/readme.md +++ b/readme.md @@ -85,7 +85,12 @@ func main() { // run the command completion, as part of the main() function. // this triggers the autocompletion when needed. - // name must be exactly as the binary that we want to complete. - complete.Run("run", run) + // name must be exactly as the binary that we want to complete. + complete.New("run", run).Run() } ``` + +## Self completing program + +In case that the program that we want to complete is written in go we +can make it self completing. Here is an [example](./example/self/main.go) diff --git a/run.go b/run.go deleted file mode 100644 index 5d9706f..0000000 --- a/run.go +++ /dev/null @@ -1,75 +0,0 @@ -// Package complete provides a tool for bash writing bash completion in go. -// -// Writing bash completion scripts is a hard work. This package provides an easy way -// to create bash completion scripts for any command, and also an easy way to install/uninstall -// the completion of the command. -package complete - -import ( - "fmt" - "os" - "strings" - - "github.com/posener/complete/cmd" -) - -const ( - envComplete = "COMP_LINE" - envDebug = "COMP_DEBUG" -) - -// Run get a command, get the typed arguments from environment -// variable, and print out the complete options -// name is the name of command we want to auto complete. -// IMPORTANT: it must be the same name - if the auto complete -// completes the 'go' command, name must be equal to "go". -func Run(name string, c Command) { - args, ok := getLine() - if !ok { - cmd.Run(name) - return - } - Log("Completing args: %s", args) - - options := complete(c, args) - - Log("Completion: %s", options) - output(options) -} - -// complete get a command an command line arguments and returns -// matching completion options -func complete(c Command, args []string) (matching []string) { - options, _ := c.options(args[:len(args)-1]) - - // choose only matching options - l := last(args) - for _, option := range options { - if option.Match(l) { - matching = append(matching, option.String()) - } - } - return -} - -func getLine() ([]string, bool) { - line := os.Getenv(envComplete) - if line == "" { - return nil, false - } - return strings.Split(line, " "), true -} - -func last(args []string) (last string) { - if len(args) > 0 { - last = args[len(args)-1] - } - return -} - -func output(options []string) { - // stdout of program defines the complete options - for _, option := range options { - fmt.Println(option) - } -} diff --git a/run_test.go b/run_test.go deleted file mode 100644 index 147a361..0000000 --- a/run_test.go +++ /dev/null @@ -1,201 +0,0 @@ -package complete - -import ( - "os" - "sort" - "testing" -) - -func TestCompleter_Complete(t *testing.T) { - t.Parallel() - initTests() - - c := Command{ - Sub: map[string]Command{ - "sub1": { - Flags: map[string]Predicate{ - "-flag1": PredictAnything, - "-flag2": PredictNothing, - }, - }, - "sub2": { - Flags: map[string]Predicate{ - "-flag2": PredictNothing, - "-flag3": PredictSet("opt1", "opt2", "opt12"), - }, - Args: Predicate(PredictDirs).Or(PredictFiles("*.md")), - }, - }, - Flags: map[string]Predicate{ - "-h": PredictNothing, - "-global1": PredictAnything, - "-o": PredictFiles("*.txt"), - }, - } - - allGlobals := []string{} - for sub := range c.Sub { - allGlobals = append(allGlobals, sub) - } - for flag := range c.Flags { - allGlobals = append(allGlobals, flag) - } - - testTXTFiles := []string{"./a.txt", "./b.txt", "./c.txt"} - - tests := []struct { - args string - want []string - }{ - { - args: "", - want: allGlobals, - }, - { - args: "-", - want: []string{"-h", "-global1", "-o"}, - }, - { - args: "-h ", - want: allGlobals, - }, - { - args: "-global1 ", // global1 is known follow flag - want: []string{}, - }, - { - args: "sub", - want: []string{"sub1", "sub2"}, - }, - { - args: "sub1", - want: []string{"sub1"}, - }, - { - args: "sub2", - want: []string{"sub2"}, - }, - { - args: "sub1 ", - want: []string{"-flag1", "-flag2", "-h", "-global1", "-o"}, - }, - { - args: "sub2 ", - want: []string{"./", "./dir", "./readme.md", "-flag2", "-flag3", "-h", "-global1", "-o"}, - }, - { - args: "sub2 ./", - want: []string{"./", "./readme.md", "./dir"}, - }, - { - args: "sub2 re", - want: []string{"./readme.md"}, - }, - { - args: "sub2 -flag2 ", - want: []string{"./", "./dir", "./readme.md", "-flag2", "-flag3", "-h", "-global1", "-o"}, - }, - { - args: "sub1 -fl", - want: []string{"-flag1", "-flag2"}, - }, - { - args: "sub1 -flag1", - want: []string{"-flag1"}, - }, - { - args: "sub1 -flag1 ", - want: []string{}, // flag1 is unknown follow flag - }, - { - args: "sub1 -flag2 ", - want: []string{"-flag1", "-flag2", "-h", "-global1", "-o"}, - }, - { - args: "-no-such-flag", - want: []string{}, - }, - { - args: "-no-such-flag ", - want: allGlobals, - }, - { - args: "no-such-command", - want: []string{}, - }, - { - args: "no-such-command ", - want: allGlobals, - }, - { - args: "-o ", - want: testTXTFiles, - }, - { - args: "-o ./no-su", - want: []string{}, - }, - { - args: "-o ./", - want: testTXTFiles, - }, - { - args: "-o ", - want: testTXTFiles, - }, - { - args: "-o ./read", - want: []string{}, - }, - { - args: "-o ./readme.md", - want: []string{}, - }, - { - args: "-o ./readme.md ", - want: allGlobals, - }, - { - args: "-o sub2 -flag3 ", - want: []string{"opt1", "opt2", "opt12"}, - }, - { - args: "-o sub2 -flag3 opt1", - want: []string{"opt1", "opt12"}, - }, - { - args: "-o sub2 -flag3 opt", - want: []string{"opt1", "opt2", "opt12"}, - }, - } - - for _, tt := range tests { - t.Run(tt.args, func(t *testing.T) { - - tt.args = "cmd " + tt.args - os.Setenv(envComplete, tt.args) - args, _ := getLine() - - got := complete(c, args) - - sort.Strings(tt.want) - sort.Strings(got) - - if !equalSlices(got, tt.want) { - t.Errorf("failed '%s'\ngot = %s\nwant: %s", t.Name(), got, tt.want) - } - }) - } -} - -func equalSlices(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} -- cgit v1.2.3