diff options
Diffstat (limited to 'predict')
| -rw-r--r-- | predict/files.go | 175 | ||||
| -rw-r--r-- | predict/files_test.go | 233 | ||||
| -rw-r--r-- | predict/predict.go | 34 | ||||
| -rw-r--r-- | predict/predict_test.go | 61 | ||||
| -rw-r--r-- | predict/testdata/.dot.txt | 0 | ||||
| -rw-r--r-- | predict/testdata/a.txt | 0 | ||||
| -rw-r--r-- | predict/testdata/b.txt | 0 | ||||
| -rw-r--r-- | predict/testdata/c.txt | 0 | ||||
| -rw-r--r-- | predict/testdata/dir/bar | 0 | ||||
| -rw-r--r-- | predict/testdata/dir/foo | 0 | ||||
| -rw-r--r-- | predict/testdata/outer/inner/readme.md | 0 | ||||
| -rw-r--r-- | predict/testdata/readme.md | 3 |
12 files changed, 506 insertions, 0 deletions
diff --git a/predict/files.go b/predict/files.go new file mode 100644 index 0000000..4654ec4 --- /dev/null +++ b/predict/files.go @@ -0,0 +1,175 @@ +package predict + +import ( + "io/ioutil" + "os" + "path/filepath" + "strings" +) + +// Dirs returns a predictor that predict directory paths. If a non-empty pattern is given, the +// predicted paths will match that pattern. +func Dirs(pattern string) FilesPredictor { + return FilesPredictor{pattern: pattern, includeFiles: false} +} + +// Dirs returns a predictor that predict file or directory paths. If a non-empty pattern is given, +// the predicted paths will match that pattern. +func Files(pattern string) FilesPredictor { + return FilesPredictor{pattern: pattern, includeFiles: true} +} + +type FilesPredictor struct { + pattern string + includeFiles bool +} + +// Predict searches for files according to the given prefix. +// If the only predicted path is a single directory, the search will continue another recursive +// layer into that directory. +func (f FilesPredictor) Predict(prefix string) (options []string) { + options = f.predictFiles(prefix) + + // If the number of prediction is not 1, we either have many results or have no results, so we + // return it. + if len(options) != 1 { + return + } + + // Only try deeper, if the one item is a directory. + if stat, err := os.Stat(options[0]); err != nil || !stat.IsDir() { + return + } + + return f.predictFiles(options[0]) +} + +func (f FilesPredictor) predictFiles(prefix string) []string { + if strings.HasSuffix(prefix, "/..") { + return nil + } + + dir := directory(prefix) + files := f.listFiles(dir) + + // Add dir if match. + files = append(files, dir) + + return FilesSet(files).Predict(prefix) +} + +func (f FilesPredictor) listFiles(dir string) []string { + // Set of all file names. + m := map[string]bool{} + + // List files. + if files, err := filepath.Glob(filepath.Join(dir, f.pattern)); err == nil { + for _, file := range files { + if stat, err := os.Stat(file); err != nil || stat.IsDir() || f.includeFiles { + m[file] = true + } + } + } + + // List directories. + if dirs, err := ioutil.ReadDir(dir); err == nil { + for _, d := range dirs { + if d.IsDir() { + m[filepath.Join(dir, d.Name())] = true + } + } + } + + list := make([]string, 0, len(m)) + for k := range m { + list = append(list, k) + } + return list +} + +// directory gives the directory of the given partial path in case that it is not, we fall back to +// the current directory. +func directory(path string) string { + if info, err := os.Stat(path); err == nil && info.IsDir() { + return fixPathForm(path, path) + } + dir := filepath.Dir(path) + if info, err := os.Stat(dir); err == nil && info.IsDir() { + return fixPathForm(path, dir) + } + return "./" +} + +// FilesSet predict according to file rules to a given fixed set of file names. +type FilesSet []string + +func (s FilesSet) Predict(prefix string) (prediction []string) { + // add all matching files to prediction + for _, f := range s { + f = fixPathForm(prefix, f) + + // test matching of file to the argument + if matchFile(f, prefix) { + prediction = append(prediction, f) + } + } + if len(prediction) == 0 { + return s + } + return +} + +// MatchFile returns true if prefix can match the file +func matchFile(file, prefix string) bool { + // special case for current directory completion + if file == "./" && (prefix == "." || prefix == "") { + return true + } + if prefix == "." && strings.HasPrefix(file, ".") { + return true + } + + file = strings.TrimPrefix(file, "./") + prefix = strings.TrimPrefix(prefix, "./") + + return strings.HasPrefix(file, prefix) +} + +// fixPathForm changes a file name to a relative name +func fixPathForm(last string, file string) string { + // Get wording directory for relative name. + workDir, err := os.Getwd() + if err != nil { + return file + } + + abs, err := filepath.Abs(file) + if err != nil { + return file + } + + // If last is absolute, return path as absolute. + if filepath.IsAbs(last) { + return fixDirPath(abs) + } + + rel, err := filepath.Rel(workDir, abs) + if err != nil { + return file + } + + // Fix ./ prefix of path. + if rel != "." && strings.HasPrefix(last, ".") { + rel = "./" + rel + } + + return fixDirPath(rel) +} + +func fixDirPath(path string) string { + info, err := os.Stat(path) + if err == nil && info.IsDir() && !strings.HasSuffix(path, "/") { + path += "/" + } + return path +} diff --git a/predict/files_test.go b/predict/files_test.go new file mode 100644 index 0000000..6d6cba2 --- /dev/null +++ b/predict/files_test.go @@ -0,0 +1,233 @@ +package predict + +import ( + "fmt" + "os" + "sort" + "strings" + "testing" +) + +func TestFiles(t *testing.T) { + err := os.Chdir("testdata") + if err != nil { + panic(err) + } + defer os.Chdir("..") + + tests := []struct { + name string + p FilesPredictor + prefixes []string + want []string + }{ + { + name: "files/txt", + p: Files("*.txt"), + prefixes: []string{""}, + want: []string{"./", "dir/", "outer/", "a.txt", "b.txt", "c.txt", ".dot.txt"}, + }, + { + name: "files/txt", + p: Files("*.txt"), + prefixes: []string{"./dir/"}, + want: []string{"./dir/"}, + }, + { + name: "complete files inside dir if it is the only match", + p: Files("foo"), + prefixes: []string{"./dir/", "./d"}, + want: []string{"./dir/", "./dir/foo"}, + }, + { + name: "complete files inside dir when argList includes file name", + p: Files("*"), + prefixes: []string{"./dir/f", "./dir/foo"}, + want: []string{"./dir/foo"}, + }, + { + name: "files/md", + p: Files("*.md"), + prefixes: []string{""}, + want: []string{"./", "dir/", "outer/", "readme.md"}, + }, + { + name: "files/md with ./ prefix", + p: Files("*.md"), + prefixes: []string{".", "./"}, + want: []string{"./", "./dir/", "./outer/", "./readme.md"}, + }, + { + name: "dirs", + p: Dirs("*"), + prefixes: []string{"di", "dir", "dir/"}, + want: []string{"dir/"}, + }, + { + name: "dirs with ./ prefix", + p: Dirs("*"), + prefixes: []string{"./di", "./dir", "./dir/"}, + want: []string{"./dir/"}, + }, + { + name: "predict anything in dir", + p: Files("*"), + prefixes: []string{"dir", "dir/", "di"}, + want: []string{"dir/", "dir/foo", "dir/bar"}, + }, + { + name: "predict anything in dir with ./ prefix", + p: Files("*"), + prefixes: []string{"./dir", "./dir/", "./di"}, + want: []string{"./dir/", "./dir/foo", "./dir/bar"}, + }, + { + name: "root directories", + p: Dirs("*"), + prefixes: []string{""}, + want: []string{"./", "dir/", "outer/"}, + }, + { + name: "root directories with ./ prefix", + p: Dirs("*"), + prefixes: []string{".", "./"}, + want: []string{"./", "./dir/", "./outer/"}, + }, + { + name: "nested directories", + p: Dirs("*.md"), + prefixes: []string{"ou", "outer", "outer/"}, + want: []string{"outer/", "outer/inner/"}, + }, + { + name: "nested directories with ./ prefix", + p: Dirs("*.md"), + prefixes: []string{"./ou", "./outer", "./outer/"}, + want: []string{"./outer/", "./outer/inner/"}, + }, + { + name: "nested inner directory", + p: Files("*.md"), + prefixes: []string{"outer/i"}, + want: []string{"outer/inner/", "outer/inner/readme.md"}, + }, + } + + for _, tt := range tests { + for _, prefix := range tt.prefixes { + t.Run(tt.name+"/prefix="+prefix, func(t *testing.T) { + + matches := tt.p.Predict(prefix) + + sort.Strings(matches) + sort.Strings(tt.want) + + got := strings.Join(matches, ",") + want := strings.Join(tt.want, ",") + + if got != want { + t.Errorf("failed %s\ngot = %s\nwant: %s", t.Name(), got, want) + } + }) + } + } +} + +func TestMatchFile(t *testing.T) { + // Change to tests directory for testing completion of + // files and directories + err := os.Chdir("testdata") + if err != nil { + panic(err) + } + defer os.Chdir("..") + + type matcherTest struct { + prefix string + want bool + } + + tests := []struct { + long string + tests []matcherTest + }{ + { + long: "file.txt", + tests: []matcherTest{ + {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}, + {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}, + {prefix: "/.", want: false}, + }, + }, + { + long: "./file.txt", + tests: []matcherTest{ + {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}, + {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}, + {prefix: "/.", want: false}, + }, + }, + { + long: "/file.txt", + tests: []matcherTest{ + {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}, + {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}, + {prefix: "/.", want: false}, + }, + }, + { + long: "./", + tests: []matcherTest{ + {prefix: "", want: true}, + {prefix: ".", want: true}, + {prefix: "./", want: true}, + {prefix: "./.", want: false}, + }, + }, + } + + for _, tt := range tests { + for _, ttt := range tt.tests { + name := fmt.Sprintf("long=%q&prefix=%q", tt.long, ttt.prefix) + t.Run(name, func(t *testing.T) { + got := matchFile(tt.long, ttt.prefix) + if got != ttt.want { + t.Errorf("Failed %s: got = %t, want: %t", name, got, ttt.want) + } + }) + } + } +} diff --git a/predict/predict.go b/predict/predict.go new file mode 100644 index 0000000..f4d5bb7 --- /dev/null +++ b/predict/predict.go @@ -0,0 +1,34 @@ +// Package predict provides helper functions for completion predictors. +package predict + +import "github.com/posener/complete" + +// Set predicts a set of predefined values. +type Set []string + +func (p Set) Predict(_ string) (options []string) { + return p +} + +var ( + // Something is used to indicate that does not completes somthing. Such that other prediction + // wont be applied. + Something = Set{""} + + // Nothing is used to indicate that does not completes anything. + Nothing = Set{} +) + +// Or unions prediction functions, so that the result predication is the union of their +// predications. +func Or(ps ...complete.Predictor) complete.Predictor { + return complete.PredictFunc(func(prefix string) (options []string) { + for _, p := range ps { + if p == nil { + continue + } + options = append(options, p.Predict(prefix)...) + } + return + }) +} diff --git a/predict/predict_test.go b/predict/predict_test.go new file mode 100644 index 0000000..af3bf69 --- /dev/null +++ b/predict/predict_test.go @@ -0,0 +1,61 @@ +package predict + +import ( + "testing" + + "github.com/posener/complete" + "github.com/stretchr/testify/assert" +) + +func TestPredict(t *testing.T) { + tests := []struct { + name string + p complete.Predictor + prefix string + want []string + }{ + { + name: "set", + p: Set{"a", "b", "c"}, + want: []string{"a", "b", "c"}, + }, + { + name: "set/empty", + p: Set{}, + want: []string{}, + }, + { + name: "or: word with nil", + p: Or(Set{"a"}, nil), + want: []string{"a"}, + }, + { + name: "or: nil with word", + p: Or(nil, Set{"a"}), + want: []string{"a"}, + }, + { + name: "or: word with word with word", + p: Or(Set{"a"}, Set{"b"}, Set{"c"}), + want: []string{"a", "b", "c"}, + }, + { + name: "something", + p: Something, + want: []string{""}, + }, + { + name: "nothing", + p: Nothing, + prefix: "a", + want: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.p.Predict(tt.prefix) + assert.ElementsMatch(t, tt.want, got, "Got: %+v", got) + }) + } +} diff --git a/predict/testdata/.dot.txt b/predict/testdata/.dot.txt new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/predict/testdata/.dot.txt diff --git a/predict/testdata/a.txt b/predict/testdata/a.txt new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/predict/testdata/a.txt diff --git a/predict/testdata/b.txt b/predict/testdata/b.txt new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/predict/testdata/b.txt diff --git a/predict/testdata/c.txt b/predict/testdata/c.txt new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/predict/testdata/c.txt diff --git a/predict/testdata/dir/bar b/predict/testdata/dir/bar new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/predict/testdata/dir/bar diff --git a/predict/testdata/dir/foo b/predict/testdata/dir/foo new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/predict/testdata/dir/foo diff --git a/predict/testdata/outer/inner/readme.md b/predict/testdata/outer/inner/readme.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/predict/testdata/outer/inner/readme.md diff --git a/predict/testdata/readme.md b/predict/testdata/readme.md new file mode 100644 index 0000000..25ea22c --- /dev/null +++ b/predict/testdata/readme.md @@ -0,0 +1,3 @@ +# About this directory + +This directory is for testing file completion purposes
\ No newline at end of file |
