diff --git a/docs/docs/guides/special-commands.md b/docs/docs/guides/special-commands.md index ddbc1f6..b7c4628 100644 --- a/docs/docs/guides/special-commands.md +++ b/docs/docs/guides/special-commands.md @@ -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: diff --git a/internal/app/complete.go b/internal/app/complete.go index 6e9b7d8..83ce1e7 100644 --- a/internal/app/complete.go +++ b/internal/app/complete.go @@ -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) @@ -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 diff --git a/internal/app/complete_local.go b/internal/app/complete_local.go new file mode 100644 index 0000000..d2f455b --- /dev/null +++ b/internal/app/complete_local.go @@ -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() +} diff --git a/internal/app/complete_test.go b/internal/app/complete_test.go new file mode 100644 index 0000000..886148a --- /dev/null +++ b/internal/app/complete_test.go @@ -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 +} diff --git a/internal/app/local_commands.go b/internal/app/local_commands.go index 1658c82..ee4794e 100644 --- a/internal/app/local_commands.go +++ b/internal/app/local_commands.go @@ -1,18 +1,120 @@ package app -import tea "charm.land/bubbletea/v2" +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "unicode" -type localCommand func(*pgxCLI) tea.Cmd + tea "charm.land/bubbletea/v2" +) + +type localCommand struct { + acceptsArguments bool + handler func(*pgxCLI, string) (tea.Cmd, error) +} var localCommands = map[string]localCommand{ - "\\clear": func(_ *pgxCLI) tea.Cmd { return tea.ClearScreen }, + "\\clear": { + handler: func(_ *pgxCLI, _ string) (tea.Cmd, error) { + return tea.ClearScreen, nil + }, + }, + "\\cd": { + acceptsArguments: true, + handler: func(_ *pgxCLI, arguments string) (tea.Cmd, error) { + return nil, changeWorkingDirectory(arguments) + }, + }, } func (p *pgxCLI) runLocal(query string) (tea.Cmd, bool) { - command, ok := localCommands[query] - if !ok { + commandName, arguments, hasSeparator := splitLocalCommand(query) + command, ok := localCommands[commandName] + // A separator means arguments were supplied; \clear remains an exact match. + if !ok || hasSeparator && !command.acceptsArguments { return nil, false } - return command(p), true + cmd, err := command.handler(p, arguments) + if err != nil { + return p.printError(fmt.Errorf("%s: %w", commandName, err)), true + } + + return cmd, true +} + +func splitLocalCommand(query string) (command, arguments string, hasSeparator bool) { + separator := strings.IndexFunc(query, unicode.IsSpace) + if separator == -1 { + return query, "", false + } + + return query[:separator], strings.TrimSpace(query[separator:]), true +} + +func parseDirectoryArgument(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + + quote := raw[0] + if quote == '\'' || quote == '"' { + closingQuote := strings.IndexByte(raw[1:], quote) + if closingQuote == -1 { + return "", errors.New("unterminated quoted path") + } + closingQuote++ + if strings.TrimSpace(raw[closingQuote+1:]) != "" { + return "", errors.New("unexpected text after quoted path") + } + return raw[1:closingQuote], nil + } + + if strings.ContainsFunc(raw, unicode.IsSpace) { + return "", errors.New("quote paths containing whitespace") + } + + return raw, nil +} + +func expandHomeDirectory(path string) (string, error) { + if path != "~" && !strings.HasPrefix(path, "~/") && !strings.HasPrefix(path, `~\`) { + return path, nil + } + + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("find home directory: %w", err) + } + if path == "~" { + return home, nil + } + + return filepath.Join(home, path[2:]), nil +} + +func changeWorkingDirectory(raw string) error { + path := "~" + if strings.TrimSpace(raw) != "" { + var err error + path, err = parseDirectoryArgument(raw) + if err != nil { + return err + } + } + + expandedPath, err := expandHomeDirectory(path) + if err != nil { + return err + } + // \cd changes the process working directory for later local path operations. + if err := os.Chdir(expandedPath); err != nil { + return fmt.Errorf("change directory to %q: %w", path, err) + } + + return nil } diff --git a/internal/app/local_commands_test.go b/internal/app/local_commands_test.go index b87a95b..d9eb2ab 100644 --- a/internal/app/local_commands_test.go +++ b/internal/app/local_commands_test.go @@ -1,6 +1,8 @@ package app import ( + "os" + "path/filepath" "testing" tea "charm.land/bubbletea/v2" @@ -22,6 +24,8 @@ func TestRunLocalMatchesClearExactly(t *testing.T) { {name: "leading whitespace", query: ` \clear`, match: false}, {name: "trailing whitespace", query: `\clear `, match: false}, {name: "argument", query: `\clear now`, match: false}, + {name: "connect short form", query: `\c database`, match: false}, + {name: "connect long form", query: `\connect database`, match: false}, {name: "empty", query: "", match: false}, } @@ -41,3 +45,131 @@ func TestRunLocalMatchesClearExactly(t *testing.T) { }) } } + +func TestSplitLocalCommand(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + query string + wantCommand string + wantArguments string + wantSeparator bool + }{ + {name: "command only", query: `\cd`, wantCommand: `\cd`}, + {name: "space separator", query: `\cd migrations`, wantCommand: `\cd`, wantArguments: "migrations", wantSeparator: true}, + {name: "tab separator", query: "\\cd\t../sql", wantCommand: `\cd`, wantArguments: "../sql", wantSeparator: true}, + {name: "empty argument", query: `\cd `, wantCommand: `\cd`, wantSeparator: true}, + {name: "leading whitespace", query: ` \cd migrations`, wantArguments: `\cd migrations`, wantSeparator: true}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + command, arguments, separator := splitLocalCommand(testCase.query) + assert.Equal(t, testCase.wantCommand, command) + assert.Equal(t, testCase.wantArguments, arguments) + assert.Equal(t, testCase.wantSeparator, separator) + }) + } +} + +func TestParseDirectoryArgument(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + raw string + want string + wantErr string + }{ + {name: "relative", raw: "migrations", want: "migrations"}, + {name: "parent", raw: "../sql", want: "../sql"}, + {name: "single quoted", raw: `'directory with spaces'`, want: "directory with spaces"}, + {name: "double quoted", raw: `"directory with spaces"`, want: "directory with spaces"}, + {name: "unquoted whitespace", raw: "directory with spaces", wantErr: "quote paths containing whitespace"}, + {name: "unterminated single quote", raw: `'migrations`, wantErr: "unterminated quoted path"}, + {name: "unterminated double quote", raw: `"migrations`, wantErr: "unterminated quoted path"}, + {name: "text after closing quote", raw: `'migrations' extra`, wantErr: "unexpected text after quoted path"}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + got, err := parseDirectoryArgument(testCase.raw) + if testCase.wantErr != "" { + require.ErrorContains(t, err, testCase.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, testCase.want, got) + }) + } +} + +// os.Chdir is process-wide; keep this test serial and restore the original directory. +func TestChangeWorkingDirectory(t *testing.T) { + originalDirectory, err := os.Getwd() + require.NoError(t, err) + root := t.TempDir() + t.Cleanup(func() { + require.NoError(t, os.Chdir(originalDirectory)) + }) + + home := filepath.Join(root, "home") + quotedTarget := filepath.Join(root, "directory with spaces") + tildeTarget := filepath.Join(home, "migrations") + require.NoError(t, os.Mkdir(home, 0o755)) + require.NoError(t, os.Mkdir(quotedTarget, 0o755)) + require.NoError(t, os.Mkdir(tildeTarget, 0o755)) + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + t.Run("local command dispatch", func(t *testing.T) { + require.NoError(t, os.Chdir(root)) + cmd, matched := (&pgxCLI{}).runLocal(`\cd "` + quotedTarget + `"`) + assert.True(t, matched) + assert.Nil(t, cmd) + assertWorkingDirectory(t, quotedTarget) + }) + + t.Run("quoted absolute path", func(t *testing.T) { + require.NoError(t, os.Chdir(root)) + require.NoError(t, changeWorkingDirectory(`"`+quotedTarget+`"`)) + assertWorkingDirectory(t, quotedTarget) + }) + + t.Run("home by default", func(t *testing.T) { + require.NoError(t, os.Chdir(root)) + require.NoError(t, changeWorkingDirectory("")) + assertWorkingDirectory(t, home) + }) + + t.Run("tilde path", func(t *testing.T) { + require.NoError(t, os.Chdir(root)) + require.NoError(t, changeWorkingDirectory("~/migrations")) + assertWorkingDirectory(t, tildeTarget) + }) + + t.Run("missing path", func(t *testing.T) { + require.NoError(t, os.Chdir(root)) + err := changeWorkingDirectory("missing") + require.ErrorContains(t, err, `change directory to "missing"`) + assertWorkingDirectory(t, root) + }) +} + +func assertWorkingDirectory(t *testing.T, want string) { + t.Helper() + + got, err := os.Getwd() + require.NoError(t, err) + gotInfo, err := os.Stat(got) + require.NoError(t, err) + wantInfo, err := os.Stat(want) + require.NoError(t, err) + assert.True(t, os.SameFile(gotInfo, wantInfo), "working directory = %q, want %q", got, want) +}