More work on merging completions with parse tree

This commit is contained in:
Kovid Goyal 2022-09-26 07:34:49 +05:30
parent bf74413c1f
commit 97716fea8b
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
16 changed files with 263 additions and 426 deletions

View File

@ -47,37 +47,27 @@ def replace(template: str, **kw: str) -> str:
# }}} # }}}
def generate_completion_for_rc(name: str) -> None:
cmd = command_for_name(name)
if cmd.short_desc:
print(f'{name}.Description = "{serialize_as_go_string(cmd.short_desc)}"')
for x in cmd.args.as_go_completion_code(name):
print(x)
for opt in rc_command_options(name):
print(opt.as_completion_option(name))
def generate_kittens_completion() -> None: def generate_kittens_completion() -> None:
from kittens.runner import ( from kittens.runner import (
all_kitten_names, get_kitten_cli_docs, get_kitten_wrapper_of, all_kitten_names, get_kitten_cli_docs, get_kitten_wrapper_of,
) )
for kitten in sorted(all_kitten_names()): for kitten in sorted(all_kitten_names()):
kn = 'kitten_' + kitten kn = 'kitten_' + kitten
print(f'{kn} := plus_kitten.add_command("{kitten}", "Kittens")') print(f'{kn} := plus_kitten.AddSubCommand(&cli.Command{{Name:"{kitten}", Group: "Kittens"}})')
wof = get_kitten_wrapper_of(kitten) wof = get_kitten_wrapper_of(kitten)
if wof: if wof:
print(f'{kn}.Parse_args = completion_for_wrapper("{serialize_as_go_string(wof)}")') print(f'{kn}.ArgCompleter = cli.CompletionForWrapper("{serialize_as_go_string(wof)}")')
continue continue
kcd = get_kitten_cli_docs(kitten) kcd = get_kitten_cli_docs(kitten)
if kcd: if kcd:
ospec = kcd['options'] ospec = kcd['options']
for opt in go_options_for_seq(parse_option_spec(ospec())[0]): for opt in go_options_for_seq(parse_option_spec(ospec())[0]):
print(opt.as_completion_option(kn)) print(opt.as_option(kn))
ac = kcd.get('args_completion') ac = kcd.get('args_completion')
if ac is not None: if ac is not None:
print(''.join(ac.as_go_code(kn))) print(''.join(ac.as_go_code(kn + '.ArgCompleter', ' = ')))
else: else:
print(f'{kn}.Description = ""') print(f'{kn}.HelpText = ""')
def completion_for_launch_wrappers(*names: str) -> None: def completion_for_launch_wrappers(*names: str) -> None:
@ -87,63 +77,57 @@ def completion_for_launch_wrappers(*names: str) -> None:
for o in opts: for o in opts:
if o.obj_dict['name'] in allowed: if o.obj_dict['name'] in allowed:
for name in names: for name in names:
print(o.as_completion_option(name)) print(o.as_option(name))
def generate_completions_for_kitty() -> None: def generate_completions_for_kitty() -> None:
from kitty.config import option_names_for_completion
print('package completion\n') print('package completion\n')
print('func kitty(root *Command) {') print('import "kitty/tools/cli"')
print('import "kitty/tools/cmd/at"')
conf_names = ', '.join((f'"{serialize_as_go_string(x)}"' for x in option_names_for_completion()))
print('var kitty_option_names_for_completion = []string{' + conf_names + '}')
print('func kitty(root *cli.Command) {')
# The kitty exe # The kitty exe
print('k := root.AddSubCommand(&Command{Name:"kitty", SubCommandIsOptional: true, ArgCompleter: complete_kitty, SubCommandMustBeFirst: true })') print('k := root.AddSubCommand(&cli.Command{'
'Name:"kitty", SubCommandIsOptional: true, ArgCompleter: cli.CompleteExecutableFirstArg, SubCommandMustBeFirst: true })')
for opt in go_options_for_seq(parse_option_spec()[0]): for opt in go_options_for_seq(parse_option_spec()[0]):
print(opt.as_completion_option('k')) print(opt.as_option('k'))
from kitty.config import option_names_for_completion
conf_names = ', '.join((f'"{serialize_as_go_string(x)}"' for x in option_names_for_completion()))
print(f'k.find_option("-o").Completion_for_arg = complete_kitty_override("Config directives", []string{{{conf_names}}})')
print('k.find_option("--listen-on").Completion_for_arg = complete_kitty_listen_on')
# kitty + # kitty +
print('plus := k.add_command("+", "Entry points")') print('plus := k.AddSubCommand(&cli.Command{Name:"+", Group:"Entry points", ShortDescription: "Various special purpose tools and kittens"})')
print('plus.Description = "Various special purpose tools and kittens"')
print('plus_launch := plus.add_command("launch", "Entry points")') print('plus_launch := plus.AddSubCommand(&cli.Command{'
print('plus_launch.Completion_for_arg = complete_plus_launch') 'Name:"launch", Group:"Entry points", ShortDescription: "Launch Python scripts", ArgCompleter: complete_plus_launch})')
print('k.add_clone("+launch", "Launch Python scripts", plus_launch)') print('k.AddClone("", plus_launch).Name = "+launch"')
print('plus_runpy := plus.add_command("runpy", "Entry points")') print('plus_runpy := plus.AddSubCommand(&cli.Command{'
print('plus_runpy.Completion_for_arg = complete_plus_runpy') 'Name: "runpy", Group:"Entry points", ArgCompleter: complete_plus_runpy, ShortDescription: "Run Python code"})')
print('k.add_clone("+runpy", "Run Python code", plus_runpy)') print('k.AddClone("", plus_runpy).Name = "+runpy"')
print('plus_open := plus.add_command("open", "Entry points")') print('plus_open := plus.AddSubCommand(&cli.Command{'
print('plus_open.Completion_for_arg = complete_plus_open') 'Name:"open", Group:"Entry points", ArgCompleter: complete_plus_open, ShortDescription: "Open files and URLs"})')
print('plus_open.clone_options_from(k)') print('k.AddClone("", plus_open).Name = "+open"')
print('k.add_clone("+open", "Open files and URLs", plus_open)')
# kitty +kitten # kitty +kitten
print('plus_kitten := plus.add_command("kitten", "Kittens")') print('plus_kitten := plus.AddSubCommand(&cli.Command{Name:"kitten", Group:"Kittens", SubCommandMustBeFirst: true})')
print('plus_kitten.Subcommand_must_be_first = true')
generate_kittens_completion() generate_kittens_completion()
print('k.add_clone("+kitten", "Kittens", plus_kitten)') print('k.AddClone("", plus_kitten).Name = "+kitten"')
# kitten @ # @
print('at := k.add_command("@", "Remote control")') print('at.EntryPoint(k)')
print('at.Description = "Control kitty using commands"')
for go_name in sorted(all_command_names()):
name = go_name.replace('_', '-')
print(f'{go_name} := at.add_command("{name}", "")')
generate_completion_for_rc(go_name)
print(f'k.add_clone("@{name}", "Remote control", {go_name})')
# clone-in-kitty, edit-in-kitty # clone-in-kitty, edit-in-kitty
print('cik := root.add_command("clone-in-kitty", "")') print('cik := root.AddSubCommand(&cli.Command{Name:"clone-in-kitty"})')
print('eik := root.add_command("edit-in-kitty", "")') print('eik := root.AddSubCommand(&cli.Command{Name:"edit-in-kitty"})')
completion_for_launch_wrappers('cik', 'eik') completion_for_launch_wrappers('cik', 'eik')
print(''.join(CompletionSpec.from_string('type:file mime:text/* group:"Text files"').as_go_code('eik'))) print(''.join(CompletionSpec.from_string('type:file mime:text/* group:"Text files"').as_go_code('eik.ArgCompleter', ' = ')))
print('}') print('}')
print('func init() {') print('func init() {')
print('registered_exes["kitty"] = kitty') print('cli.RegisterExeForCompletion(kitty)')
print('}') print('}')
@ -187,6 +171,7 @@ def go_code_for_remote_command(name: str, cmd: RemoteCommand, template: str) ->
NO_RESPONSE_BASE = 'false' NO_RESPONSE_BASE = 'false'
af: List[str] = [] af: List[str] = []
a = af.append a = af.append
af.extend(cmd.args.as_go_completion_code('ans'))
od: List[str] = [] od: List[str] = []
option_map: Dict[str, GoOption] = {} option_map: Dict[str, GoOption] = {}
for o in rc_command_options(name): for o in rc_command_options(name):
@ -358,7 +343,7 @@ func add_rc_global_opts(cmd *cli.Command) {{
def update_completion() -> None: def update_completion() -> None:
orig = sys.stdout orig = sys.stdout
try: try:
with replace_if_needed('tools/cli/completion-kitty_generated.go') as f: with replace_if_needed('tools/cmd/completion/kitty_generated.go') as f:
sys.stdout = f sys.stdout = f
generate_completions_for_kitty() generate_completions_for_kitty()
finally: finally:

View File

@ -18,7 +18,6 @@ from ..tui.operations import (
OPTIONS = r''' OPTIONS = r'''
--get-clipboard --get-clipboard
default=False
type=bool-set type=bool-set
Output the current contents of the clipboard to STDOUT. Note that by default Output the current contents of the clipboard to STDOUT. Note that by default
kitty will prompt for permission to access the clipboard. Can be controlled kitty will prompt for permission to access the clipboard. Can be controlled
@ -26,14 +25,12 @@ by :opt:`clipboard_control`.
--use-primary --use-primary
default=False
type=bool-set type=bool-set
Use the primary selection rather than the clipboard on systems that support it, Use the primary selection rather than the clipboard on systems that support it,
such as X11. such as X11.
--wait-for-completion --wait-for-completion
default=False
type=bool-set type=bool-set
Wait till the copy to clipboard is complete before exiting. Useful if running Wait till the copy to clipboard is complete before exiting. Useful if running
the kitten in a dedicated, ephemeral window. the kitten in a dedicated, ephemeral window.

View File

@ -78,24 +78,22 @@ class CompletionSpec:
if self.kwds: if self.kwds:
kwds = (f'"{serialize_as_go_string(x)}"' for x in self.kwds) kwds = (f'"{serialize_as_go_string(x)}"' for x in self.kwds)
g = (self.group if self.type is CompletionType.keyword else '') or "Keywords" g = (self.group if self.type is CompletionType.keyword else '') or "Keywords"
completers.append(f'NamesCompleter("{serialize_as_go_string(g)}", ' + ', '.join(kwds) + ')') completers.append(f'cli.NamesCompleter("{serialize_as_go_string(g)}", ' + ', '.join(kwds) + ')')
relative_to = 'CONFIG' if self.relative_to is CompletionRelativeTo.config_dir else 'CWD' relative_to = 'CONFIG' if self.relative_to is CompletionRelativeTo.config_dir else 'CWD'
if self.type is CompletionType.file: if self.type is CompletionType.file:
g = serialize_as_go_string(self.group or 'Files') g = serialize_as_go_string(self.group or 'Files')
if self.extensions: if self.extensions:
pats = (f'"*.{ext}"' for ext in self.extensions) pats = (f'"*.{ext}"' for ext in self.extensions)
completers.append(f'fnmatch_completer("{g}", {relative_to}, ' + ', '.join(pats) + ')') completers.append(f'cli.FnmatchCompleter("{g}", cli.{relative_to}, ' + ', '.join(pats) + ')')
if self.mime_patterns: if self.mime_patterns:
completers.append(f'mimepat_completer("{g}", {relative_to}, ' + ', '.join(f'"{p}"' for p in self.mime_patterns) + ')') completers.append(f'cli.MimepatCompleter("{g}", cli.{relative_to}, ' + ', '.join(f'"{p}"' for p in self.mime_patterns) + ')')
if self.type is CompletionType.directory: if self.type is CompletionType.directory:
g = serialize_as_go_string(self.group or 'Directories') g = serialize_as_go_string(self.group or 'Directories')
completers.append(f'directory_completer("{g}", {relative_to})') completers.append(f'cli.DirectoryCompleter("{g}", cli.{relative_to})')
if self.type is CompletionType.special: if self.type is CompletionType.special:
completers.append(self.group) completers.append(self.group)
if go_name:
go_name += '.'
if len(completers) > 1: if len(completers) > 1:
yield f'{go_name}{sep}chain_completers(' + ', '.join(completers) + ')' yield f'{go_name}{sep}cli.ChainCompleters(' + ', '.join(completers) + ')'
elif completers: elif completers:
yield f'{go_name}{sep}{completers[0]}' yield f'{go_name}{sep}{completers[0]}'
@ -161,7 +159,7 @@ class GoOption:
c = ', '.join(self.sorted_choices) c = ', '.join(self.sorted_choices)
cx = ', '.join(f'"{serialize_as_go_string(x)}"' for x in self.sorted_choices) cx = ', '.join(f'"{serialize_as_go_string(x)}"' for x in self.sorted_choices)
ans += f'\nChoices: "{serialize_as_go_string(c)}",\n' ans += f'\nChoices: "{serialize_as_go_string(c)}",\n'
ans += f'\nCompleter: NamesCompleter("Choices for {self.long}", {cx}),' ans += f'\nCompleter: cli.NamesCompleter("Choices for {self.long}", {cx}),'
elif self.obj_dict['completion'].type is not CompletionType.none: elif self.obj_dict['completion'].type is not CompletionType.none:
ans += ''.join(self.obj_dict['completion'].as_go_code('Completer', ': ')) + ',' ans += ''.join(self.obj_dict['completion'].as_go_code('Completer', ': ')) + ','
if depth > 0: if depth > 0:
@ -878,6 +876,7 @@ completion=type:file ext:conf group:"Config files" kwds:none,NONE
--override -o --override -o
type=list type=list
completion=type:special group:complete_kitty_override
Override individual configuration options, can be specified multiple times. Override individual configuration options, can be specified multiple times.
Syntax: :italic:`name=value`. For example: :option:`{appname} -o` font_size=20 Syntax: :italic:`name=value`. For example: :option:`{appname} -o` font_size=20
@ -936,6 +935,7 @@ regardless of this option.
--listen-on --listen-on
completion=type:special group:complete_kitty_listen_on
Listen on the specified socket address for control messages. For example, Listen on the specified socket address for control messages. For example,
:option:`{appname} --listen-on`=unix:/tmp/mykitty or :option:`{appname} :option:`{appname} --listen-on`=unix:/tmp/mykitty or :option:`{appname}
--listen-on`=tcp:localhost:12345. On Linux systems, you can also use abstract --listen-on`=tcp:localhost:12345. On Linux systems, you can also use abstract

View File

@ -203,7 +203,7 @@ class ArgsHandling:
if c is not None: if c is not None:
yield f'{go_name}.StopCompletingAtArg = {c}' yield f'{go_name}.StopCompletingAtArg = {c}'
if self.completion: if self.completion:
yield from self.completion.as_go_code(go_name) yield from self.completion.as_go_code(go_name + '.ArgCompleter', ' = ')
def as_go_code(self, cmd_name: str, field_types: Dict[str, str], handled_fields: Set[str]) -> Iterator[str]: def as_go_code(self, cmd_name: str, field_types: Dict[str, str], handled_fields: Set[str]) -> Iterator[str]:
c = self.args_count c = self.args_count

View File

@ -73,7 +73,7 @@ If specified the tab containing the window this command is run in is used
instead of the active tab instead of the active tab
''' + '\n\n' + launch_options_spec().replace(':option:`launch', ':option:`kitty @ launch') ''' + '\n\n' + launch_options_spec().replace(':option:`launch', ':option:`kitty @ launch')
args = RemoteCommand.Args(spec='[CMD ...]', json_field='args', completion=RemoteCommand.CompletionSpec.from_string( args = RemoteCommand.Args(spec='[CMD ...]', json_field='args', completion=RemoteCommand.CompletionSpec.from_string(
'type:special group:complete_kitty')) 'type:special group:cli.CompleteExecutableFirstArg'))
def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
ans = {'args': args or []} ans = {'args': args or []}

View File

@ -40,8 +40,8 @@ type Command struct {
Args []string Args []string
option_map map[string]*Option option_map map[string]*Option
index_of_first_arg int IndexOfFirstArg int
} }
func (self *Command) Clone(parent *Command) *Command { func (self *Command) Clone(parent *Command) *Command {

View File

@ -1,131 +0,0 @@
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
package cli
import (
"bufio"
"bytes"
"fmt"
"kitty/tools/utils"
"os"
"os/exec"
"path/filepath"
"strings"
"golang.org/x/sys/unix"
)
var _ = fmt.Print
func complete_kitty(completions *Completions, word string, arg_num int) {
if arg_num > 1 {
completions.Delegate.NumToRemove = completions.current_cmd.index_of_first_arg + 1 // +1 because the first word is not present in all_words
completions.Delegate.Command = completions.all_words[completions.current_cmd.index_of_first_arg]
return
}
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 {
mg := completions.add_match_group("Executables")
mg.IsFiles = true
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(entry.abspath)
if err == nil {
for _, x := range entries {
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(entry.abspath, unix.X_OK) == nil {
mg.add_match(entry.completion_candidate)
}
}, "")
}
}
func complete_kitty_override(title string, names []string) CompletionFunc {
return func(completions *Completions, word string, arg_num int) {
mg := completions.add_match_group(title)
mg.NoTrailingSpace = true
for _, q := range names {
if strings.HasPrefix(q, word) {
mg.add_match(q + "=")
}
}
}
}
func complete_kitty_listen_on(completions *Completions, word string, arg_num int) {
if !strings.Contains(word, ":") {
mg := completions.add_match_group("Address family")
mg.NoTrailingSpace = true
for _, q := range []string{"unix:", "tcp:"} {
if strings.HasPrefix(q, word) {
mg.add_match(q)
}
}
} else if strings.HasPrefix(word, "unix:") && !strings.HasPrefix(word, "unix:@") {
fnmatch_completer("UNIX sockets", CWD, "*")(completions, word[len("unix:"):], arg_num)
completions.add_prefix_to_all_matches("unix:")
}
}
func complete_plus_launch(completions *Completions, word string, arg_num int) {
if arg_num == 1 {
fnmatch_completer("Python scripts", CWD, "*.py")(completions, word, arg_num)
if strings.HasPrefix(word, ":") {
exes := complete_executables_in_path(word[1:])
mg := completions.add_match_group("Python scripts in PATH")
for _, exe := range exes {
mg.add_match(":" + exe)
}
}
} else {
fnmatch_completer("Files", CWD, "*")(completions, word, arg_num)
}
}
func complete_plus_runpy(completions *Completions, word string, arg_num int) {
if arg_num > 1 {
fnmatch_completer("Files", CWD, "*")(completions, word, arg_num)
}
}
func complete_plus_open(completions *Completions, word string, arg_num int) {
fnmatch_completer("Files", CWD, "*")(completions, word, arg_num)
}
func complete_themes(completions *Completions, word string, arg_num int) {
kitty, err := utils.KittyExe()
if err == nil {
out, err := exec.Command(kitty, "+runpy", "from kittens.themes.collection import *; print_theme_names()").Output()
if err == nil {
mg := completions.add_match_group("Themes")
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
theme_name := strings.TrimSpace(scanner.Text())
if theme_name != "" && strings.HasPrefix(theme_name, word) {
mg.add_match(theme_name)
}
}
}
}
}
func completion_for_wrapper(wrapped_cmd string) func(*Command, []string, *Completions) {
return func(cmd *Command, args []string, completions *Completions) {
completions.Delegate.NumToRemove = completions.current_word_idx + 1
completions.Delegate.Command = wrapped_cmd
}
}

View File

@ -42,9 +42,16 @@ func init() {
output_serializers["json"] = json_output_serializer output_serializers["json"] = json_output_serializer
} }
var registered_exes = make(map[string]func(root *Command)) var registered_exes []func(root *Command)
func Main(args []string) error { func RegisterExeForCompletion(x func(root *Command)) {
if registered_exes == nil {
registered_exes = make([]func(root *Command), 0, 4)
}
registered_exes = append(registered_exes, x)
}
func GenerateCompletions(args []string) error {
output_type := "json" output_type := "json"
if len(args) > 0 { if len(args) > 0 {
output_type = args[0] output_type = args[0]

View File

@ -18,7 +18,7 @@ func (self *Completions) add_group(group *MatchGroup) {
} }
func (self *Completions) add_options_group(options []*Option, word string) { func (self *Completions) add_options_group(options []*Option, word string) {
group := self.add_match_group("Options") group := self.AddMatchGroup("Options")
if strings.HasPrefix(word, "--") { if strings.HasPrefix(word, "--") {
if word == "--" { if word == "--" {
group.Matches = append(group.Matches, &Match{Word: "--", Description: "End of options"}) group.Matches = append(group.Matches, &Match{Word: "--", Description: "End of options"})
@ -38,7 +38,7 @@ func (self *Completions) add_options_group(options []*Option, word string) {
has_single_letter_alias := false has_single_letter_alias := false
for _, q := range opt.Aliases { for _, q := range opt.Aliases {
if q.IsShort { if q.IsShort {
group.add_match("-"+q.NameWithoutHyphens, opt.Help) group.AddMatch("-"+q.NameWithoutHyphens, opt.Help)
has_single_letter_alias = true has_single_letter_alias = true
break break
} }
@ -46,7 +46,7 @@ func (self *Completions) add_options_group(options []*Option, word string) {
if !has_single_letter_alias { if !has_single_letter_alias {
for _, q := range opt.Aliases { for _, q := range opt.Aliases {
if !q.IsShort { if !q.IsShort {
group.add_match(q.String(), opt.Help) group.AddMatch(q.String(), opt.Help)
break break
} }
} }
@ -58,7 +58,7 @@ func (self *Completions) add_options_group(options []*Option, word string) {
for _, opt := range options { for _, opt := range options {
for _, q := range opt.Aliases { for _, q := range opt.Aliases {
if q.IsShort && q.NameWithoutHyphens == last_letter { if q.IsShort && q.NameWithoutHyphens == last_letter {
group.add_match(word, opt.Help) group.AddMatch(word, opt.Help)
return return
} }
} }
@ -69,13 +69,13 @@ func (self *Completions) add_options_group(options []*Option, word string) {
func (self *Command) sub_command_allowed_at(completions *Completions, arg_num int) bool { func (self *Command) sub_command_allowed_at(completions *Completions, arg_num int) bool {
if self.SubCommandMustBeFirst { if self.SubCommandMustBeFirst {
return arg_num == 1 && completions.current_word_idx_in_parent == 1 return arg_num == 1 && completions.CurrentWordIdxInParent == 1
} }
return arg_num == 1 return arg_num == 1
} }
func complete_word(word string, completions *Completions, only_args_allowed bool, expecting_arg_for *Option, arg_num int) { func complete_word(word string, completions *Completions, only_args_allowed bool, expecting_arg_for *Option, arg_num int) {
cmd := completions.current_cmd cmd := completions.CurrentCmd
if expecting_arg_for != nil { if expecting_arg_for != nil {
if expecting_arg_for.Completer != nil { if expecting_arg_for.Completer != nil {
expecting_arg_for.Completer(completions, word, arg_num) expecting_arg_for.Completer(completions, word, arg_num)
@ -89,7 +89,7 @@ func complete_word(word string, completions *Completions, only_args_allowed bool
if option != nil { if option != nil {
if option.Completer != nil { if option.Completer != nil {
option.Completer(completions, word[idx+1:], arg_num) option.Completer(completions, word[idx+1:], arg_num)
completions.add_prefix_to_all_matches(word[:idx+1]) completions.AddPrefixToAllMatches(word[:idx+1])
} }
} }
} else { } else {
@ -99,13 +99,17 @@ func complete_word(word string, completions *Completions, only_args_allowed bool
} }
if cmd.HasVisibleSubCommands() && cmd.sub_command_allowed_at(completions, arg_num) { if cmd.HasVisibleSubCommands() && cmd.sub_command_allowed_at(completions, arg_num) {
for _, cg := range cmd.SubCommandGroups { for _, cg := range cmd.SubCommandGroups {
group := completions.add_match_group(cg.Title) group := completions.AddMatchGroup(cg.Title)
if group.Title == "" { if group.Title == "" {
group.Title = "Sub-commands" group.Title = "Sub-commands"
} }
for _, sc := range cg.SubCommands { for _, sc := range cg.SubCommands {
if strings.HasPrefix(sc.Name, word) { if strings.HasPrefix(sc.Name, word) {
group.add_match(sc.Name, sc.HelpText) t := sc.ShortDescription
if t == "" {
t = sc.HelpText
}
group.AddMatch(sc.Name, t)
} }
} }
} }
@ -122,21 +126,21 @@ func complete_word(word string, completions *Completions, only_args_allowed bool
} }
func completion_parse_args(cmd *Command, words []string, completions *Completions) { func completion_parse_args(cmd *Command, words []string, completions *Completions) {
completions.current_cmd = cmd completions.CurrentCmd = cmd
if len(words) == 0 { if len(words) == 0 {
complete_word("", completions, false, nil, 0) complete_word("", completions, false, nil, 0)
return return
} }
completions.all_words = words completions.AllWords = words
var expecting_arg_for *Option var expecting_arg_for *Option
only_args_allowed := false only_args_allowed := false
arg_num := 0 arg_num := 0
for i, word := range words { for i, word := range words {
cmd = completions.current_cmd cmd = completions.CurrentCmd
completions.current_word_idx = i completions.CurrentWordIdx = i
completions.current_word_idx_in_parent++ completions.CurrentWordIdxInParent++
is_last_word := i == len(words)-1 is_last_word := i == len(words)-1
is_option_equal := completions.split_on_equals && word == "=" && expecting_arg_for != nil is_option_equal := completions.split_on_equals && word == "=" && expecting_arg_for != nil
if only_args_allowed || (expecting_arg_for == nil && !strings.HasPrefix(word, "-")) { if only_args_allowed || (expecting_arg_for == nil && !strings.HasPrefix(word, "-")) {
@ -144,7 +148,7 @@ func completion_parse_args(cmd *Command, words []string, completions *Completion
arg_num++ arg_num++
} }
if arg_num == 1 { if arg_num == 1 {
cmd.index_of_first_arg = completions.current_word_idx cmd.IndexOfFirstArg = completions.CurrentWordIdx
} }
} }
if is_last_word { if is_last_word {
@ -179,10 +183,10 @@ func completion_parse_args(cmd *Command, words []string, completions *Completion
only_args_allowed = true only_args_allowed = true
continue continue
} }
completions.current_cmd = sc completions.CurrentCmd = sc
cmd = sc cmd = sc
arg_num = 0 arg_num = 0
completions.current_word_idx_in_parent = 0 completions.CurrentWordIdxInParent = 0
only_args_allowed = false only_args_allowed = false
if cmd.ParseArgsForCompletion != nil { if cmd.ParseArgsForCompletion != nil {
cmd.ParseArgsForCompletion(cmd, words[i+1:], completions) cmd.ParseArgsForCompletion(cmd, words[i+1:], completions)

View File

@ -46,13 +46,13 @@ func (self *MatchGroup) remove_common_prefix() string {
return "" return ""
} }
func (self *MatchGroup) add_match(word string, description ...string) *Match { func (self *MatchGroup) AddMatch(word string, description ...string) *Match {
ans := Match{Word: word, Description: strings.Join(description, " ")} ans := Match{Word: word, Description: strings.Join(description, " ")}
self.Matches = append(self.Matches, &ans) self.Matches = append(self.Matches, &ans)
return &ans return &ans
} }
func (self *MatchGroup) add_prefix_to_all_matches(prefix string) { func (self *MatchGroup) AddPrefixToAllMatches(prefix string) {
for _, m := range self.Matches { for _, m := range self.Matches {
m.Word = prefix + m.Word m.Word = prefix + m.Word
} }
@ -107,21 +107,21 @@ type Completions struct {
Groups []*MatchGroup `json:"groups,omitempty"` Groups []*MatchGroup `json:"groups,omitempty"`
Delegate Delegate `json:"delegate,omitempty"` Delegate Delegate `json:"delegate,omitempty"`
current_cmd *Command CurrentCmd *Command
all_words []string // all words passed to parse_args() AllWords []string // all words passed to parse_args()
current_word_idx int // index of current word in all_words CurrentWordIdx int // index of current word in all_words
current_word_idx_in_parent int // index of current word in parents command line 1 for first word after parent CurrentWordIdxInParent int // index of current word in parents command line 1 for first word after parent
split_on_equals bool // true if the cmdline is split on = (BASH does this because readline does this) split_on_equals bool // true if the cmdline is split on = (BASH does this because readline does this)
} }
func (self *Completions) add_prefix_to_all_matches(prefix string) { func (self *Completions) AddPrefixToAllMatches(prefix string) {
for _, mg := range self.Groups { for _, mg := range self.Groups {
mg.add_prefix_to_all_matches(prefix) mg.AddPrefixToAllMatches(prefix)
} }
} }
func (self *Completions) add_match_group(title string) *MatchGroup { func (self *Completions) AddMatchGroup(title string) *MatchGroup {
for _, q := range self.Groups { for _, q := range self.Groups {
if q.Title == title { if q.Title == title {
return q return q
@ -136,10 +136,10 @@ type CompletionFunc func(completions *Completions, word string, arg_num int)
func NamesCompleter(title string, names ...string) CompletionFunc { func NamesCompleter(title string, names ...string) CompletionFunc {
return func(completions *Completions, word string, arg_num int) { return func(completions *Completions, word string, arg_num int) {
mg := completions.add_match_group(title) mg := completions.AddMatchGroup(title)
for _, q := range names { for _, q := range names {
if strings.HasPrefix(q, word) { if strings.HasPrefix(q, word) {
mg.add_match(q) mg.AddMatch(q)
} }
} }
} }
@ -152,3 +152,10 @@ func ChainCompleters(completers ...CompletionFunc) CompletionFunc {
} }
} }
} }
func CompletionForWrapper(wrapped_cmd string) func(completions *Completions, word string, arg_num int) {
return func(completions *Completions, word string, arg_num int) {
completions.Delegate.NumToRemove = completions.CurrentWordIdx + 1
completions.Delegate.Command = wrapped_cmd
}
}

View File

@ -1,156 +0,0 @@
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
package completion
import (
"kitty/tools/utils"
"kitty/tools/wcswidth"
"path/filepath"
"strings"
)
type Option struct {
Name string
Aliases []string
Description string
Has_following_arg bool
Completion_for_arg CompletionFunc
}
type CommandGroup struct {
Title string
Commands []*Command
}
type Command struct {
Name string
Description string
Options []*Option
Groups []*CommandGroup
Completion_for_arg CompletionFunc
Stop_processing_at_arg int
First_arg_may_not_be_subcommand bool
Subcommand_must_be_first bool
Parse_args func(*Command, []string, *Completions)
// index in Completions.all_words of the first non-option argument to this command.
// A value of zero means no arg was found while parsing.
index_of_first_arg int
}
func (self *Command) clone_options_from(other *Command) {
for _, opt := range other.Options {
self.Options = append(self.Options, opt)
}
}
func (self *Command) add_group(name string) *CommandGroup {
for _, g := range self.Groups {
if g.Title == name {
return g
}
}
g := CommandGroup{Title: name, Commands: make([]*Command, 0, 8)}
self.Groups = append(self.Groups, &g)
return &g
}
func (self *Command) add_command(name string, group_title string) *Command {
ans := Command{Name: name}
ans.Options = make([]*Option, 0, 8)
ans.Groups = make([]*CommandGroup, 0, 2)
g := self.add_group(group_title)
g.Commands = append(g.Commands, &ans)
return &ans
}
func (self *Command) add_clone(name string, group_title string, clone_of *Command) *Command {
ans := *clone_of
ans.Name = name
g := self.add_group(group_title)
g.Commands = append(g.Commands, &ans)
return &ans
}
func (self *Command) find_subcommand(is_ok func(cmd *Command) bool) *Command {
for _, g := range self.Groups {
for _, q := range g.Commands {
if is_ok(q) {
return q
}
}
}
return nil
}
func (self *Command) find_subcommand_with_name(name string) *Command {
return self.find_subcommand(func(cmd *Command) bool { return cmd.Name == name })
}
func (self *Command) has_subcommands() bool {
for _, g := range self.Groups {
if len(g.Commands) > 0 {
return true
}
}
return false
}
func (self *Command) sub_command_allowed_at(completions *Completions, arg_num int) bool {
if self.Subcommand_must_be_first {
return arg_num == 1 && completions.current_word_idx_in_parent == 1
}
return arg_num == 1
}
func (self *Command) add_option(opt *Option) {
self.Options = append(self.Options, opt)
}
func (self *Command) GetCompletions(argv []string, init_completions func(*Completions)) *Completions {
ans := Completions{Groups: make([]*MatchGroup, 0, 4)}
if init_completions != nil {
init_completions(&ans)
}
if len(argv) > 0 {
exe := argv[0]
cmd := self.find_subcommand_with_name(exe)
if cmd != nil {
if cmd.Parse_args != nil {
cmd.Parse_args(cmd, argv[1:], &ans)
} else {
default_parse_args(cmd, 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
}
func names_completer(title string, names ...string) CompletionFunc {
return func(completions *Completions, word string, arg_num int) {
mg := completions.add_match_group(title)
for _, q := range names {
if strings.HasPrefix(q, word) {
mg.add_match(q)
}
}
}
}
func chain_completers(completers ...CompletionFunc) CompletionFunc {
return func(completions *Completions, word string, arg_num int) {
for _, f := range completers {
f(completions, word, arg_num)
}
}
}

View File

@ -26,12 +26,12 @@ func absolutize_path(path string) string {
} }
type FileEntry struct { type FileEntry struct {
name, completion_candidate, abspath string Name, CompletionCandidate, Abspath string
mode os.FileMode Mode os.FileMode
is_dir, is_symlink, is_empty_dir bool IsDir, IsSymlink, IsEmptyDir bool
} }
func complete_files(prefix string, callback func(*FileEntry), cwd string) error { func CompleteFiles(prefix string, callback func(*FileEntry), cwd string) error {
if cwd == "" { if cwd == "" {
var err error var err error
cwd, err = os.Getwd() cwd, err = os.Getwd()
@ -90,24 +90,24 @@ func complete_files(prefix string, callback func(*FileEntry), cwd string) error
abspath := filepath.Join(base_dir, entry.Name()) abspath := filepath.Join(base_dir, entry.Name())
dir_to_check := "" dir_to_check := ""
data := FileEntry{ data := FileEntry{
name: entry.Name(), abspath: abspath, mode: entry.Type(), is_dir: entry.IsDir(), Name: entry.Name(), Abspath: abspath, Mode: entry.Type(), IsDir: entry.IsDir(),
is_symlink: entry.Type()&os.ModeSymlink == os.ModeSymlink, completion_candidate: q} IsSymlink: entry.Type()&os.ModeSymlink == os.ModeSymlink, CompletionCandidate: q}
if data.is_symlink { if data.IsSymlink {
target, err := filepath.EvalSymlinks(abspath) target, err := filepath.EvalSymlinks(abspath)
if err == nil && target != base_dir { if err == nil && target != base_dir {
td, err := os.Stat(target) td, err := os.Stat(target)
if err == nil && td.IsDir() { if err == nil && td.IsDir() {
dir_to_check = target dir_to_check = target
data.is_dir = true data.IsDir = true
} }
} }
} }
if dir_to_check != "" { if dir_to_check != "" {
subentries, err := os.ReadDir(dir_to_check) subentries, err := os.ReadDir(dir_to_check)
data.is_empty_dir = err != nil || len(subentries) == 0 data.IsEmptyDir = err != nil || len(subentries) == 0
} }
if data.is_dir { if data.IsDir {
data.completion_candidate += utils.Sep data.CompletionCandidate += utils.Sep
} }
callback(&data) callback(&data)
} }
@ -150,22 +150,22 @@ func is_dir_or_symlink_to_dir(entry os.DirEntry, path string) bool {
func fname_based_completer(prefix, cwd string, is_match func(string) bool) []string { func fname_based_completer(prefix, cwd string, is_match func(string) bool) []string {
ans := make([]string, 0, 1024) ans := make([]string, 0, 1024)
complete_files(prefix, func(entry *FileEntry) { CompleteFiles(prefix, func(entry *FileEntry) {
if entry.is_dir && !entry.is_empty_dir { if entry.IsDir && !entry.IsEmptyDir {
entries, err := os.ReadDir(entry.abspath) entries, err := os.ReadDir(entry.Abspath)
if err == nil { if err == nil {
for _, e := range entries { for _, e := range entries {
if is_match(e.Name()) || is_dir_or_symlink_to_dir(e, filepath.Join(entry.abspath, e.Name())) { if is_match(e.Name()) || is_dir_or_symlink_to_dir(e, filepath.Join(entry.Abspath, e.Name())) {
ans = append(ans, entry.completion_candidate) ans = append(ans, entry.CompletionCandidate)
return return
} }
} }
} }
return return
} }
q := strings.ToLower(entry.name) q := strings.ToLower(entry.Name)
if is_match(q) { if is_match(q) {
ans = append(ans, entry.completion_candidate) ans = append(ans, entry.CompletionCandidate)
} }
}, cwd) }, cwd)
return ans return ans
@ -244,15 +244,52 @@ func make_completer(title string, relative_to relative_to, patterns []string, f
return func(completions *Completions, word string, arg_num int) { return func(completions *Completions, word string, arg_num int) {
q := f(word, cwd, lpats) q := f(word, cwd, lpats)
if len(q) > 0 { if len(q) > 0 {
mg := completions.add_match_group(title) mg := completions.AddMatchGroup(title)
mg.IsFiles = true mg.IsFiles = true
for _, c := range q { for _, c := range q {
mg.add_match(c) mg.AddMatch(c)
} }
} }
} }
} }
func CompleteExecutableFirstArg(completions *Completions, word string, arg_num int) {
if arg_num > 1 {
completions.Delegate.NumToRemove = completions.CurrentCmd.IndexOfFirstArg + 1 // +1 because the first word is not present in all_words
completions.Delegate.Command = completions.AllWords[completions.CurrentCmd.IndexOfFirstArg]
return
}
exes := CompleteExecutablesInPath(word)
if len(exes) > 0 {
mg := completions.AddMatchGroup("Executables in PATH")
for _, exe := range exes {
mg.AddMatch(exe)
}
}
if len(word) > 0 {
mg := completions.AddMatchGroup("Executables")
mg.IsFiles = true
CompleteFiles(word, func(entry *FileEntry) {
if entry.IsDir && !entry.IsEmptyDir {
// only allow directories that have sub-dirs or executable files in them
entries, err := os.ReadDir(entry.Abspath)
if err == nil {
for _, x := range entries {
if x.IsDir() || unix.Access(filepath.Join(entry.Abspath, x.Name()), unix.X_OK) == nil {
mg.AddMatch(entry.CompletionCandidate)
break
}
}
}
} else if unix.Access(entry.Abspath, unix.X_OK) == nil {
mg.AddMatch(entry.CompletionCandidate)
}
}, "")
}
}
func FnmatchCompleter(title string, relative_to relative_to, patterns ...string) CompletionFunc { func FnmatchCompleter(title string, relative_to relative_to, patterns ...string) CompletionFunc {
return make_completer(title, relative_to, patterns, complete_by_fnmatch) return make_completer(title, relative_to, patterns, complete_by_fnmatch)
} }
@ -268,12 +305,12 @@ func DirectoryCompleter(title string, relative_to relative_to) CompletionFunc {
cwd := get_cwd_for_completion(relative_to) cwd := get_cwd_for_completion(relative_to)
return func(completions *Completions, word string, arg_num int) { return func(completions *Completions, word string, arg_num int) {
mg := completions.add_match_group(title) mg := completions.AddMatchGroup(title)
mg.NoTrailingSpace = true mg.NoTrailingSpace = true
mg.IsFiles = true mg.IsFiles = true
complete_files(word, func(entry *FileEntry) { CompleteFiles(word, func(entry *FileEntry) {
if entry.mode.IsDir() { if entry.Mode.IsDir() {
mg.add_match(entry.completion_candidate) mg.AddMatch(entry.CompletionCandidate)
} }
}, cwd) }, cwd)
} }

View File

@ -39,10 +39,10 @@ func TestCompleteFiles(t *testing.T) {
} }
sort.Strings(expected) sort.Strings(expected)
actual := make([]string, 0, len(expected)) actual := make([]string, 0, len(expected))
complete_files(prefix, func(entry *FileEntry) { CompleteFiles(prefix, func(entry *FileEntry) {
actual = append(actual, entry.completion_candidate) actual = append(actual, entry.CompletionCandidate)
if _, err := os.Stat(entry.abspath); err != nil { if _, err := os.Stat(entry.Abspath); err != nil {
t.Fatalf("Abspath does not exist: %#v", entry.abspath) t.Fatalf("Abspath does not exist: %#v", entry.Abspath)
} }
}, "") }, "")
sort.Strings(actual) sort.Strings(actual)

View File

@ -18,9 +18,9 @@ func fish_output_serializer(completions []*Completions, shell_state map[string]s
n := completions[0].Delegate.NumToRemove n := completions[0].Delegate.NumToRemove
fm := markup.New(false) // fish freaks out if there are escape codes in the description strings fm := markup.New(false) // fish freaks out if there are escape codes in the description strings
if n > 0 { if n > 0 {
words := make([]string, len(completions[0].all_words)-n+1) words := make([]string, len(completions[0].AllWords)-n+1)
words[0] = completions[0].Delegate.Command words[0] = completions[0].Delegate.Command
copy(words[1:], completions[0].all_words[n:]) copy(words[1:], completions[0].AllWords[n:])
for i, w := range words { for i, w := range words {
words[i] = fmt.Sprintf("(string escape -- %s)", utils.QuoteStringForFish(w)) words[i] = fmt.Sprintf("(string escape -- %s)", utils.QuoteStringForFish(w))
} }

View File

@ -0,0 +1,96 @@
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
package completion
import (
"bufio"
"bytes"
"fmt"
"os/exec"
"strings"
"kitty/tools/cli"
"kitty/tools/utils"
)
var _ = fmt.Print
func complete_kitty_override(completions *cli.Completions, word string, arg_num int) {
mg := completions.AddMatchGroup("Config directives")
mg.NoTrailingSpace = true
for _, q := range kitty_option_names_for_completion {
if strings.HasPrefix(q, word) {
mg.AddMatch(q + "=")
}
}
}
func complete_kitty_listen_on(completions *cli.Completions, word string, arg_num int) {
if !strings.Contains(word, ":") {
mg := completions.AddMatchGroup("Address family")
mg.NoTrailingSpace = true
for _, q := range []string{"unix:", "tcp:"} {
if strings.HasPrefix(q, word) {
mg.AddMatch(q)
}
}
} else if strings.HasPrefix(word, "unix:") && !strings.HasPrefix(word, "unix:@") {
cli.FnmatchCompleter("UNIX sockets", cli.CWD, "*")(completions, word[len("unix:"):], arg_num)
completions.AddPrefixToAllMatches("unix:")
}
}
func complete_plus_launch(completions *cli.Completions, word string, arg_num int) {
if arg_num == 1 {
cli.FnmatchCompleter("Python scripts", cli.CWD, "*.py")(completions, word, arg_num)
if strings.HasPrefix(word, ":") {
exes := cli.CompleteExecutablesInPath(word[1:])
mg := completions.AddMatchGroup("Python scripts in PATH")
for _, exe := range exes {
mg.AddMatch(":" + exe)
}
}
} else {
cli.FnmatchCompleter("Files", cli.CWD, "*")(completions, word, arg_num)
}
}
func complete_plus_runpy(completions *cli.Completions, word string, arg_num int) {
if arg_num > 1 {
cli.FnmatchCompleter("Files", cli.CWD, "*")(completions, word, arg_num)
}
}
func complete_plus_open(completions *cli.Completions, word string, arg_num int) {
cli.FnmatchCompleter("Files", cli.CWD, "*")(completions, word, arg_num)
}
func complete_themes(completions *cli.Completions, word string, arg_num int) {
kitty, err := utils.KittyExe()
if err == nil {
out, err := exec.Command(kitty, "+runpy", "from kittens.themes.collection import *; print_theme_names()").Output()
if err == nil {
mg := completions.AddMatchGroup("Themes")
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
theme_name := strings.TrimSpace(scanner.Text())
if theme_name != "" && strings.HasPrefix(theme_name, word) {
mg.AddMatch(theme_name)
}
}
}
}
}
func EntryPoint(tool_root *cli.Command) {
tool_root.AddSubCommand(&cli.Command{
Name: "__complete__", Hidden: true,
Usage: "output_type [shell state...]",
ShortDescription: "Generate completions for kitty commands",
HelpText: "Generate completion candidates for kitty commands. The command line is read from STDIN. output_type can be one of the supported shells or 'json' for JSON output.",
Run: func(cmd *cli.Command, args []string) (ret int, err error) {
return ret, cli.GenerateCompletions(args)
},
})
}

View File

@ -4,28 +4,19 @@ package main
import ( import (
"kitty/tools/cli" "kitty/tools/cli"
"kitty/tools/cli/completion"
"kitty/tools/cmd/at" "kitty/tools/cmd/at"
"kitty/tools/cmd/completion"
) )
func completion_entry_point(tool_root *cli.Command) {
tool_root.AddSubCommand(&cli.Command{
Name: "__complete__", Hidden: true,
Usage: "output_type [shell state...]",
ShortDescription: "Generate completions for kitty commands",
HelpText: "Generate completion candidates for kitty commands. The command line is read from STDIN. output_type can be one of the supported shells or 'json' for JSON output.",
Run: func(cmd *cli.Command, args []string) (ret int, err error) {
return ret, completion.Main(args)
},
})
}
func main() { func main() {
root := cli.NewRootCommand() root := cli.NewRootCommand()
root.ShortDescription = "Fast, statically compiled implementations for various kitty command-line tools" root.ShortDescription = "Fast, statically compiled implementations for various kitty command-line tools"
root.Usage = "command [command options] [command args]" root.Usage = "command [command options] [command args]"
// @
at.EntryPoint(root) at.EntryPoint(root)
completion_entry_point(root) // __complete__
completion.EntryPoint(root)
root.Exec() root.Exec()
} }