validate() parsed every value to bounds-check it, then the typed getters re-parsed the same string at read time (with a comment admitting the failure was unreachable). A single parseValue() now validates and converts, finalize() runs it once per option after layering, and the getters just read the stored typed value.
342 lines
9.3 KiB
Go
342 lines
9.3 KiB
Go
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 noConf, _ := boolWord(cmdVals["no-conf"]); !noConf {
|
|
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
|
|
}
|
|
}
|
|
if err := opts.finalize(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &Result{Action: Run, Options: opts, URIs: uris}, nil
|
|
}
|
|
|
|
// finalize parses every resolved value once into its typed form, validating it
|
|
// in the same pass; the numeric/bool getters then just read the result. It runs
|
|
// after all layering, so each option is converted exactly once, from its final
|
|
// (last-wins) string — not once per layer and again per read.
|
|
func (o *Options) finalize() error {
|
|
for i := range options {
|
|
opt := &options[i]
|
|
raw, ok := o.vals[opt.Long]
|
|
if !ok {
|
|
continue
|
|
}
|
|
v, err := parseValue(opt, raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if v != nil {
|
|
o.typed[opt.Long] = v
|
|
}
|
|
}
|
|
return 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 {
|
|
vals[o.Long] = appendList(vals[o.Long], val)
|
|
return
|
|
}
|
|
vals[o.Long] = val
|
|
}
|
|
|
|
// appendList is the single home of the List newline-join rule, shared by the
|
|
// raw-parse layer (accumulate) and the resolved layer (set). An empty prev (the
|
|
// option not yet seen) yields val unchanged, so the first value carries no
|
|
// leading newline.
|
|
func appendList(prev, val string) string {
|
|
if prev == "" {
|
|
return val
|
|
}
|
|
return prev + "\n" + 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 stores a raw value, marking the option as explicitly set. Validation and
|
|
// conversion happen later, once, in finalize(); set only records the string.
|
|
func set(o *Options, name, val string) error {
|
|
opt := byLong[name]
|
|
if opt == nil {
|
|
return fmt.Errorf("unknown option %q", name)
|
|
}
|
|
if opt.Kind == List && o.set[name] {
|
|
val = appendList(o.vals[name], val)
|
|
}
|
|
o.vals[name] = val
|
|
o.set[name] = true
|
|
return nil
|
|
}
|
|
|
|
// parseValue converts a raw string to the typed value for o.Kind, validating it
|
|
// in the process: it is the single place a value is parsed. Bool/Int/Size/Float
|
|
// return their typed value (read back by the getters); Str/List/Enum need no
|
|
// stored conversion and return nil, with Enum still checked for membership.
|
|
func parseValue(o *Opt, val string) (any, error) {
|
|
switch o.Kind {
|
|
case Bool:
|
|
// Booleans accept exactly true/false (the boolWords vocabulary the Bool
|
|
// getter honours); --enable-dht=flase is a parse error.
|
|
v, ok := boolWord(val)
|
|
if !ok {
|
|
return nil, fmt.Errorf("--%s: %q is not a boolean (true/false)", o.Long, val)
|
|
}
|
|
return v, nil
|
|
case Int:
|
|
n, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("--%s: %q is not an integer", o.Long, val)
|
|
}
|
|
if err := checkBounds(o, n); err != nil {
|
|
return nil, err
|
|
}
|
|
return n, nil
|
|
case Size:
|
|
n, err := parseSize(val)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("--%s: %v", o.Long, err)
|
|
}
|
|
if err := checkBounds(o, n); err != nil {
|
|
return nil, err
|
|
}
|
|
return n, nil
|
|
case Float:
|
|
// seed-time and seed-ratio must be non-negative: a negative SeedTime would
|
|
// fire the seed timer immediately and a negative SeedRatio is meaningless.
|
|
f, err := strconv.ParseFloat(strings.TrimSpace(val), 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("--%s: %q is not a number", o.Long, val)
|
|
}
|
|
if f < 0 {
|
|
return nil, fmt.Errorf("--%s: %v below minimum 0", o.Long, f)
|
|
}
|
|
return f, nil
|
|
case Enum:
|
|
for _, e := range o.Enum {
|
|
if val == e {
|
|
return nil, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("--%s: %q not one of %s", o.Long, val, strings.Join(o.Enum, "|"))
|
|
}
|
|
return nil, 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
|
|
}
|