From 847b6cd5c656d25e6a1f3da621051b745a780147 Mon Sep 17 00:00:00 2001 From: Florian Walther Date: Sat, 3 Oct 2026 16:33:40 +0200 Subject: [PATCH] fixing auto-completion --- main.go | 259 ++++++++++++++++++++++++--------------------------- main_test.go | 67 +++++++++++++ 2 files changed, 191 insertions(+), 135 deletions(-) diff --git a/main.go b/main.go index 435208d..8eb03df 100644 --- a/main.go +++ b/main.go @@ -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 diff --git a/main_test.go b/main_test.go index 917f4c9..46beea6 100644 --- a/main_test.go +++ b/main_test.go @@ -5,6 +5,8 @@ import ( "os" "os/exec" "path/filepath" + "reflect" + "sort" "strings" "testing" @@ -781,3 +783,68 @@ func TestFilePathCompleter_NoMatches(t *testing.T) { 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) + } + } +}