first commit
This commit is contained in:
311
cli/parse.go
Normal file
311
cli/parse.go
Normal file
@@ -0,0 +1,311 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Action tells main what the user asked for.
|
||||
type Action int
|
||||
|
||||
const (
|
||||
Run Action = iota
|
||||
ShowHelp
|
||||
ShowVersion
|
||||
)
|
||||
|
||||
// Result is everything Parse extracts from the command line.
|
||||
type Result struct {
|
||||
Action Action
|
||||
HelpTag string // when Action == ShowHelp; "" means all
|
||||
Options *Options
|
||||
URIs []string
|
||||
}
|
||||
|
||||
// Parse resolves args into options and URIs, applying the layered override
|
||||
// order: built-in defaults, then the config file, then proxy
|
||||
// environment variables, then the command line (last wins).
|
||||
func Parse(args []string) (*Result, error) {
|
||||
cmdVals, uris, action, helpTag, err := parseArgs(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if action != Run {
|
||||
return &Result{Action: action, HelpTag: helpTag}, nil
|
||||
}
|
||||
|
||||
opts := newOptions()
|
||||
applyDefaults(opts)
|
||||
|
||||
if !truthy(cmdVals["no-conf"]) {
|
||||
explicit := cmdVals["conf-path"]
|
||||
path, fromUser := confPath(explicit)
|
||||
if err := applyConfig(opts, path, fromUser); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
applyEnv(opts)
|
||||
|
||||
for name, val := range cmdVals {
|
||||
if err := set(opts, name, val); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &Result{Action: Run, Options: opts, URIs: uris}, nil
|
||||
}
|
||||
|
||||
// parseArgs walks the argument list once. It recognises --long, --long=val,
|
||||
// -x, -xval, -x val and short bool bundles like -cq. Like getopt_long, it
|
||||
// permutes: a non-flag token becomes a URI but scanning continues so flags may
|
||||
// follow URIs. A bare "--" is a hard terminator forcing the rest to URIs.
|
||||
// --help/-h and --version/-v short-circuit. Values are returned raw; layering
|
||||
// happens in Parse.
|
||||
func parseArgs(args []string) (vals map[string]string, uris []string, action Action, helpTag string, err error) {
|
||||
vals = map[string]string{}
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
switch {
|
||||
case a == "--": // end of options: the rest are URIs
|
||||
uris = append(uris, args[i+1:]...)
|
||||
return
|
||||
case a == "-" || !strings.HasPrefix(a, "-"): // URI (or stdin); keep scanning
|
||||
uris = append(uris, a)
|
||||
continue
|
||||
case strings.HasPrefix(a, "--"):
|
||||
name, val, hasVal := strings.Cut(a[2:], "=")
|
||||
if name == "help" {
|
||||
return vals, uris, ShowHelp, val, nil
|
||||
}
|
||||
if name == "version" {
|
||||
return vals, uris, ShowVersion, "", nil
|
||||
}
|
||||
o := byLong[name]
|
||||
if o == nil {
|
||||
return nil, nil, Run, "", fmt.Errorf("unknown option --%s", name)
|
||||
}
|
||||
if !hasVal {
|
||||
if o.Kind == Bool {
|
||||
val = "true"
|
||||
} else {
|
||||
if i+1 >= len(args) {
|
||||
return nil, nil, Run, "", fmt.Errorf("option --%s needs a value", name)
|
||||
}
|
||||
i++
|
||||
val = args[i]
|
||||
}
|
||||
}
|
||||
accumulate(vals, o, val)
|
||||
default: // short flags: -x, -xval, -x val, bundles -cq
|
||||
j := 1
|
||||
for j < len(a) {
|
||||
c := a[j]
|
||||
if c == 'h' {
|
||||
return vals, uris, ShowHelp, "", nil
|
||||
}
|
||||
if c == 'v' {
|
||||
return vals, uris, ShowVersion, "", nil
|
||||
}
|
||||
o := byShort[c]
|
||||
if o == nil {
|
||||
return nil, nil, Run, "", fmt.Errorf("unknown option -%c", c)
|
||||
}
|
||||
if o.Kind == Bool {
|
||||
accumulate(vals, o, "true")
|
||||
j++
|
||||
continue
|
||||
}
|
||||
// value-taking short: rest of token, else next arg.
|
||||
val := a[j+1:]
|
||||
if val == "" {
|
||||
if i+1 >= len(args) {
|
||||
return nil, nil, Run, "", fmt.Errorf("option -%c needs a value", c)
|
||||
}
|
||||
i++
|
||||
val = args[i]
|
||||
}
|
||||
accumulate(vals, o, val)
|
||||
break // consumed the rest of the token as the value
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// accumulate stores a value, joining repeatable List options with newlines.
|
||||
func accumulate(vals map[string]string, o *Opt, val string) {
|
||||
if o.Kind == List {
|
||||
if prev, ok := vals[o.Long]; ok {
|
||||
vals[o.Long] = prev + "\n" + val
|
||||
return
|
||||
}
|
||||
}
|
||||
vals[o.Long] = val
|
||||
}
|
||||
|
||||
func applyDefaults(o *Options) {
|
||||
for i := range options {
|
||||
opt := &options[i]
|
||||
if opt.Default != "" {
|
||||
o.vals[opt.Long] = opt.Default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyConfig reads "name=value" lines (with # comments) into o. A missing
|
||||
// file is silently ignored for the default path, but is fatal when the user
|
||||
// named it explicitly via --conf-path: an explicitly requested config file
|
||||
// that cannot be read is an error.
|
||||
func applyConfig(o *Options, path string, explicit bool) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) && !explicit {
|
||||
return nil // a missing default config file is not an error
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
name, val, _ := strings.Cut(line, "=")
|
||||
name = strings.TrimSpace(strings.TrimPrefix(name, "--"))
|
||||
opt := byLong[name]
|
||||
if opt == nil {
|
||||
return fmt.Errorf("%s: unknown option %q", path, name)
|
||||
}
|
||||
if err := set(o, name, strings.TrimSpace(val)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return sc.Err()
|
||||
}
|
||||
|
||||
// applyEnv folds the conventional proxy environment variables into all-proxy
|
||||
// unless it was already set explicitly.
|
||||
func applyEnv(o *Options) {
|
||||
if o.set["all-proxy"] {
|
||||
return
|
||||
}
|
||||
for _, key := range []string{"all_proxy", "https_proxy", "http_proxy"} {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
o.vals["all-proxy"] = v
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// set validates a value against its option descriptor and stores it, marking
|
||||
// the option as explicitly set.
|
||||
func set(o *Options, name, val string) error {
|
||||
opt := byLong[name]
|
||||
if opt == nil {
|
||||
return fmt.Errorf("unknown option %q", name)
|
||||
}
|
||||
if err := validate(opt, val); err != nil {
|
||||
return err
|
||||
}
|
||||
if opt.Kind == List {
|
||||
if prev, ok := o.vals[name]; ok && o.set[name] {
|
||||
val = prev + "\n" + val
|
||||
}
|
||||
}
|
||||
o.vals[name] = val
|
||||
o.set[name] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func validate(o *Opt, val string) error {
|
||||
switch o.Kind {
|
||||
case Bool:
|
||||
// Booleans accept true/false plus the yes/no/1/0/on/off spellings, but
|
||||
// nothing else, so e.g. --enable-dht=flase is a parse error. The accepted
|
||||
// set is boolWords, the same vocabulary the readers honour.
|
||||
if _, ok := boolWord(val); !ok {
|
||||
return fmt.Errorf("--%s: %q is not a boolean (true/false)", o.Long, val)
|
||||
}
|
||||
case Int:
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--%s: %q is not an integer", o.Long, val)
|
||||
}
|
||||
if err := checkBounds(o, n); err != nil {
|
||||
return err
|
||||
}
|
||||
case Size:
|
||||
n, err := parseSize(val)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--%s: %v", o.Long, err)
|
||||
}
|
||||
if err := checkBounds(o, n); err != nil {
|
||||
return err
|
||||
}
|
||||
case Float:
|
||||
// seed-time and seed-ratio are both rates/durations that must be
|
||||
// non-negative; a negative SeedTime would fire the seed timer immediately
|
||||
// and a negative SeedRatio is silently ignored. Reject negatives here.
|
||||
// Opt.Min/Max are int64, so we only enforce the floor, not full bounds.
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(val), 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--%s: %q is not a number", o.Long, val)
|
||||
}
|
||||
if f < 0 {
|
||||
return fmt.Errorf("--%s: %v below minimum 0", o.Long, f)
|
||||
}
|
||||
case Enum:
|
||||
for _, e := range o.Enum {
|
||||
if val == e {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("--%s: %q not one of %s", o.Long, val, strings.Join(o.Enum, "|"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkBounds enforces an Int/Size option's numeric range. Min defaults to a
|
||||
// floor of 0 (negative sizes and counts are rejected); a zero Max means "no
|
||||
// upper bound". For Size options n and the bounds are byte counts.
|
||||
func checkBounds(o *Opt, n int64) error {
|
||||
if n < o.Min {
|
||||
return fmt.Errorf("--%s: %d below minimum %d", o.Long, n, o.Min)
|
||||
}
|
||||
if o.Max > 0 && n > o.Max {
|
||||
return fmt.Errorf("--%s: %d above maximum %d", o.Long, n, o.Max)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// confPath picks the config file and reports whether the user named it. An
|
||||
// explicit --conf-path wins (and is treated as user-requested, so a missing
|
||||
// file is fatal). Otherwise it falls back to $XDG_CONFIG_HOME/got/got.conf,
|
||||
// then ~/.config/got/got.conf.
|
||||
func confPath(explicit string) (path string, fromUser bool) {
|
||||
if explicit != "" {
|
||||
return explicit, true
|
||||
}
|
||||
// XDG_CONFIG_HOME is only honoured when it is an absolute path.
|
||||
if x := os.Getenv("XDG_CONFIG_HOME"); filepath.IsAbs(x) {
|
||||
return filepath.Join(x, "got", "got.conf"), false
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return filepath.Join(home, ".config", "got", "got.conf"), false
|
||||
}
|
||||
|
||||
func truthy(s string) bool {
|
||||
val, _ := boolWord(s)
|
||||
return val
|
||||
}
|
||||
Reference in New Issue
Block a user