diff --git a/args.go b/args.go new file mode 100644 index 0000000..86dd41a --- /dev/null +++ b/args.go @@ -0,0 +1,39 @@ +package complete + +// Args describes command line arguments +type Args struct { + All []string + Completed []string + Last string + LastCompleted string +} + +func newArgs(line []string) Args { + completed := removeLast(line) + return Args{ + All: line[1:], + Completed: completed, + Last: last(line), + LastCompleted: last(completed), + } +} + +func (a Args) from(i int) Args { + a.All = a.All[i:] + a.Completed = a.Completed[i:] + return a +} + +func removeLast(a []string) []string { + if len(a) > 0 { + return a[:len(a)-1] + } + return a +} + +func last(args []string) (last string) { + if len(args) > 0 { + last = args[len(args)-1] + } + return +} diff --git a/command.go b/command.go index f52d175..80b2f99 100644 --- a/command.go +++ b/command.go @@ -3,7 +3,7 @@ package complete import "github.com/posener/complete/match" // Command represents a command line -// It holds the data that enables auto completion of a given typed command line +// It holds the data that enables auto completion of command line // Command can also be a sub command. type Command struct { // Sub is map of sub commands of the current command @@ -12,88 +12,79 @@ type Command struct { Sub Commands // Flags is a map of flags that the command accepts. - // The key is the flag name, and the value is it's prediction options. + // The key is the flag name, and the value is it's predictions. Flags Flags // Args are extra arguments that the command accepts, those who are // given without any flag before. - Args Predicate + Args Predictor } // Commands is the type of Sub member, it maps a command name to a command struct type Commands map[string]Command -// Flags is the type Flags of the Flags member, it maps a flag name to the flag -// prediction options. -type Flags map[string]Predicate +// Flags is the type Flags of the Flags member, it maps a flag name to the flag predictions. +type Flags map[string]Predictor -// options returns all available complete options for the given command -// args are all except the last command line arguments relevant to the command -func (c *Command) options(args []string) (options []match.Matcher, only bool) { +// Predict returns all possible predictions for args according to the command struct +func (c *Command) Predict(a Args) (predictions []string) { + predictions, _ = c.predict(a) + return +} + +func (c *Command) predict(a Args) (options []string, only bool) { - // remove the first argument, which is the command name - args = args[1:] - wordCurrent := last(args) - wordCompleted := last(removeLast(args)) // if wordCompleted has something that needs to follow it, // it is the most relevant completion - if predicate, ok := c.Flags[wordCompleted]; ok && predicate != nil { - Log("Predicting according to flag %s", wordCurrent) - return predicate.predict(wordCurrent), true + if predictor, ok := c.Flags[a.LastCompleted]; ok && predictor != nil { + Log("Predicting according to flag %s", a.Last) + return predictor.Predict(a), true } - sub, options, only := c.searchSub(args) + sub, options, only := c.searchSub(a) if only { return } - // if no subcommand was entered in any of the args, add the - // subcommands as complete options. + // if no sub command was found, return a list of the sub commands if sub == "" { - options = append(options, c.subCommands()...) + options = append(options, c.subCommands(a.Last)...) } // add global available complete options for flag := range c.Flags { - options = append(options, match.Prefix(flag)) + if match.Prefix(flag, a.Last) { + options = append(options, flag) + } } // add additional expected argument of the command - options = append(options, c.Args.predict(wordCurrent)...) + if c.Args != nil { + options = append(options, c.Args.Predict(a)...) + } return } // searchSub searches recursively within sub commands if the sub command appear // in the on of the arguments. -func (c *Command) searchSub(args []string) (sub string, all []match.Matcher, only bool) { - - // search for sub command in all arguments except the last one - // because that one might not be completed yet - searchArgs := removeLast(args) - - for i, arg := range searchArgs { +func (c *Command) searchSub(a Args) (sub string, all []string, only bool) { + for i, arg := range a.Completed { if cmd, ok := c.Sub[arg]; ok { sub = arg - all, only = cmd.options(args[i:]) + all, only = cmd.predict(a.from(i)) return } } - return "", nil, false + return } -// suvCommands returns a list of matchers according to the sub command names -func (c *Command) subCommands() []match.Matcher { - subs := make([]match.Matcher, 0, len(c.Sub)) +// subCommands returns a list of matching sub commands +func (c *Command) subCommands(last string) (prediction []string) { for sub := range c.Sub { - subs = append(subs, match.Prefix(sub)) + if match.Prefix(sub, last) { + prediction = append(prediction, sub) + } } - return subs -} - -func removeLast(a []string) []string { - if len(a) > 0 { - return a[:len(a)-1] - } - return a + return } diff --git a/complete.go b/complete.go index c91bf5f..be0876e 100644 --- a/complete.go +++ b/complete.go @@ -41,37 +41,23 @@ func New(name string, command Command) *Complete { // 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() + line, 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) + Log("Completing line: %s", line) - options := complete(c.Command, args) + a := newArgs(line) + + options := c.Command.Predict(a) 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) - - // choose only matching options - l := last(args) - for _, option := range options { - Log("option %T, %s -> %t", option, option, option.Match(l)) - if option.Match(l) { - matching = append(matching, option.String()) - } - } - return -} - func getLine() ([]string, bool) { line := os.Getenv(envComplete) if line == "" { @@ -80,13 +66,6 @@ func getLine() ([]string, bool) { 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) { Log("") // stdout of program defines the complete options diff --git a/complete_test.go b/complete_test.go index 282a2f6..ee5a133 100644 --- a/complete_test.go +++ b/complete_test.go @@ -13,20 +13,20 @@ func TestCompleter_Complete(t *testing.T) { c := Command{ Sub: map[string]Command{ "sub1": { - Flags: map[string]Predicate{ + Flags: map[string]Predictor{ "-flag1": PredictAnything, "-flag2": PredictNothing, }, }, "sub2": { - Flags: map[string]Predicate{ + Flags: map[string]Predictor{ "-flag2": PredictNothing, "-flag3": PredictSet("opt1", "opt2", "opt12"), }, - Args: Predicate(PredictDirs("*")).Or(PredictFiles("*.md")), + Args: PredictOr(PredictDirs("*"), PredictFiles("*.md")), }, }, - Flags: map[string]Predicate{ + Flags: map[string]Predictor{ "-h": PredictNothing, "-global1": PredictAnything, "-o": PredictFiles("*.txt"), @@ -174,9 +174,9 @@ func TestCompleter_Complete(t *testing.T) { tt.args = "cmd " + tt.args os.Setenv(envComplete, tt.args) - args, _ := getLine() + line, _ := getLine() - got := complete(c, args) + got := c.Predict(newArgs(line)) sort.Strings(tt.want) sort.Strings(got) diff --git a/gocomplete/complete.go b/gocomplete/complete.go index 75d3672..1575e1b 100644 --- a/gocomplete/complete.go +++ b/gocomplete/complete.go @@ -6,9 +6,11 @@ import "github.com/posener/complete" var ( predictEllipsis = complete.PredictSet("./...") - goFilesOrPackages = complete.PredictFiles("*.go"). - Or(complete.PredictDirs("*")). - Or(predictEllipsis) + goFilesOrPackages = complete.PredictOr( + complete.PredictFiles("*.go"), + complete.PredictDirs("*"), + predictEllipsis, + ) ) func main() { diff --git a/gocomplete/tests.go b/gocomplete/tests.go index 40210d4..d2c32e7 100644 --- a/gocomplete/tests.go +++ b/gocomplete/tests.go @@ -17,15 +17,16 @@ import ( // and then all the relevant function names. // for test names use prefix of 'Test' or 'Example', and for benchmark // test names use 'Benchmark' -func predictTest(funcPrefix ...string) complete.Predicate { - return func(last string) []match.Matcher { +func predictTest(funcPrefix ...string) complete.Predictor { + return complete.PredictFunc(func(a complete.Args) (prediction []string) { tests := testNames(funcPrefix) - options := make([]match.Matcher, len(tests)) - for i := range tests { - options[i] = match.Prefix(tests[i]) + for _, t := range tests { + if match.Prefix(t, a.Last) { + prediction = append(prediction, t) + } } - return options - } + return + }) } // get all test names in current directory diff --git a/match/file.go b/match/file.go index 0b554ce..eee5bec 100644 --- a/match/file.go +++ b/match/file.go @@ -1,26 +1,16 @@ package match -import ( - "strings" -) +import "strings" -// File is a file name Matcher, if the last word can prefix the -// File path, there is a possible match -type File string - -func (a File) String() string { - return string(a) -} - -// Match returns true if prefix's abs path prefixes a's abs path -func (a File) Match(prefix string) bool { +// File returns true if prefix can match the file +func File(file, prefix string) bool { // special case for current directory completion - if a == "./" && (prefix == "." || prefix == "") { + if file == "./" && (prefix == "." || prefix == "") { return true } - cmp := strings.TrimPrefix(string(a), "./") + file = strings.TrimPrefix(file, "./") prefix = strings.TrimPrefix(prefix, "./") - return strings.HasPrefix(cmp, prefix) + return strings.HasPrefix(file, prefix) } diff --git a/match/match.go b/match/match.go index ae95549..812fcac 100644 --- a/match/match.go +++ b/match/match.go @@ -1,11 +1,6 @@ package match -import "fmt" - -// Matcher matches itself to a string -// it is used for comparing a given argument to the last typed -// word, and see if it is a possible auto complete option. -type Matcher interface { - fmt.Stringer - Match(prefix string) bool -} +// Match matches two strings +// it is used for comparing a term to the last typed +// word, the prefix, and see if it is a possible auto complete option. +type Match func(term, prefix string) bool diff --git a/match/match_test.go b/match/match_test.go index d7a851a..b5a0d87 100644 --- a/match/match_test.go +++ b/match/match_test.go @@ -1,6 +1,7 @@ package match import ( + "fmt" "os" "testing" ) @@ -21,11 +22,13 @@ func TestMatch(t *testing.T) { } tests := []struct { - m Matcher + m Match + long string tests []matcherTest }{ { - m: Prefix("abcd"), + m: Prefix, + long: "abcd", tests: []matcherTest{ {prefix: "", want: true}, {prefix: "ab", want: true}, @@ -33,14 +36,16 @@ func TestMatch(t *testing.T) { }, }, { - m: Prefix(""), + m: Prefix, + long: "", tests: []matcherTest{ {prefix: "ac", want: false}, {prefix: "", want: true}, }, }, { - m: File("file.txt"), + m: File, + long: "file.txt", tests: []matcherTest{ {prefix: "", want: true}, {prefix: "f", want: true}, @@ -59,7 +64,8 @@ func TestMatch(t *testing.T) { }, }, { - m: File("./file.txt"), + m: File, + long: "./file.txt", tests: []matcherTest{ {prefix: "", want: true}, {prefix: "f", want: true}, @@ -78,7 +84,8 @@ func TestMatch(t *testing.T) { }, }, { - m: File("/file.txt"), + m: File, + long: "/file.txt", tests: []matcherTest{ {prefix: "", want: true}, {prefix: "f", want: false}, @@ -97,7 +104,8 @@ func TestMatch(t *testing.T) { }, }, { - m: File("./"), + m: File, + long: "./", tests: []matcherTest{ {prefix: "", want: true}, {prefix: ".", want: true}, @@ -109,9 +117,9 @@ func TestMatch(t *testing.T) { for _, tt := range tests { for _, ttt := range tt.tests { - name := "matcher='" + tt.m.String() + "'&prefix='" + ttt.prefix + "'" + name := fmt.Sprintf("matcher=%T&long='%s'&prefix='%s'", tt.m, tt.long, ttt.prefix) t.Run(name, func(t *testing.T) { - got := tt.m.Match(ttt.prefix) + got := tt.m(tt.long, ttt.prefix) if got != ttt.want { t.Errorf("Failed %s: got = %t, want: %t", name, got, ttt.want) } diff --git a/match/prefix.go b/match/prefix.go index d54902d..9a01ba6 100644 --- a/match/prefix.go +++ b/match/prefix.go @@ -3,13 +3,7 @@ package match import "strings" // Prefix is a simple Matcher, if the word is it's prefix, there is a match -type Prefix string - -func (a Prefix) String() string { - return string(a) -} - // Match returns true if a has the prefix as prefix -func (a Prefix) Match(prefix string) bool { - return strings.HasPrefix(string(a), prefix) +func Prefix(long, prefix string) bool { + return strings.HasPrefix(long, prefix) } diff --git a/predicate.go b/predict.go similarity index 62% rename from predicate.go rename to predict.go index bb7e8cb..9e1cce9 100644 --- a/predicate.go +++ b/predict.go @@ -7,52 +7,66 @@ import ( "github.com/posener/complete/match" ) -// Predicate determines what terms can follow a command or a flag -// It is used for auto completion, given last - the last word in the already -// in the command line, what words can complete it. -type Predicate func(last string) []match.Matcher - -// Or unions two predicate functions, so that the result predicate -// returns the union of their predication -func (p Predicate) Or(other Predicate) Predicate { - if p == nil { - return other - } - if other == nil { - return p - } - return func(last string) []match.Matcher { return append(p.predict(last), other.predict(last)...) } +// Predictor implements a predict method, in which given +// command line arguments returns a list of options it predicts. +type Predictor interface { + Predict(Args) []string } -func (p Predicate) predict(last string) []match.Matcher { +// PredictOr unions two predicate functions, so that the result predicate +// returns the union of their predication +func PredictOr(predictors ...Predictor) Predictor { + return PredictFunc(func(a Args) (prediction []string) { + for _, p := range predictors { + if p == nil { + continue + } + prediction = append(prediction, p.Predict(a)...) + } + return + }) +} + +// PredictFunc determines what terms can follow a command or a flag +// It is used for auto completion, given last - the last word in the already +// in the command line, what words can complete it. +type PredictFunc func(Args) []string + +// Predict invokes the predict function and implements the Predictor interface +func (p PredictFunc) Predict(a Args) []string { if p == nil { return nil } - return p(last) + return p(a) } // PredictNothing does not expect anything after. -var PredictNothing Predicate +var PredictNothing Predictor // PredictAnything expects something, but nothing particular, such as a number // or arbitrary name. -func PredictAnything(last string) []match.Matcher { return nil } +var PredictAnything = PredictFunc(func(Args) []string { return nil }) // PredictSet expects specific set of terms, given in the options argument. -func PredictSet(options ...string) Predicate { - return func(last string) []match.Matcher { - ret := make([]match.Matcher, len(options)) - for i := range options { - ret[i] = match.Prefix(options[i]) +func PredictSet(options ...string) Predictor { + return predictSet(options) +} + +type predictSet []string + +func (p predictSet) Predict(a Args) (prediction []string) { + for _, m := range p { + if match.Prefix(m, a.Last) { + prediction = append(prediction, m) } - return ret } + return } // PredictDirs will search for directories in the given started to be typed // path, if no path was started to be typed, it will complete to directories // in the current working directory. -func PredictDirs(pattern string) Predicate { +func PredictDirs(pattern string) Predictor { return files(pattern, true, false) } @@ -60,19 +74,19 @@ func PredictDirs(pattern string) Predicate { // be typed path, if no path was started to be typed, it will complete to files that // match the pattern in the current working directory. // To match any file, use "*" as pattern. To match go files use "*.go", and so on. -func PredictFiles(pattern string) Predicate { +func PredictFiles(pattern string) Predictor { return files(pattern, false, true) } -// PredictFilesOrDirs predict any file or directory that matches the pattern -func PredictFilesOrDirs(pattern string) Predicate { +// PredictFilesOrDirs any file or directory that matches the pattern +func PredictFilesOrDirs(pattern string) Predictor { return files(pattern, true, true) } -func files(pattern string, allowDirs, allowFiles bool) Predicate { - return func(last string) []match.Matcher { - dir := dirFromLast(last) - Log("looking for files in %s (last=%s)", dir, last) +func files(pattern string, allowDirs, allowFiles bool) PredictFunc { + return func(a Args) (prediction []string) { + dir := dirFromLast(a.Last) + Log("looking for files in %s (last=%s)", dir, a.Last) files, err := filepath.Glob(filepath.Join(dir, pattern)) if err != nil { Log("failed glob operation with pattern '%s': %s", pattern, err) @@ -84,7 +98,13 @@ func files(pattern string, allowDirs, allowFiles bool) Predicate { if !filepath.IsAbs(pattern) { filesToRel(files) } - return filesToMatchers(files) + // add all matching files to prediction + for _, f := range files { + if match.File(f, a.Last) { + prediction = append(prediction, f) + } + } + return } } @@ -130,14 +150,6 @@ func filesToRel(files []string) { return } -func filesToMatchers(files []string) []match.Matcher { - options := make([]match.Matcher, len(files)) - for i, f := range files { - options[i] = match.File(f) - } - return options -} - // dirFromLast gives the directory of the current written // last argument if it represents a file name being written. // in case that it is not, we fall back to the current directory. diff --git a/predicate_test.go b/predict_test.go similarity index 78% rename from predicate_test.go rename to predict_test.go index 1a694a1..6b77fbe 100644 --- a/predicate_test.go +++ b/predict_test.go @@ -12,7 +12,7 @@ func TestPredicate(t *testing.T) { tests := []struct { name string - p Predicate + p Predictor arg string want []string }{ @@ -37,29 +37,24 @@ func TestPredicate(t *testing.T) { p: PredictAnything, want: []string{}, }, - { - name: "nothing", - p: PredictNothing, - want: []string{}, - }, { name: "or: word with nil", - p: PredictSet("a").Or(PredictNothing), + p: PredictOr(PredictSet("a"), nil), want: []string{"a"}, }, { name: "or: nil with word", - p: PredictNothing.Or(PredictSet("a")), + p: PredictOr(nil, PredictSet("a")), want: []string{"a"}, }, { name: "or: nil with nil", - p: PredictNothing.Or(PredictNothing), + p: PredictOr(PredictNothing, PredictNothing), want: []string{}, }, { name: "or: word with word with word", - p: PredictSet("a").Or(PredictSet("b")).Or(PredictSet("c")), + p: PredictOr(PredictSet("a"), PredictSet("b"), PredictSet("c")), want: []string{"a", "b", "c"}, }, { @@ -118,18 +113,12 @@ func TestPredicate(t *testing.T) { for _, tt := range tests { t.Run(tt.name+"?arg='"+tt.arg+"'", func(t *testing.T) { - matchers := tt.p.predict(tt.arg) + matches := tt.p.Predict(newArgs(strings.Split(tt.arg, " "))) - matchersString := []string{} - for _, m := range matchers { - if m.Match(tt.arg) { - matchersString = append(matchersString, m.String()) - } - } - sort.Strings(matchersString) + sort.Strings(matches) sort.Strings(tt.want) - got := strings.Join(matchersString, ",") + got := strings.Join(matches, ",") want := strings.Join(tt.want, ",") if got != want {