diff --git a/tools/cmd/main.go b/tools/cmd/main.go index 57b89e4c8..f0052b7e9 100644 --- a/tools/cmd/main.go +++ b/tools/cmd/main.go @@ -9,6 +9,7 @@ import ( "kitty/tools/cli" "kitty/tools/cmd/at" + "kitty/tools/completion" ) func main() { @@ -18,6 +19,8 @@ func main() { }) root.AddCommand(at.EntryPoint(root)) + root.AddCommand(completion.EntryPoint(root)) + cli.Init(root) if err := cli.Execute(root); err != nil { cli.PrintError(err) diff --git a/tools/completion/main.go b/tools/completion/main.go new file mode 100644 index 000000000..2f00829a9 --- /dev/null +++ b/tools/completion/main.go @@ -0,0 +1,84 @@ +// License: GPLv3 Copyright: 2022, Kovid Goyal, + +package completion + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "github.com/spf13/cobra" + + "kitty/tools/cli" + "kitty/tools/utils" +) + +func json_input_parser(data []byte, shell_state map[string]string) ([]string, error) { + ans := make([]string, 0, 32) + err := json.Unmarshal(data, &ans) + return ans, err +} + +func json_output_serializer(completions *Completions, shell_state map[string]string) ([]byte, error) { + return json.Marshal(completions) +} + +type parser_func func(data []byte, shell_state map[string]string) ([]string, error) +type serializer_func func(completions *Completions, shell_state map[string]string) ([]byte, error) + +var input_parsers = make(map[string]parser_func, 4) +var output_serializers = make(map[string]serializer_func, 4) + +func init() { + input_parsers["json"] = json_input_parser + output_serializers["json"] = json_output_serializer +} + +func main(args []string) error { + output_type := "json" + if len(args) > 0 { + output_type = args[0] + args = args[1:] + } + shell_state := make(map[string]string, len(args)) + for _, arg := range args { + k, v, found := utils.Cut(arg, "=") + if !found { + return fmt.Errorf("Invalid shell state specification: %s", arg) + } + shell_state[k] = v + } + input_parser := input_parsers[output_type] + output_serializer := output_serializers[output_type] + if input_parser == nil || output_serializer == nil { + return fmt.Errorf("Unknown output type: %s", output_type) + } + data, err := io.ReadAll(os.Stdin) + if err != nil { + return err + } + argv, err := input_parser(data, shell_state) + if err != nil { + return err + } + completions := GetCompletions(argv) + output, err := output_serializer(completions, shell_state) + if err == nil { + os.Stdout.Write(output) + } + return err +} + +func EntryPoint(tool_root *cobra.Command) *cobra.Command { + complete_command := cli.CreateCommand(&cobra.Command{ + Use: "__complete__ output_type [shell state...]", + Short: "Generate completions for kitty commands", + Long: "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.", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + return main(args) + }, + }) + return complete_command +} diff --git a/tools/completion/parse-args.go b/tools/completion/parse-args.go index 2fe4213ef..756206f79 100644 --- a/tools/completion/parse-args.go +++ b/tools/completion/parse-args.go @@ -12,29 +12,36 @@ func (self *Completions) add_group(group *MatchGroup) { } } -func (self *command) find_option(name_including_leading_dash string) *option { - q := strings.TrimLeft(name_including_leading_dash, "-") - for _, opt := range self.options { - for _, alias := range opt.aliases { +func (self *Command) find_option(name_including_leading_dash string) *Option { + var q string + if strings.HasPrefix(name_including_leading_dash, "--") { + q = name_including_leading_dash[2:] + } else if strings.HasPrefix(name_including_leading_dash, "-") { + q = name_including_leading_dash[len(name_including_leading_dash)-1:] + } else { + q = name_including_leading_dash + } + for _, opt := range self.Options { + for _, alias := range opt.Aliases { if alias == q { - return &opt + return opt } } } return nil } -func (self *Completions) add_options_group(options []option, word string) { +func (self *Completions) add_options_group(options []*Option, word string) { group := MatchGroup{Title: "Options"} group.Matches = make([]*Match, 0, 8) seen_flags := make(map[string]bool) if strings.HasPrefix(word, "--") { prefix := word[2:] for _, opt := range options { - for _, q := range opt.aliases { + for _, q := range opt.Aliases { if len(q) > 1 && strings.HasPrefix(q, prefix) { seen_flags[q] = true - group.Matches = append(group.Matches, &Match{Word: "--" + q, Description: opt.description}) + group.Matches = append(group.Matches, &Match{Word: "--" + q, Description: opt.Description}) } } } @@ -48,10 +55,10 @@ func (self *Completions) add_options_group(options []option, word string) { } group.WordPrefix = word for _, opt := range options { - for _, q := range opt.aliases { + for _, q := range opt.Aliases { if len(q) == 1 && !seen_flags[q] { seen_flags[q] = true - group.Matches = append(group.Matches, &Match{Word: q, FullForm: "-" + q, Description: opt.description}) + group.Matches = append(group.Matches, &Match{Word: q, FullForm: "-" + q, Description: opt.Description}) } } } @@ -59,11 +66,11 @@ func (self *Completions) add_options_group(options []option, word string) { self.add_group(&group) } -func complete_word(word string, completions *Completions, only_args_allowed bool, expecting_arg_for *option, arg_num int) { - cmd := Completions.current_cmd +func complete_word(word string, completions *Completions, only_args_allowed bool, expecting_arg_for *Option, arg_num int) { + cmd := completions.current_cmd if expecting_arg_for != nil { - if expecting_arg_for.completion_for_arg != nil { - expecting_arg_for.completion_for_arg(completions, word) + if expecting_arg_for.Completion_for_arg != nil { + expecting_arg_for.Completion_for_arg(completions, word) } return } @@ -72,27 +79,27 @@ func complete_word(word string, completions *Completions, only_args_allowed bool idx := strings.Index(word, "=") option := cmd.find_option(word[:idx]) if option != nil { - if option.completion_for_arg != nil { + if option.Completion_for_arg != nil { completions.WordPrefix = word[:idx+1] - option.completion_for_arg(completions, word[idx+1:]) + option.Completion_for_arg(completions, word[idx+1:]) } } } else { - completions.add_options_group(cmd.options, word) + completions.add_options_group(cmd.Options, word) } return } - if arg_num == 1 && len(cmd.subcommands) > 0 { - for _, sc := range cmd.subcommands { - if strings.HasPrefix(sc.name, word) { - title := cmd.subcommands_title + if arg_num == 1 && len(cmd.Subcommands) > 0 { + for _, sc := range cmd.Subcommands { + if strings.HasPrefix(sc.Name, word) { + title := cmd.Subcommands_title if title == "" { title = "Sub-commands" } group := MatchGroup{Title: title} - group.Matches = make([]*Match, 0, len(cmd.subcommands)) - if strings.HasPrefix(sc, word) { - group.Matches = append(group.Matches, &Match{Word: sc.name, Description: sc.description}) + group.Matches = make([]*Match, 0, len(cmd.Subcommands)) + if strings.HasPrefix(sc.Name, word) { + group.Matches = append(group.Matches, &Match{Word: sc.Name, Description: sc.Description}) } completions.add_group(&group) } @@ -100,20 +107,29 @@ func complete_word(word string, completions *Completions, only_args_allowed bool return } - if cmd.completion_for_arg != nil { - cmd.completion_for_arg(completions, word) + if cmd.Completion_for_arg != nil { + cmd.Completion_for_arg(completions, word) } return } -func parse_args(cmd *command, words []string, completions *Completions) { +func (self *Command) find_subcommand(name string) *Command { + for _, sc := range self.Subcommands { + if sc.Name == name { + return sc + } + } + return nil +} + +func (cmd *Command) parse_args(words []string, completions *Completions) { completions.current_cmd = cmd if len(words) == 0 { complete_word("", completions, false, nil, 0) return } - var expecting_arg_for *option + var expecting_arg_for *Option only_args_allowed := false arg_num := 0 @@ -135,14 +151,17 @@ func parse_args(cmd *command, words []string, completions *Completions) { continue } if !only_args_allowed && strings.HasPrefix(word, "-") { - // TODO: - // handle single letter multiple options -abcd - // handle standalone --long-opt - // handle long opt ends with = - // handle long opt containing = + idx := strings.Index(word, "=") + if idx > -1 { + continue + } + option := cmd.find_option(word[:idx]) + if option != nil { + expecting_arg_for = option + } continue } - if len(cmd.subcommands) > 0 && arg_num == 1 { + if len(cmd.Subcommands) > 0 && arg_num == 1 { sc := cmd.find_subcommand(word) if sc == nil { only_args_allowed = true @@ -152,7 +171,7 @@ func parse_args(cmd *command, words []string, completions *Completions) { cmd = sc arg_num = 0 only_args_allowed = false - } else if cmd.stop_processing_at_arg > 0 && arg_num >= cmd.stop_processing_at_arg { + } else if cmd.Stop_processing_at_arg > 0 && arg_num >= cmd.Stop_processing_at_arg { return } else { only_args_allowed = true diff --git a/tools/completion/types.go b/tools/completion/types.go index d24bd0394..eb2da2b44 100644 --- a/tools/completion/types.go +++ b/tools/completion/types.go @@ -3,44 +3,62 @@ package completion type Match struct { - Word string - FullForm string - Description string + Word string `json:"word,omitempty"` + FullForm string `json:"full_form,omitempty"` + Description string `json:"description,omitempty"` } type MatchGroup struct { - Title string - NoTrailingSpace, IsFiles bool - Matches []*Match - WordPrefix string + Title string `json:"title,omitempty"` + NoTrailingSpace bool `json:"no_trailing_space,omitempty"` + IsFiles bool `json:"is_files,omitempty"` + Matches []*Match `json:"matches,omitempty"` + WordPrefix string `json:"word_prefix,omitempty"` } type Completions struct { - Groups []*MatchGroup - WordPrefix string + Groups []*MatchGroup `json:"groups,omitempty"` + WordPrefix string `json:"word_prefix,omitempty"` - current_cmd *command + current_cmd *Command } type completion_func func(completions *Completions, partial_word string) -type option struct { - name string - aliases []string - description string - has_following_arg bool - completion_for_arg completion_func +type Option struct { + Name string + Aliases []string + Description string + Has_following_arg bool + Completion_for_arg completion_func } -type command struct { - name string - description string +type Command struct { + Name string + Description string - options []option + // List of options for this command + Options []*Option - subcommands []command - subcommands_title string + // List of subcommands + Subcommands []*Command + // Optional title used as a header when displaying the list of matching sub-commands for a completion + Subcommands_title string - completion_for_arg completion_func - stop_processing_at_arg int + Completion_for_arg completion_func + Stop_processing_at_arg int +} + +var Root = Command{Options: make([]*Option, 0), Subcommands: make([]*Command, 0, 32)} + +func GetCompletions(argv []string) *Completions { + ans := Completions{Groups: make([]*MatchGroup, 0, 4)} + if len(argv) > 0 { + exe := argv[0] + cmd := Root.find_subcommand(exe) + if cmd != nil { + cmd.parse_args(argv[1:], &ans) + } + } + return &ans }