Work on completion of file args
This commit is contained in:
parent
833e9625f9
commit
5d89a6c3c4
@ -57,6 +57,8 @@ def generate_completions_for_kitty() -> None:
|
|||||||
print('package completion\n')
|
print('package completion\n')
|
||||||
print('func kitty(root *Command) {')
|
print('func kitty(root *Command) {')
|
||||||
print('k := root.add_command("kitty", "")')
|
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 := k.add_command("@", "Remote control")')
|
||||||
print('at.Description = "Control kitty using commands"')
|
print('at.Description = "Control kitty using commands"')
|
||||||
for go_name in all_command_names():
|
for go_name in all_command_names():
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@ -15,13 +16,14 @@ from . import BaseTest
|
|||||||
class TestCompletion(BaseTest):
|
class TestCompletion(BaseTest):
|
||||||
|
|
||||||
def test_completion(self):
|
def test_completion(self):
|
||||||
completion(self)
|
with tempfile.TemporaryDirectory() as tdir:
|
||||||
|
completion(self, tdir)
|
||||||
|
|
||||||
|
|
||||||
def has_words(*words):
|
def has_words(*words):
|
||||||
def t(self, result):
|
def t(self, result):
|
||||||
q = set(words)
|
q = set(words)
|
||||||
for group in result['groups']:
|
for group in result.get('groups', ()):
|
||||||
for m in group['matches']:
|
for m in group['matches']:
|
||||||
if m['word'] in words:
|
if m['word'] in words:
|
||||||
q.discard(m['word'])
|
q.discard(m['word'])
|
||||||
@ -29,7 +31,19 @@ def has_words(*words):
|
|||||||
return t
|
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_cmds = []
|
||||||
all_argv = []
|
all_argv = []
|
||||||
all_tests = []
|
all_tests = []
|
||||||
@ -45,16 +59,39 @@ def completion(self: TestCompletion):
|
|||||||
all_tests.append(tests)
|
all_tests.append(tests)
|
||||||
|
|
||||||
def run_tool():
|
def run_tool():
|
||||||
with tempfile.TemporaryDirectory() as tdir:
|
env = os.environ.copy()
|
||||||
return json.loads(subprocess.run(
|
env['PATH'] = os.path.join(tdir, 'bin')
|
||||||
[kitty_tool(), '__complete__', 'json'],
|
cp = subprocess.run(
|
||||||
check=True, stdout=subprocess.PIPE, cwd=tdir, input=json.dumps(all_argv).encode()
|
[kitty_tool(), '__complete__', 'json'],
|
||||||
).stdout)
|
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 ', has_words('@', '@ls'))
|
||||||
add('kitty @ l', has_words('ls', 'last-used-layout', 'launch'))
|
add('kitty @ l', has_words('ls', 'last-used-layout', 'launch'))
|
||||||
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()):
|
for cmd, tests, result in zip(all_cmds, all_tests, run_tool()):
|
||||||
self.current_cmd = cmd
|
self.current_cmd = cmd
|
||||||
for test in tests:
|
for test in tests:
|
||||||
|
|||||||
@ -14,35 +14,97 @@ import (
|
|||||||
|
|
||||||
var _ = fmt.Print
|
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 {
|
func complete_files(prefix string, callback CompleteFilesCallback) error {
|
||||||
base := "."
|
base := "."
|
||||||
base_len := len(base) + 1
|
|
||||||
has_cwd_prefix := strings.HasPrefix(prefix, "./")
|
has_cwd_prefix := strings.HasPrefix(prefix, "./")
|
||||||
is_abs_path := filepath.IsAbs(prefix)
|
is_abs_path := filepath.IsAbs(prefix)
|
||||||
wd := ""
|
wd := ""
|
||||||
if is_abs_path {
|
if is_abs_path {
|
||||||
base = prefix
|
base = prefix
|
||||||
base_len = 0
|
|
||||||
if s, err := os.Stat(prefix); err != nil || !s.IsDir() {
|
if s, err := os.Stat(prefix); err != nil || !s.IsDir() {
|
||||||
base = filepath.Dir(prefix)
|
base = filepath.Dir(prefix)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
wd, _ = os.Getwd()
|
var qe error
|
||||||
}
|
wd, qe = os.Getwd()
|
||||||
filepath.WalkDir(base, func(path string, d fs.DirEntry, err error) error {
|
if qe != nil {
|
||||||
if err != nil {
|
wd = ""
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
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
|
return nil
|
||||||
}
|
}
|
||||||
completion_candidate := path
|
completion_candidate := path
|
||||||
abspath := path
|
abspath := path
|
||||||
if is_abs_path {
|
if !is_abs_path {
|
||||||
completion_candidate = path[base_len:]
|
|
||||||
} else {
|
|
||||||
abspath = filepath.Join(wd, path)
|
abspath = filepath.Join(wd, path)
|
||||||
if has_cwd_prefix {
|
if has_cwd_prefix {
|
||||||
completion_candidate = "./" + completion_candidate
|
completion_candidate = "./" + completion_candidate
|
||||||
|
|||||||
47
tools/completion/kitty.go
Normal file
47
tools/completion/kitty.go
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,9 +3,12 @@
|
|||||||
package completion
|
package completion
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var _ = fmt.Print
|
||||||
|
|
||||||
func (self *Completions) add_group(group *MatchGroup) {
|
func (self *Completions) add_group(group *MatchGroup) {
|
||||||
if len(group.Matches) > 0 {
|
if len(group.Matches) > 0 {
|
||||||
self.Groups = append(self.Groups, group)
|
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 {
|
for _, q := range opt.Aliases {
|
||||||
if len(q) == 1 && !seen_flags[q] {
|
if len(q) == 1 && !seen_flags[q] {
|
||||||
seen_flags[q] = true
|
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() {
|
if arg_num == 1 && cmd.has_subcommands() {
|
||||||
for _, cg := range cmd.Groups {
|
for _, cg := range cmd.Groups {
|
||||||
group := MatchGroup{Title: cg.Title}
|
group := completions.add_match_group(cg.Title)
|
||||||
if group.Title == "" {
|
if group.Title == "" {
|
||||||
group.Title = "Sub-commands"
|
group.Title = "Sub-commands"
|
||||||
}
|
}
|
||||||
group.Matches = make([]*Match, 0, len(cg.Commands))
|
|
||||||
for _, sc := range cg.Commands {
|
for _, sc := range cg.Commands {
|
||||||
if strings.HasPrefix(sc.Name, word) {
|
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
|
return
|
||||||
}
|
}
|
||||||
@ -120,6 +122,7 @@ func (cmd *Command) parse_args(words []string, completions *Completions) {
|
|||||||
complete_word("", completions, false, nil, 0)
|
complete_word("", completions, false, nil, 0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
completions.all_words = words
|
||||||
|
|
||||||
var expecting_arg_for *Option
|
var expecting_arg_for *Option
|
||||||
only_args_allowed := false
|
only_args_allowed := false
|
||||||
@ -127,8 +130,9 @@ func (cmd *Command) parse_args(words []string, completions *Completions) {
|
|||||||
|
|
||||||
for i, word := range words {
|
for i, word := range words {
|
||||||
cmd = completions.current_cmd
|
cmd = completions.current_cmd
|
||||||
|
completions.current_word_idx = i
|
||||||
is_last_word := i == len(words)-1
|
is_last_word := i == len(words)-1
|
||||||
if expecting_arg_for == nil && word != "--" {
|
if expecting_arg_for == nil && !strings.HasPrefix(word, "-") {
|
||||||
arg_num++
|
arg_num++
|
||||||
}
|
}
|
||||||
if is_last_word {
|
if is_last_word {
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
package completion
|
package completion
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
type Match struct {
|
type Match struct {
|
||||||
Word string `json:"word,omitempty"`
|
Word string `json:"word,omitempty"`
|
||||||
FullForm string `json:"full_form,omitempty"`
|
FullForm string `json:"full_form,omitempty"`
|
||||||
@ -16,14 +18,28 @@ type MatchGroup struct {
|
|||||||
WordPrefix string `json:"word_prefix,omitempty"`
|
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 {
|
type Completions struct {
|
||||||
Groups []*MatchGroup `json:"groups,omitempty"`
|
Groups []*MatchGroup `json:"groups,omitempty"`
|
||||||
WordPrefix string `json:"word_prefix,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 {
|
type Option struct {
|
||||||
Name string
|
Name string
|
||||||
@ -45,8 +61,9 @@ type Command struct {
|
|||||||
Options []*Option
|
Options []*Option
|
||||||
Groups []*CommandGroup
|
Groups []*CommandGroup
|
||||||
|
|
||||||
Completion_for_arg completion_func
|
Completion_for_arg completion_func
|
||||||
Stop_processing_at_arg int
|
Stop_processing_at_arg int
|
||||||
|
First_arg_may_not_be_subcommand bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Command) add_group(name string) *CommandGroup {
|
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)
|
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
|
return &ans
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user