fixing auto-completion
This commit is contained in:
1 parent
f1e20856c8
commit
847b6cd5c6
2 files changed
+191
-135
No files matched your search
@@ -109,131 +109,135 @@ func newShellCompleter() *shellCompleter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do implements readline.AutoCompleter interface
|
// Do implements readline.AutoCompleter interface.
|
||||||
|
// readline expects the candidates to contain only the text that still has to
|
||||||
|
// be inserted after the cursor, and the returned length to be the number of
|
||||||
|
// runes of the current word that are already typed.
|
||||||
func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) {
|
func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) {
|
||||||
str := string(line[:pos])
|
word, start, quote := currentWord(string(line[:pos]))
|
||||||
|
isFirstWord := strings.TrimSpace(string(line[:start])) == ""
|
||||||
// Find the start of the current word being completed
|
|
||||||
lastSpace := strings.LastIndex(str, " ")
|
// Plain "~" -> "~/"
|
||||||
var prefixStart int
|
if word == "~" {
|
||||||
var prefix string
|
return [][]rune{[]rune("/")}, 1
|
||||||
var isFirstWord bool
|
|
||||||
|
|
||||||
if lastSpace == -1 {
|
|
||||||
// No space found - could be first word (command) or a path without space like ls/file
|
|
||||||
prefix = str
|
|
||||||
prefixStart = 0
|
|
||||||
// Check if it contains a slash - if so, it's a path, not a command
|
|
||||||
if strings.Contains(prefix, "/") || strings.HasPrefix(prefix, "~") {
|
|
||||||
isFirstWord = false
|
|
||||||
} else {
|
|
||||||
isFirstWord = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
prefix = str[lastSpace+1:]
|
|
||||||
prefixStart = lastSpace + 1
|
|
||||||
isFirstWord = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove surrounding quotes from the prefix for matching
|
if isFirstWord && !strings.Contains(word, "/") && !strings.HasPrefix(word, "~") {
|
||||||
unquotedPrefix := strings.Trim(prefix, `"'`)
|
|
||||||
|
|
||||||
if isFirstWord {
|
|
||||||
// Complete commands
|
// Complete commands
|
||||||
var candidates [][]rune
|
var candidates [][]rune
|
||||||
for _, cmd := range c.commands {
|
seen := make(map[string]bool)
|
||||||
if strings.HasPrefix(cmd, unquotedPrefix) {
|
for _, cmd := range append(c.commands, pathExecutables(word)...) {
|
||||||
candidates = append(candidates, []rune(cmd))
|
if strings.HasPrefix(cmd, word) && !seen[cmd] {
|
||||||
|
seen[cmd] = true
|
||||||
|
candidates = append(candidates, []rune(escapeWord(cmd[len(word):])+" "))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return candidates, prefixStart
|
return candidates, len([]rune(word))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete file paths
|
// Complete file paths; only the part after the last slash is completed
|
||||||
matches := completeFilePath(str, unquotedPrefix)
|
pattern := word[strings.LastIndex(word, "/")+1:]
|
||||||
if len(matches) == 0 {
|
matches := completeFilePath(string(line[:pos]), word)
|
||||||
return nil, prefixStart
|
var candidates [][]rune
|
||||||
}
|
for _, m := range matches {
|
||||||
|
rest := m[len(pattern):]
|
||||||
// Build candidates: for each match, construct the full replacement
|
if quote == 0 {
|
||||||
candidates := make([][]rune, len(matches))
|
rest = escapeWord(rest)
|
||||||
for i, m := range matches {
|
|
||||||
var fullPath string
|
|
||||||
if strings.HasSuffix(prefix, "/") {
|
|
||||||
// If prefix ends with /, just append the match
|
|
||||||
fullPath = prefix + m
|
|
||||||
} else if strings.HasPrefix(prefix, "/") {
|
|
||||||
// Absolute path: /usr/lo + local/ -> /usr/local/
|
|
||||||
fullPath = filepath.Join(filepath.Dir(prefix), m)
|
|
||||||
} else if strings.HasPrefix(prefix, "~") {
|
|
||||||
// Tilde path: ~/Doc + uments/ -> ~/Documents/
|
|
||||||
fullPath = filepath.Join(prefix, m)
|
|
||||||
} else {
|
|
||||||
// Relative path
|
|
||||||
fullPath = prefix + m
|
|
||||||
}
|
}
|
||||||
candidates[i] = []rune(fullPath)
|
candidates = append(candidates, []rune(rest))
|
||||||
}
|
}
|
||||||
return candidates, prefixStart
|
return candidates, len([]rune(pattern))
|
||||||
}
|
}
|
||||||
|
|
||||||
// completeFilePath handles file path completion
|
// pathExecutables returns the names of all executables in $PATH starting
|
||||||
// This is exported for testing purposes
|
// with prefix
|
||||||
|
func pathExecutables(prefix string) []string {
|
||||||
|
var names []string
|
||||||
|
for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
|
||||||
|
if dir == "" {
|
||||||
|
dir = "."
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if !strings.HasPrefix(name, prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Follow symlinks, only regular files with an executable bit count
|
||||||
|
info, err := os.Stat(filepath.Join(dir, name))
|
||||||
|
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0111 == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentWord returns the (unescaped, unquoted) word the cursor is in, the
|
||||||
|
// byte offset where it starts in s and the quote character that is still open
|
||||||
|
// (0 if none).
|
||||||
|
func currentWord(s string) (word string, start int, quote byte) {
|
||||||
|
var b strings.Builder
|
||||||
|
escaped := false
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
ch := s[i]
|
||||||
|
switch {
|
||||||
|
case escaped:
|
||||||
|
b.WriteByte(ch)
|
||||||
|
escaped = false
|
||||||
|
case ch == '\\' && quote != '\'':
|
||||||
|
escaped = true
|
||||||
|
case quote != 0:
|
||||||
|
if ch == quote {
|
||||||
|
quote = 0
|
||||||
|
} else {
|
||||||
|
b.WriteByte(ch)
|
||||||
|
}
|
||||||
|
case ch == '"' || ch == '\'':
|
||||||
|
quote = ch
|
||||||
|
case ch == ' ' || ch == '\t':
|
||||||
|
b.Reset()
|
||||||
|
start = i + 1
|
||||||
|
default:
|
||||||
|
b.WriteByte(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String(), start, quote
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeWord escapes characters that would otherwise be interpreted by shlex
|
||||||
|
func escapeWord(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
if strings.ContainsRune(" \t\\'\"", r) {
|
||||||
|
b.WriteByte('\\')
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// completeFilePath handles file path completion.
|
||||||
|
// prefix is the unquoted path typed so far. It returns the names (with a
|
||||||
|
// trailing "/" for directories) of all entries in the directory part of
|
||||||
|
// prefix that start with the part after the last slash.
|
||||||
func completeFilePath(line, prefix string) []string {
|
func completeFilePath(line, prefix string) []string {
|
||||||
// Get the current working directory
|
// Split textually so that "." or "~" are not normalized away
|
||||||
wd, err := os.Getwd()
|
slash := strings.LastIndex(prefix, "/")
|
||||||
if err != nil {
|
dir, pattern := prefix[:slash+1], prefix[slash+1:]
|
||||||
return nil
|
|
||||||
|
switch {
|
||||||
|
case dir == "":
|
||||||
|
dir = "."
|
||||||
|
case strings.HasPrefix(dir, "~"):
|
||||||
|
dir = expandTilde(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the directory to search and the pattern to match
|
// Read the directory contents (relative paths resolve against the cwd)
|
||||||
var dir string
|
|
||||||
var pattern string
|
|
||||||
|
|
||||||
// Check if prefix ends with / - if so, we're completing inside that directory
|
|
||||||
endsWithSlash := strings.HasSuffix(prefix, "/")
|
|
||||||
|
|
||||||
// Handle absolute paths
|
|
||||||
if strings.HasPrefix(prefix, "/") {
|
|
||||||
if endsWithSlash {
|
|
||||||
// prefix is like "/path/to/dir/" - complete inside dir
|
|
||||||
dir = prefix
|
|
||||||
pattern = ""
|
|
||||||
} else {
|
|
||||||
// prefix is like "/path/to/dir" or "/path/to/file_"
|
|
||||||
dir = filepath.Dir(prefix)
|
|
||||||
pattern = filepath.Base(prefix)
|
|
||||||
}
|
|
||||||
} else if strings.HasPrefix(prefix, "~") {
|
|
||||||
// Handle tilde expansion
|
|
||||||
home := getHomeDir()
|
|
||||||
if home == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Expand the tilde path
|
|
||||||
fullPrefix := expandTilde(prefix)
|
|
||||||
if endsWithSlash {
|
|
||||||
dir = fullPrefix
|
|
||||||
pattern = ""
|
|
||||||
} else {
|
|
||||||
dir = filepath.Dir(fullPrefix)
|
|
||||||
pattern = filepath.Base(prefix)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Relative path - join with current directory
|
|
||||||
if endsWithSlash {
|
|
||||||
// prefix is like "subdir/" - complete inside subdir
|
|
||||||
fullPrefix := filepath.Join(wd, prefix)
|
|
||||||
dir = fullPrefix
|
|
||||||
pattern = ""
|
|
||||||
} else {
|
|
||||||
fullPrefix := filepath.Join(wd, prefix)
|
|
||||||
dir = filepath.Dir(fullPrefix)
|
|
||||||
pattern = filepath.Base(prefix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the directory contents
|
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -243,33 +247,18 @@ func completeFilePath(line, prefix string) []string {
|
|||||||
var matches []string
|
var matches []string
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
name := entry.Name()
|
name := entry.Name()
|
||||||
if pattern == "" || strings.HasPrefix(name, pattern) {
|
if !strings.HasPrefix(name, pattern) {
|
||||||
// Determine if this is a directory
|
continue
|
||||||
var suffix string
|
|
||||||
if entry.IsDir() {
|
|
||||||
suffix = "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the completion - return only the part to be appended
|
|
||||||
// For paths with trailing slash, return just the matched name
|
|
||||||
// For partial paths without trailing slash, return the full matching path part
|
|
||||||
var completion string
|
|
||||||
if endsWithSlash {
|
|
||||||
// Prefix ends with /, so just append the matched name + suffix
|
|
||||||
completion = name + suffix
|
|
||||||
} else if strings.HasPrefix(prefix, "/") {
|
|
||||||
// Absolute path without trailing slash - return remaining part
|
|
||||||
completion = name + suffix
|
|
||||||
} else if strings.HasPrefix(prefix, "~") {
|
|
||||||
// Tilde path - return remaining part
|
|
||||||
completion = name + suffix
|
|
||||||
} else {
|
|
||||||
// Relative path
|
|
||||||
completion = name + suffix
|
|
||||||
}
|
|
||||||
|
|
||||||
matches = append(matches, completion)
|
|
||||||
}
|
}
|
||||||
|
// Hide dotfiles unless explicitly asked for
|
||||||
|
if strings.HasPrefix(name, ".") && !strings.HasPrefix(pattern, ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Follow symlinks to decide whether this is a directory
|
||||||
|
if info, err := os.Stat(filepath.Join(dir, name)); err == nil && info.IsDir() {
|
||||||
|
name += "/"
|
||||||
|
}
|
||||||
|
matches = append(matches, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
return matches
|
return matches
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -781,3 +783,68 @@ func TestFilePathCompleter_NoMatches(t *testing.T) {
|
|||||||
t.Errorf("expected no matches for non-existent prefix, got: %v", matches)
|
t.Errorf("expected no matches for non-existent prefix, got: %v", matches)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// completeLine runs the completer on line (cursor at the end) and returns the
|
||||||
|
// candidates as strings together with the reported length.
|
||||||
|
func completeLine(line string) ([]string, int) {
|
||||||
|
cands, length := newShellCompleter().Do([]rune(line), len([]rune(line)))
|
||||||
|
var out []string
|
||||||
|
for _, c := range cands {
|
||||||
|
out = append(out, string(c))
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out, length
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellCompleter_Do(t *testing.T) {
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
defer os.Chdir(origDir)
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
os.Chdir(tmpDir)
|
||||||
|
os.Create("main.go")
|
||||||
|
os.Create("my file.txt")
|
||||||
|
os.Create(".hidden")
|
||||||
|
os.Mkdir("subdir", 0755)
|
||||||
|
os.Create("subdir/inner.txt")
|
||||||
|
|
||||||
|
// Controlled PATH with one executable, one non-executable and a
|
||||||
|
// duplicate of a builtin
|
||||||
|
binDir := t.TempDir()
|
||||||
|
os.WriteFile(filepath.Join(binDir, "gitty"), nil, 0755)
|
||||||
|
os.WriteFile(filepath.Join(binDir, "girl"), nil, 0644)
|
||||||
|
os.WriteFile(filepath.Join(binDir, "echo"), nil, 0755)
|
||||||
|
os.Mkdir(filepath.Join(binDir, "gidir"), 0755)
|
||||||
|
t.Setenv("PATH", binDir)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
line string
|
||||||
|
want []string
|
||||||
|
length int
|
||||||
|
}{
|
||||||
|
{"c", []string{"d "}, 1},
|
||||||
|
{"e", []string{"cho ", "xit ", "xport "}, 1},
|
||||||
|
{"gi", []string{"tty "}, 2},
|
||||||
|
{" gi", []string{"tty "}, 2},
|
||||||
|
{"ls gi", nil, 2},
|
||||||
|
{"ls ma", []string{"in.go"}, 2},
|
||||||
|
{"ls su", []string{"bdir/"}, 2},
|
||||||
|
{"ls subdir/", []string{"inner.txt"}, 0},
|
||||||
|
{"ls subdir/in", []string{"ner.txt"}, 2},
|
||||||
|
{"ls my", []string{`\ file.txt`}, 2},
|
||||||
|
{`ls my\ f`, []string{"ile.txt"}, 4},
|
||||||
|
{`ls "my f`, []string{"ile.txt"}, 4},
|
||||||
|
{"ls ./ma", []string{"in.go"}, 2},
|
||||||
|
{"ls .h", []string{"idden"}, 2},
|
||||||
|
{"ls ", []string{"main.go", `my\ file.txt`, "subdir/"}, 0},
|
||||||
|
{"ls " + tmpDir + "/ma", []string{"in.go"}, 2},
|
||||||
|
{"ls xyz", nil, 3},
|
||||||
|
{"ls ~", []string{"/"}, 1},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got, length := completeLine(tt.line)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) || length != tt.length {
|
||||||
|
t.Errorf("Do(%q) = %q, %d; want %q, %d", tt.line, got, length, tt.want, tt.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user