diff --git a/args.go b/args.go index 17ab2c6..1a50385 100644 --- a/args.go +++ b/args.go @@ -29,14 +29,15 @@ type Args struct { // last argument if it represents a file name being written. // in case that it is not, we fall back to the current directory. func (a Args) Directory() string { - if info, err := os.Stat(a.Last); err == nil && info.IsDir() { - return fixPathForm(a.Last, a.Last) + path := fixPathForm(a.Last, true, a.Last) + if info, err := os.Stat(path); err == nil && info.IsDir() { + return fixPathForm(path, true, path) } - dir := filepath.Dir(a.Last) + dir := filepath.Dir(path) if info, err := os.Stat(dir); err != nil || !info.IsDir() { return "./" } - return fixPathForm(a.Last, dir) + return fixPathForm(path, true, dir) } func newArgs(line string) Args { diff --git a/glide.lock b/glide.lock new file mode 100644 index 0000000..28069ea --- /dev/null +++ b/glide.lock @@ -0,0 +1,10 @@ +hash: 4a4bf605b36984489638d6807399e2bc08f16680aa8e4ae42ad1625316bfa9ac +updated: 2018-11-24T13:16:25.513580171+05:30 +imports: +- name: github.com/hashicorp/errwrap + version: 8a6fb523712970c966eefc6b39ed2c5e74880354 +- name: github.com/hashicorp/go-multierror + version: 886a7fbe3eb1c874d46f623bfa70af45f425b3d1 +- name: github.com/mitchellh/go-homedir + version: ae18d6b8b3205b561c79e8e5f69bff09736185f4 +testImports: [] diff --git a/glide.yaml b/glide.yaml new file mode 100644 index 0000000..9548931 --- /dev/null +++ b/glide.yaml @@ -0,0 +1,3 @@ +package: github.com/posener/complete +import: +- package: github.com/mitchellh/go-homedir diff --git a/predict_files.go b/predict_files.go index c8adf7e..9795b1d 100644 --- a/predict_files.go +++ b/predict_files.go @@ -39,7 +39,7 @@ func files(pattern string, allowFiles bool) PredictFunc { } // only try deeper, if the one item is a directory - if stat, err := os.Stat(prediction[0]); err != nil || !stat.IsDir() { + if stat, err := os.Stat(fixPathForm(prediction[0], true, prediction[0])); err != nil || !stat.IsDir() { return } @@ -67,7 +67,7 @@ func PredictFilesSet(files []string) PredictFunc { return func(a Args) (prediction []string) { // add all matching files to prediction for _, f := range files { - f = fixPathForm(a.Last, f) + f = fixPathForm(a.Last, false, f) // test matching of file to the argument if match.File(f, a.Last) { diff --git a/predict_test.go b/predict_test.go index 24df78d..9f9834d 100644 --- a/predict_test.go +++ b/predict_test.go @@ -1,6 +1,7 @@ package complete import ( + "os" "sort" "strings" "testing" @@ -11,10 +12,13 @@ func TestPredicate(t *testing.T) { initTests() tests := []struct { - name string - p Predictor - argList []string - want []string + name string + p Predictor + argList []string + want []string + prepEnv func() (string, map[string]string, error) + cleanEnv func(dirTreeBase string) + checkEqual func(dirTreeMappings map[string]string, got []string) bool }{ { name: "set", @@ -110,6 +114,44 @@ func TestPredicate(t *testing.T) { argList: []string{"./dir", "./dir/", "./di"}, want: []string{"./dir/", "./dir/foo", "./dir/bar"}, }, + { + name: "predict anything in home directory with `~` prefix", + p: PredictFiles("*"), + argList: []string{"~/foo"}, + want: []string{"~/foo", "~/foo/foo.md", "~/foo/foo-dir"}, + prepEnv: func() (string, map[string]string, error) { + basePath, dirTreeMappings, err := CreateDirTree( + `~`, + "foo", + []FileProperties{ + FileProperties{ + FilePath: "foo.md", + FileParent: "", + FileType: RegularFile, + ModificationType: CREATE, + }, + FileProperties{ + FilePath: "foo-dir", + FileParent: "", + FileType: Directory, + ModificationType: CREATE, + }, + }, + ) + return basePath, dirTreeMappings, err + }, + cleanEnv: func(dirTreeBase string) { + os.RemoveAll(dirTreeBase) + }, + checkEqual: func(dirTreeMappings map[string]string, got []string) bool { + want := []string{dirTreeMappings["foo"], dirTreeMappings["foo/foo.md"], dirTreeMappings["foo/-dir"]} + sort.Strings(got) + sort.Strings(want) + gotStr := strings.Join(got, ",") + wantStr := strings.Join(want, ",") + return gotStr == wantStr + }, + }, { name: "root directories", p: PredictDirs("*"), @@ -142,8 +184,21 @@ func TestPredicate(t *testing.T) { }, } + var basePath string + var err error + var dirTreeMappings map[string]string + for _, tt := range tests { + if tt.prepEnv != nil { + if basePath, dirTreeMappings, err = tt.prepEnv(); err != nil { + t.Errorf("error setting up env. Error %v", err) + } + } + if tt.cleanEnv != nil { + defer tt.cleanEnv(basePath) + } + // no args in argList, means an empty argument if len(tt.argList) == 0 { tt.argList = append(tt.argList, "") @@ -157,6 +212,10 @@ func TestPredicate(t *testing.T) { sort.Strings(matches) sort.Strings(tt.want) + if tt.checkEqual != nil { + tt.checkEqual(dirTreeMappings, matches) + return + } got := strings.Join(matches, ",") want := strings.Join(tt.want, ",") diff --git a/test_utils.go b/test_utils.go new file mode 100644 index 0000000..7b8a2bb --- /dev/null +++ b/test_utils.go @@ -0,0 +1,184 @@ +package complete + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" +) + +// TempMkdir creates a temporary directory +func TempMkdir(parentDir string, newDirPrefix string) (string, error) { + parentDir = filepath.FromSlash(parentDir) + dir, err := ioutil.TempDir(parentDir, newDirPrefix) + if err != nil { + return "", fmt.Errorf("failed to create dir with prefix %s in directory %s. Error %v", newDirPrefix, parentDir, err) + } + return dir, nil +} + +// TempMkFile creates a temporary file. +func TempMkFile(dir string, fileName string) (string, error) { + dir = filepath.FromSlash(dir) + f, err := ioutil.TempFile(dir, fileName) + if err != nil { + return "", fmt.Errorf("failed to create test file %s in dir %s. Error %v", fileName, dir, err) + } + if err := f.Close(); err != nil { + return "", err + } + return f.Name(), nil +} + +// FileType custom type to indicate type of file +type FileType int + +const ( + // RegularFile enum to represent regular file + RegularFile FileType = 0 + // Directory enum to represent directory + Directory FileType = 1 +) + +// ModificationType custom type to indicate file modification type +type ModificationType string + +const ( + // UPDATE enum representing update operation on a file + UPDATE ModificationType = "update" + // CREATE enum representing create operation for a file/folder + CREATE ModificationType = "create" + // DELETE enum representing delete operation for a file/folder + DELETE ModificationType = "delete" + // APPEND enum representing append operation on a file + APPEND ModificationType = "append" +) + +// FileProperties to contain meta-data of a file like, file/folder name, file/folder parent dir, file type and desired file modification type +type FileProperties struct { + FilePath string + FileParent string + FileType FileType + ModificationType ModificationType +} + +// SimulateFileModifications mock function to simulate requested file/folder operation +// Parameters: +// basePath: The parent directory for file/folder involved in desired file operation +// fileModification: Meta-data of file/folder +// Returns: +// path to file/folder involved in the operation +// error if any or nil +func SimulateFileModifications(basePath string, fileModification FileProperties) (string, error) { + // Files/folders intended to be directly under basepath will be indicated by fileModification.FileParent set to empty string + if fileModification.FileParent != "" { + // If fileModification.FileParent is not empty, use it to generate file/folder absolute path + basePath = filepath.Join(basePath, fileModification.FileParent) + } + + switch fileModification.ModificationType { + case CREATE: + if fileModification.FileType == Directory { + filePath, err := TempMkdir(basePath, fileModification.FilePath) + // t.Logf("In simulateFileModifications, Attempting to create folder %s in %s. Error : %v", fileModification.filePath, basePath, err) + return filePath, err + } else if fileModification.FileType == RegularFile { + folderPath, err := TempMkFile(basePath, fileModification.FilePath) + // t.Logf("In simulateFileModifications, Attempting to create file %s in %s", fileModification.filePath, basePath) + return folderPath, err + } + case DELETE: + if fileModification.FileType == Directory { + return filepath.Join(basePath, fileModification.FilePath), os.RemoveAll(filepath.Join(basePath, fileModification.FilePath)) + } else if fileModification.FileType == RegularFile { + return filepath.Join(basePath, fileModification.FilePath), os.Remove(filepath.Join(basePath, fileModification.FilePath)) + } + case UPDATE: + if fileModification.FileType == Directory { + return "", fmt.Errorf("Updating directory %s is not supported", fileModification.FilePath) + } else if fileModification.FileType == RegularFile { + f, err := os.Open(filepath.Join(basePath, fileModification.FilePath)) + if err != nil { + return "", err + } + if _, err := f.WriteString("Hello from Odo"); err != nil { + return "", err + } + if err := f.Sync(); err != nil { + return "", err + } + if err := f.Close(); err != nil { + return "", err + } + return filepath.Join(basePath, fileModification.FilePath), nil + } + case APPEND: + if fileModification.FileType == RegularFile { + err := ioutil.WriteFile(filepath.Join(basePath, fileModification.FilePath), []byte("// Check watch command"), os.ModeAppend) + if err != nil { + return "", err + } + return filepath.Join(basePath, fileModification.FilePath), nil + } else { + return "", fmt.Errorf("Append not supported for file of type %v", fileModification.FileType) + } + default: + return "", fmt.Errorf("Unsupported file operation %s", fileModification.ModificationType) + } + return "", nil +} + +// CreateDirTree sets up a mock directory tree +// Parameters: +// srcParentPath: The base path where src/dir tree is expected to be rooted +// srcName: Name of the source directory +// requiredFilePaths: list of required sources, their description like whether regularfile/directory, parent directory path of source and desired modification type like update/create/delete/append +// Returns: +// absolute base path of source code +// directory structure containing mappings from desired relative paths to their respective absolute path. +// error if any +func CreateDirTree(srcParentPath string, srcName string, requiredFilePaths []FileProperties) (string, map[string]string, error) { + + if srcParentPath == `~` { + srcParentPath = fixPathForm(srcParentPath, true, srcParentPath) + } + // This is required because ioutil#TempFile and ioutil#TempFolder creates paths with random numeric suffixes. + // So, to be able to refer to the file/folder at any later point in time the created paths returned by ioutil#TempFile or ioutil#TempFolder will need to be saved. + dirTreeMappings := make(map[string]string) + + // Create temporary directory for mock component source code + srcPath, err := TempMkdir(srcParentPath, srcName) + if err != nil { + return "", dirTreeMappings, fmt.Errorf("failed to create dir %s under %s. Error: %v", srcName, srcParentPath, err) + } + dirTreeMappings[srcName] = srcPath + + // For each of the passed(desired) files/folders under component source + for _, fileProperties := range requiredFilePaths { + + // get relative path using file parent and file name passed + relativePath := filepath.Join(fileProperties.FileParent, fileProperties.FilePath) + + // get its absolute path using the mappings preserved from previous creates + if realParentPath, ok := dirTreeMappings[fileProperties.FileParent]; ok { + // real path for the intended file operation is obtained from previously maintained directory tree mappings by joining parent path and file name + realPath := filepath.Join(realParentPath, fileProperties.FilePath) + // Preserve the new paths for further reference + fileProperties.FilePath = filepath.Base(realPath) + fileProperties.FileParent, _ = filepath.Rel(srcPath, filepath.Dir(realPath)) + } + + // Perform mock operation as requested by the parameter + newPath, err := SimulateFileModifications(srcPath, fileProperties) + dirTreeMappings[relativePath] = newPath + if err != nil { + return "", dirTreeMappings, fmt.Errorf("unable to setup test env. Error %v", err) + } + + fileProperties.FilePath = filepath.Base(newPath) + fileProperties.FileParent = filepath.Dir(newPath) + } + + // Return base source path and directory tree mappings + return srcPath, dirTreeMappings, nil +} diff --git a/utils.go b/utils.go index 58b8b79..08a3b50 100644 --- a/utils.go +++ b/utils.go @@ -4,16 +4,33 @@ import ( "os" "path/filepath" "strings" + + homedir "github.com/mitchellh/go-homedir" ) -// fixPathForm changes a file name to a relative name -func fixPathForm(last string, file string) string { +// fixPathForm changes a file name to a relative name in accordance with isExpandLast which if true, +// will have the form of absolute path for cases like `~` which are not recognized by anyone other than +// the shell +func fixPathForm(last string, isExpandLast bool, file string) string { // get wording directory for relative name workDir, err := os.Getwd() if err != nil { return file } + if strings.Contains(last, "~") { + if isExpandLast { + path, err := homedir.Expand(last) + if err != nil { + path = last + } + return fixDirPath(path) + } else { + // else here although not required, is added here to stress the orthogonality + // nature of this block with the block above + return unResolveHome(last, file) + } + } abs, err := filepath.Abs(file) if err != nil { return file @@ -37,8 +54,43 @@ func fixPathForm(last string, file string) string { return fixDirPath(rel) } +func unResolveHome(last string, path string) string { + if strings.Contains(last, "~") { + + // Resolve `~` to complete resolvable path + lastEq, _ := homedir.Expand(last) + // Get parent equivalent for cases of partially complete last + lastParentEq, _ := homedir.Expand(filepath.Dir(lastEq)) + + /* + The following might be the possible cases to unresolve absolute path to path completion of path with `~`: + * Case when the last as entered in terminal is partial + ** This is when, parent dir of incompletely entered last, is same as parent dir of possible path + Because, possible path is in this case expected to have contents after resolving the incomplete last on terminal + * Case when the last as entered in terminal is complete + ** Resolved form of complete last is equal to path + This is possible because we include the same directory also as one of the possible auto-completion solutions + ** Resolved form of complete last is equal to parent of resolved auto-completion path + This happens because normally, except for the last itself in the auto-complete suggestions, + all other paths are expected to be only 1 step ahead of last at any given auto-completion step + + According to the cases above, we replace the maximum possible path out of the obtained from passed potential + auto-completion path with the maximum available form of last so that we retain `~` in the suggestions provided + */ + if fixDirPath(lastEq) == fixDirPath(path) { + path = strings.Replace(path, fixDirPath(path), fixDirPath(last), 1) + } else if fixDirPath(lastEq) == fixDirPath(filepath.Dir(path)) { + path = strings.Replace(path, fixDirPath(lastEq), fixDirPath(last), 1) + } else if fixDirPath(lastParentEq) == fixDirPath(filepath.Dir(path)) { + path = strings.Replace(path, fixDirPath(filepath.Dir(lastEq)), fixDirPath(filepath.Dir(last)), 1) + } + } + return path +} + func fixDirPath(path string) string { - info, err := os.Stat(path) + tmpPath, _ := homedir.Expand(path) + info, err := os.Stat(tmpPath) if err == nil && info.IsDir() && !strings.HasSuffix(path, "/") { path += "/" }