Improve files and directories completion

This commit is contained in:
Eyal Posener 2017-05-11 00:38:24 +03:00
parent 1c743d8c0b
commit 72dfe017e9
9 changed files with 128 additions and 63 deletions

View File

@ -33,11 +33,13 @@ func (c *Command) options(args []string) (options []match.Matcher, only bool) {
// remove the first argument, which is the command name // remove the first argument, which is the command name
args = args[1:] args = args[1:]
last := last(args) wordCurrent := last(args)
// if prev has something that needs to follow it, wordCompleted := last(removeLast(args))
// if wordCompleted has something that needs to follow it,
// it is the most relevant completion // it is the most relevant completion
if predicate, ok := c.Flags[last]; ok && predicate != nil { if predicate, ok := c.Flags[wordCompleted]; ok && predicate != nil {
return predicate.predict(last), true Log("Predicting according to flag %s", wordCurrent)
return predicate.predict(wordCurrent), true
} }
sub, options, only := c.searchSub(args) sub, options, only := c.searchSub(args)
@ -57,7 +59,7 @@ func (c *Command) options(args []string) (options []match.Matcher, only bool) {
} }
// add additional expected argument of the command // add additional expected argument of the command
options = append(options, c.Args.predict(last)...) options = append(options, c.Args.predict(wordCurrent)...)
return return
} }
@ -65,7 +67,12 @@ func (c *Command) options(args []string) (options []match.Matcher, only bool) {
// searchSub searches recursively within sub commands if the sub command appear // searchSub searches recursively within sub commands if the sub command appear
// in the on of the arguments. // in the on of the arguments.
func (c *Command) searchSub(args []string) (sub string, all []match.Matcher, only bool) { func (c *Command) searchSub(args []string) (sub string, all []match.Matcher, only bool) {
for i, arg := range args {
// 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 {
if cmd, ok := c.Sub[arg]; ok { if cmd, ok := c.Sub[arg]; ok {
sub = arg sub = arg
all, only = cmd.options(args[i:]) all, only = cmd.options(args[i:])
@ -83,3 +90,10 @@ func (c *Command) subCommands() []match.Matcher {
} }
return subs return subs
} }
func removeLast(a []string) []string {
if len(a) > 0 {
return a[:len(a)-1]
}
return a
}

View File

@ -59,11 +59,12 @@ func (c *Complete) Run() bool {
// complete get a command an command line arguments and returns // complete get a command an command line arguments and returns
// matching completion options // matching completion options
func complete(c Command, args []string) (matching []string) { func complete(c Command, args []string) (matching []string) {
options, _ := c.options(args[:len(args)-1]) options, _ := c.options(args)
// choose only matching options // choose only matching options
l := last(args) l := last(args)
for _, option := range options { for _, option := range options {
Log("option %T, %s -> %t", option, option, option.Match(l))
if option.Match(l) { if option.Match(l) {
matching = append(matching, option.String()) matching = append(matching, option.String())
} }
@ -87,6 +88,7 @@ func last(args []string) (last string) {
} }
func output(options []string) { func output(options []string) {
Log("")
// stdout of program defines the complete options // stdout of program defines the complete options
for _, option := range options { for _, option := range options {
fmt.Println(option) fmt.Println(option)

View File

@ -23,7 +23,7 @@ func TestCompleter_Complete(t *testing.T) {
"-flag2": PredictNothing, "-flag2": PredictNothing,
"-flag3": PredictSet("opt1", "opt2", "opt12"), "-flag3": PredictSet("opt1", "opt2", "opt12"),
}, },
Args: Predicate(PredictDirs).Or(PredictFiles("*.md")), Args: Predicate(PredictDirs("*")).Or(PredictFiles("*.md")),
}, },
}, },
Flags: map[string]Predicate{ Flags: map[string]Predicate{
@ -41,7 +41,7 @@ func TestCompleter_Complete(t *testing.T) {
allGlobals = append(allGlobals, flag) allGlobals = append(allGlobals, flag)
} }
testTXTFiles := []string{"./a.txt", "./b.txt", "./c.txt"} testTXTFiles := []string{"./a.txt", "./b.txt", "./c.txt", "./.dot.txt"}
tests := []struct { tests := []struct {
args string args string
@ -81,11 +81,11 @@ func TestCompleter_Complete(t *testing.T) {
}, },
{ {
args: "sub2 ", args: "sub2 ",
want: []string{"./", "./dir", "./readme.md", "-flag2", "-flag3", "-h", "-global1", "-o"}, want: []string{"./", "./dir/", "./readme.md", "-flag2", "-flag3", "-h", "-global1", "-o"},
}, },
{ {
args: "sub2 ./", args: "sub2 ./",
want: []string{"./", "./readme.md", "./dir"}, want: []string{"./", "./readme.md", "./dir/"},
}, },
{ {
args: "sub2 re", args: "sub2 re",
@ -93,7 +93,7 @@ func TestCompleter_Complete(t *testing.T) {
}, },
{ {
args: "sub2 -flag2 ", args: "sub2 -flag2 ",
want: []string{"./", "./dir", "./readme.md", "-flag2", "-flag3", "-h", "-global1", "-o"}, want: []string{"./", "./dir/", "./readme.md", "-flag2", "-flag3", "-h", "-global1", "-o"},
}, },
{ {
args: "sub1 -fl", args: "sub1 -fl",

View File

@ -7,7 +7,7 @@ var (
predictEllipsis = complete.PredictSet("./...") predictEllipsis = complete.PredictSet("./...")
goFilesOrPackages = complete.PredictFiles("*.go"). goFilesOrPackages = complete.PredictFiles("*.go").
Or(complete.PredictDirs). Or(complete.PredictDirs("*")).
Or(predictEllipsis) Or(predictEllipsis)
) )
@ -33,7 +33,7 @@ func main() {
"-installsuffix": complete.PredictAnything, "-installsuffix": complete.PredictAnything,
"-ldflags": complete.PredictAnything, "-ldflags": complete.PredictAnything,
"-linkshared": complete.PredictNothing, "-linkshared": complete.PredictNothing,
"-pkgdir": complete.PredictDirs, "-pkgdir": complete.PredictDirs("*"),
"-tags": complete.PredictAnything, "-tags": complete.PredictAnything,
"-toolexec": complete.PredictAnything, "-toolexec": complete.PredictAnything,
}, },
@ -58,7 +58,7 @@ func main() {
"-count": complete.PredictAnything, "-count": complete.PredictAnything,
"-cover": complete.PredictNothing, "-cover": complete.PredictNothing,
"-covermode": complete.PredictSet("set", "count", "atomic"), "-covermode": complete.PredictSet("set", "count", "atomic"),
"-coverpkg": complete.PredictDirs, "-coverpkg": complete.PredictDirs("*"),
"-cpu": complete.PredictAnything, "-cpu": complete.PredictAnything,
"-run": predictTest("Test", "Example"), "-run": predictTest("Test", "Example"),
"-short": complete.PredictNothing, "-short": complete.PredictNothing,
@ -73,7 +73,7 @@ func main() {
"-memprofilerate": complete.PredictAnything, "-memprofilerate": complete.PredictAnything,
"-mutexprofile": complete.PredictFiles("*.out"), "-mutexprofile": complete.PredictFiles("*.out"),
"-mutexprofilefraction": complete.PredictAnything, "-mutexprofilefraction": complete.PredictAnything,
"-outputdir": complete.PredictDirs, "-outputdir": complete.PredictDirs("*"),
"-trace": complete.PredictFiles("*.out"), "-trace": complete.PredictFiles("*.out"),
}, },
Args: goFilesOrPackages, Args: goFilesOrPackages,
@ -114,7 +114,7 @@ func main() {
"-n": complete.PredictNothing, "-n": complete.PredictNothing,
"-x": complete.PredictNothing, "-x": complete.PredictNothing,
}, },
Args: complete.PredictDirs, Args: complete.PredictDirs("*"),
} }
list := complete.Command{ list := complete.Command{
@ -123,7 +123,7 @@ func main() {
"-f": complete.PredictAnything, "-f": complete.PredictAnything,
"-json": complete.PredictNothing, "-json": complete.PredictNothing,
}, },
Args: complete.PredictDirs, Args: complete.PredictDirs("*"),
} }
tool := complete.Command{ tool := complete.Command{
@ -140,7 +140,7 @@ func main() {
"-n": complete.PredictNothing, "-n": complete.PredictNothing,
"-x": complete.PredictNothing, "-x": complete.PredictNothing,
}, },
Args: complete.PredictDirs, Args: complete.PredictDirs("*"),
} }
env := complete.Command{ env := complete.Command{
@ -151,7 +151,7 @@ func main() {
version := complete.Command{} version := complete.Command{}
fix := complete.Command{ fix := complete.Command{
Args: complete.PredictDirs, Args: complete.PredictDirs("*"),
} }
// commands that also accepts the build flags // commands that also accepts the build flags

View File

@ -1,7 +1,6 @@
package match package match
import ( import (
"path/filepath"
"strings" "strings"
) )
@ -15,16 +14,13 @@ func (a File) String() string {
// Match returns true if prefix's abs path prefixes a's abs path // Match returns true if prefix's abs path prefixes a's abs path
func (a File) Match(prefix string) bool { func (a File) Match(prefix string) bool {
full, err := filepath.Abs(string(a))
if err != nil { // special case for current directory completion
return false if a == "./" && (prefix == "." || prefix == "") {
} return true
prefixFull, err := filepath.Abs(prefix)
if err != nil {
return false
} }
// if the file has the prefix as prefix, cmp := strings.TrimPrefix(string(a), "./")
// but we don't want to show too many files, so, if it is in a deeper directory - omit it. prefix = strings.TrimPrefix(prefix, "./")
return strings.HasPrefix(full, prefixFull) && (full == prefixFull || !strings.Contains(full[len(prefixFull)+1:], "/")) return strings.HasPrefix(cmp, prefix)
} }

View File

@ -45,6 +45,7 @@ func TestMatch(t *testing.T) {
{prefix: "", want: true}, {prefix: "", want: true},
{prefix: "f", want: true}, {prefix: "f", want: true},
{prefix: "./f", want: true}, {prefix: "./f", want: true},
{prefix: "./.", want: false},
{prefix: "file.", want: true}, {prefix: "file.", want: true},
{prefix: "./file.", want: true}, {prefix: "./file.", want: true},
{prefix: "file.txt", want: true}, {prefix: "file.txt", want: true},
@ -54,6 +55,7 @@ func TestMatch(t *testing.T) {
{prefix: "/file.txt", want: false}, {prefix: "/file.txt", want: false},
{prefix: "/fil", want: false}, {prefix: "/fil", want: false},
{prefix: "/file.txt2", want: false}, {prefix: "/file.txt2", want: false},
{prefix: "/.", want: false},
}, },
}, },
{ {
@ -62,6 +64,7 @@ func TestMatch(t *testing.T) {
{prefix: "", want: true}, {prefix: "", want: true},
{prefix: "f", want: true}, {prefix: "f", want: true},
{prefix: "./f", want: true}, {prefix: "./f", want: true},
{prefix: "./.", want: false},
{prefix: "file.", want: true}, {prefix: "file.", want: true},
{prefix: "./file.", want: true}, {prefix: "./file.", want: true},
{prefix: "file.txt", want: true}, {prefix: "file.txt", want: true},
@ -71,14 +74,16 @@ func TestMatch(t *testing.T) {
{prefix: "/file.txt", want: false}, {prefix: "/file.txt", want: false},
{prefix: "/fil", want: false}, {prefix: "/fil", want: false},
{prefix: "/file.txt2", want: false}, {prefix: "/file.txt2", want: false},
{prefix: "/.", want: false},
}, },
}, },
{ {
m: File("/file.txt"), m: File("/file.txt"),
tests: []matcherTest{ tests: []matcherTest{
{prefix: "", want: false}, {prefix: "", want: true},
{prefix: "f", want: false}, {prefix: "f", want: false},
{prefix: "./f", want: false}, {prefix: "./f", want: false},
{prefix: "./.", want: false},
{prefix: "file.", want: false}, {prefix: "file.", want: false},
{prefix: "./file.", want: false}, {prefix: "./file.", want: false},
{prefix: "file.txt", want: false}, {prefix: "file.txt", want: false},
@ -88,13 +93,23 @@ func TestMatch(t *testing.T) {
{prefix: "/file.txt", want: true}, {prefix: "/file.txt", want: true},
{prefix: "/fil", want: true}, {prefix: "/fil", want: true},
{prefix: "/file.txt2", want: false}, {prefix: "/file.txt2", want: false},
{prefix: "/.", want: false},
},
},
{
m: File("./"),
tests: []matcherTest{
{prefix: "", want: true},
{prefix: ".", want: true},
{prefix: "./", want: true},
{prefix: "./.", want: false},
}, },
}, },
} }
for _, tt := range tests { for _, tt := range tests {
for _, ttt := range tt.tests { for _, ttt := range tt.tests {
name := "matcher:" + tt.m.String() + "/prefix:" + ttt.prefix name := "matcher='" + tt.m.String() + "'&prefix='" + ttt.prefix + "'"
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
got := tt.m.Match(ttt.prefix) got := tt.m.Match(ttt.prefix)
if got != ttt.want { if got != ttt.want {

View File

@ -3,7 +3,6 @@ package complete
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/posener/complete/match" "github.com/posener/complete/match"
) )
@ -53,23 +52,8 @@ func PredictSet(options ...string) Predicate {
// PredictDirs will search for directories in the given started to be typed // 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 // path, if no path was started to be typed, it will complete to directories
// in the current working directory. // in the current working directory.
func PredictDirs(last string) (options []match.Matcher) { func PredictDirs(pattern string) Predicate {
path := dirFromLast(last) return files(pattern, true, false)
dirs := []string{}
filepath.Walk(path, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
dirs = append(dirs, path)
}
return nil
})
// if given path is not absolute, return relative paths
if !filepath.IsAbs(path) {
filesToRel(dirs)
}
return filesToMatchers(dirs)
} }
// PredictFiles will search for files matching the given pattern in the started to // PredictFiles will search for files matching the given pattern in the started to
@ -77,12 +61,26 @@ func PredictDirs(last string) (options []match.Matcher) {
// match the pattern in the current working directory. // match the pattern in the current working directory.
// To match any file, use "*" as pattern. To match go files use "*.go", and so on. // 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) Predicate {
return files(pattern, false, true)
}
// PredictFilesOrDirs predict any file or directory that matches the pattern
func PredictFilesOrDirs(pattern string) Predicate {
return files(pattern, true, true)
}
func files(pattern string, allowDirs, allowFiles bool) Predicate {
return func(last string) []match.Matcher { return func(last string) []match.Matcher {
dir := dirFromLast(last) dir := dirFromLast(last)
Log("looking for files in %s (last=%s)", dir, last)
files, err := filepath.Glob(filepath.Join(dir, pattern)) files, err := filepath.Glob(filepath.Join(dir, pattern))
if err != nil { if err != nil {
Log("failed glob operation with pattern '%s': %s", pattern, err) Log("failed glob operation with pattern '%s': %s", pattern, err)
} }
if allowDirs {
files = append(files, dir)
}
files = selectByType(files, allowDirs, allowFiles)
if !filepath.IsAbs(pattern) { if !filepath.IsAbs(pattern) {
filesToRel(files) filesToRel(files)
} }
@ -90,6 +88,21 @@ func PredictFiles(pattern string) Predicate {
} }
} }
func selectByType(names []string, allowDirs bool, allowFiles bool) []string {
filtered := make([]string, 0, len(names))
for _, name := range names {
stat, err := os.Stat(name)
if err != nil {
continue
}
if (stat.IsDir() && !allowDirs) || (!stat.IsDir() && !allowFiles) {
continue
}
filtered = append(filtered, name)
}
return filtered
}
// filesToRel, change list of files to their names in the relative // filesToRel, change list of files to their names in the relative
// to current directory form. // to current directory form.
func filesToRel(files []string) { func filesToRel(files []string) {
@ -106,12 +119,12 @@ func filesToRel(files []string) {
if err != nil { if err != nil {
continue continue
} }
if rel == "." { if rel != "." {
rel = ""
}
if !strings.HasPrefix(rel, ".") {
rel = "./" + rel rel = "./" + rel
} }
if info, err := os.Stat(rel); err == nil && info.IsDir() {
rel += "/"
}
files[i] = rel files[i] = rel
} }
return return
@ -129,6 +142,9 @@ func filesToMatchers(files []string) []match.Matcher {
// last argument if it represents a file name being written. // last argument if it represents a file name being written.
// in case that it is not, we fall back to the current directory. // in case that it is not, we fall back to the current directory.
func dirFromLast(last string) string { func dirFromLast(last string) string {
if info, err := os.Stat(last); err == nil && info.IsDir() {
return last
}
dir := filepath.Dir(last) dir := filepath.Dir(last)
_, err := os.Stat(dir) _, err := os.Stat(dir)
if err != nil { if err != nil {

View File

@ -21,6 +21,12 @@ func TestPredicate(t *testing.T) {
p: PredictSet("a", "b", "c"), p: PredictSet("a", "b", "c"),
want: []string{"a", "b", "c"}, want: []string{"a", "b", "c"},
}, },
{
name: "set with does",
p: PredictSet("./..", "./x"),
arg: "./.",
want: []string{"./.."},
},
{ {
name: "set/empty", name: "set/empty",
p: PredictSet(), p: PredictSet(),
@ -59,7 +65,7 @@ func TestPredicate(t *testing.T) {
{ {
name: "files/txt", name: "files/txt",
p: PredictFiles("*.txt"), p: PredictFiles("*.txt"),
want: []string{"./a.txt", "./b.txt", "./c.txt"}, want: []string{"./a.txt", "./b.txt", "./c.txt", "./.dot.txt"},
}, },
{ {
name: "files/txt", name: "files/txt",
@ -86,24 +92,40 @@ func TestPredicate(t *testing.T) {
}, },
{ {
name: "dirs", name: "dirs",
p: PredictDirs, p: PredictDirs("*"),
arg: "./dir/", arg: "./dir/",
want: []string{"./dir"}, want: []string{"./dir/"},
},
{
name: "dirs and files",
p: PredictFilesOrDirs("*"),
arg: "./dir",
want: []string{"./dir/", "./dir/x"},
}, },
{ {
name: "dirs", name: "dirs",
p: PredictDirs, p: PredictDirs("*"),
want: []string{"./", "./dir"}, want: []string{"./", "./dir/"},
},
{
name: "subdir",
p: PredictFiles("*"),
arg: "./dir/",
want: []string{"./dir/x"},
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name+"/"+tt.arg, func(t *testing.T) { t.Run(tt.name+"?arg='"+tt.arg+"'", func(t *testing.T) {
matchers := tt.p.predict(tt.arg) matchers := tt.p.predict(tt.arg)
matchersString := []string{} matchersString := []string{}
for _, m := range matchers { for _, m := range matchers {
if m.Match(tt.arg) {
matchersString = append(matchersString, m.String()) matchersString = append(matchersString, m.String())
} }
}
sort.Strings(matchersString) sort.Strings(matchersString)
sort.Strings(tt.want) sort.Strings(tt.want)
@ -111,7 +133,7 @@ func TestPredicate(t *testing.T) {
want := strings.Join(tt.want, ",") want := strings.Join(tt.want, ",")
if got != want { if got != want {
t.Errorf("failed %s\ngot = %s\nwant: %s", tt.name, got, want) t.Errorf("failed %s\ngot = %s\nwant: %s", t.Name(), got, want)
} }
}) })
} }

0
tests/.dot.txt Normal file
View File