fixing auto-completion

This commit is contained in:
Florian Walther committed 2026-10-03 16:33:40 +02:00
1 parent f1e20856c8
commit 847b6cd5c6
2 files changed
+191 -135

No files matched your search

+124 -135
View File
@@ -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) {
str := string(line[:pos])
// Find the start of the current word being completed
lastSpace := strings.LastIndex(str, " ")
var prefixStart int
var prefix string
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
word, start, quote := currentWord(string(line[:pos]))
isFirstWord := strings.TrimSpace(string(line[:start])) == ""
// Plain "~" -> "~/"
if word == "~" {
return [][]rune{[]rune("/")}, 1
}
// Remove surrounding quotes from the prefix for matching
unquotedPrefix := strings.Trim(prefix, `"'`)
if isFirstWord {
if isFirstWord && !strings.Contains(word, "/") && !strings.HasPrefix(word, "~") {
// Complete commands
var candidates [][]rune
for _, cmd := range c.commands {
if strings.HasPrefix(cmd, unquotedPrefix) {
candidates = append(candidates, []rune(cmd))
seen := make(map[string]bool)
for _, cmd := range append(c.commands, pathExecutables(word)...) {
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
matches := completeFilePath(str, unquotedPrefix)
if len(matches) == 0 {
return nil, prefixStart
}
// Build candidates: for each match, construct the full replacement
candidates := make([][]rune, len(matches))
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
// Complete file paths; only the part after the last slash is completed
pattern := word[strings.LastIndex(word, "/")+1:]
matches := completeFilePath(string(line[:pos]), word)
var candidates [][]rune
for _, m := range matches {
rest := m[len(pattern):]
if quote == 0 {
rest = escapeWord(rest)
}
candidates[i] = []rune(fullPath)
candidates = append(candidates, []rune(rest))
}
return candidates, prefixStart
return candidates, len([]rune(pattern))
}
// completeFilePath handles file path completion
// This is exported for testing purposes
// pathExecutables returns the names of all executables in $PATH starting
// 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 {
// Get the current working directory
wd, err := os.Getwd()
if err != nil {
return nil
// Split textually so that "." or "~" are not normalized away
slash := strings.LastIndex(prefix, "/")
dir, pattern := prefix[:slash+1], prefix[slash+1:]
switch {
case dir == "":
dir = "."
case strings.HasPrefix(dir, "~"):
dir = expandTilde(dir)
}
// Determine the directory to search and the pattern to match
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
// Read the directory contents (relative paths resolve against the cwd)
entries, err := os.ReadDir(dir)
if err != nil {
return nil
@@ -243,33 +247,18 @@ func completeFilePath(line, prefix string) []string {
var matches []string
for _, entry := range entries {
name := entry.Name()
if pattern == "" || strings.HasPrefix(name, pattern) {
// Determine if this is a directory
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)
if !strings.HasPrefix(name, pattern) {
continue
}
// 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