diff --git a/tools/cli/option-from-string.go b/tools/cli/option-from-string.go new file mode 100644 index 000000000..c4812004a --- /dev/null +++ b/tools/cli/option-from-string.go @@ -0,0 +1,142 @@ +// License: GPLv3 Copyright: 2022, Kovid Goyal, + +package cli + +import ( + "bufio" + "fmt" + "regexp" + "strconv" + "strings" +) + +var _ = fmt.Print + +// OptionFromString {{{ + +/* +Create an [Option] from a string. Syntax is:: + + --option-name, --option-alias, -s + type: string + dest: destination + choices: choice1, choice2, choice 3 + depth: 0 + Help text on multiple lines. Indented lines are peserved as indented blocks. Blank lines + are preserved as blank lines. #placeholder_for_formatting# is replaced by the empty string. + +Available types are: string, str, list, int, float, count, bool-set, bool-reset, choices +The default dest is the first --option-name which must be a long option. +If choices are specified type is set to choices automatically. +If depth is negative option is added to all subcommands. If depth is positive option is added to sub-commands upto +the specified depth. +Set the help text to "!" to have an option hidden. +*/ +func OptionFromString(entries ...string) (*Option, error) { + if mpat == nil { + mpat = regexp.MustCompile("^([a-z]+)=(.+)") + } + ans := Option{} + scanner := bufio.NewScanner(strings.NewReader(strings.Join(entries, "\n"))) + in_help := false + prev_indent := 0 + help := strings.Builder{} + help.Grow(2048) + + indent_of_line := func(x string) int { + return len(x) - len(strings.TrimLeft(x, " \n\t\v\f")) + } + + for scanner.Scan() { + line := scanner.Text() + if ans.Aliases == nil { + if strings.HasPrefix(line, "--") { + parts := strings.Split(line, " ") + ans.Name = strings.ReplaceAll(parts[0], "-", "_") + ans.Aliases = make([]Alias, 0, len(parts)) + for i, x := range parts { + ans.Aliases[i] = Alias{NameWithoutHyphens: strings.TrimLeft(x, "-"), IsShort: !strings.HasPrefix(x, "--")} + } + } + } else if in_help { + if line != "" { + current_indent := indent_of_line(line) + if current_indent > 1 { + if prev_indent == 0 { + help.WriteString("\n") + } else { + line = strings.TrimSpace(line) + } + } + prev_indent = current_indent + if !strings.HasSuffix(help.String(), "\n") { + help.WriteString(" ") + } + help.WriteString(line) + } else { + prev_indent = 0 + help.WriteString("\n") + if !strings.HasSuffix(help.String(), "::") { + help.WriteString("\n") + } + } + } else { + matches := mpat.FindStringSubmatch(line) + if matches == nil { + continue + } + k, v := matches[1], matches[2] + switch k { + case "choices": + parts := strings.Split(v, ",") + ans.Choices = make(map[string]bool, len(parts)) + ans.OptionType = StringOption + for i, x := range parts { + x = strings.TrimSpace(x) + ans.Choices[x] = true + if i == 0 && ans.Default == "" { + ans.Default = x + } + } + case "default": + ans.Default = v + case "dest": + ans.Name = v + case "depth": + depth, err := strconv.ParseInt(v, 0, 0) + if err != nil { + return nil, err + } + ans.Depth = int(depth) + case "condition", "completion": + default: + return nil, fmt.Errorf("Unknown option metadata key: %s", k) + case "type": + switch v { + case "choice", "choices": + ans.OptionType = StringOption + case "int": + ans.OptionType = IntegerOption + case "float": + ans.OptionType = FloatOption + case "count": + ans.OptionType = CountOption + case "bool-set": + ans.OptionType = BoolOption + case "bool-reset": + ans.OptionType = BoolOption + for _, a := range ans.Aliases { + a.IsUnset = true + } + case "list", "str", "string": + ans.OptionType = StringOption + default: + return nil, fmt.Errorf("Unknown option type: %s", v) + } + } + } + } + ans.HelpText = help.String() + ans.Hidden = ans.HelpText == "!" + return &ans, nil +} // }}} diff --git a/tools/cli/parse-args.go b/tools/cli/parse-args.go new file mode 100644 index 000000000..cb7bb69c6 --- /dev/null +++ b/tools/cli/parse-args.go @@ -0,0 +1,95 @@ +// License: GPLv3 Copyright: 2022, Kovid Goyal, + +package cli + +import ( + "fmt" + "strings" +) + +var _ = fmt.Print + +func (self *Command) parse_args(ctx *Context, args []string) error { + args_to_parse := make([]string, 0, len(args)) + copy(args_to_parse, args) + ctx.SeenCommands = append(ctx.SeenCommands, self) + + var expecting_arg_for *Option + options_allowed := true + + consume_arg := func() string { ans := args_to_parse[0]; args_to_parse = args_to_parse[1:]; return ans } + + handle_option := func(opt_str string, has_val bool, opt_val string) error { + opt := self.FindOption(opt_str) + if opt == nil { + return &ParseError{Message: fmt.Sprintf("Unknown option: :yellow:`%s`", opt_str)} + } + opt.seen_option = opt_str + if has_val { + if !opt.needs_argument() { + return &ParseError{Message: fmt.Sprintf("The option: :yellow:`%s` does not take values", opt_str)} + } + return opt.add_value(opt_val) + } else if opt.needs_argument() { + expecting_arg_for = opt + } + return nil + } + + for len(self.args) > 0 { + arg := consume_arg() + + if expecting_arg_for == nil { + if options_allowed && strings.HasPrefix(arg, "-") && arg != "-" { + // handle option arg + if arg == "--" { + options_allowed = false + continue + } + opt_str := "" + opt_val := "" + has_val := false + if strings.HasPrefix(opt_str, "--") || len(opt_str) == 2 { + parts := strings.SplitN(arg, "=", 2) + if len(parts) > 1 { + has_val = true + opt_val = parts[1] + } + opt_str = parts[0] + handle_option(opt_str, has_val, opt_val) + } else { + for _, sl := range opt_str[1:] { + err := handle_option("-"+string(sl), false, "") + if err != nil { + return err + } + } + } + } else { + // handle non option arg + if self.AllowOptionsAfterArgs <= len(self.args) { + options_allowed = false + } + if self.HasSubCommands() { + sc := self.FindSubCommand(arg) + if sc == nil { + if !self.SubCommandIsOptional { + return &ParseError{Message: fmt.Sprintf(":yellow:`%s` is not a known subcommand for :emph:`%s`. Use --help to get a list of valid subcommands.", arg, self.Name)} + } + } else { + return sc.parse_args(ctx, args_to_parse) + } + } + self.args = append(self.args, arg) + } + } else { + // handle option value + err := expecting_arg_for.add_value(arg) + if err != nil { + return err + } + expecting_arg_for = nil + } + } + return nil +} diff --git a/tools/cli/types.go b/tools/cli/types.go new file mode 100644 index 000000000..36d84ca7d --- /dev/null +++ b/tools/cli/types.go @@ -0,0 +1,269 @@ +// License: GPLv3 Copyright: 2022, Kovid Goyal, + +package cli + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +var _ = fmt.Print + +type OptionType int + +const ( + StringOption OptionType = iota + IntegerOption + FloatOption + BoolOption + CountOption +) + +type Alias struct { + NameWithoutHyphens string + IsShort bool + IsUnset bool +} + +func (self *Alias) String() string { + if self.IsShort { + return "-" + self.NameWithoutHyphens + } + return "--" + self.NameWithoutHyphens +} + +type Option struct { + Name string + Aliases []Alias + Choices map[string]bool + Default string + OptionType OptionType + Hidden bool + Depth int + HelpText string + Parent *Command + + values_from_cmdline []string + parsed_values_from_cmdline []interface{} + seen_option string +} + +func (self *Option) needs_argument() bool { + return self.OptionType != BoolOption && self.OptionType != CountOption +} + +func (self *Option) HasAlias(name_without_hyphens string, is_short bool) bool { + for _, a := range self.Aliases { + if a.IsShort == is_short && a.NameWithoutHyphens == name_without_hyphens { + return true + } + } + return false +} + +var mpat *regexp.Regexp + +type ParseError struct { + Option *Option + Message string +} + +func (self *ParseError) Error() string { return self.Message } + +func NormalizeOptionName(name string) string { + return strings.ReplaceAll(strings.TrimLeft(name, "-"), "_", "-") +} + +func (self *Option) add_value(val string) error { + name_without_hyphens := NormalizeOptionName(self.seen_option) + switch self.OptionType { + case BoolOption: + for _, x := range self.Aliases { + if x.NameWithoutHyphens == name_without_hyphens { + if x.IsUnset { + self.values_from_cmdline = append(self.values_from_cmdline, "false") + self.parsed_values_from_cmdline = append(self.parsed_values_from_cmdline, false) + } else { + self.values_from_cmdline = append(self.values_from_cmdline, "true") + self.parsed_values_from_cmdline = append(self.parsed_values_from_cmdline, true) + } + return nil + } + } + case StringOption: + if self.Choices != nil && !self.Choices[val] { + c := make([]string, len(self.Choices)) + for k := range self.Choices { + c = append(c, k) + } + return &ParseError{Option: self, Message: fmt.Sprintf(":yellow:`%s` is not a valid value for :bold:`%s`. Valid values: %s", + val, self.seen_option, strings.Join(c, ", "), + )} + } + self.values_from_cmdline = append(self.values_from_cmdline, val) + self.parsed_values_from_cmdline = append(self.parsed_values_from_cmdline, val) + case IntegerOption: + pval, err := strconv.ParseInt(val, 0, 0) + if err != nil { + return &ParseError{Option: self, Message: fmt.Sprintf( + ":yellow:`%s` is not a valid number for :bold:`%s`. Only integers in decimal, hexadecimal, binary or octal notation are accepted.", val, self.seen_option)} + } + self.values_from_cmdline = append(self.values_from_cmdline, val) + self.parsed_values_from_cmdline = append(self.parsed_values_from_cmdline, pval) + case FloatOption: + pval, err := strconv.ParseFloat(val, 64) + if err != nil { + return &ParseError{Option: self, Message: fmt.Sprintf( + ":yellow:`%s` is not a valid number for :bold:`%s`. Only floats in decimal and hexadecimal notation are accepted.", val, self.seen_option)} + } + self.values_from_cmdline = append(self.values_from_cmdline, val) + self.parsed_values_from_cmdline = append(self.parsed_values_from_cmdline, pval) + case CountOption: + self.values_from_cmdline = append(self.values_from_cmdline, val) + self.parsed_values_from_cmdline = append(self.parsed_values_from_cmdline, 1) + } + return nil +} + +type CommandGroup struct { + SubCommands []*Command + Title string +} + +func (self *CommandGroup) AddSubCommand(parent *Command, name string) (*Command, error) { + for _, c := range self.SubCommands { + if c.Name == name { + return nil, fmt.Errorf("A subcommand with the name %#v already exists in the parent command: %#v", name, parent.Name) + } + } + ans := Command{ + Name: name, + Parent: parent, + } + return &ans, nil +} + +type OptionGroup struct { + Options []*Option + Title string +} + +func (self *OptionGroup) AddOption(parent *Command, items ...string) (*Option, error) { + ans, err := OptionFromString(items...) + if err == nil { + ans.Parent = parent + } + return ans, err +} + +func (self *OptionGroup) FindOption(name_with_hyphens string) *Option { + is_short := !strings.HasPrefix(name_with_hyphens, "--") + option_name := NormalizeOptionName(name_with_hyphens) + for _, q := range self.Options { + if q.HasAlias(option_name, is_short) { + return q + } + } + return nil +} + +type Command struct { + Name string + Usage, HelpText string + Hidden bool + SubCommandGroups []*CommandGroup + OptionGroups []*OptionGroup + Parent *Command + + AllowOptionsAfterArgs int + SubCommandIsOptional bool + + args []string +} + +func NewRootCommand() *Command { + ans := Command{ + SubCommandGroups: make([]*CommandGroup, 0, 8), + OptionGroups: make([]*OptionGroup, 0, 8), + args: make([]string, 0, 8), + } + return &ans +} + +func (self *Command) AddSubCommandGroup(title string) *CommandGroup { + for _, g := range self.SubCommandGroups { + if g.Title == title { + return g + } + } + ans := CommandGroup{Title: title, SubCommands: make([]*Command, 0, 8)} + self.SubCommandGroups = append(self.SubCommandGroups, &ans) + return &ans +} + +func (self *Command) AddSubCommand(group string, name string) (*Command, error) { + return self.AddSubCommandGroup(group).AddSubCommand(self, name) +} + +func (self *Command) HasSubCommands() bool { + for _, g := range self.SubCommandGroups { + if len(g.SubCommands) > 0 { + return true + } + } + return false +} + +func (self *Command) FindSubCommand(name string) *Command { + for _, g := range self.SubCommandGroups { + for _, c := range g.SubCommands { + if c.Name == name { + return c + } + } + } + return nil +} + +func (self *Command) AddOptionGroup(title string) *OptionGroup { + for _, g := range self.OptionGroups { + if g.Title == title { + return g + } + } + ans := OptionGroup{Title: title, Options: make([]*Option, 0, 8)} + self.OptionGroups = append(self.OptionGroups, &ans) + return &ans +} + +func (self *Command) AddOption(items ...string) (*Option, error) { + return self.AddOptionGroup("").AddOption(self, items...) +} + +func (self *Command) AddOptionToGroup(group string, items ...string) (*Option, error) { + return self.AddOptionGroup(group).AddOption(self, items...) +} + +func (self *Command) FindOption(name_with_hyphens string) *Option { + for _, g := range self.OptionGroups { + q := g.FindOption(name_with_hyphens) + if q != nil { + return q + } + } + depth := 0 + for p := self.Parent; p != nil; p = p.Parent { + depth++ + q := p.FindOption(name_with_hyphens) + if q != nil && q.Depth >= depth { + return q + } + } + return nil +} + +type Context struct { + SeenCommands []*Command +}