From 5d89a6c3c428f30f9c0c69169d727a2cb136a79c Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 15 Sep 2022 18:43:52 +0530 Subject: [PATCH] Work on completion of file args --- gen-go-code.py | 2 + kitty_tests/completion.py | 53 +++++++++++++++++---- tools/completion/files.go | 86 +++++++++++++++++++++++++++++----- tools/completion/kitty.go | 47 +++++++++++++++++++ tools/completion/parse-args.go | 20 ++++---- tools/completion/types.go | 32 +++++++++++-- 6 files changed, 208 insertions(+), 32 deletions(-) create mode 100644 tools/completion/kitty.go diff --git a/gen-go-code.py b/gen-go-code.py index 63a06bb2b..1539fc72e 100755 --- a/gen-go-code.py +++ b/gen-go-code.py @@ -57,6 +57,8 @@ def generate_completions_for_kitty() -> None: print('package completion\n') print('func kitty(root *Command) {') print('k := root.add_command("kitty", "")') + print('k.First_arg_may_not_be_subcommand = true') + print('k.Completion_for_arg = complete_kitty') print('at := k.add_command("@", "Remote control")') print('at.Description = "Control kitty using commands"') for go_name in all_command_names(): diff --git a/kitty_tests/completion.py b/kitty_tests/completion.py index f7b89cbb9..55a08fbd2 100644 --- a/kitty_tests/completion.py +++ b/kitty_tests/completion.py @@ -3,6 +3,7 @@ import json +import os import shlex import subprocess import tempfile @@ -15,13 +16,14 @@ from . import BaseTest class TestCompletion(BaseTest): def test_completion(self): - completion(self) + with tempfile.TemporaryDirectory() as tdir: + completion(self, tdir) def has_words(*words): def t(self, result): q = set(words) - for group in result['groups']: + for group in result.get('groups', ()): for m in group['matches']: if m['word'] in words: q.discard(m['word']) @@ -29,7 +31,19 @@ def has_words(*words): return t -def completion(self: TestCompletion): +def all_words(*words): + def t(self, result): + expected = set(words) + actual = set() + + for group in result.get('groups', ()): + for m in group['matches']: + actual.add(m['word']) + self.assertEqual(expected, actual, f'Command line: {self.current_cmd!r}') + return t + + +def completion(self: TestCompletion, tdir: str): all_cmds = [] all_argv = [] all_tests = [] @@ -45,16 +59,39 @@ def completion(self: TestCompletion): all_tests.append(tests) def run_tool(): - with tempfile.TemporaryDirectory() as tdir: - return json.loads(subprocess.run( - [kitty_tool(), '__complete__', 'json'], - check=True, stdout=subprocess.PIPE, cwd=tdir, input=json.dumps(all_argv).encode() - ).stdout) + env = os.environ.copy() + env['PATH'] = os.path.join(tdir, 'bin') + cp = subprocess.run( + [kitty_tool(), '__complete__', 'json'], + check=True, stdout=subprocess.PIPE, cwd=tdir, input=json.dumps(all_argv).encode(), env=env + ) + self.assertEqual(cp.returncode, 0, f'kitty-tool __complete__ failed with exit code: {cp.returncode}') + return json.loads(cp.stdout) add('kitty ', has_words('@', '@ls')) add('kitty @ l', has_words('ls', 'last-used-layout', 'launch')) add('kitty @l', has_words('@ls', '@last-used-layout', '@launch')) + def make_file(path, mode=None): + with open(os.path.join(tdir, path), mode='x') as f: + if mode is not None: + os.chmod(f.fileno(), mode) + + os.mkdir(os.path.join(tdir, 'bin')) + os.mkdir(os.path.join(tdir, 'sub')) + make_file('bin/exe1', 0o700) + make_file('bin/exe-not1') + make_file('exe2', 0o700) + make_file('exe-not2') + make_file('sub/exe3', 0o700) + make_file('sub/exe-not3') + + add('kitty x', all_words()) + add('kitty e', all_words('exe1')) + add('kitty ./', all_words('./bin', './bin/exe1', './sub', './exe2', './sub/exe3')) + add('kitty ./e', all_words('./exe2')) + add('kitty ./s', all_words('./sub', './sub/exe3')) + for cmd, tests, result in zip(all_cmds, all_tests, run_tool()): self.current_cmd = cmd for test in tests: diff --git a/tools/completion/files.go b/tools/completion/files.go index d9065fcba..b826567d6 100644 --- a/tools/completion/files.go +++ b/tools/completion/files.go @@ -14,35 +14,97 @@ import ( var _ = fmt.Print -type CompleteFilesCallback func(completion_candidate string, abspath string, d fs.DirEntry) error +type CompleteFilesCallback func(completion_candidate, abspath string, d fs.DirEntry) error +type Walk_callback func(path string, d fs.DirEntry, err error) error + +func transform_symlink(path string) string { + if q, err := filepath.EvalSymlinks(path); err == nil { + return q + } + return path +} + +func needs_symlink_recurse(path string, d fs.DirEntry) bool { + if d.Type()&os.ModeSymlink == os.ModeSymlink { + if s, serr := os.Stat(path); serr == nil && s.IsDir() { + return true + } + } + return false +} + +type transformed_walker struct { + seen map[string]bool + real_callback Walk_callback + transform_func func(string) string + needs_recurse_func func(string, fs.DirEntry) bool +} + +func (self *transformed_walker) walk(dirpath string) error { + resolved_path := self.transform_func(dirpath) + if self.seen[resolved_path] { + return nil + } + self.seen[resolved_path] = true + + c := func(path string, d fs.DirEntry, err error) error { + if err != nil { + // Happens if ReadDir on d failed, skip it in that case + return fs.SkipDir + } + rpath, err := filepath.Rel(resolved_path, path) + if err != nil { + return err + } + path_based_on_original_dir := filepath.Join(dirpath, rpath) + if self.needs_recurse_func(path, d) { + err = self.walk(path_based_on_original_dir) + } else { + err = self.real_callback(path_based_on_original_dir, d, err) + } + return err + } + + return filepath.WalkDir(resolved_path, c) +} + +// Walk, recursing into symlinks that point to directories. Ignores directories +// that could not be read. +func WalkWithSymlink(dirpath string, callback Walk_callback) error { + sw := transformed_walker{ + seen: make(map[string]bool), real_callback: callback, transform_func: transform_symlink, needs_recurse_func: needs_symlink_recurse} + return sw.walk(dirpath) +} func complete_files(prefix string, callback CompleteFilesCallback) error { base := "." - base_len := len(base) + 1 has_cwd_prefix := strings.HasPrefix(prefix, "./") is_abs_path := filepath.IsAbs(prefix) wd := "" if is_abs_path { base = prefix - base_len = 0 if s, err := os.Stat(prefix); err != nil || !s.IsDir() { base = filepath.Dir(prefix) } } else { - wd, _ = os.Getwd() - } - filepath.WalkDir(base, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return nil + var qe error + wd, qe = os.Getwd() + if qe != nil { + wd = "" } - if path == base { + } + num := 0 + WalkWithSymlink(base, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return fs.SkipDir + } + num++ + if num == 1 { return nil } completion_candidate := path abspath := path - if is_abs_path { - completion_candidate = path[base_len:] - } else { + if !is_abs_path { abspath = filepath.Join(wd, path) if has_cwd_prefix { completion_candidate = "./" + completion_candidate diff --git a/tools/completion/kitty.go b/tools/completion/kitty.go new file mode 100644 index 000000000..baeb00562 --- /dev/null +++ b/tools/completion/kitty.go @@ -0,0 +1,47 @@ +// License: GPLv3 Copyright: 2022, Kovid Goyal, + +package completion + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" +) + +var _ = fmt.Print + +func complete_kitty(completions *Completions, word string, arg_num int) { + exes := complete_executables_in_path(word) + if len(exes) > 0 { + mg := completions.add_match_group("Executables in PATH") + for _, exe := range exes { + mg.add_match(exe) + } + } + + if len(word) > 0 && (filepath.IsAbs(word) || strings.HasPrefix(word, "./")) { + mg := completions.add_match_group("Executables") + mg.IsFiles = true + + complete_files(word, func(q, abspath string, d fs.DirEntry) error { + if d.IsDir() { + // only allow directories that have sub-dirs or executable files in them + entries, err := os.ReadDir(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) + } + } + } + } else if unix.Access(abspath, unix.X_OK) == nil { + mg.add_match(q) + } + return nil + }) + } +} diff --git a/tools/completion/parse-args.go b/tools/completion/parse-args.go index 1d30a932c..2296268e6 100644 --- a/tools/completion/parse-args.go +++ b/tools/completion/parse-args.go @@ -3,9 +3,12 @@ package completion import ( + "fmt" "strings" ) +var _ = fmt.Print + func (self *Completions) add_group(group *MatchGroup) { if len(group.Matches) > 0 { self.Groups = append(self.Groups, group) @@ -58,7 +61,7 @@ func (self *Completions) add_options_group(options []*Option, word string) { for _, q := range opt.Aliases { if len(q) == 1 && !seen_flags[q] { seen_flags[q] = true - group.Matches = append(group.Matches, &Match{Word: q, FullForm: "-" + q, Description: opt.Description}) + group.add_match(q, opt.Description).FullForm = "-" + q } } } @@ -91,19 +94,18 @@ func complete_word(word string, completions *Completions, only_args_allowed bool } if arg_num == 1 && cmd.has_subcommands() { for _, cg := range cmd.Groups { - group := MatchGroup{Title: cg.Title} + group := completions.add_match_group(cg.Title) if group.Title == "" { group.Title = "Sub-commands" } - group.Matches = make([]*Match, 0, len(cg.Commands)) for _, sc := range cg.Commands { if strings.HasPrefix(sc.Name, word) { - group.Matches = append(group.Matches, &Match{Word: sc.Name, Description: sc.Description}) + group.add_match(sc.Name, sc.Description) } } - if len(group.Matches) > 0 { - completions.add_group(&group) - } + } + if cmd.First_arg_may_not_be_subcommand && cmd.Completion_for_arg != nil { + cmd.Completion_for_arg(completions, word, arg_num) } return } @@ -120,6 +122,7 @@ func (cmd *Command) parse_args(words []string, completions *Completions) { complete_word("", completions, false, nil, 0) return } + completions.all_words = words var expecting_arg_for *Option only_args_allowed := false @@ -127,8 +130,9 @@ func (cmd *Command) parse_args(words []string, completions *Completions) { for i, word := range words { cmd = completions.current_cmd + completions.current_word_idx = i is_last_word := i == len(words)-1 - if expecting_arg_for == nil && word != "--" { + if expecting_arg_for == nil && !strings.HasPrefix(word, "-") { arg_num++ } if is_last_word { diff --git a/tools/completion/types.go b/tools/completion/types.go index 69f72d4b4..b68e43921 100644 --- a/tools/completion/types.go +++ b/tools/completion/types.go @@ -2,6 +2,8 @@ package completion +import "strings" + type Match struct { Word string `json:"word,omitempty"` FullForm string `json:"full_form,omitempty"` @@ -16,14 +18,28 @@ type MatchGroup struct { WordPrefix string `json:"word_prefix,omitempty"` } +func (self *MatchGroup) add_match(word string, description ...string) *Match { + ans := Match{Word: word, Description: strings.Join(description, " ")} + self.Matches = append(self.Matches, &ans) + return &ans +} + type Completions struct { Groups []*MatchGroup `json:"groups,omitempty"` WordPrefix string `json:"word_prefix,omitempty"` - current_cmd *Command + current_cmd *Command + all_words []string // all words passed to parse_args() + current_word_idx int // index of current word in all_words } -type completion_func func(completions *Completions, partial_word string, arg_num int) +func (self *Completions) add_match_group(title string) *MatchGroup { + ans := MatchGroup{Title: title, Matches: make([]*Match, 0, 8)} + self.Groups = append(self.Groups, &ans) + return &ans +} + +type completion_func func(completions *Completions, word string, arg_num int) type Option struct { Name string @@ -45,8 +61,9 @@ type Command struct { Options []*Option Groups []*CommandGroup - Completion_for_arg completion_func - Stop_processing_at_arg int + Completion_for_arg completion_func + Stop_processing_at_arg int + First_arg_may_not_be_subcommand bool } func (self *Command) add_group(name string) *CommandGroup { @@ -110,5 +127,12 @@ func (self *Command) GetCompletions(argv []string) *Completions { cmd.parse_args(argv[1:], &ans) } } + non_empty_groups := make([]*MatchGroup, 0, len(ans.Groups)) + for _, gr := range ans.Groups { + if len(gr.Matches) > 0 { + non_empty_groups = append(non_empty_groups, gr) + } + } + ans.Groups = non_empty_groups return &ans }