Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/docs/guides/special-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,22 @@ Commands with `[pattern]` accept an optional filter. For example, `\dt public.*`

---

## Change the Working Directory

Use `\cd` to change the working directory used for local paths:

```text
\cd migrations
\cd ../sql
\cd "directory with spaces"
```

Running `\cd` without a directory changes to your home directory. Paths may be absolute, relative to the current working directory, or start with `~` to refer to your home directory.

Press `Tab` after `\cd` to complete directory names. Hidden directories are suggested when the name being completed starts with `.`.

---

## Built-in Commands

These are pgxcli-specific:
Expand Down
5 changes: 5 additions & 0 deletions internal/app/complete.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ func (p *pgxCLI) getCompletions() bubbline.AutoCompleteFn {
compEngine := engine.NewCompleter(p.compWorker.Cache())

return func(v [][]rune, line, col int) (msg string, comps bubbline.Completions) {
// Complete \cd paths before handing the input to the SQL completer.
if comps, handled := completeChangeDirectory(v, line, col, maxCompletions); handled {
return "", comps
}

sql, _ := computil.Flatten(v, line, col)
word, wstart, wend := computil.FindWord(v, line, col)
Expand Down Expand Up @@ -103,6 +107,7 @@ func (p *pgxCLI) getCompletions() bubbline.AutoCompleteFn {

func completeMetaCommand(s string, col, wStart, wEnd, limit int) (string, bubbline.Completions) {
cmds := pgxspecial.Export()
cmds = append(cmds, pgxspecial.New(`\cd`, `\cd [directory]`, "Change the current working directory."))

var matches struct {
cmds []string
Expand Down
183 changes: 183 additions & 0 deletions internal/app/complete_local.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package app

import (
"os"
"path/filepath"
"strings"
"unicode"

"github.com/balajz/bubbline"
"github.com/balajz/bubbline/editline"
)

func completeChangeDirectory(input [][]rune, line, column, limit int) (bubbline.Completions, bool) {
currentLine, argumentStart, handled := changeDirectoryArgumentBounds(input, line, column)
if !handled {
return nil, false
}
if column < argumentStart {
return nil, true
}

prefix, quote := directoryCompletionPrefix(string(currentLine[argumentStart:column]))
// Keep ~ in the input and append a separator so later completion reads its home directory.
if prefix == "~" {
candidate, ok := quoteCompletionPath("~"+string(os.PathSeparator), quote)
if !ok {
return nil, true
}

return editline.SimpleWordsCompletionWithDescriptions(
[]string{candidate},
[]string{"home directory"},
"directories",
column,
argumentStart,
len(currentLine),
), true
}

directoryPrefix, namePrefix := filepath.Split(prefix)
searchDirectory := directoryPrefix
if searchDirectory == "" {
searchDirectory = "."
}

// Read from the expanded path but preserve ~ in the replacement.
expandedDirectory, err := expandHomeDirectory(searchDirectory)
if err != nil {
return nil, true
}
entries, err := os.ReadDir(expandedDirectory)
if err != nil {
return nil, true
}

words, descriptions := matchingDirectories(
entries,
expandedDirectory,
directoryPrefix,
namePrefix,
quote,
limit,
)

return editline.SimpleWordsCompletionWithDescriptions(
words,
descriptions,
"directories",
column,
argumentStart,
len(currentLine),
), true
}

// Complete only a single-line \cd with an argument; bare \cd stays with meta-command completion.
func changeDirectoryArgumentBounds(input [][]rune, line, column int) ([]rune, int, bool) {
if len(input) != 1 || line != 0 || column < 0 || column > len(input[0]) {
return nil, 0, false
}

currentLine := input[0]
command := []rune(`\cd`)
if len(currentLine) <= len(command) || string(currentLine[:len(command)]) != string(command) {
return nil, 0, false
}
if !unicode.IsSpace(currentLine[len(command)]) || column <= len(command) {
return nil, 0, false
}

argumentStart := len(command)
for argumentStart < len(currentLine) && unicode.IsSpace(currentLine[argumentStart]) {
argumentStart++
}
return currentLine, argumentStart, true
}

func matchingDirectories(
entries []os.DirEntry,
searchDirectory, directoryPrefix, namePrefix string,
quote byte,
limit int,
) (words, descriptions []string) {
if limit <= 0 {
return nil, nil
}

includeHidden := strings.HasPrefix(namePrefix, ".")
for _, entry := range entries {
if !entryIsDirectory(searchDirectory, entry) || !strings.HasPrefix(entry.Name(), namePrefix) {
continue
}
if !includeHidden && strings.HasPrefix(entry.Name(), ".") {
continue
}

candidate := directoryPrefix + entry.Name() + completionPathSeparator(directoryPrefix)
candidate, ok := quoteCompletionPath(candidate, quote)
if !ok {
continue
}
words = append(words, candidate)
descriptions = append(descriptions, "directory")
if len(words) >= limit {
break
}
}
return words, descriptions
}

func directoryCompletionPrefix(raw string) (path string, quote byte) {
if raw == "" || raw[0] != '\'' && raw[0] != '"' {
return raw, 0
}

quote = raw[0]
path = raw[1:]
if len(path) > 0 && path[len(path)-1] == quote {
path = path[:len(path)-1]
}
return path, quote
}

func completionPathSeparator(directoryPrefix string) string {
if strings.HasSuffix(directoryPrefix, "/") {
return "/"
}
if strings.HasSuffix(directoryPrefix, `\`) {
return `\`
}
return string(os.PathSeparator)
}

func quoteCompletionPath(path string, quote byte) (string, bool) {
if quote != 0 {
if strings.ContainsRune(path, rune(quote)) {
return "", false
}
return string(quote) + path + string(quote), true
}
if !strings.ContainsFunc(path, unicode.IsSpace) {
return path, true
}
if !strings.ContainsRune(path, '\'') {
return "'" + path + "'", true
}
if !strings.ContainsRune(path, '"') {
return `"` + path + `"`, true
}
return "", false
}

func entryIsDirectory(parent string, entry os.DirEntry) bool {
if entry.IsDir() {
return true
}
if entry.Type()&os.ModeSymlink == 0 {
return false
}

// Resolve symlinks because DirEntry describes the link, not its target.
info, err := os.Stat(filepath.Join(parent, entry.Name()))
return err == nil && info.IsDir()
}
135 changes: 135 additions & 0 deletions internal/app/complete_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package app

import (
"os"
"path/filepath"
"testing"
"unicode/utf8"

"github.com/balajz/bubbline"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestCompleteMetaCommandIncludesChangeDirectory(t *testing.T) {
t.Parallel()

_, completions := completeMetaCommand(`\c`, 2, 0, 2, maxCompletions)
require.NotNil(t, completions)
assert.Contains(t, completionReplacements(completions), `\cd`)
}

func TestCompleteChangeDirectory(t *testing.T) {
originalDirectory, err := os.Getwd()
require.NoError(t, err)
root := t.TempDir()
t.Cleanup(func() {
require.NoError(t, os.Chdir(originalDirectory))
})

require.NoError(t, os.Mkdir(filepath.Join(root, "alpha"), 0o755))
require.NoError(t, os.Mkdir(filepath.Join(root, "alpha", "nested"), 0o755))
require.NoError(t, os.Mkdir(filepath.Join(root, "two words"), 0o755))
require.NoError(t, os.Mkdir(filepath.Join(root, ".hidden"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(root, "alpha.sql"), nil, 0o600))
require.NoError(t, os.Chdir(root))
t.Setenv("HOME", root)
t.Setenv("USERPROFILE", root)

t.Run("relative path", func(t *testing.T) {
completions, handled := completeDirectoryAtEnd(`\cd al`)
require.True(t, handled)
require.NotNil(t, completions)
assert.Equal(t, []string{"alpha" + string(os.PathSeparator)}, completionReplacements(completions))
})

t.Run("nested path", func(t *testing.T) {
completions, handled := completeDirectoryAtEnd(`\cd alpha/n`)
require.True(t, handled)
require.NotNil(t, completions)
assert.Equal(t, []string{"alpha/nested/"}, completionReplacements(completions))
})

t.Run("tilde path", func(t *testing.T) {
completions, handled := completeDirectoryAtEnd(`\cd ~/al`)
require.True(t, handled)
require.NotNil(t, completions)
assert.Equal(t, []string{"~/alpha/"}, completionReplacements(completions))
})

t.Run("tilde root", func(t *testing.T) {
completions, handled := completeDirectoryAtEnd(`\cd ~`)
require.True(t, handled)
require.NotNil(t, completions)
assert.Equal(t, []string{"~" + string(os.PathSeparator)}, completionReplacements(completions))
})

t.Run("quoted path", func(t *testing.T) {
completions, handled := completeDirectoryAtEnd(`\cd "two`)
require.True(t, handled)
require.NotNil(t, completions)
assert.Equal(t, []string{`"two words` + string(os.PathSeparator) + `"`}, completionReplacements(completions))
})

t.Run("adds quotes when needed", func(t *testing.T) {
completions, handled := completeDirectoryAtEnd(`\cd two`)
require.True(t, handled)
require.NotNil(t, completions)
assert.Equal(t, []string{`'two words` + string(os.PathSeparator) + `'`}, completionReplacements(completions))
})

t.Run("filters files and hidden directories", func(t *testing.T) {
completions, handled := completeDirectoryAtEnd(`\cd `)
require.True(t, handled)
require.NotNil(t, completions)
replacements := completionReplacements(completions)
assert.Contains(t, replacements, "alpha"+string(os.PathSeparator))
assert.NotContains(t, replacements, "alpha.sql")
assert.NotContains(t, replacements, ".hidden"+string(os.PathSeparator))
})

t.Run("shows explicitly requested hidden directories", func(t *testing.T) {
completions, handled := completeDirectoryAtEnd(`\cd .`)
require.True(t, handled)
require.NotNil(t, completions)
assert.Contains(t, completionReplacements(completions), ".hidden"+string(os.PathSeparator))
})
}

func TestCompleteChangeDirectoryIgnoresOtherInput(t *testing.T) {
t.Parallel()

testCases := []string{
"select 1",
`\clear `,
`\c database`,
`\connect database`,
`\cdx `,
}

for _, input := range testCases {
completions, handled := completeDirectoryAtEnd(input)
assert.False(t, handled, input)
assert.Nil(t, completions, input)
}
}

func completeDirectoryAtEnd(input string) (bubbline.Completions, bool) {
return completeChangeDirectory(
[][]rune{[]rune(input)},
0,
utf8.RuneCountInString(input),
maxCompletions,
)
}

func completionReplacements(completions bubbline.Completions) []string {
var replacements []string
for category := 0; category < completions.NumCategories(); category++ {
for entryIndex := 0; entryIndex < completions.NumEntries(category); entryIndex++ {
entry := completions.Entry(category, entryIndex)
replacements = append(replacements, completions.Candidate(entry).Replacement())
}
}
return replacements
}
Loading
Loading