diff --git a/gen-go-code.py b/gen-go-code.py index f2d01de70..f660fcbb4 100755 --- a/gen-go-code.py +++ b/gen-go-code.py @@ -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: from kittens.runner import ( all_kitten_names, get_kitten_cli_docs, get_kitten_wrapper_of, ) for kitten in sorted(all_kitten_names()): 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) 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 kcd = get_kitten_cli_docs(kitten) if kcd: ospec = kcd['options'] 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') if ac is not None: - print(''.join(ac.as_go_code(kn))) + print(''.join(ac.as_go_code(kn + '.ArgCompleter', ' = '))) else: - print(f'{kn}.Description = ""') + print(f'{kn}.HelpText = ""') def completion_for_launch_wrappers(*names: str) -> None: @@ -87,63 +77,57 @@ def completion_for_launch_wrappers(*names: str) -> None: for o in opts: if o.obj_dict['name'] in allowed: for name in names: - print(o.as_completion_option(name)) + print(o.as_option(name)) def generate_completions_for_kitty() -> None: + from kitty.config import option_names_for_completion 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 - 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]): - print(opt.as_completion_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') + print(opt.as_option('k')) # kitty + - print('plus := k.add_command("+", "Entry points")') - print('plus.Description = "Various special purpose tools and kittens"') + print('plus := k.AddSubCommand(&cli.Command{Name:"+", Group:"Entry points", ShortDescription: "Various special purpose tools and kittens"})') - print('plus_launch := plus.add_command("launch", "Entry points")') - print('plus_launch.Completion_for_arg = complete_plus_launch') - print('k.add_clone("+launch", "Launch Python scripts", plus_launch)') + print('plus_launch := plus.AddSubCommand(&cli.Command{' + 'Name:"launch", Group:"Entry points", ShortDescription: "Launch Python scripts", ArgCompleter: complete_plus_launch})') + print('k.AddClone("", plus_launch).Name = "+launch"') - print('plus_runpy := plus.add_command("runpy", "Entry points")') - print('plus_runpy.Completion_for_arg = complete_plus_runpy') - print('k.add_clone("+runpy", "Run Python code", plus_runpy)') + print('plus_runpy := plus.AddSubCommand(&cli.Command{' + 'Name: "runpy", Group:"Entry points", ArgCompleter: complete_plus_runpy, ShortDescription: "Run Python code"})') + print('k.AddClone("", plus_runpy).Name = "+runpy"') - print('plus_open := plus.add_command("open", "Entry points")') - print('plus_open.Completion_for_arg = complete_plus_open') - print('plus_open.clone_options_from(k)') - print('k.add_clone("+open", "Open files and URLs", plus_open)') + print('plus_open := plus.AddSubCommand(&cli.Command{' + 'Name:"open", Group:"Entry points", ArgCompleter: complete_plus_open, ShortDescription: "Open files and URLs"})') + print('k.AddClone("", plus_open).Name = "+open"') # kitty +kitten - print('plus_kitten := plus.add_command("kitten", "Kittens")') - print('plus_kitten.Subcommand_must_be_first = true') + print('plus_kitten := plus.AddSubCommand(&cli.Command{Name:"kitten", Group:"Kittens", SubCommandMustBeFirst: true})') 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.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})') + # @ + print('at.EntryPoint(k)') # clone-in-kitty, edit-in-kitty - print('cik := root.add_command("clone-in-kitty", "")') - print('eik := root.add_command("edit-in-kitty", "")') + print('cik := root.AddSubCommand(&cli.Command{Name:"clone-in-kitty"})') + print('eik := root.AddSubCommand(&cli.Command{Name:"edit-in-kitty"})') 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('func init() {') - print('registered_exes["kitty"] = kitty') + print('cli.RegisterExeForCompletion(kitty)') print('}') @@ -187,6 +171,7 @@ def go_code_for_remote_command(name: str, cmd: RemoteCommand, template: str) -> NO_RESPONSE_BASE = 'false' af: List[str] = [] a = af.append + af.extend(cmd.args.as_go_completion_code('ans')) od: List[str] = [] option_map: Dict[str, GoOption] = {} for o in rc_command_options(name): @@ -358,7 +343,7 @@ def update_at_commands() -> None: def update_completion() -> None: orig = sys.stdout 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 generate_completions_for_kitty() finally: diff --git a/kittens/clipboard/main.py b/kittens/clipboard/main.py index b80c21cf2..57b707456 100644 --- a/kittens/clipboard/main.py +++ b/kittens/clipboard/main.py @@ -18,7 +18,6 @@ OPTIONS = r''' --get-clipboard -default=False type=bool-set 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 @@ -26,14 +25,12 @@ --use-primary -default=False type=bool-set Use the primary selection rather than the clipboard on systems that support it, such as X11. --wait-for-completion -default=False type=bool-set Wait till the copy to clipboard is complete before exiting. Useful if running the kitten in a dedicated, ephemeral window. diff --git a/kitty/cli.py b/kitty/cli.py index 3edd1852f..dc51de49b 100644 --- a/kitty/cli.py +++ b/kitty/cli.py @@ -78,24 +78,22 @@ def as_go_code(self, go_name: str, sep: str = ': ') -> Iterator[str]: if 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" - 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' if self.type is CompletionType.file: g = serialize_as_go_string(self.group or 'Files') if 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: - 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: 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: completers.append(self.group) - if go_name: - go_name += '.' if len(completers) > 1: - yield f'{go_name}{sep}chain_completers(' + ', '.join(completers) + ')' + yield f'{go_name}{sep}cli.ChainCompleters(' + ', '.join(completers) + ')' elif completers: yield f'{go_name}{sep}{completers[0]}' @@ -161,7 +159,7 @@ def as_option(self, cmd_name: str = 'cmd', depth: int = 0, group: str = '') -> s c = ', '.join(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'\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: ans += ''.join(self.obj_dict['completion'].as_go_code('Completer', ': ')) + ',' if depth > 0: @@ -878,6 +876,7 @@ def options_spec() -> str: --override -o type=list +completion=type:special group:complete_kitty_override Override individual configuration options, can be specified multiple times. Syntax: :italic:`name=value`. For example: :option:`{appname} -o` font_size=20 @@ -936,6 +935,7 @@ def options_spec() -> str: --listen-on +completion=type:special group:complete_kitty_listen_on Listen on the specified socket address for control messages. For example, :option:`{appname} --listen-on`=unix:/tmp/mykitty or :option:`{appname} --listen-on`=tcp:localhost:12345. On Linux systems, you can also use abstract diff --git a/kitty/rc/base.py b/kitty/rc/base.py index 9b49481b5..f5f8bfbd2 100644 --- a/kitty/rc/base.py +++ b/kitty/rc/base.py @@ -203,7 +203,7 @@ def as_go_completion_code(self, go_name: str) -> Iterator[str]: if c is not None: yield f'{go_name}.StopCompletingAtArg = {c}' 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]: c = self.args_count diff --git a/kitty/rc/launch.py b/kitty/rc/launch.py index 6c14060c5..5c5f8f077 100644 --- a/kitty/rc/launch.py +++ b/kitty/rc/launch.py @@ -73,7 +73,7 @@ class Launch(RemoteCommand): instead of the active tab ''' + '\n\n' + launch_options_spec().replace(':option:`launch', ':option:`kitty @ launch') 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: ans = {'args': args or []} diff --git a/tools/cli/command.go b/tools/cli/command.go index 642b76447..76ec0c5e0 100644 --- a/tools/cli/command.go +++ b/tools/cli/command.go @@ -40,8 +40,8 @@ type Command struct { Args []string - option_map map[string]*Option - index_of_first_arg int + option_map map[string]*Option + IndexOfFirstArg int } func (self *Command) Clone(parent *Command) *Command { diff --git a/tools/cli/completion-kitty.go b/tools/cli/completion-kitty.go deleted file mode 100644 index 3a3c6d769..000000000 --- a/tools/cli/completion-kitty.go +++ /dev/null @@ -1,131 +0,0 @@ -// License: GPLv3 Copyright: 2022, Kovid Goyal, - -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 - } -} diff --git a/tools/cli/completion-main.go b/tools/cli/completion-main.go index fbcf7e55b..5c560c1b6 100644 --- a/tools/cli/completion-main.go +++ b/tools/cli/completion-main.go @@ -42,9 +42,16 @@ func init() { 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" if len(args) > 0 { output_type = args[0] diff --git a/tools/cli/completion-parse-args.go b/tools/cli/completion-parse-args.go index be3a249b7..335902685 100644 --- a/tools/cli/completion-parse-args.go +++ b/tools/cli/completion-parse-args.go @@ -18,7 +18,7 @@ func (self *Completions) add_group(group *MatchGroup) { } 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 word == "--" { 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 for _, q := range opt.Aliases { if q.IsShort { - group.add_match("-"+q.NameWithoutHyphens, opt.Help) + group.AddMatch("-"+q.NameWithoutHyphens, opt.Help) has_single_letter_alias = true break } @@ -46,7 +46,7 @@ func (self *Completions) add_options_group(options []*Option, word string) { if !has_single_letter_alias { for _, q := range opt.Aliases { if !q.IsShort { - group.add_match(q.String(), opt.Help) + group.AddMatch(q.String(), opt.Help) break } } @@ -58,7 +58,7 @@ func (self *Completions) add_options_group(options []*Option, word string) { for _, opt := range options { for _, q := range opt.Aliases { if q.IsShort && q.NameWithoutHyphens == last_letter { - group.add_match(word, opt.Help) + group.AddMatch(word, opt.Help) 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 { 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 } 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.Completer != nil { 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.Completer != nil { option.Completer(completions, word[idx+1:], arg_num) - completions.add_prefix_to_all_matches(word[:idx+1]) + completions.AddPrefixToAllMatches(word[:idx+1]) } } } 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) { for _, cg := range cmd.SubCommandGroups { - group := completions.add_match_group(cg.Title) + group := completions.AddMatchGroup(cg.Title) if group.Title == "" { group.Title = "Sub-commands" } for _, sc := range cg.SubCommands { 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) { - completions.current_cmd = cmd + completions.CurrentCmd = cmd if len(words) == 0 { complete_word("", completions, false, nil, 0) return } - completions.all_words = words + completions.AllWords = words var expecting_arg_for *Option only_args_allowed := false arg_num := 0 for i, word := range words { - cmd = completions.current_cmd - completions.current_word_idx = i - completions.current_word_idx_in_parent++ + cmd = completions.CurrentCmd + completions.CurrentWordIdx = i + completions.CurrentWordIdxInParent++ is_last_word := i == len(words)-1 is_option_equal := completions.split_on_equals && word == "=" && expecting_arg_for != nil 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++ } if arg_num == 1 { - cmd.index_of_first_arg = completions.current_word_idx + cmd.IndexOfFirstArg = completions.CurrentWordIdx } } if is_last_word { @@ -179,10 +183,10 @@ func completion_parse_args(cmd *Command, words []string, completions *Completion only_args_allowed = true continue } - completions.current_cmd = sc + completions.CurrentCmd = sc cmd = sc arg_num = 0 - completions.current_word_idx_in_parent = 0 + completions.CurrentWordIdxInParent = 0 only_args_allowed = false if cmd.ParseArgsForCompletion != nil { cmd.ParseArgsForCompletion(cmd, words[i+1:], completions) diff --git a/tools/cli/completion.go b/tools/cli/completion.go index d3c46e259..393710bf2 100644 --- a/tools/cli/completion.go +++ b/tools/cli/completion.go @@ -46,13 +46,13 @@ func (self *MatchGroup) remove_common_prefix() string { 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, " ")} self.Matches = append(self.Matches, &ans) return &ans } -func (self *MatchGroup) add_prefix_to_all_matches(prefix string) { +func (self *MatchGroup) AddPrefixToAllMatches(prefix string) { for _, m := range self.Matches { m.Word = prefix + m.Word } @@ -107,21 +107,21 @@ type Completions struct { Groups []*MatchGroup `json:"groups,omitempty"` Delegate Delegate `json:"delegate,omitempty"` - current_cmd *Command - all_words []string // all words passed to parse_args() - current_word_idx 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 + CurrentCmd *Command + AllWords []string // all words passed to parse_args() + CurrentWordIdx int // index of current word in all_words + 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) } -func (self *Completions) add_prefix_to_all_matches(prefix string) { +func (self *Completions) AddPrefixToAllMatches(prefix string) { 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 { if q.Title == title { return q @@ -136,10 +136,10 @@ type CompletionFunc func(completions *Completions, word string, arg_num int) func NamesCompleter(title string, names ...string) CompletionFunc { return func(completions *Completions, word string, arg_num int) { - mg := completions.add_match_group(title) + mg := completions.AddMatchGroup(title) for _, q := range names { 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 + } +} diff --git a/tools/cli/completion/types.go b/tools/cli/completion/types.go deleted file mode 100644 index 96a3473ae..000000000 --- a/tools/cli/completion/types.go +++ /dev/null @@ -1,156 +0,0 @@ -// License: GPLv3 Copyright: 2022, Kovid Goyal, - -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) - } - } -} diff --git a/tools/cli/files.go b/tools/cli/files.go index 252aea4ec..b2b8ef18b 100644 --- a/tools/cli/files.go +++ b/tools/cli/files.go @@ -26,12 +26,12 @@ func absolutize_path(path string) string { } type FileEntry struct { - name, completion_candidate, abspath string - mode os.FileMode - is_dir, is_symlink, is_empty_dir bool + Name, CompletionCandidate, Abspath string + Mode os.FileMode + 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 == "" { var err error 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()) 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 { + Name: entry.Name(), Abspath: abspath, Mode: entry.Type(), IsDir: entry.IsDir(), + IsSymlink: entry.Type()&os.ModeSymlink == os.ModeSymlink, CompletionCandidate: q} + if data.IsSymlink { 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 + data.IsDir = true } } } if 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 { - data.completion_candidate += utils.Sep + if data.IsDir { + data.CompletionCandidate += utils.Sep } 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 { ans := make([]string, 0, 1024) - complete_files(prefix, func(entry *FileEntry) { - if entry.is_dir && !entry.is_empty_dir { - entries, err := os.ReadDir(entry.abspath) + CompleteFiles(prefix, func(entry *FileEntry) { + if entry.IsDir && !entry.IsEmptyDir { + entries, err := os.ReadDir(entry.Abspath) if err == nil { for _, e := range entries { - if is_match(e.Name()) || is_dir_or_symlink_to_dir(e, filepath.Join(entry.abspath, e.Name())) { - ans = append(ans, entry.completion_candidate) + if is_match(e.Name()) || is_dir_or_symlink_to_dir(e, filepath.Join(entry.Abspath, e.Name())) { + ans = append(ans, entry.CompletionCandidate) return } } } return } - q := strings.ToLower(entry.name) + q := strings.ToLower(entry.Name) if is_match(q) { - ans = append(ans, entry.completion_candidate) + ans = append(ans, entry.CompletionCandidate) } }, cwd) 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) { q := f(word, cwd, lpats) if len(q) > 0 { - mg := completions.add_match_group(title) + mg := completions.AddMatchGroup(title) mg.IsFiles = true 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 { 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) return func(completions *Completions, word string, arg_num int) { - mg := completions.add_match_group(title) + mg := completions.AddMatchGroup(title) mg.NoTrailingSpace = true mg.IsFiles = true - complete_files(word, func(entry *FileEntry) { - if entry.mode.IsDir() { - mg.add_match(entry.completion_candidate) + CompleteFiles(word, func(entry *FileEntry) { + if entry.Mode.IsDir() { + mg.AddMatch(entry.CompletionCandidate) } }, cwd) } diff --git a/tools/cli/files_test.go b/tools/cli/files_test.go index e52650ff6..c8f69dace 100644 --- a/tools/cli/files_test.go +++ b/tools/cli/files_test.go @@ -39,10 +39,10 @@ func TestCompleteFiles(t *testing.T) { } sort.Strings(expected) actual := make([]string, 0, len(expected)) - 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) + CompleteFiles(prefix, func(entry *FileEntry) { + actual = append(actual, entry.CompletionCandidate) + if _, err := os.Stat(entry.Abspath); err != nil { + t.Fatalf("Abspath does not exist: %#v", entry.Abspath) } }, "") sort.Strings(actual) diff --git a/tools/cli/fish.go b/tools/cli/fish.go index ce434170f..1a4f99a60 100644 --- a/tools/cli/fish.go +++ b/tools/cli/fish.go @@ -18,9 +18,9 @@ func fish_output_serializer(completions []*Completions, shell_state map[string]s n := completions[0].Delegate.NumToRemove fm := markup.New(false) // fish freaks out if there are escape codes in the description strings 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 - copy(words[1:], completions[0].all_words[n:]) + copy(words[1:], completions[0].AllWords[n:]) for i, w := range words { words[i] = fmt.Sprintf("(string escape -- %s)", utils.QuoteStringForFish(w)) } diff --git a/tools/cmd/completion/kitty.go b/tools/cmd/completion/kitty.go new file mode 100644 index 000000000..4446a77ab --- /dev/null +++ b/tools/cmd/completion/kitty.go @@ -0,0 +1,96 @@ +// License: GPLv3 Copyright: 2022, Kovid Goyal, + +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) + }, + }) + +} diff --git a/tools/cmd/main.go b/tools/cmd/main.go index f24be8424..d4fbebd19 100644 --- a/tools/cmd/main.go +++ b/tools/cmd/main.go @@ -4,28 +4,19 @@ package main import ( "kitty/tools/cli" - "kitty/tools/cli/completion" "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() { root := cli.NewRootCommand() root.ShortDescription = "Fast, statically compiled implementations for various kitty command-line tools" root.Usage = "command [command options] [command args]" + // @ at.EntryPoint(root) - completion_entry_point(root) + // __complete__ + completion.EntryPoint(root) root.Exec() }