Merge pull request #11 from posener/files-complete

Improve files and directories completion
This commit is contained in:
Eyal Posener 2017-05-11 02:20:27 +03:00 committed by GitHub
commit dd2171d085
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
args = args[1:]
last := last(args)
// if prev has something that needs to follow it,
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[last]; ok && predicate != nil {
return predicate.predict(last), true
if predicate, ok := c.Flags[wordCompleted]; ok && predicate != nil {
Log("Predicting according to flag %s", wordCurrent)
return predicate.predict(wordCurrent), true
}
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
options = append(options, c.Args.predict(last)...)
options = append(options, c.Args.predict(wordCurrent)...)
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
// in the on of the arguments.
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 {
sub = arg
all, only = cmd.options(args[i:])
@ -83,3 +90,10 @@ func (c *Command) subCommands() []match.Matcher {
}
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
// matching completion options
func complete(c Command, args []string) (matching []string) {
options, _ := c.options(args[:len(args)-1])
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())
}
@ -87,6 +88,7 @@ func last(args []string) (last string) {
}
func output(options []string) {
Log("")
// stdout of program defines the complete options
for _, option := range options {
fmt.Println(option)

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,6 @@ package complete
import (
"os"
"path/filepath"
"strings"
"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
// path, if no path was started to be typed, it will complete to directories
// in the current working directory.
func PredictDirs(last string) (options []match.Matcher) {
path := dirFromLast(last)
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)
func PredictDirs(pattern string) Predicate {
return files(pattern, true, false)
}
// 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.
// To match any file, use "*" as pattern. To match go files use "*.go", and so on.
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 {
dir := dirFromLast(last)
Log("looking for files in %s (last=%s)", dir, last)
files, err := filepath.Glob(filepath.Join(dir, pattern))
if err != nil {
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) {
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
// to current directory form.
func filesToRel(files []string) {
@ -106,12 +119,12 @@ func filesToRel(files []string) {
if err != nil {
continue
}
if rel == "." {
rel = ""
}
if !strings.HasPrefix(rel, ".") {
if rel != "." {
rel = "./" + rel
}
if info, err := os.Stat(rel); err == nil && info.IsDir() {
rel += "/"
}
files[i] = rel
}
return
@ -129,6 +142,9 @@ func filesToMatchers(files []string) []match.Matcher {
// last argument if it represents a file name being written.
// in case that it is not, we fall back to the current directory.
func dirFromLast(last string) string {
if info, err := os.Stat(last); err == nil && info.IsDir() {
return last
}
dir := filepath.Dir(last)
_, err := os.Stat(dir)
if err != nil {

View File

@ -21,6 +21,12 @@ func TestPredicate(t *testing.T) {
p: PredictSet("a", "b", "c"),
want: []string{"a", "b", "c"},
},
{
name: "set with does",
p: PredictSet("./..", "./x"),
arg: "./.",
want: []string{"./.."},
},
{
name: "set/empty",
p: PredictSet(),
@ -59,7 +65,7 @@ func TestPredicate(t *testing.T) {
{
name: "files/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",
@ -86,23 +92,39 @@ func TestPredicate(t *testing.T) {
},
{
name: "dirs",
p: PredictDirs,
p: PredictDirs("*"),
arg: "./dir/",
want: []string{"./dir"},
want: []string{"./dir/"},
},
{
name: "dirs and files",
p: PredictFilesOrDirs("*"),
arg: "./dir",
want: []string{"./dir/", "./dir/x"},
},
{
name: "dirs",
p: PredictDirs,
want: []string{"./", "./dir"},
p: PredictDirs("*"),
want: []string{"./", "./dir/"},
},
{
name: "subdir",
p: PredictFiles("*"),
arg: "./dir/",
want: []string{"./dir/x"},
},
}
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)
matchersString := []string{}
for _, m := range matchers {
matchersString = append(matchersString, m.String())
if m.Match(tt.arg) {
matchersString = append(matchersString, m.String())
}
}
sort.Strings(matchersString)
sort.Strings(tt.want)
@ -111,7 +133,7 @@ func TestPredicate(t *testing.T) {
want := strings.Join(tt.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