package mcfg import ( "context" "fmt" "io" "os" "reflect" "sort" "strings" "github.com/mediocregopher/mediocre-go-lib/mctx" "github.com/mediocregopher/mediocre-go-lib/merr" ) type cliKey int const ( cliKeyTailPtr cliKey = iota ) // WithCLITail returns a Context which modifies the behavior of SourceCLI's // Parse, if SourceCLI is used with that Context at all. Normally when SourceCLI // encounters an unexpected Arg it will immediately return an error. This // function modifies the Context to indicate to Parse that the unexpected Arg, // and all subsequent Args (i.e. the tail) should be set to the returned // []string value. // // If multiple WithCLITail calls are used then only the latest returned pointer // will be filled. func WithCLITail(ctx context.Context) (context.Context, *[]string) { tailPtr := new([]string) return context.WithValue(ctx, cliKeyTailPtr, tailPtr), tailPtr } func populateCLITail(ctx context.Context, tail []string) bool { tailPtr, ok := ctx.Value(cliKeyTailPtr).(*[]string) if ok { *tailPtr = tail } return ok } // SourceCLI is a Source which will parse configuration from the CLI. // // Possible CLI options are generated by joining a Param's Path and Name with // dashes. For example: // // ctx := mctx.New() // ctx = mctx.ChildOf(ctx, "foo") // ctx = mctx.ChildOf(ctx, "bar") // addr := mcfg.String(ctx, "addr", "", "Some address") // // the CLI option to fill addr will be "--foo-bar-addr" // // If the "-h" option is seen then a help page will be printed to // stdout and the process will exit. Since all normally-defined parameters must // being with double-dash ("--") they won't ever conflict with the help option. // // SourceCLI behaves a little differently with boolean parameters. Setting the // value of a boolean parameter directly _must_ be done with an equals, for // example: `--boolean-flag=1` or `--boolean-flag=false`. Using the // space-separated format will not work. If a boolean has no equal-separated // value it is assumed to be setting the value to `true`, as would be expected. type SourceCLI struct { Args []string // if nil then os.Args[1:] is used DisableHelpPage bool } const ( cliKeyJoin = "-" cliKeyPrefix = "--" cliValSep = "=" cliHelpArg = "-h" ) // Parse implements the method for the Source interface func (cli *SourceCLI) Parse(ctx context.Context, params []Param) (context.Context, []ParamValue, error) { args := cli.Args if cli.Args == nil { args = os.Args[1:] } pM, err := cli.cliParams(params) if err != nil { return nil, nil, err } pvs := make([]ParamValue, 0, len(args)) var ( key string p Param pOk bool pvStrVal string pvStrValOk bool ) for i, arg := range args { if pOk { pvStrVal = arg pvStrValOk = true } else if !cli.DisableHelpPage && arg == cliHelpArg { cli.printHelp(os.Stdout, pM) os.Stdout.Sync() os.Exit(1) } else { for key, p = range pM { if arg == key { pOk = true break } prefix := key + cliValSep if !strings.HasPrefix(arg, prefix) { continue } pOk = true pvStrVal = strings.TrimPrefix(arg, prefix) pvStrValOk = true break } if !pOk { if ok := populateCLITail(ctx, args[i:]); ok { return ctx, pvs, nil } ctx := mctx.Annotate(context.Background(), "param", arg) return nil, nil, merr.New("unexpected config parameter", ctx) } } // pOk is always true at this point, and so p is filled in // As a special case for CLI, if a boolean has no value set it means it // is true. if p.IsBool && !pvStrValOk { pvStrVal = "true" } else if !pvStrValOk { // everything else should have a value. if pvStrVal isn't filled it // means the next arg should be one. Continue the loop, it'll get // filled with the next one (hopefully) continue } pvs = append(pvs, ParamValue{ Name: p.Name, Path: mctx.Path(p.Context), Value: p.fuzzyParse(pvStrVal), }) key = "" p = Param{} pOk = false pvStrVal = "" pvStrValOk = false } if pOk && !pvStrValOk { ctx := mctx.Annotate(p.Context, "param", key) return nil, nil, merr.New("param expected a value", ctx) } return ctx, pvs, nil } func (cli *SourceCLI) cliParams(params []Param) (map[string]Param, error) { m := map[string]Param{} for _, p := range params { key := strings.Join(append(mctx.Path(p.Context), p.Name), cliKeyJoin) m[cliKeyPrefix+key] = p } return m, nil } func (cli *SourceCLI) printHelp(w io.Writer, pM map[string]Param) { type pEntry struct { arg string Param } pA := make([]pEntry, 0, len(pM)) for arg, p := range pM { pA = append(pA, pEntry{arg: arg, Param: p}) } sort.Slice(pA, func(i, j int) bool { if pA[i].Required != pA[j].Required { return pA[i].Required } return pA[i].arg < pA[j].arg }) fmtDefaultVal := func(ptr interface{}) string { if ptr == nil { return "" } val := reflect.Indirect(reflect.ValueOf(ptr)) zero := reflect.Zero(val.Type()) if reflect.DeepEqual(val.Interface(), zero.Interface()) { return "" } else if val.Type().Kind() == reflect.String { return fmt.Sprintf("%q", val.Interface()) } return fmt.Sprint(val.Interface()) } for _, p := range pA { fmt.Fprintf(w, "\n%s", p.arg) if p.IsBool { fmt.Fprintf(w, " (Flag)") } else if p.Required { fmt.Fprintf(w, " (Required)") } else if defVal := fmtDefaultVal(p.Into); defVal != "" { fmt.Fprintf(w, " (Default: %s)", defVal) } fmt.Fprintf(w, "\n") if usage := p.Usage; usage != "" { // make all usages end with a period, because I say so usage = strings.TrimSpace(usage) if !strings.HasSuffix(usage, ".") { usage += "." } fmt.Fprintln(w, "\t"+usage) } } fmt.Fprintf(w, "\n") }