Dont recurse for file completion

We could potentially end up recursing over the entire file system. And
for completion we only present the candidates in the immediate directory
anyway.
This commit is contained in:
Kovid Goyal 2022-09-16 08:46:26 +05:30
parent c9d986f9a8
commit 3c29ce936b
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
6 changed files with 116 additions and 64 deletions

View File

@ -89,9 +89,9 @@ def completion(self: TestCompletion, tdir: str):
add('kitty x', all_words())
add('kitty e', all_words('exe1'))
add('kitty ./', all_words('./bin', './bin/exe1', './sub', './exe2', './sub/exe3'))
add('kitty ./', all_words('./bin/', './sub/', './exe2'))
add('kitty ./e', all_words('./exe2'))
add('kitty ./s', all_words('./sub', './sub/exe3'))
add('kitty ./s', all_words('./sub/'))
add('kitty ~', all_words('~/exe3'))
add('kitty ~/', all_words('~/exe3'))
add('kitty ~/e', all_words('~/exe3'))
@ -99,7 +99,7 @@ def completion(self: TestCompletion, tdir: str):
add('kitty @ goto-layout ', has_words('tall', 'fat'))
add('kitty @ goto-layout spli', all_words('splits'))
add('kitty @ goto-layout f f', all_words())
add('kitty @ set-window-logo ', all_words('exe-not2.jpeg', 'sub/exe-not3.png'))
add('kitty @ set-window-logo ', all_words('exe-not2.jpeg', 'sub/', 'bin/'))
add('kitty @ set-window-logo e', all_words('exe-not2.jpeg'))
add('kitty @ set-window-logo e e', all_words())

View File

@ -4,7 +4,6 @@ package completion
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
@ -25,39 +24,82 @@ func absolutize_path(path string) string {
return path
}
type CompleteFilesCallback func(completion_candidate, abspath string, d fs.DirEntry) error
type FileEntry struct {
name, completion_candidate, abspath string
mode os.FileMode
is_dir, is_symlink, is_empty_dir bool
}
func complete_files(prefix string, callback CompleteFilesCallback) error {
base := prefix
if base != "~" && base != "./" && base != "/" {
// cant use filepath.Dir() as it calls filepath.Clean() which
// can cause base to no longer match prefix
idx := strings.LastIndex(base, string(os.PathSeparator))
if idx > 0 {
base = base[:idx]
func complete_files(prefix string, callback func(*FileEntry)) error {
location := absolutize_path(prefix)
base_dir := ""
joinable_prefix := ""
switch prefix {
case ".":
base_dir = "."
joinable_prefix = ""
case "./":
base_dir = "."
joinable_prefix = "./"
case "/":
base_dir = "/"
joinable_prefix = "/"
case "~":
base_dir = location
joinable_prefix = "~/"
case "":
base_dir = "."
joinable_prefix = ""
default:
if strings.HasSuffix(prefix, utils.Sep) {
base_dir = location
joinable_prefix = prefix
} else {
base = ""
idx := strings.LastIndex(prefix, utils.Sep)
if idx > 0 {
joinable_prefix = prefix[:idx+1]
base_dir = filepath.Dir(location)
}
}
num := 0
utils.WalkWithSymlink(base, func(path, abspath string, d fs.DirEntry, err error) error {
}
if base_dir == "" {
base_dir = "."
}
// fmt.Printf("prefix=%#v base_dir=%#v joinable_prefix=%#v\n", prefix, base_dir, joinable_prefix)
entries, err := os.ReadDir(base_dir)
if err != nil {
return fs.SkipDir
return err
}
num++
if num == 1 {
return nil
}
completion_candidate := path
if strings.HasPrefix(completion_candidate, prefix) {
return callback(completion_candidate, abspath, d)
}
if d.IsDir() {
return fs.SkipDir
}
return nil
}, absolutize_path)
for _, entry := range entries {
q := joinable_prefix + entry.Name()
if !strings.HasPrefix(q, prefix) {
continue
}
abspath := filepath.Join(base_dir, entry.Name())
dir_to_check := ""
data := FileEntry{
name: entry.Name(), abspath: abspath, mode: entry.Type(), is_dir: entry.IsDir(),
is_symlink: entry.Type()&os.ModeSymlink == os.ModeSymlink, completion_candidate: q}
if data.is_symlink {
target, err := filepath.EvalSymlinks(abspath)
if err == nil && target != base_dir {
td, err := os.Stat(target)
if err == nil && td.IsDir() {
dir_to_check = target
data.is_dir = true
}
}
}
if dir_to_check != "" {
subentries, err := os.ReadDir(dir_to_check)
data.is_empty_dir = err != nil || len(subentries) == 0
}
if data.is_dir {
data.completion_candidate += utils.Sep
}
callback(&data)
}
return nil
}
@ -81,15 +123,18 @@ func complete_executables_in_path(prefix string, paths ...string) []string {
func complete_by_fnmatch(prefix string, patterns []string) []string {
ans := make([]string, 0, 1024)
complete_files(prefix, func(completion_candidate, abspath string, d fs.DirEntry) error {
q := strings.ToLower(filepath.Base(abspath))
complete_files(prefix, func(entry *FileEntry) {
if entry.is_dir && !entry.is_empty_dir {
ans = append(ans, entry.completion_candidate)
return
}
q := strings.ToLower(entry.name)
for _, pat := range patterns {
matched, err := filepath.Match(pat, q)
if err == nil && matched {
ans = append(ans, completion_candidate)
ans = append(ans, entry.completion_candidate)
}
}
return nil
})
return ans
}

View File

@ -4,7 +4,7 @@ package completion
import (
"fmt"
"io/fs"
"kitty/tools/utils"
"os"
"path/filepath"
"reflect"
@ -13,6 +13,8 @@ import (
"testing"
)
var _ = fmt.Print
func TestCompleteFiles(t *testing.T) {
tdir := t.TempDir()
cwd, _ := os.Getwd()
@ -37,13 +39,11 @@ func TestCompleteFiles(t *testing.T) {
}
sort.Strings(expected)
actual := make([]string, 0, len(expected))
complete_files(prefix, func(completion_candidate string, abspath string, d fs.DirEntry) error {
actual = append(actual, completion_candidate)
if _, err := os.Stat(abspath); err != nil {
t.Fatalf("Abspath does not exist: %#v", abspath)
return fmt.Errorf("abspath does not exist")
complete_files(prefix, func(entry *FileEntry) {
actual = append(actual, entry.completion_candidate)
if _, err := os.Stat(entry.abspath); err != nil {
t.Fatalf("Abspath does not exist: %#v", entry.abspath)
}
return nil
})
sort.Strings(actual)
if !reflect.DeepEqual(expected, actual) {
@ -58,6 +58,9 @@ func TestCompleteFiles(t *testing.T) {
e[i] = x
} else {
e[i] = filepath.Join(tdir, x)
if strings.HasSuffix(x, utils.Sep) {
e[i] += utils.Sep
}
}
}
test_candidates(prefix, e...)
@ -71,17 +74,17 @@ func TestCompleteFiles(t *testing.T) {
test_candidates("./"+prefix, e...)
}
test_cwd_prefix("", "one.txt", "two.txt", "odir", "odir/three.txt", "odir/four.txt")
test_cwd_prefix("", "one.txt", "two.txt", "odir/")
test_cwd_prefix("t", "two.txt")
test_cwd_prefix("x")
test_abs_candidates(tdir, tdir, "one.txt", "two.txt", "odir", "odir/three.txt", "odir/four.txt")
test_abs_candidates(filepath.Join(tdir, "o"), "one.txt", "odir", "odir/three.txt", "odir/four.txt")
test_abs_candidates(tdir+utils.Sep, "one.txt", "two.txt", "odir/")
test_abs_candidates(filepath.Join(tdir, "o"), "one.txt", "odir/")
test_candidates("", "one.txt", "two.txt", "odir", "odir/three.txt", "odir/four.txt")
test_candidates("", "one.txt", "two.txt", "odir/")
test_candidates("t", "two.txt")
test_candidates("o", "one.txt", "odir", "odir/three.txt", "odir/four.txt")
test_candidates("odir", "odir", "odir/three.txt", "odir/four.txt")
test_candidates("o", "one.txt", "odir/")
test_candidates("odir", "odir/")
test_candidates("odir/", "odir/three.txt", "odir/four.txt")
test_candidates("odir/f", "odir/four.txt")
test_candidates("x")

View File

@ -4,7 +4,6 @@ package completion
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
@ -27,21 +26,21 @@ func complete_kitty(completions *Completions, word string, arg_num int) {
mg := completions.add_match_group("Executables")
mg.IsFiles = true
complete_files(word, func(q, abspath string, d fs.DirEntry) error {
if d.IsDir() {
complete_files(word, func(entry *FileEntry) {
if entry.is_dir && !entry.is_empty_dir {
// only allow directories that have sub-dirs or executable files in them
entries, err := os.ReadDir(abspath)
entries, err := os.ReadDir(entry.abspath)
if err == nil {
for _, x := range entries {
if x.IsDir() || unix.Access(filepath.Join(abspath, x.Name()), unix.X_OK) == nil {
mg.add_match(q)
if x.IsDir() || unix.Access(filepath.Join(entry.abspath, x.Name()), unix.X_OK) == nil {
mg.add_match(entry.completion_candidate)
break
}
}
}
} else if unix.Access(abspath, unix.X_OK) == nil {
mg.add_match(q)
} else if unix.Access(entry.abspath, unix.X_OK) == nil {
mg.add_match(entry.completion_candidate)
}
return nil
})
}
}

View File

@ -34,6 +34,11 @@ type Completions struct {
}
func (self *Completions) add_match_group(title string) *MatchGroup {
for _, q := range self.Groups {
if q.Title == title {
return q
}
}
ans := MatchGroup{Title: title, Matches: make([]*Match, 0, 8)}
self.Groups = append(self.Groups, &ans)
return &ans

View File

@ -11,6 +11,8 @@ import (
"strings"
)
var Sep = string(os.PathSeparator)
func Expanduser(path string) string {
if !strings.HasPrefix(path, "~") {
return path
@ -28,7 +30,7 @@ func Expanduser(path string) string {
if path == "~" {
return home
}
path = strings.ReplaceAll(path, string(os.PathSeparator), "/")
path = strings.ReplaceAll(path, Sep, "/")
parts := strings.Split(path, "/")
if parts[0] == "~" {
parts[0] = home
@ -41,7 +43,7 @@ func Expanduser(path string) string {
}
}
}
return strings.Join(parts, string(os.PathSeparator))
return strings.Join(parts, Sep)
}
func Abspath(path string) string {
@ -114,8 +116,6 @@ type transformed_walker struct {
needs_recurse_func func(string, fs.DirEntry) bool
}
var sep = string(os.PathSeparator)
func (self *transformed_walker) walk(dirpath string) error {
resolved_path := self.transform_func(dirpath)
if self.seen[resolved_path] {
@ -134,8 +134,8 @@ func (self *transformed_walker) walk(dirpath string) error {
}
// we cant use filepath.Join here as it calls Clean() which can alter dirpath if it contains .. or . etc.
path_based_on_original_dir := dirpath
if !strings.HasSuffix(dirpath, sep) && dirpath != "" {
path_based_on_original_dir += sep
if !strings.HasSuffix(dirpath, Sep) && dirpath != "" {
path_based_on_original_dir += Sep
}
path_based_on_original_dir += rpath
if self.needs_recurse_func(path, d) {