Add command line autocomplete to the fs commands (#1622)

## Changes
This PR adds autocomplete for cat, cp, ls, mkdir and rm.

The new completer can do completion for any `Filer`. The command
completion for the `sync` command can be moved to use this general
completer as a follow-up.

## Tests
- Tested manually against a workspace
- Unit tests
This commit is contained in:
andersrexdb 2024-08-09 12:40:25 +03:00 committed by GitHub
parent d3d828d175
commit 65f4aad87c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 532 additions and 129 deletions

View File

@ -30,5 +30,8 @@ func newCatCommand() *cobra.Command {
return cmdio.Render(ctx, r)
}
v := newValidArgs()
cmd.ValidArgsFunction = v.Validate
return cmd
}

View File

@ -200,5 +200,10 @@ func newCpCommand() *cobra.Command {
return c.cpFileToFile(sourcePath, targetPath)
}
v := newValidArgs()
// The copy command has two paths that can be completed (SOURCE_PATH & TARGET_PATH)
v.pathArgCount = 2
cmd.ValidArgsFunction = v.Validate
return cmd
}

View File

@ -8,6 +8,8 @@ import (
"github.com/databricks/cli/cmd/root"
"github.com/databricks/cli/libs/filer"
"github.com/databricks/cli/libs/filer/completer"
"github.com/spf13/cobra"
)
func filerForPath(ctx context.Context, fullPath string) (filer.Filer, string, error) {
@ -46,6 +48,58 @@ func filerForPath(ctx context.Context, fullPath string) (filer.Filer, string, er
return f, path, err
}
const dbfsPrefix string = "dbfs:"
func isDbfsPath(path string) bool {
return strings.HasPrefix(path, "dbfs:/")
return strings.HasPrefix(path, dbfsPrefix)
}
type validArgs struct {
mustWorkspaceClientFunc func(cmd *cobra.Command, args []string) error
filerForPathFunc func(ctx context.Context, fullPath string) (filer.Filer, string, error)
pathArgCount int
onlyDirs bool
}
func newValidArgs() *validArgs {
return &validArgs{
mustWorkspaceClientFunc: root.MustWorkspaceClient,
filerForPathFunc: filerForPath,
pathArgCount: 1,
onlyDirs: false,
}
}
func (v *validArgs) Validate(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
cmd.SetContext(root.SkipPrompt(cmd.Context()))
if len(args) >= v.pathArgCount {
return nil, cobra.ShellCompDirectiveNoFileComp
}
err := v.mustWorkspaceClientFunc(cmd, args)
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
filer, toCompletePath, err := v.filerForPathFunc(cmd.Context(), toComplete)
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
completer := completer.New(cmd.Context(), filer, v.onlyDirs)
// Dbfs should have a prefix and always use the "/" separator
isDbfsPath := isDbfsPath(toComplete)
if isDbfsPath {
completer.SetPrefix(dbfsPrefix)
completer.SetIsLocalPath(false)
}
completions, directive, err := completer.CompletePath(toCompletePath)
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
return completions, directive
}

View File

@ -3,9 +3,13 @@ package fs
import (
"context"
"runtime"
"strings"
"testing"
"github.com/databricks/cli/cmd/root"
"github.com/databricks/cli/libs/filer"
"github.com/databricks/databricks-sdk-go/experimental/mocks"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -60,3 +64,88 @@ func TestFilerForWindowsLocalPaths(t *testing.T) {
testWindowsFilerForPath(t, ctx, `d:\abc`)
testWindowsFilerForPath(t, ctx, `f:\abc\ef`)
}
func mockMustWorkspaceClientFunc(cmd *cobra.Command, args []string) error {
return nil
}
func setupCommand(t *testing.T) (*cobra.Command, *mocks.MockWorkspaceClient) {
m := mocks.NewMockWorkspaceClient(t)
ctx := context.Background()
ctx = root.SetWorkspaceClient(ctx, m.WorkspaceClient)
cmd := &cobra.Command{}
cmd.SetContext(ctx)
return cmd, m
}
func setupTest(t *testing.T) (*validArgs, *cobra.Command, *mocks.MockWorkspaceClient) {
cmd, m := setupCommand(t)
fakeFilerForPath := func(ctx context.Context, fullPath string) (filer.Filer, string, error) {
fakeFiler := filer.NewFakeFiler(map[string]filer.FakeFileInfo{
"dir": {FakeName: "root", FakeDir: true},
"dir/dirA": {FakeDir: true},
"dir/dirB": {FakeDir: true},
"dir/fileA": {},
})
return fakeFiler, strings.TrimPrefix(fullPath, "dbfs:/"), nil
}
v := newValidArgs()
v.filerForPathFunc = fakeFilerForPath
v.mustWorkspaceClientFunc = mockMustWorkspaceClientFunc
return v, cmd, m
}
func TestGetValidArgsFunctionDbfsCompletion(t *testing.T) {
v, cmd, _ := setupTest(t)
completions, directive := v.Validate(cmd, []string{}, "dbfs:/dir/")
assert.Equal(t, []string{"dbfs:/dir/dirA/", "dbfs:/dir/dirB/", "dbfs:/dir/fileA"}, completions)
assert.Equal(t, cobra.ShellCompDirectiveNoSpace, directive)
}
func TestGetValidArgsFunctionLocalCompletion(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip()
}
v, cmd, _ := setupTest(t)
completions, directive := v.Validate(cmd, []string{}, "dir/")
assert.Equal(t, []string{"dir/dirA/", "dir/dirB/", "dir/fileA", "dbfs:/"}, completions)
assert.Equal(t, cobra.ShellCompDirectiveNoSpace, directive)
}
func TestGetValidArgsFunctionLocalCompletionWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip()
}
v, cmd, _ := setupTest(t)
completions, directive := v.Validate(cmd, []string{}, "dir/")
assert.Equal(t, []string{"dir\\dirA\\", "dir\\dirB\\", "dir\\fileA", "dbfs:/"}, completions)
assert.Equal(t, cobra.ShellCompDirectiveNoSpace, directive)
}
func TestGetValidArgsFunctionCompletionOnlyDirs(t *testing.T) {
v, cmd, _ := setupTest(t)
v.onlyDirs = true
completions, directive := v.Validate(cmd, []string{}, "dbfs:/dir/")
assert.Equal(t, []string{"dbfs:/dir/dirA/", "dbfs:/dir/dirB/"}, completions)
assert.Equal(t, cobra.ShellCompDirectiveNoSpace, directive)
}
func TestGetValidArgsFunctionNotCompletedArgument(t *testing.T) {
cmd, _ := setupCommand(t)
v := newValidArgs()
v.pathArgCount = 0
v.mustWorkspaceClientFunc = mockMustWorkspaceClientFunc
completions, directive := v.Validate(cmd, []string{}, "dbfs:/")
assert.Nil(t, completions)
assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive)
}

View File

@ -89,5 +89,9 @@ func newLsCommand() *cobra.Command {
`))
}
v := newValidArgs()
v.onlyDirs = true
cmd.ValidArgsFunction = v.Validate
return cmd
}

View File

@ -28,5 +28,9 @@ func newMkdirCommand() *cobra.Command {
return f.Mkdir(ctx, path)
}
v := newValidArgs()
v.onlyDirs = true
cmd.ValidArgsFunction = v.Validate
return cmd
}

View File

@ -32,5 +32,8 @@ func newRmCommand() *cobra.Command {
return f.Delete(ctx, path)
}
v := newValidArgs()
cmd.ValidArgsFunction = v.Validate
return cmd
}

View File

@ -0,0 +1,27 @@
package internal
import (
"context"
"fmt"
"strings"
"testing"
_ "github.com/databricks/cli/cmd/fs"
"github.com/databricks/cli/libs/filer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupCompletionFile(t *testing.T, f filer.Filer) {
err := f.Write(context.Background(), "dir1/file1.txt", strings.NewReader("abc"), filer.CreateParentDirectories)
require.NoError(t, err)
}
func TestAccFsCompletion(t *testing.T) {
f, tmpDir := setupDbfsFiler(t)
setupCompletionFile(t, f)
stdout, _ := RequireSuccessfulRun(t, "__complete", "fs", "ls", tmpDir)
expectedOutput := fmt.Sprintf("%s/dir1/\n:2\n", tmpDir)
assert.Equal(t, expectedOutput, stdout.String())
}

View File

@ -0,0 +1,95 @@
package completer
import (
"context"
"path"
"path/filepath"
"strings"
"github.com/databricks/cli/libs/filer"
"github.com/spf13/cobra"
)
type completer struct {
ctx context.Context
// The filer to use for completing remote or local paths.
filer filer.Filer
// CompletePath will only return directories when onlyDirs is true.
onlyDirs bool
// Prefix to prepend to completions.
prefix string
// Whether the path is local or remote. If the path is local we use the `filepath`
// package for path manipulation. Otherwise we use the `path` package.
isLocalPath bool
}
// General completer that takes a filer to complete remote paths when TAB-ing through a path.
func New(ctx context.Context, filer filer.Filer, onlyDirs bool) *completer {
return &completer{ctx: ctx, filer: filer, onlyDirs: onlyDirs, prefix: "", isLocalPath: true}
}
func (c *completer) SetPrefix(p string) {
c.prefix = p
}
func (c *completer) SetIsLocalPath(i bool) {
c.isLocalPath = i
}
func (c *completer) CompletePath(p string) ([]string, cobra.ShellCompDirective, error) {
trailingSeparator := "/"
joinFunc := path.Join
// Use filepath functions if we are in a local path.
if c.isLocalPath {
joinFunc = filepath.Join
trailingSeparator = string(filepath.Separator)
}
// If the user is TAB-ing their way through a path and the
// path ends in a trailing slash, we should list nested directories.
// If the path is incomplete, however, then we should list adjacent
// directories.
dirPath := p
if !strings.HasSuffix(p, trailingSeparator) {
dirPath = path.Dir(p)
}
entries, err := c.filer.ReadDir(c.ctx, dirPath)
if err != nil {
return nil, cobra.ShellCompDirectiveError, err
}
completions := []string{}
for _, entry := range entries {
if c.onlyDirs && !entry.IsDir() {
continue
}
// Join directory path and entry name
completion := joinFunc(dirPath, entry.Name())
// Prepend prefix if it has been set
if c.prefix != "" {
completion = joinFunc(c.prefix, completion)
}
// Add trailing separator for directories.
if entry.IsDir() {
completion += trailingSeparator
}
completions = append(completions, completion)
}
// If the path is local, we add the dbfs:/ prefix suggestion as an option
if c.isLocalPath {
completions = append(completions, "dbfs:/")
}
return completions, cobra.ShellCompDirectiveNoSpace, err
}

View File

@ -0,0 +1,104 @@
package completer
import (
"context"
"runtime"
"testing"
"github.com/databricks/cli/cmd/root"
"github.com/databricks/cli/libs/filer"
"github.com/databricks/databricks-sdk-go/experimental/mocks"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
)
func setupCompleter(t *testing.T, onlyDirs bool) *completer {
ctx := context.Background()
// Needed to make type context.valueCtx for mockFilerForPath
ctx = root.SetWorkspaceClient(ctx, mocks.NewMockWorkspaceClient(t).WorkspaceClient)
fakeFiler := filer.NewFakeFiler(map[string]filer.FakeFileInfo{
"dir": {FakeName: "root", FakeDir: true},
"dir/dirA": {FakeDir: true},
"dir/dirB": {FakeDir: true},
"dir/fileA": {},
})
completer := New(ctx, fakeFiler, onlyDirs)
completer.SetIsLocalPath(false)
return completer
}
func TestFilerCompleterSetsPrefix(t *testing.T) {
completer := setupCompleter(t, true)
completer.SetPrefix("dbfs:")
completions, directive, err := completer.CompletePath("dir/")
assert.Equal(t, []string{"dbfs:/dir/dirA/", "dbfs:/dir/dirB/"}, completions)
assert.Equal(t, cobra.ShellCompDirectiveNoSpace, directive)
assert.Nil(t, err)
}
func TestFilerCompleterReturnsNestedDirs(t *testing.T) {
completer := setupCompleter(t, true)
completions, directive, err := completer.CompletePath("dir/")
assert.Equal(t, []string{"dir/dirA/", "dir/dirB/"}, completions)
assert.Equal(t, cobra.ShellCompDirectiveNoSpace, directive)
assert.Nil(t, err)
}
func TestFilerCompleterReturnsAdjacentDirs(t *testing.T) {
completer := setupCompleter(t, true)
completions, directive, err := completer.CompletePath("dir/wrong_path")
assert.Equal(t, []string{"dir/dirA/", "dir/dirB/"}, completions)
assert.Equal(t, cobra.ShellCompDirectiveNoSpace, directive)
assert.Nil(t, err)
}
func TestFilerCompleterReturnsNestedDirsAndFiles(t *testing.T) {
completer := setupCompleter(t, false)
completions, directive, err := completer.CompletePath("dir/")
assert.Equal(t, []string{"dir/dirA/", "dir/dirB/", "dir/fileA"}, completions)
assert.Equal(t, cobra.ShellCompDirectiveNoSpace, directive)
assert.Nil(t, err)
}
func TestFilerCompleterAddsDbfsPath(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip()
}
completer := setupCompleter(t, true)
completer.SetIsLocalPath(true)
completions, directive, err := completer.CompletePath("dir/")
assert.Equal(t, []string{"dir/dirA/", "dir/dirB/", "dbfs:/"}, completions)
assert.Equal(t, cobra.ShellCompDirectiveNoSpace, directive)
assert.Nil(t, err)
}
func TestFilerCompleterWindowsSeparator(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip()
}
completer := setupCompleter(t, true)
completer.SetIsLocalPath(true)
completions, directive, err := completer.CompletePath("dir/")
assert.Equal(t, []string{"dir\\dirA\\", "dir\\dirB\\", "dbfs:/"}, completions)
assert.Equal(t, cobra.ShellCompDirectiveNoSpace, directive)
assert.Nil(t, err)
}
func TestFilerCompleterNoCompletions(t *testing.T) {
completer := setupCompleter(t, true)
completions, directive, err := completer.CompletePath("wrong_dir/wrong_dir")
assert.Nil(t, completions)
assert.Equal(t, cobra.ShellCompDirectiveError, directive)
assert.Error(t, err)
}

134
libs/filer/fake_filer.go Normal file
View File

@ -0,0 +1,134 @@
package filer
import (
"context"
"fmt"
"io"
"io/fs"
"path"
"sort"
"strings"
"time"
)
type FakeDirEntry struct {
FakeFileInfo
}
func (entry FakeDirEntry) Type() fs.FileMode {
typ := fs.ModePerm
if entry.FakeDir {
typ |= fs.ModeDir
}
return typ
}
func (entry FakeDirEntry) Info() (fs.FileInfo, error) {
return entry.FakeFileInfo, nil
}
type FakeFileInfo struct {
FakeName string
FakeSize int64
FakeDir bool
FakeMode fs.FileMode
}
func (info FakeFileInfo) Name() string {
return info.FakeName
}
func (info FakeFileInfo) Size() int64 {
return info.FakeSize
}
func (info FakeFileInfo) Mode() fs.FileMode {
return info.FakeMode
}
func (info FakeFileInfo) ModTime() time.Time {
return time.Now()
}
func (info FakeFileInfo) IsDir() bool {
return info.FakeDir
}
func (info FakeFileInfo) Sys() any {
return nil
}
type FakeFiler struct {
entries map[string]FakeFileInfo
}
func (f *FakeFiler) Write(ctx context.Context, p string, reader io.Reader, mode ...WriteMode) error {
return fmt.Errorf("not implemented")
}
func (f *FakeFiler) Read(ctx context.Context, p string) (io.ReadCloser, error) {
_, ok := f.entries[p]
if !ok {
return nil, fs.ErrNotExist
}
return io.NopCloser(strings.NewReader("foo")), nil
}
func (f *FakeFiler) Delete(ctx context.Context, p string, mode ...DeleteMode) error {
return fmt.Errorf("not implemented")
}
func (f *FakeFiler) ReadDir(ctx context.Context, p string) ([]fs.DirEntry, error) {
p = strings.TrimSuffix(p, "/")
entry, ok := f.entries[p]
if !ok {
return nil, NoSuchDirectoryError{p}
}
if !entry.FakeDir {
return nil, fs.ErrInvalid
}
// Find all entries contained in the specified directory `p`.
var out []fs.DirEntry
for k, v := range f.entries {
if k == p || path.Dir(k) != p {
continue
}
out = append(out, FakeDirEntry{v})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
return out, nil
}
func (f *FakeFiler) Mkdir(ctx context.Context, path string) error {
return fmt.Errorf("not implemented")
}
func (f *FakeFiler) Stat(ctx context.Context, path string) (fs.FileInfo, error) {
entry, ok := f.entries[path]
if !ok {
return nil, fs.ErrNotExist
}
return entry, nil
}
func NewFakeFiler(entries map[string]FakeFileInfo) *FakeFiler {
fakeFiler := &FakeFiler{
entries: entries,
}
for k, v := range fakeFiler.entries {
if v.FakeName != "" {
continue
}
v.FakeName = path.Base(k)
fakeFiler.entries[k] = v
}
return fakeFiler
}

View File

@ -2,124 +2,14 @@ package filer
import (
"context"
"fmt"
"io"
"io/fs"
"path"
"sort"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeDirEntry struct {
fakeFileInfo
}
func (entry fakeDirEntry) Type() fs.FileMode {
typ := fs.ModePerm
if entry.dir {
typ |= fs.ModeDir
}
return typ
}
func (entry fakeDirEntry) Info() (fs.FileInfo, error) {
return entry.fakeFileInfo, nil
}
type fakeFileInfo struct {
name string
size int64
dir bool
mode fs.FileMode
}
func (info fakeFileInfo) Name() string {
return info.name
}
func (info fakeFileInfo) Size() int64 {
return info.size
}
func (info fakeFileInfo) Mode() fs.FileMode {
return info.mode
}
func (info fakeFileInfo) ModTime() time.Time {
return time.Now()
}
func (info fakeFileInfo) IsDir() bool {
return info.dir
}
func (info fakeFileInfo) Sys() any {
return nil
}
type fakeFiler struct {
entries map[string]fakeFileInfo
}
func (f *fakeFiler) Write(ctx context.Context, p string, reader io.Reader, mode ...WriteMode) error {
return fmt.Errorf("not implemented")
}
func (f *fakeFiler) Read(ctx context.Context, p string) (io.ReadCloser, error) {
_, ok := f.entries[p]
if !ok {
return nil, fs.ErrNotExist
}
return io.NopCloser(strings.NewReader("foo")), nil
}
func (f *fakeFiler) Delete(ctx context.Context, p string, mode ...DeleteMode) error {
return fmt.Errorf("not implemented")
}
func (f *fakeFiler) ReadDir(ctx context.Context, p string) ([]fs.DirEntry, error) {
entry, ok := f.entries[p]
if !ok {
return nil, fs.ErrNotExist
}
if !entry.dir {
return nil, fs.ErrInvalid
}
// Find all entries contained in the specified directory `p`.
var out []fs.DirEntry
for k, v := range f.entries {
if k == p || path.Dir(k) != p {
continue
}
out = append(out, fakeDirEntry{v})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
return out, nil
}
func (f *fakeFiler) Mkdir(ctx context.Context, path string) error {
return fmt.Errorf("not implemented")
}
func (f *fakeFiler) Stat(ctx context.Context, path string) (fs.FileInfo, error) {
entry, ok := f.entries[path]
if !ok {
return nil, fs.ErrNotExist
}
return entry, nil
}
func TestFsImplementsFS(t *testing.T) {
var _ fs.FS = &filerFS{}
}
@ -145,22 +35,12 @@ func TestFsDirImplementsFsReadDirFile(t *testing.T) {
}
func fakeFS() fs.FS {
fakeFiler := &fakeFiler{
entries: map[string]fakeFileInfo{
".": {name: "root", dir: true},
"dirA": {dir: true},
"dirB": {dir: true},
"fileA": {size: 3},
},
}
for k, v := range fakeFiler.entries {
if v.name != "" {
continue
}
v.name = path.Base(k)
fakeFiler.entries[k] = v
}
fakeFiler := NewFakeFiler(map[string]FakeFileInfo{
".": {FakeName: "root", FakeDir: true},
"dirA": {FakeDir: true},
"dirB": {FakeDir: true},
"fileA": {FakeSize: 3},
})
return NewFS(context.Background(), fakeFiler)
}

View File

@ -20,7 +20,7 @@ func collectRelativePaths(files []File) []string {
}
func TestGlobFileset(t *testing.T) {
root := vfs.MustNew("../filer")
root := vfs.MustNew("./")
entries, err := root.ReadDir(".")
require.NoError(t, err)
@ -32,6 +32,7 @@ func TestGlobFileset(t *testing.T) {
files, err := g.All()
require.NoError(t, err)
// +1 as there's one folder in ../filer
require.Equal(t, len(files), len(entries))
for _, f := range files {
exists := slices.ContainsFunc(entries, func(de fs.DirEntry) bool {
@ -51,7 +52,7 @@ func TestGlobFileset(t *testing.T) {
}
func TestGlobFilesetWithRelativeRoot(t *testing.T) {
root := vfs.MustNew("../filer")
root := vfs.MustNew("../set")
entries, err := root.ReadDir(".")
require.NoError(t, err)