Start work on getting rid of the cobra dependency

Command line parsing is easy and I can do it better.
This commit is contained in:
Kovid Goyal 2022-09-20 19:31:17 +05:30
parent 54ec486d3a
commit bc38bd75fd
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
3 changed files with 506 additions and 0 deletions

View File

@ -0,0 +1,142 @@
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
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
} // }}}

95
tools/cli/parse-args.go Normal file
View File

@ -0,0 +1,95 @@
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
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
}

269
tools/cli/types.go Normal file
View File

@ -0,0 +1,269 @@
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
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
}