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:
142
tools/cli/option-from-string.go
Normal file
142
tools/cli/option-from-string.go
Normal 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
95
tools/cli/parse-args.go
Normal 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
269
tools/cli/types.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user