diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..f0c5726 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,80 @@ +name: test +on: + pull_request: + types: [opened, synchronize] + push: + branches: [main] + schedule: + - cron: "0 12 1 * *" # first day of the month at 12:00 + +jobs: + test: + strategy: + matrix: + platform: [ubuntu-latest, macos-latest, windows-latest] + + runs-on: ${{ matrix.platform }} + + defaults: + run: + shell: bash + + steps: + - name: Install Go + uses: actions/setup-go@v3 + with: + go-version: 1.x + + - name: Check out repo + uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Prepare cache + id: cache + run: | + echo "GOCACHE=$(go env GOCACHE)" >> $GITHUB_OUTPUT + echo "GOMODCACHE=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT + echo "GOVERSION=$(go env GOVERSION)" >> $GITHUB_OUTPUT + mkdir -p $(go env GOCACHE) || true + mkdir -p $(go env GOMODCACHE) || true + + - name: Cache + uses: actions/cache@v3 + with: + path: | + ${{ steps.cache.outputs.GOCACHE }} + ${{ steps.cache.outputs.GOMODCACHE }} + key: test.1-${{ runner.os }}-${{ steps.cache.outputs.GOVERSION }}-${{ hashFiles('**/go.mod') }} + restore-keys: | + test.1-${{ runner.os }}-${{ steps.cache.outputs.GOVERSION }}-${{ hashFiles('**/go.mod') }} + test.1-${{ runner.os }}-${{ steps.cache.outputs.GOVERSION }}- + test.1-${{ runner.os }}- + + - name: Install tools + run: | + go install honnef.co/go/tools/cmd/staticcheck@latest + go install mvdan.cc/gofumpt@latest + go install github.com/mgechev/revive@latest + + - name: Run gofmt + if: matrix.platform != 'windows-latest' # :< + run: diff <(gofmt -d . 2>/dev/null) <(printf '') + + - name: Run go vet + run: go vet ./... + + - name: Run staticcheck + run: staticcheck ./... + + - name: Run gofumpt + run: gofumpt -d -e -l . + + - name: Run revive + run: revive --set_exit_status --exclude="./examples/..." ./... + + - name: Run lint-parallel-tests + run: hack/lint-parallel-tests + + - name: Run go test + run: go test -v -race ./... diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index d52919c..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,37 +0,0 @@ -on: push -name: Test -jobs: - test: - strategy: - matrix: - go-version: [1.x] - platform: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.platform }} - steps: - - name: Install Go - uses: actions/setup-go@v1 - with: - go-version: ${{ matrix.go-version }} - - name: Install staticcheck - run: go install honnef.co/go/tools/cmd/staticcheck@latest - shell: bash - - name: Install golint - run: go install golang.org/x/lint/golint@latest - shell: bash - - name: Update PATH - run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH - shell: bash - - name: Checkout code - uses: actions/checkout@v1 - - name: Fmt - if: matrix.platform != 'windows-latest' # :( - run: "diff <(gofmt -d .) <(printf '')" - shell: bash - - name: Vet - run: go vet ./... - - name: Staticcheck - run: staticcheck ./... - - name: Lint - run: golint ./... - - name: Test - run: go test -race ./... diff --git a/LICENSE b/LICENSE index 8dada3e..261eeb9 100644 --- a/LICENSE +++ b/LICENSE @@ -178,7 +178,7 @@ APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" + boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright {yyyy} {name of copyright owner} + Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 6ffdd05..32b0794 100644 --- a/README.md +++ b/README.md @@ -1,131 +1,166 @@ -# ff [![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/github.com/peterbourgon/ff/v3) [![Latest Release](https://img.shields.io/github/v/release/peterbourgon/ff?style=flat-square)](https://github.com/peterbourgon/ff/releases/latest) ![Build Status](https://github.com/peterbourgon/ff/actions/workflows/test.yml/badge.svg?branch=main) +# ff [![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/github.com/peterbourgon/ff/v4) [![Latest Release](https://img.shields.io/github/v/release/peterbourgon/ff?style=flat-square)](https://github.com/peterbourgon/ff/releases/latest) ![Build Status](https://github.com/peterbourgon/ff/actions/workflows/test.yaml/badge.svg?branch=main) -ff stands for flags-first, and provides an opinionated way to populate a -[flag.FlagSet](https://golang.org/pkg/flag#FlagSet) with configuration data from -the environment. By default, it parses only from the command line, but you can -enable parsing from environment variables (lower priority) and/or a -configuration file (lowest priority). +ff is a flags-first approach to configuration. -Building a commandline application in the style of `kubectl` or `docker`? -Consider [package ffcli](https://pkg.go.dev/github.com/peterbourgon/ff/v3/ffcli), -a natural companion to, and extension of, package ff. +The basic rationale is that that `myprogram -h` should reliably describe the +complete configuration "surface area" of a program. Therefore, every config +parameter should be defined as a flag. This module provides a simple and robust +way to define those flags, and to parse them from command-line arguments, +environment variables, and/or a config file. + +Building a command-line application in the style of `kubectl` or `docker`? +[Command](#command) provides a declarative approach that's simpler to write, and +easier to maintain, than many common alternatives. ## Usage -Define a flag.FlagSet in your func main. +This module provides a getopts(3)-inspired flag set, used as follows. ```go -import ( - "flag" - "os" - "time" - - "github.com/peterbourgon/ff/v3" +fs := ff.NewFlags("myprogram") +var ( + listenAddr = fs.StringLong("listen", "localhost:8080", "listen address") + refresh = fs.Duration('r', "refresh", 15*time.Second, "refresh interval") + debug = fs.Bool('d', "debug", false, "log debug information") + _ = fs.StringLong("config", "", "config file (optional)") ) - -func main() { - fs := flag.NewFlagSet("my-program", flag.ContinueOnError) - var ( - listenAddr = fs.String("listen-addr", "localhost:8080", "listen address") - refresh = fs.Duration("refresh", 15*time.Second, "refresh interval") - debug = fs.Bool("debug", false, "log debug information") - _ = fs.String("config", "", "config file (optional)") - ) ``` -Then, call ff.Parse instead of fs.Parse. -[Options](https://pkg.go.dev/github.com/peterbourgon/ff/v3#Option) -are available to control parse behavior. +It's also possible to adapt a standard library flag set. In this case, be sure +to use the ContinueOnError error handling strategy. Other options either panic +or terminate the program on parse errors. Rude! ```go - err := ff.Parse(fs, os.Args[1:], - ff.WithEnvVarPrefix("MY_PROGRAM"), - ff.WithConfigFileFlag("config"), - ff.WithConfigFileParser(ff.PlainParser), - ) +fs := flag.NewFlagSet("myprogram", flag.ContinueOnError) +var ( + listenAddr = fs.String("listen", "localhost:8080", "listen address") + refresh = fs.Duration("refresh", 15*time.Second, "refresh interval") + debug = fs.Bool("debug", "log debug information") + _ = fs.String("config", "", "config file (optional)") +) ``` -This example will parse flags from the commandline args, just like regular -package flag, with the highest priority. (The flag's default value will be used -only if the flag remains unset after parsing all provided sources of -configuration.) +Once you have a set of flags, you can parse them as follows, using options to +control parse behavior. -Additionally, the example will look in the environment for variables with a -`MY_PROGRAM` prefix. Flag names are capitalized, and separator characters are -converted to underscores. In this case, for example, `MY_PROGRAM_LISTEN_ADDR` -would match to `listen-addr`. +```go +err := ff.Parse(fs, os.Args[1:], + ff.WithEnvVarPrefix("MY_PROGRAM"), + ff.WithConfigFileFlag("config"), + ff.WithConfigFileParser(ff.PlainParser), +) +``` -Finally, if a `-config` file is specified, the example will try to parse it -using the PlainParser, which expects files in this format. +Here, flags are first set from the provided command-line arguments, then from +env vars beginning with `MY_PROGRAM`, and, finally, if the user specifies a +config file, from values in that file, as parsed by PlainParser. +Unlike other flag packages, help/usage text is not automatically printed as a +side effect of parse. Instead, when a user requests help via e.g. -h or --help, +it's reported as a parse error. Callers are always responsible for checking +parse errors, and printing help/usage text as appropriate. -``` -listen-addr localhost:8080 -refresh 30s -debug true +```go +if errors.Is(err, ff.ErrHelp) { + fmt.Fprint(os.Stderr, ffhelp.Flags(fs)) + os.Exit(0) +} else if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", ) + os.Exit(1) +} ``` -You could also use the JSONParser, which expects a JSON object. +## Environment variables -```json -{ - "listen-addr": "localhost:8080", - "refresh": "30s", - "debug": true -} -``` +It's possible to take runtime configuration from the environment. The options +[WithEnvVars][withenvvars] and [WithEnvVarPrefix][withenvvarprefix] enable this +feature, and determine how flag names are mapped to environment variable names. -Or, you could write your own config file parser. +[withenvvars]: https://pkg.go.dev/github.com/peterbourgon/ff/v4#WithEnvVars +[withenvvarprefix]: https://pkg.go.dev/github.com/peterbourgon/ff/v4#WithEnvVarPrefix ```go -// ConfigFileParser interprets the config file represented by the reader -// and calls the set function for each parsed flag pair. -type ConfigFileParser func(r io.Reader, set func(name, value string) error) error +fs := ff.NewFlags("myservice") +var ( + port = fs.Int('p', "port", 8080, "listen port for server (also via PORT)") + debug = fs.Bool('d', "debug", false, "log debug information (also via DEBUG)") +) +ff.Parse(fs, os.Args[1:], ff.WithEnvVars()) +fmt.Printf("port %d, debug %v\n", *port, *debug) ``` -## Flags and env vars +```shell +$ env PORT=9090 myservice +port 9090, debug false +$ env PORT=9090 DEBUG=1 myservice --port=1234 +port 1234, debug true +``` -One common use case is to allow configuration from both flags and env vars. +## Config files -```go -package main +It's possible to take runtime configuration from config files. The options +[WithConfigFile][withconfigfile], [WithConfigFileFlag][withconfigfileflag], and +[WithConfigFileParser][withconfigfileparser] control how config files are +specified and parsed. This module includes support for JSON, YAML, TOML, and +.env config files, as well as the simple [PlainParser][plainparser] format. -import ( - "flag" - "fmt" - "os" +[withconfigfile]: https://pkg.go.dev/github.com/peterbourgon/ff/v4#WithConfigFile +[withconfigfileflag]: https://pkg.go.dev/github.com/peterbourgon/ff/v4#WithConfigFileFlag +[withconfigfileparser]: https://pkg.go.dev/github.com/peterbourgon/ff/v4#WithConfigFileParser +[plainparser]: https://pkg.go.dev/github.com/peterbourgon/ff/v4#PlainParser - "github.com/peterbourgon/ff/v3" +```go +fs := ff.NewFlags("myservice") +var ( + port = fs.IntLong("port", 8080, "listen port for server") + debug = fs.BoolLong("debug", false, "log debug information") + _ = fs.StringLong("config", "", "config file") ) - -func main() { - fs := flag.NewFlagSet("myservice", flag.ContinueOnError) - var ( - port = fs.Int("port", 8080, "listen port for server (also via PORT)") - debug = fs.Bool("debug", false, "log debug information (also via DEBUG)") - ) - if err := ff.Parse(fs, os.Args[1:], ff.WithEnvVars()); err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - os.Exit(1) - } - - fmt.Printf("port %d, debug %v\n", *port, *debug) -} +ff.Parse(fs, os.Args[1:], ff.WithConfigFileFlag("config"), ff.WithConfigFileParser(ff.PlainParser)) +fmt.Printf("port %d, debug %v\n", *port, *debug) ``` -``` -$ env PORT=9090 myservice +```shell +$ printf "port 9090\n" >1.conf ; myservice --config=1.conf port 9090, debug false -$ env PORT=9090 DEBUG=1 myservice -port=1234 +$ printf "port 9090\ndebug\n" >2.conf ; myservice --config=2.conf --port=1234 port 1234, debug true ``` -## Error handling +## Priority + +Command-line args have the highest priority, because they're explicitly given to +each running instance of a program by the user. Think of command-line args as the +"user" configuration. + +Environment variables have the next-highest priority, because they reflect +configuration set in the runtime context. Think of env vars as the "session" +configuration. + +Config files have the lowest priority, because they represent config that's +static to the host. Think of config files as the "host" configuration. + +# Commands + +[Command][command] is a declarative and lightweight alternative to common CLI +frameworks like [spf13/cobra][cobra], [urfave/cli][urfave], or +[alecthomas/kingpin][kingpin]. + +[command]: https://pkg.go.dev/github.com/peterbourgon/ff/v4#Command +[cobra]: https://github.com/spf13/cobra +[urfave]: https://github.com/urfave/cli +[kingpin]: https://github.com/alecthomas/kingpin + +Those frameworks have relatively large APIs, in order to support a large number +of "table stakes" features. In contrast, the command API is quite small, with +the immediate goal of being intuitive and productive, and the long-term goal of +producing CLI applications that are substantially easier to understand and +maintain. -In general, you should call flag.NewFlagSet with the flag.ContinueOnError error -handling strategy, which, somewhat confusingly, is the only way that ff.Parse can -return errors. (The other strategies terminate the program on error. Rude!) This -is [the only way to detect certain types of parse failures][90], in addition to -being good practice in general. +Commands are concerned only with the core mechanics of defining a command tree, +parsing flags, and selecting a command to run. They're not intended to be a +one-stop-shop for everything a command-line application may need. Features like +tab completion, colorized output, etc. are orthogonal to command tree parsing, +and can be easily provided by the consumer. -[90]: https://github.com/peterbourgon/ff/issues/90 +See [the examples directory](examples/) for some CLI tools built with commands. diff --git a/command.go b/command.go new file mode 100644 index 0000000..2e5f4f3 --- /dev/null +++ b/command.go @@ -0,0 +1,232 @@ +package ff + +import ( + "context" + "fmt" + "strings" +) + +// Command is a declarative structure that combines a main function with a flag +// set and zero or more subcommands. It's intended to model CLI applications +// which can be represented as a tree of such commands. +type Command struct { + // Name of the command, which is used when producing the help output for the + // command, as well as for subcommand matching. + // + // Required. + Name string + + // Usage is a single line string which should describe the syntax of the + // command, including flags and arguments. It's typically printed at the top + // of the help output for the command. For example, + // + // USAGE + // cmd [FLAGS] subcmd [FLAGS] [...] + // + // Here, the usage string begins with "cmd [FLAGS] ...". + // + // Recommended. If not provided, the help output for the command should not + // include a usage section. + Usage string + + // ShortHelp is a single line which should very briefly describe the purpose + // of the command in prose. It's typically printed next to the command name + // when it appears as a subcommand in help output. For example, + // + // SUBCOMMANDS + // commandname this is the short help string + // + // Recommended. + ShortHelp string + + // LongHelp is a multi-line string, usually one or more paragraphs of prose, + // which explain the command in detail. It's typically included in the help + // output for the command, separate from other sections. + // + // Long help should be formatted for user readability. For example, if help + // output is written to a terminal, long help should include newlines which + // hard-wrap the string at an appropriate column width for that terminal. + // + // Optional. + LongHelp string + + // Flags is the set of flags associated with, and parsed by, this command. + // + // When building a command tree, it's often useful to allow flags defined by + // parent commands to be specified by any subcommand. The core flag set + // supports this behavior via SetParent, see the documentation of that + // method for details. + // + // Optional. If not provided, an empty flag set will be constructed and used + // so that the -h, --help flag works as expected. + Flags Flags + + // Subcommands which are available underneath (i.e. after) this command. + // Selecting a subcommand is done via a case-insensitive comparison of the + // first post-parse argument to this command, against the name of each + // subcommand. + // + // Optional. + Subcommands []*Command + + isParsed bool + selected *Command + parent *Command + args []string + + // Exec is invoked by Run (or ParseAndRun) if this command was selected as + // the terminal command during the parse phase. The args passed to Exec are + // the args left over after parsing. + // + // Optional. If not provided, running this command will result in ErrNoExec. + Exec func(ctx context.Context, args []string) error +} + +// Parse the args and options against the defined command, which sets relevant +// flags, traverses the command hierarchy to select a terminal command, and +// captures the arguments that will be given to that command's exec function. +// The args should not include the program name: pass os.Args[1:], not os.Args. +func (cmd *Command) Parse(args []string, options ...Option) error { + // Initial validation and safety checks. + if cmd.Name == "" { + return fmt.Errorf("name is required") + } + if cmd.isParsed { + return fmt.Errorf("%s: %w", cmd.Name, ErrAlreadyParsed) + } + + // If no flag set was given, set an empty default, so -h, --help works. + if cmd.Flags == nil { + cmd.Flags = NewFlags(cmd.Name) + } + + // Parse this command's flag set from the provided args. + if err := parseFlags(cmd.Flags, args, options...); err != nil { + cmd.selected = cmd // allow GetSelected to work even with errors + return fmt.Errorf("%s: %w", cmd.Name, err) + } + + // If the parse was successful, mark the command as parsed. + cmd.isParsed = true + + // Set this command's args to the args left over after parsing. + cmd.args = cmd.Flags.GetArgs() + + // If there were any args, we might need to descend to a subcommand. + if len(cmd.args) > 0 { + first := cmd.args[0] + for _, subcommand := range cmd.Subcommands { + if strings.EqualFold(first, subcommand.Name) { + cmd.selected = subcommand + subcommand.parent = cmd + return subcommand.Parse(cmd.args[1:], options...) + } + } + } + + // We didn't find a matching subcommand, so we selected ourselves. + cmd.selected = cmd + + // Parse complete. + return nil +} + +// Run the Exec function of the terminal command selected during the parse +// phase, passing the args left over after parsing. Calling [Command.Run] +// without first calling [Command.Parse] will result in [ErrNotParsed]. +func (cmd *Command) Run(ctx context.Context) error { + switch { + case !cmd.isParsed: + return ErrNotParsed + case cmd.isParsed && cmd.selected == nil: + return ErrNotParsed + case cmd.isParsed && cmd.selected == cmd && cmd.Exec == nil: + return fmt.Errorf("%s: %w", cmd.Name, ErrNoExec) + case cmd.isParsed && cmd.selected == cmd && cmd.Exec != nil: + return cmd.Exec(ctx, cmd.args) + default: + return cmd.selected.Run(ctx) + } +} + +// ParseAndRun calls [Command.Parse] and, upon success, [Command.Run]. +func (cmd *Command) ParseAndRun(ctx context.Context, args []string, options ...Option) error { + if err := cmd.Parse(args, options...); err != nil { + return fmt.Errorf("parse: %w", err) + } + + if err := cmd.Run(ctx); err != nil { + return fmt.Errorf("run: %w", err) + } + + return nil +} + +// GetSelected returns the terminal command selected during the parse phase, or +// nil if the command hasn't been successfully parsed. +func (cmd *Command) GetSelected() *Command { + if cmd.selected == nil { + return nil + } + + if cmd.selected == cmd { + return cmd + } + + return cmd.selected.GetSelected() +} + +// GetParent returns the parent command of this command, or nil if a parent +// hasn't been set. Parents are set during the parse phase, but only for +// commands which are traversed. +func (cmd *Command) GetParent() *Command { + return cmd.parent +} + +// Reset every command in the command tree to its initial state, including all +// flag sets. Every flag set must implement [Resetter], or else reset will +// return an error. +func (cmd *Command) Reset() error { + var check func(*Command) error + + check = func(c *Command) error { + if c.Flags != nil { + if _, ok := c.Flags.(Resetter); !ok { + return fmt.Errorf("flag set (%T) doesn't implement Resetter", c.Flags) + } + } + for _, sc := range c.Subcommands { + if err := check(sc); err != nil { + return err + } + } + return nil + } + + if err := check(cmd); err != nil { + return err + } + + if cmd.Flags != nil { + r, ok := cmd.Flags.(Resetter) + if !ok { + panic(fmt.Errorf("flag set (%T) doesn't implement Resetter, even after check (programmer error)", cmd.Flags)) + } + if err := r.Reset(); err != nil { + return fmt.Errorf("reset flags: %w", err) + } + } + + for _, subcommand := range cmd.Subcommands { + if err := subcommand.Reset(); err != nil { + return fmt.Errorf("%s: %w", subcommand.Name, err) + } + } + + cmd.isParsed = false + cmd.selected = nil + cmd.parent = nil + cmd.args = []string{} + + return nil +} diff --git a/command_test.go b/command_test.go new file mode 100644 index 0000000..0e6d03a --- /dev/null +++ b/command_test.go @@ -0,0 +1,158 @@ +package ff_test + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/peterbourgon/ff/v4" +) + +func TestCommandNoFlags(t *testing.T) { + t.Parallel() + + var ( + cmd = &ff.Command{Name: "root"} + ctx = context.Background() + ) + if err := cmd.ParseAndRun(ctx, []string{"-h"}); !errors.Is(err, ff.ErrHelp) { + t.Errorf("err: want %v, have %v", ff.ErrHelp, err) + } + if err := cmd.ParseAndRun(ctx, []string{"--help"}); !errors.Is(err, ff.ErrHelp) { + t.Errorf("err: want %v, have %v", ff.ErrHelp, err) + } + if err := cmd.ParseAndRun(ctx, []string{}); !errors.Is(err, ff.ErrNoExec) { + t.Errorf("err: want %v, have %v", ff.ErrNoExec, err) + } +} + +func TestCommandReset(t *testing.T) { + t.Parallel() + + ctx := context.Background() + rootcmd, testvars := makeTestCommand(t) + defaults := *testvars + + t.Run("first run", func(t *testing.T) { + args := []string{"--verbose", "foo", "-b", "hello world"} + if err := rootcmd.ParseAndRun(ctx, args); err != nil { + t.Fatalf("first run: %v", err) + } + + want := defaults // copy + want.Verbose = true + want.Beta = true + + compareTestCommandVars(t, want, *testvars) + }) + + t.Run("second run without reset", func(t *testing.T) { + want := ff.ErrAlreadyParsed + have := rootcmd.ParseAndRun(ctx, nil) + if !errors.Is(have, want) { + t.Errorf("second run without reset: want error %v, have %v", want, have) + } + }) + + t.Run("reset", func(t *testing.T) { + if err := rootcmd.Reset(); err != nil { + t.Fatalf("reset: %s: %v", rootcmd.Name, err) + } + }) + + t.Run("second run after reset", func(t *testing.T) { + args := []string{"--config-file=my.conf", "foo", "bar", "-a3", "hello world"} + if err := rootcmd.ParseAndRun(ctx, args); err != nil { + t.Fatalf("second run: %v", err) + } + + want := defaults // copy + want.ConfigFile = "my.conf" + want.Alpha = 3 + + compareTestCommandVars(t, want, *testvars) + }) +} + +func makeTestCommand(t *testing.T) (*ff.Command, *testCommandVars) { + t.Helper() + + var vars testCommandVars + + rootFlags := ff.NewFlags("root") + rootFlags.BoolVar(&vars.Verbose, 'v', "verbose", false, "verbose logging") + rootFlags.StringVar(&vars.ConfigFile, 0, "config-file", "", "config file") + rootCommand := &ff.Command{ + Name: "testcmd", + Usage: "testcmd [FLAGS] ...", + LongHelp: loremIpsum, + Flags: rootFlags, + } + + fooFlags := ff.NewFlags("foo").SetParent(rootFlags) + fooFlags.IntVar(&vars.Alpha, 'a', "alpha", 10, "alpha integer") + fooFlags.BoolVar(&vars.Beta, 'b', "beta", false, "beta boolean") + fooCommand := &ff.Command{ + Name: "foo", + Usage: "foo [FLAGS] ...", + ShortHelp: "the foo subcommand", + Flags: fooFlags, + Exec: func(_ context.Context, args []string) error { t.Logf("foo %+v %#v", vars, args); return nil }, + } + rootCommand.Subcommands = append(rootCommand.Subcommands, fooCommand) + + barFlags := ff.NewFlags("bar").SetParent(fooFlags) + barFlags.DurationVar(&vars.Delta, 'd', "delta", 3*time.Second, "delta `δ` duration") + barFlags.Float64Var(&vars.Epsilon, 'e', "epsilon", 3.21, "epsilon float") + barCommand := &ff.Command{ + Name: "bar", + Usage: "bar [FLAGS] ...", + ShortHelp: "the bar subcommand", + Flags: barFlags, + Exec: func(_ context.Context, args []string) error { t.Logf("bar %+v %#v", vars, args); return nil }, + } + fooCommand.Subcommands = append(fooCommand.Subcommands, barCommand) + + return rootCommand, &vars +} + +type testCommandVars struct { + Verbose bool + ConfigFile string + Alpha int + Beta bool + Delta time.Duration + Epsilon float64 +} + +var loremIpsum = strings.TrimSpace(` +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam diam eros, +vestibulum at pulvinar vulputate, vehicula id lacus. Class aptent taciti +sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. +Mauris venenatis felis orci, ac consectetur mi molestie ac. Integer pharetra +pharetra odio. Maecenas metus eros, viverra eget efficitur ut, feugiat in +tortor. Quisque elit nibh, rhoncus in posuere et, bibendum non turpis. +Maecenas eget dui malesuada, pretium tellus quis, bibendum felis. Duis erat +enim, faucibus id auctor ac, ornare sed metus. +`) + +func compareTestCommandVars(t *testing.T, want, have testCommandVars) { + t.Helper() + var ( + structType = reflect.TypeOf(testCommandVars{}) + wantStruct = reflect.ValueOf(want) + haveStruct = reflect.ValueOf(have) + ) + for _, f := range reflect.VisibleFields(structType) { + var ( + wantValue = wantStruct.FieldByIndex(f.Index) + haveValue = haveStruct.FieldByIndex(f.Index) + ) + if !wantValue.Equal(haveValue) { + t.Errorf("%s: want %#v, have %#v", f.Name, wantValue, haveValue) + } + } +} diff --git a/core_flags.go b/core_flags.go new file mode 100644 index 0000000..aa47459 --- /dev/null +++ b/core_flags.go @@ -0,0 +1,865 @@ +package ff + +import ( + "flag" + "fmt" + "regexp" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/peterbourgon/ff/v4/ffval" +) + +// CoreFlags is the default implementation of a [Flags]. It's broadly similar to +// a [flag.FlagSet], but with additional capabilities inspired by getopt(3). +// +// CoreFlags is not safe for concurrent use by multiple goroutines. +type CoreFlags struct { + setName string + flagSlice []*coreFlag + isParsed bool + postParseArgs []string + isStdAdapter bool // stdlib package flag behavior: treat -foo the same as --foo + parent *CoreFlags +} + +var _ Flags = (*CoreFlags)(nil) +var _ Resetter = (*CoreFlags)(nil) + +// NewFlags returns a new core flag set with the given name. +func NewFlags(name string) *CoreFlags { + return &CoreFlags{ + setName: name, + flagSlice: []*coreFlag{}, + isParsed: false, + postParseArgs: []string{}, + isStdAdapter: false, + parent: nil, + } +} + +// NewStdFlags returns a core flag set which acts as an adapter for the provided +// [flag.FlagSet], allowing it to implement the [Flags] interface. +// +// The returned core flag set has slightly different behavior than normal. It's +// a fixed "snapshot" of the provided stdfs, which means it doesn't allow new +// flags to be defined, and won't reflect changes made to the stdfs in the +// future. Also, to approximate standard parsing behavior, it treats every flag +// name as a long name, and treats "-" and "--" equivalently when parsing +// arguments. +func NewStdFlags(stdfs *flag.FlagSet) *CoreFlags { + corefs := NewFlags(stdfs.Name()) + stdfs.VisitAll(func(f *flag.Flag) { + if _, err := corefs.AddFlag(CoreFlagConfig{ + LongName: f.Name, + Usage: f.Usage, + Value: f.Value, + }); err != nil { + panic(fmt.Errorf("add %s: %w", f.Name, err)) + } + }) + corefs.isStdAdapter = true + return corefs +} + +// SetParent assigns a parent flag set to this one. In this case, all of the +// flags in all parent flag sets are available, recursively, to the child. For +// example, Parse will match against any parent flag, WalkFlags will traverse +// all parent flags, etc. +// +// This method returns its receiver to allow for builder-style initialization. +func (fs *CoreFlags) SetParent(parent *CoreFlags) *CoreFlags { + fs.parent = parent + return fs +} + +// GetName returns the name of the flag set provided during construction. +func (fs *CoreFlags) GetName() string { + return fs.setName +} + +// Parse the provided args against the flag set, assigning flag values as +// appropriate. Args are matched to flags defined in this flag set, and, if a +// parent is set, all parent flag sets, recursively. If a specified flag can't +// be found, parse fails with [ErrUnknownFlag]. After a successful parse, +// subsequent calls to parse fail with [ErrAlreadyParsed], until and unless the +// flag set is reset. +func (fs *CoreFlags) Parse(args []string) error { + if fs.isParsed { + return ErrAlreadyParsed + } + + err := fs.parseArgs(args) + switch { + case err == nil: + fs.isParsed = true + case err != nil: + fs.postParseArgs = []string{} + } + return err +} + +func (fs *CoreFlags) parseArgs(args []string) error { + // Credit where credit is due: this implementation is adapted from + // https://pkg.go.dev/github.com/pborman/getopt/v2. + + fs.postParseArgs = args + + for len(args) > 0 { + arg := args[0] + args = args[1:] + + var ( + isEmpty = arg == "" + noDash = !isEmpty && arg[0] != '-' + parseDone = isEmpty || noDash + ) + if parseDone { + return nil // fs.postParseArgs should include arg + } + + if arg == "--" { + fs.postParseArgs = args // fs.postParseArgs should not include "--" + return nil + } + + var ( + isLongFlag = len(arg) > 2 && arg[0:2] == "--" + isShortFlag = len(arg) > 1 && arg[0] == '-' && !isLongFlag + ) + + // The stdlib package flag parses -abc and --abc the same. If we want to + // reproduce that behavior, convert -short flags to --long flags. This + // changes the semantics of concatenated short flags like -abc. + if isShortFlag && fs.isStdAdapter { + isShortFlag = false + isLongFlag = true + arg = "-" + arg + } + + var parseErr error + switch { + case isShortFlag: + args, parseErr = fs.parseShortFlag(arg, args) + case isLongFlag: + args, parseErr = fs.parseLongFlag(arg, args) + } + if parseErr != nil { + return parseErr + } + + fs.postParseArgs = args // we parsed arg, so update fs.postParseArgs with the remainder + } + + return nil +} + +// findFlag finds the first matching flag in the flags hierarchy. +func (fs *CoreFlags) findFlag(short rune, long string) *coreFlag { + var ( + haveShort = isValidShortName(short) + haveLong = isValidLongName(long) + ) + for cursor := fs; cursor != nil; cursor = cursor.parent { + for _, candidate := range cursor.flagSlice { + if haveShort && isValidShortName(candidate.shortName) && candidate.shortName == short { + return candidate + } + if haveLong && isValidLongName(candidate.longName) && candidate.longName == long { + return candidate + } + } + } + return nil +} + +func (fs *CoreFlags) findShortFlag(short rune) *coreFlag { + return fs.findFlag(short, "") +} + +func (fs *CoreFlags) findLongFlag(long string) *coreFlag { + return fs.findFlag(0, long) +} + +func (fs *CoreFlags) parseShortFlag(arg string, args []string) ([]string, error) { + arg = strings.TrimPrefix(arg, "-") + + for i, r := range arg { + f := fs.findShortFlag(r) + if f == nil { + switch { + case arg == "-": // `-` == `--` + return args, nil + case r == 'h': + return args, ErrHelp + default: + return args, fmt.Errorf("%w %q", ErrUnknownFlag, string(r)) + } + } + + var value string + switch { + case f.isBoolFlag: + value = "true" // -b -> b=true + default: + value = arg[i+1:] // -sabc -> s=abc + if value == "" { + if len(args) == 0 { + return args, newFlagError(f, fmt.Errorf("set: missing argument")) + } + value = args[0] // -s abc -> s=abc + args = args[1:] + } + } + + if err := f.flagValue.Set(value); err != nil { + return args, newFlagError(f, fmt.Errorf("set %q: %w", value, err)) + } + f.isSet = true + + if !f.isBoolFlag { + return args, nil + } + } + + return args, nil +} + +func (fs *CoreFlags) parseLongFlag(arg string, args []string) ([]string, error) { + var ( + name string + value string + ) + + if equals := strings.IndexRune(arg, '='); equals > 0 { + arg, value = arg[:equals], arg[equals+1:] + } + + name = strings.TrimPrefix(arg, "--") + + f := fs.findLongFlag(name) + if f == nil { + switch { + case strings.EqualFold(name, "help"): + return nil, ErrHelp + case fs.isStdAdapter && strings.EqualFold(name, "h"): + return nil, ErrHelp + default: + return nil, fmt.Errorf("%w %q", ErrUnknownFlag, name) + } + } + + if value == "" { + switch { + case f.isBoolFlag: + value = "true" // `-b` or `--foo` default to true + if len(args) > 0 { + if _, err := strconv.ParseBool(args[0]); err == nil { + value = args[0] // `-b true` or `--foo false` should also work + args = args[1:] + } + } + case !f.isBoolFlag && len(args) > 0: + value, args = args[0], args[1:] + case !f.isBoolFlag && len(args) <= 0: + return nil, fmt.Errorf("missing value") + default: + panic("unreachable") + } + } + + if err := f.flagValue.Set(value); err != nil { + return nil, newFlagError(f, fmt.Errorf("set %q: %w", value, err)) + } + f.isSet = true + + return args, nil +} + +// IsParsed returns true if the flag set has been successfully parsed. +func (fs *CoreFlags) IsParsed() bool { + return fs.isParsed +} + +// WalkFlags calls fn for every flag known to the flag set. This includes all +// parent flags, if a parent has been set. +func (fs *CoreFlags) WalkFlags(fn func(Flag) error) error { + for cursor := fs; cursor != nil; cursor = cursor.parent { + for _, f := range cursor.flagSlice { + if err := fn(f); err != nil { + return err + } + } + } + return nil +} + +// GetFlag returns the first flag known to the flag set that matches the given +// name. This includes all parent flags, if a parent has been set. The name is +// compared against each flag's long name, and, if the name is a single rune, +// it's also compared against each flag's short name. +func (fs *CoreFlags) GetFlag(name string) (Flag, bool) { + if name == "" { + return nil, false + } + + var ( + short = rune(0) + long = name + ) + if len(name) == 1 { + short, _ = utf8.DecodeRuneInString(name) + } + + f := fs.findFlag(short, long) + if f == nil { + return nil, false + } + + return f, true +} + +// GetArgs returns the args left over after a successful parse. +func (fs *CoreFlags) GetArgs() []string { + return fs.postParseArgs +} + +// Reset the flag set, and all of the flags defined in the flag set, to their +// initial state. After a successful reset, the flag set may be parsed as if it +// were newly constructed. +func (fs *CoreFlags) Reset() error { + for _, f := range fs.flagSlice { + if err := f.Reset(); err != nil { + return newFlagError(f, err) + } + } + + fs.postParseArgs = fs.postParseArgs[:0] + fs.isParsed = false + + return nil +} + +// CoreFlagConfig collects the required config for a flag in a core flag set. +type CoreFlagConfig struct { + // ShortName is the short form name of the flag, which can be provided as a + // commandline argument with a single dash - prefix. A rune value of 0 or + // utf8.RuneError is considered an invalid short name and is ignored. + // + // At least one of ShortName and/or LongName is required. + ShortName rune + + // LongName is the long form name of the flag, which can be provided as a + // commandline argument with a double-dash -- prefix. An empty string is + // considered an invalid long name and is ignored. + // + // At least one of ShortName and/or LongName is required. + LongName string + + // Placeholder is typically used to represent an example value in the help + // text for the flag. For example, a placeholder of `BAR` might result in + // help text like + // + // -f, --foo BAR set the foo parameter + // + // The placeholder is determined by the following logic. + // + // - If EmptyPlaceholder is true, use the empty string + // - If Placeholder is non-empty, use that string + // - If Usage contains a `backtick-quoted` substring, use that substring + // - If Value is a boolean with default value false, use the empty string + // - Otherwise, use a simple transformation of the concrete Value type name + // + // Optional. + Placeholder string + + // NoPlaceholder forces the placeholder of the flag to the empty string. + // This can be useful if you want to elide the placeholder from help text. + NoPlaceholder bool + + // Usage is a short help message for the flag, typically printed after the + // flag name(s) on a single line in the help output. For example, a foo flag + // might have the usage string "set the foo parameter", which might be + // rendered as follows. + // + // -f, --foo BAR set the foo parameter + // + // If the usage string contains a `backtick` quoted substring, that + // substring will be treated as a placeholder, if a placeholder was not + // otherwise explicitly provided. + // + // Recommended. + Usage string + + // Value is used to parse and store the actual flag value. The MakeFlagValue + // helper can be used to construct values for common primitive types. + // + // As a special case, if the value has an IsBoolFlag() bool method returning + // true, then it will be treated as a boolean flag. Boolean flags are parsed + // slightly differently than normal flags: they can be provided without an + // explicit value, in which case the value is assumed to be true. + // + // Required. + Value flag.Value + + // NoDefault forces the default value of the flag to the empty string. This + // can be useful if you want to elide the default value from help text. + NoDefault bool +} + +func (cfg CoreFlagConfig) isBoolFlag() bool { + bf, ok := cfg.Value.(IsBoolFlagger) + if !ok { + return false + } + return bf.IsBoolFlag() +} + +func (cfg CoreFlagConfig) getPlaceholder() string { + // If a placeholder is explicitly refused, use an empty string. + if cfg.NoPlaceholder { + return "" + } + + // If a placeholder is explicitly provided, use that. + if cfg.Placeholder != "" { + return cfg.Placeholder + } + + // If the usage text contains a `backticked` substring, use that. + for i := 0; i < len(cfg.Usage); i++ { + if cfg.Usage[i] == '`' { + for j := i + 1; j < len(cfg.Usage); j++ { + if cfg.Usage[j] == '`' { + return cfg.Usage[i+1 : j] + } + } + break + } + } + + // Bool flags with default value false should have empty placeholders. + if bf, ok := cfg.Value.(IsBoolFlagger); ok && bf.IsBoolFlag() { + if b, err := strconv.ParseBool(cfg.Value.String()); err == nil && !b { + return "" + } + } + + // Otherwise, use a transformation of the flag value type name. + var typeName string + typeName = fmt.Sprintf("%T", cfg.Value) + typeName = strings.ToUpper(typeName) + typeName = typeNameDefaultRegexp.ReplaceAllString(typeName, "$1") + typeName = strings.TrimSuffix(typeName, "VALUE") + if lastDot := strings.LastIndex(typeName, "."); lastDot > 0 { + typeName = typeName[lastDot+1:] + } + return typeName +} + +var typeNameDefaultRegexp = regexp.MustCompile(`[A-Z0-9\_\.\*]+\[(.+)\]`) + +func (cfg CoreFlagConfig) getDefaultValue() string { + // If the config explicitly declares an empty default, use the empty string. + if cfg.NoDefault { + return "" + } + + // Otherwise, use Value.String. + return cfg.Value.String() +} + +// AddFlag adds a flag to the flag set, as specified by the provided config. An +// error is returned if the config is invalid, or if a flag is already defined +// in the flag set with the same short or long name. +// +// This is a fairly low level method. Consumers may prefer type-specific helpers +// like [CoreFlags.Bool], [CoreFlags.StringVar], etc. +func (fs *CoreFlags) AddFlag(cfg CoreFlagConfig) (Flag, error) { + if fs.isStdAdapter { + return nil, fmt.Errorf("cannot add flags to standard flag set adapter") + } + + if cfg.Value == nil { + return nil, fmt.Errorf("value is required") + } + + var ( + hasShort = isValidShortName(cfg.ShortName) + hasLong = isValidLongName(cfg.LongName) + isBoolFlag = cfg.isBoolFlag() + placeholder = cfg.getPlaceholder() + defaultValue = cfg.getDefaultValue() + ) + if !hasShort && !hasLong { + return nil, fmt.Errorf("short name and/or long name is required") + } + if isBoolFlag && !hasLong { + if b, err := strconv.ParseBool(defaultValue); err == nil && b { + return nil, fmt.Errorf("%s: default true boolean flag requires a long name", string(cfg.ShortName)) + } + } + + f := &coreFlag{ + flagSet: fs, + shortName: cfg.ShortName, + longName: cfg.LongName, + placeholder: placeholder, + defaultval: defaultValue, + usageval: cfg.Usage, + flagValue: cfg.Value, + isBoolFlag: isBoolFlag, + } + + for _, existing := range fs.flagSlice { + if isDuplicate(f, existing) { + return nil, newFlagError(f, ErrDuplicateFlag) + } + } + + fs.flagSlice = append(fs.flagSlice, f) + + return f, nil +} + +// Value defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) Value(short rune, long string, value flag.Value, usage string) Flag { + f, err := fs.AddFlag(CoreFlagConfig{ + ShortName: short, + LongName: long, + Usage: usage, + Value: value, + }) + if err != nil { + panic(err) + } + return f +} + +// ValueShort defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) ValueShort(short rune, value flag.Value, usage string) Flag { + return fs.Value(short, "", value, usage) +} + +// ValueLong defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) ValueLong(long string, value flag.Value, usage string) Flag { + return fs.Value(0, long, value, usage) +} + +// BoolVar defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) BoolVar(pointer *bool, short rune, long string, def bool, usage string) Flag { + return fs.Value(short, long, ffval.NewValueDefault(pointer, def), usage) +} + +// Bool defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) Bool(short rune, long string, def bool, usage string) *bool { + var value bool + fs.BoolVar(&value, short, long, def, usage) + return &value +} + +// BoolShort defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) BoolShort(short rune, def bool, usage string) *bool { + return fs.Bool(short, "", def, usage) +} + +// BoolLong defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) BoolLong(long string, def bool, usage string) *bool { + return fs.Bool(0, long, def, usage) +} + +// StringVar defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) StringVar(pointer *string, short rune, long string, def string, usage string) Flag { + return fs.Value(short, long, ffval.NewValueDefault(pointer, def), usage) +} + +// String defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) String(short rune, long string, def string, usage string) *string { + var value string + fs.StringVar(&value, short, long, def, usage) + return &value +} + +// StringShort defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) StringShort(short rune, def string, usage string) *string { + return fs.String(short, "", def, usage) +} + +// StringLong defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) StringLong(long string, def string, usage string) *string { + return fs.String(0, long, def, usage) +} + +// StringListVar defines a new flag in the flag set, and panics on any error. +// +// The flag represents a list of strings, where each call to Set adds a new +// value to the list. Duplicate values are permitted. +func (fs *CoreFlags) StringListVar(pointer *[]string, short rune, long string, usage string) Flag { + return fs.Value(short, long, ffval.NewList(pointer), usage) +} + +// StringList defines a new flag in the flag set, and panics on any error. +// See [StringListVar] for more details. +func (fs *CoreFlags) StringList(short rune, long string, usage string) *[]string { + var value []string + fs.StringListVar(&value, short, long, usage) + return &value +} + +// StringListShort defines a new flag in the flag set, and panics on any error. +// See [StringListVar] for more details. +func (fs *CoreFlags) StringListShort(short rune, usage string) *[]string { + return fs.StringList(short, "", usage) +} + +// StringListLong defines a new flag in the flag set, and panics on any error. +// See [StringListVar] for more details. +func (fs *CoreFlags) StringListLong(long string, usage string) *[]string { + return fs.StringList(0, long, usage) +} + +// StringSetVar defines a new flag in the flag set, and panics on any error. +// +// The flag represents a unique list of strings, where each call to Set adds a +// new value to the list. Duplicate values are silently dropped. +func (fs *CoreFlags) StringSetVar(pointer *[]string, short rune, long string, usage string) Flag { + return fs.Value(short, long, ffval.NewUniqueList(pointer), usage) +} + +// StringSet defines a new flag in the flag set, and panics on any error. +// See [StringSetVar] for more details. +func (fs *CoreFlags) StringSet(short rune, long string, usage string) *[]string { + var value []string + fs.StringSetVar(&value, short, long, usage) + return &value +} + +// StringSetShort defines a new flag in the flag set, and panics on any error. +// See [StringSetVar] for more details. +func (fs *CoreFlags) StringSetShort(short rune, usage string) *[]string { + return fs.StringSet(short, "", usage) +} + +// StringSetLong defines a new flag in the flag set, and panics on any error. +// See [StringSetVar] for more details. +func (fs *CoreFlags) StringSetLong(long string, usage string) *[]string { + return fs.StringSet(0, long, usage) +} + +// Float64Var defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) Float64Var(pointer *float64, short rune, long string, def float64, usage string) Flag { + return fs.Value(short, long, ffval.NewValueDefault(pointer, def), usage) +} + +// Float64 defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) Float64(short rune, long string, def float64, usage string) *float64 { + var value float64 + fs.Float64Var(&value, short, long, def, usage) + return &value +} + +// Float64Short defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) Float64Short(short rune, def float64, usage string) *float64 { + return fs.Float64(short, "", def, usage) +} + +// Float64Long defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) Float64Long(long string, def float64, usage string) *float64 { + return fs.Float64(0, long, def, usage) +} + +// IntVar defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) IntVar(pointer *int, short rune, long string, def int, usage string) Flag { + return fs.Value(short, long, ffval.NewValueDefault(pointer, def), usage) +} + +// Int defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) Int(short rune, long string, def int, usage string) *int { + var value int + fs.IntVar(&value, short, long, def, usage) + return &value +} + +// IntShort defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) IntShort(short rune, def int, usage string) *int { + return fs.Int(short, "", def, usage) +} + +// IntLong defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) IntLong(long string, def int, usage string) *int { + return fs.Int(0, long, def, usage) +} + +// UintVar defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) UintVar(pointer *uint, short rune, long string, def uint, usage string) Flag { + return fs.Value(short, long, ffval.NewValueDefault(pointer, def), usage) +} + +// Uint defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) Uint(short rune, long string, def uint, usage string) *uint { + var value uint + fs.UintVar(&value, short, long, def, usage) + return &value +} + +// UintShort defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) UintShort(short rune, def uint, usage string) *uint { + return fs.Uint(short, "", def, usage) +} + +// UintLong defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) UintLong(long string, def uint, usage string) *uint { + return fs.Uint(0, long, def, usage) +} + +// Uint64Var defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) Uint64Var(pointer *uint64, short rune, long string, def uint64, usage string) Flag { + return fs.Value(short, long, ffval.NewValueDefault(pointer, def), usage) +} + +// Uint64 defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) Uint64(short rune, long string, def uint64, usage string) *uint64 { + var value uint64 + fs.Uint64Var(&value, short, long, def, usage) + return &value +} + +// Uint64Short defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) Uint64Short(short rune, def uint64, usage string) *uint64 { + return fs.Uint64(short, "", def, usage) +} + +// Uint64Long defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) Uint64Long(long string, def uint64, usage string) *uint64 { + return fs.Uint64(0, long, def, usage) +} + +// DurationVar defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) DurationVar(pointer *time.Duration, short rune, long string, def time.Duration, usage string) Flag { + return fs.Value(short, long, ffval.NewValueDefault(pointer, def), usage) +} + +// Duration defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) Duration(short rune, long string, def time.Duration, usage string) *time.Duration { + var value time.Duration + fs.DurationVar(&value, short, long, def, usage) + return &value +} + +// DurationShort defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) DurationShort(short rune, def time.Duration, usage string) *time.Duration { + return fs.Duration(short, "", def, usage) +} + +// DurationLong defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) DurationLong(long string, def time.Duration, usage string) *time.Duration { + return fs.Duration(0, long, def, usage) +} + +// Func defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) Func(short rune, long string, fn func(string) error, usage string) { + stdfs := flag.NewFlagSet("flagset-name", flag.ContinueOnError) + stdfs.Func("flag-name", "flag-usage", fn) + value := stdfs.Lookup("flag-name").Value + fs.Value(short, long, value, usage) +} + +// FuncShort defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) FuncShort(short rune, fn func(string) error, usage string) { + fs.Func(short, "", fn, usage) +} + +// FuncLong defines a new flag in the flag set, and panics on any error. +func (fs *CoreFlags) FuncLong(long string, fn func(string) error, usage string) { + fs.Func(0, long, fn, usage) +} + +// +// +// + +type coreFlag struct { + flagSet *CoreFlags + shortName rune + longName string + placeholder string + defaultval string + usageval string + flagValue flag.Value + isBoolFlag bool + isSet bool +} + +var _ Flag = (*coreFlag)(nil) + +func (f *coreFlag) GetFlags() Flags { + return f.flagSet +} + +func (f *coreFlag) GetShortName() (rune, bool) { + return f.shortName, isValidShortName(f.shortName) +} + +func (f *coreFlag) GetLongName() (string, bool) { + return f.longName, isValidLongName(f.longName) +} + +func (f *coreFlag) GetPlaceholder() string { + return f.placeholder +} + +func (f *coreFlag) GetDefault() string { + return f.defaultval +} + +func (f *coreFlag) GetUsage() string { + return f.usageval +} + +func (f *coreFlag) SetValue(s string) error { + if err := f.flagValue.Set(s); err != nil { + return err + } + f.isSet = true + return nil +} + +func (f *coreFlag) GetValue() string { + return f.flagValue.String() +} + +func (f *coreFlag) IsSet() bool { + return f.isSet +} + +func (f *coreFlag) Reset() error { + if r, ok := f.flagValue.(Resetter); ok { + if err := r.Reset(); err != nil { + return err + } + } + + if err := f.flagValue.Set(f.defaultval); err != nil { + return err + } + f.isSet = false + + return nil +} + +func (f *coreFlag) IsStdFlag() bool { + return f.flagSet.isStdAdapter +} + +func isDuplicate(incoming, existing *coreFlag) bool { + var ( + sameShortName = isValidShortName(incoming.shortName) && isValidShortName(existing.shortName) && incoming.shortName == existing.shortName + sameLongName = isValidLongName(incoming.longName) && isValidLongName(existing.longName) && incoming.longName == existing.longName + shortIsLong = isValidShortName(incoming.shortName) && isValidLongName(existing.longName) && len(existing.longName) == 1 && string(incoming.shortName) == existing.longName + longIsShort = isValidLongName(incoming.longName) && isValidShortName(existing.shortName) && len(incoming.longName) == 1 && incoming.longName == string(existing.shortName) + isDuplicate = sameShortName || sameLongName || shortIsLong || longIsShort + ) + return isDuplicate +} diff --git a/core_flags_test.go b/core_flags_test.go new file mode 100644 index 0000000..0e27de0 --- /dev/null +++ b/core_flags_test.go @@ -0,0 +1,289 @@ +package ff_test + +import ( + "errors" + "flag" + "strings" + "testing" + "time" + + "github.com/peterbourgon/ff/v4" + "github.com/peterbourgon/ff/v4/ffhelp" + "github.com/peterbourgon/ff/v4/fftest" + "github.com/peterbourgon/ff/v4/ffval" +) + +func TestCoreFlags_Basics(t *testing.T) { + t.Parallel() + + for _, argstr := range []string{ + "", + "-b", + "-d 250ms", + "-b -d250ms", + "-bd250ms", + "--duration 250ms --string=nondefault", + } { + t.Run(argstr, func(t *testing.T) { + fs := ff.NewFlags("myset") + fs.Bool('b', "boolean", false, "boolean flag") + fs.StringLong("string", "default", "string flag") + fs.Duration('d', "duration", 250*time.Millisecond, "duration flag") + fftest.TestFlags(t, fs, strings.Fields(argstr)) + }) + } +} + +func TestStdFlags_Basics(t *testing.T) { + t.Parallel() + + for _, argstr := range []string{ + "", + "-b", + "-d=250ms", + "-string 250ms", + "--string=250ms", + "--string 250ms", + } { + t.Run(argstr, func(t *testing.T) { + stdfs := flag.NewFlagSet("myset", flag.ContinueOnError) + stdfs.Bool("b", false, "boolean flag") + stdfs.String("string", "default", "string flag") + stdfs.Duration("d", 250*time.Millisecond, "duration flag") + corefs := ff.NewStdFlags(stdfs) + fftest.TestFlags(t, corefs, strings.Fields(argstr)) + }) + } +} + +func TestCoreFlags_Bool(t *testing.T) { + t.Parallel() + + t.Run("add bool flag", func(t *testing.T) { + var ( + fs = ff.NewFlags(t.Name()) + bflag bool + bvalue = ffval.NewValueDefault(&bflag, true) + ) + + if _, err := fs.AddFlag(ff.CoreFlagConfig{ + ShortName: 'b', + Value: bvalue, + }); err == nil { + t.Errorf("add default true bool with no long name: want error, have none") + } + + if _, err := fs.AddFlag(ff.CoreFlagConfig{ + ShortName: 'b', + LongName: "bflag", + Value: bvalue, + }); err != nil { + t.Errorf("add default true bool with long name: %v", err) + } + }) + + for _, test := range []struct { + args []string + wantX bool + wantY bool + wantErr error + }{ + {args: []string{"--xflag"}, wantX: true, wantY: true}, + {args: []string{"--xflag=true"}, wantX: true, wantY: true}, + {args: []string{"--xflag", "true"}, wantX: true, wantY: true}, + {args: []string{"-x=true"}, wantX: false, wantY: true, wantErr: ff.ErrUnknownFlag}, // = interpreted as flag + {args: []string{"-x"}, wantX: true, wantY: true}, + {args: []string{"-x", "false"}, wantX: true, wantY: true}, // false interpreted as argument + {args: []string{"-y"}, wantX: false, wantY: true}, + {args: []string{"--yflag=false"}, wantX: false, wantY: false}, + {args: []string{"--yflag", "false"}, wantX: false, wantY: false}, + {args: []string{"--yflag", "false", "-y"}, wantX: false, wantY: true}, + {args: []string{"-y=false"}, wantX: false, wantY: false, wantErr: ff.ErrUnknownFlag}, // = interpreted as flag + {args: []string{"-h"}, wantX: false, wantY: true, wantErr: ff.ErrHelp}, + {args: []string{"--help"}, wantX: false, wantY: true, wantErr: ff.ErrHelp}, + {args: []string{"--xflag", "-h"}, wantX: true, wantY: true, wantErr: ff.ErrHelp}, + {args: []string{"-y", "--help"}, wantX: false, wantY: false, wantErr: ff.ErrHelp}, + } { + t.Run(strings.Join(test.args, " "), func(t *testing.T) { + fs := ff.NewFlags(t.Name()) + xflag := fs.Bool('x', "xflag", false, "one boolean flag") + yflag := fs.Bool('y', "yflag", true, "another boolean flag") + err := fs.Parse(test.args) + switch { + case test.wantErr == nil && err == nil: + break // good, and we should test the other stuff + case test.wantErr == nil && err != nil: + t.Fatalf("want no error, got error (%v)", err) + case test.wantErr != nil && err == nil: + t.Fatalf("want error (%v), got none", test.wantErr) + case test.wantErr != nil && err != nil && !errors.Is(err, test.wantErr): + t.Fatalf("want error (%v), got different error (%v)", test.wantErr, err) + case test.wantErr != nil && err != nil && errors.Is(err, test.wantErr): + return // good, but we shouldn't test anything else + } + if want, have := test.wantX, *xflag; want != have { + t.Errorf("x: want %v, have %v", want, have) + } + if want, have := test.wantY, *yflag; want != have { + t.Errorf("y: want %v, have %v", want, have) + } + }) + } +} + +func TestStdFlags_Bool(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + args []string + wantX bool + wantY bool + wantErr error + }{ + {args: []string{"-xflag"}, wantX: true, wantY: true}, + {args: []string{"-xflag=true"}, wantX: true, wantY: true}, + {args: []string{"-xflag", "true"}, wantX: true, wantY: true}, + {args: []string{"--xflag", "true"}, wantX: true, wantY: true}, + {args: []string{"--xflag=true"}, wantX: true, wantY: true}, + {args: []string{"-y"}, wantX: false, wantY: true}, + {args: []string{"-y=false"}, wantX: false, wantY: false}, + {args: []string{"-y", "false"}, wantX: false, wantY: false}, + {args: []string{"--y=false"}, wantX: false, wantY: false}, + {args: []string{"--y", "false"}, wantX: false, wantY: false}, + {args: []string{"--y", "false", "-y"}, wantX: false, wantY: true}, + {args: []string{"-h"}, wantX: false, wantY: true, wantErr: ff.ErrHelp}, + {args: []string{"--help"}, wantX: false, wantY: true, wantErr: ff.ErrHelp}, + {args: []string{"-xflag", "-h"}, wantX: true, wantY: true, wantErr: ff.ErrHelp}, + {args: []string{"--y=false", "--help"}, wantX: false, wantY: false, wantErr: ff.ErrHelp}, + } { + t.Run(strings.Join(test.args, " "), func(t *testing.T) { + stdfs := flag.NewFlagSet(t.Name(), flag.ContinueOnError) + xflag := stdfs.Bool("xflag", false, "one boolean flag") + yflag := stdfs.Bool("y", true, "another boolean flag") + corefs := ff.NewStdFlags(stdfs) + err := corefs.Parse(test.args) + switch { + case test.wantErr == nil && err == nil: + break // good, and we should test the other stuff + case test.wantErr == nil && err != nil: + t.Fatalf("want no error, got error (%v)", err) + case test.wantErr != nil && err == nil: + t.Fatalf("want error (%v), got none", test.wantErr) + case test.wantErr != nil && err != nil && !errors.Is(err, test.wantErr): + t.Fatalf("want error (%v), got different error (%v)", test.wantErr, err) + case test.wantErr != nil && err != nil && errors.Is(err, test.wantErr): + return // good, but we shouldn't test anything else + } + if want, have := test.wantX, *xflag; want != have { + t.Errorf("x: want %v, have %v", want, have) + } + if want, have := test.wantY, *yflag; want != have { + t.Errorf("y: want %v, have %v", want, have) + } + }) + } +} + +func TestCoreFlags_HelpFlag(t *testing.T) { + t.Parallel() + + fs := ff.NewFlags(t.Name()) + helpflag := fs.BoolLong("help", false, "alternative help flag") + + // -h should still trigger ErrHelp. + if err := fs.Parse([]string{"-h"}); !errors.Is(err, ff.ErrHelp) { + t.Errorf("Parse(-h): want %v, have %v", ff.ErrHelp, err) + } + + if err := fs.Reset(); err != nil { + t.Fatalf("Reset(): %v", err) + } + + // --help should not. + if err := fs.Parse([]string{"--help"}); err != nil { + t.Errorf("Parse(--help): error: %v", err) + } + + // It should set the flag we defined. + if want, have := true, *helpflag; want != have { + t.Errorf("h: want %v, have %v", want, have) + } +} + +func TestCoreFlags_GetFlag(t *testing.T) { + t.Parallel() + + fs := ff.NewFlags(t.Name()) + fs.IntLong("foo", 0, "first flag") + fs.IntShort('f', 0, "second flag") + + f1, ok := fs.GetFlag("foo") + if !ok { + t.Fatalf(`GetFlag("foo"): returned not-OK`) + } + if want, have := "first flag", f1.GetUsage(); want != have { + t.Errorf(`GetFlag("foo"): GetUsage: want %q, have %q`, want, have) + } + + f2, ok := fs.GetFlag("f") + if !ok { + t.Fatalf(`GetFlag("f"): returned not-OK`) + } + if want, have := "second flag", f2.GetUsage(); want != have { + t.Errorf(`GetFlag("f"): GetUsage: want %q, have %q`, want, have) + } +} + +func TestCoreFlags_NoDefault(t *testing.T) { + t.Parallel() + + fs := ff.NewFlags(t.Name()) + alpha, _ := fs.AddFlag(ff.CoreFlagConfig{LongName: "alpha", Value: &ffval.Duration{}, Usage: "zero duration"}) + beta, _ := fs.AddFlag(ff.CoreFlagConfig{LongName: "beta", Value: &ffval.Duration{}, Usage: "zero duration with NoDefault", NoDefault: true}) + + if want, have := "0s", alpha.GetDefault(); want != have { + t.Errorf("alpha: default: want %q, have %q", want, have) + } + + if want, have := "", beta.GetDefault(); want != have { + t.Errorf("beta: default: want %q, have %q", want, have) + } +} + +func TestCoreFlags_NoPlaceholder(t *testing.T) { + t.Parallel() + + fs := ff.NewFlags(t.Name()) + alpha, _ := fs.AddFlag(ff.CoreFlagConfig{LongName: "alpha", Value: &ffval.Bool{}, Usage: "alpha", NoPlaceholder: true}) + beta, _ := fs.AddFlag(ff.CoreFlagConfig{LongName: "beta", Value: ffval.NewValueDefault(new(bool), true), Usage: "beta", NoPlaceholder: true}) + delta, _ := fs.AddFlag(ff.CoreFlagConfig{LongName: "delta", Value: ffval.NewValueDefault(new(bool), true), Usage: "delta `D` flag", NoPlaceholder: true}) + kappa, _ := fs.AddFlag(ff.CoreFlagConfig{LongName: "kappa", Value: ffval.NewValue(new(bool)), Usage: "kappa `K` flag", NoPlaceholder: true}) + + for _, f := range []ff.Flag{alpha, beta, delta, kappa} { + if want, have := "", f.GetPlaceholder(); want != have { + t.Errorf("%s: want %q, have %q", ffhelp.WrapFlag(f), want, have) + } + } +} + +func TestCoreFlags_Get(t *testing.T) { + t.Parallel() + + fs := ff.NewFlags(t.Name()) + f, err := fs.AddFlag(ff.CoreFlagConfig{ + LongName: "alpha", + Value: new(ffval.Int), + Placeholder: "X", + }) + if err != nil { + t.Fatal(err) + } + + if want, have := "0", f.GetDefault(); want != have { + t.Errorf("GetDefault: want %q, have %q", want, have) + } + + if want, have := "X", f.GetPlaceholder(); want != have { + t.Errorf("GetPlaceholder: want %q, have %q", want, have) + } +} diff --git a/doc.go b/doc.go index c831385..db6757e 100644 --- a/doc.go +++ b/doc.go @@ -1,9 +1,15 @@ -// Package ff is a flags-first helper package for configuring programs. +// Package ff provides a flags-first approach to runtime configuration. // -// Runtime configuration must always be specified as commandline flags, so that -// the configuration surface area of a program is self-describing. Package ff -// provides an easy way to populate those flags from environment variables and -// config files. +// [Parse] is the central function. It mirrors [flag.FlagSet.Parse] and +// populates a set of [Flags] from commandline arguments, environment variables, +// and/or a config file. [Option] values control parse behavior. // -// See the README at https://github.com/peterbourgon/ff for more information. +// [CoreFlags] is a standard, getopts(3)-inspired implementation of the [Flags] +// interface. Consumers can create a CoreFlags via [NewFlags], or adapt an +// existing [flag.FlagSet] to a CoreFlags via [NewStdFlags], or provide their +// own implementation altogether. +// +// [Command] is provided as a way to build hierarchical CLI tools, like docker +// or kubectl, in a simple and declarative style. It's intended to be easier to +// understand and maintain than more common alternatives. package ff diff --git a/env_parser.go b/env_parser.go deleted file mode 100644 index b2cea78..0000000 --- a/env_parser.go +++ /dev/null @@ -1,58 +0,0 @@ -package ff - -import ( - "bufio" - "fmt" - "io" - "strconv" - "strings" -) - -// EnvParser is a parser for .env files. Each line is tokenized on the first `=` -// character. The first token is interpreted as the flag name, and the second -// token is interpreted as the value. Both tokens are trimmed of leading and -// trailing whitespace. If the value is "double quoted", control characters like -// `\n` are expanded. Lines beginning with `#` are interpreted as comments. -// -// EnvParser respects WithEnvVarPrefix, e.g. an .env file containing `A_B=c` -// will set a flag named "b" if Parse is called with WithEnvVarPrefix("A"). -func EnvParser(r io.Reader, set func(name, value string) error) error { - s := bufio.NewScanner(r) - for s.Scan() { - line := strings.TrimSpace(s.Text()) - if line == "" { - continue // skip empties - } - - if line[0] == '#' { - continue // skip comments - } - - index := strings.IndexRune(line, '=') - if index < 0 { - return fmt.Errorf("invalid line: %s", line) - } - - var ( - name = strings.TrimSpace(line[:index]) - value = strings.TrimSpace(line[index+1:]) - ) - - if len(name) <= 0 { - return fmt.Errorf("invalid line: %s", line) - } - - if len(value) <= 0 { - return fmt.Errorf("invalid line: %s", line) - } - - if unquoted, err := strconv.Unquote(value); err == nil { - value = unquoted - } - - if err := set(name, value); err != nil { - return err - } - } - return nil -} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..6920eda --- /dev/null +++ b/errors.go @@ -0,0 +1,32 @@ +package ff + +import ( + "errors" + "flag" +) + +var ( + // ErrHelp should be returned by flag sets during parse, when the provided + // args indicate the user has requested help. + ErrHelp = flag.ErrHelp + + // ErrDuplicateFlag should be returned by flag sets when the user tries to + // add a flag with the same name as a pre-existing flag. + ErrDuplicateFlag = errors.New("duplicate flag") + + // ErrNotParsed may be returned by flag set methods which require the flag + // set to have been successfully parsed, and that condition isn't satisfied. + ErrNotParsed = errors.New("not parsed") + + // ErrAlreadyParsed may be returned by the parse method of flag sets, if the + // flag set has already been successfully parsed, and cannot be parsed + // again. + ErrAlreadyParsed = errors.New("already parsed") + + // ErrUnknownFlag should be returned by flag sets methods to indicate that a + // specific or user-requested flag was provided but could not be found. + ErrUnknownFlag = errors.New("unknown flag") + + // ErrNoExec is returned when a command without an exec function is run. + ErrNoExec = errors.New("no exec function") +) diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..c4a3800 --- /dev/null +++ b/example_test.go @@ -0,0 +1,135 @@ +package ff_test + +import ( + "flag" + "fmt" + "os" + "time" + + "github.com/peterbourgon/ff/v4" +) + +func ExampleParse_args() { + fs := ff.NewFlags("myprogram") + var ( + listen = fs.StringLong("listen", "localhost:8080", "listen address") + refresh = fs.Duration('r', "refresh", 15*time.Second, "refresh interval") + debug = fs.Bool('d', "debug", false, "log debug information") + ) + + err := ff.Parse(fs, []string{"--refresh=1s", "-d"}) + + fmt.Printf("err=%v\n", err) + fmt.Printf("listen=%v\n", *listen) + fmt.Printf("refresh=%v\n", *refresh) + fmt.Printf("debug=%v\n", *debug) + + // Output: + // err= + // listen=localhost:8080 + // refresh=1s + // debug=true +} + +func ExampleParse_env() { + fs := ff.NewFlags("myprogram") + var ( + listen = fs.StringLong("listen", "localhost:8080", "listen address") + refresh = fs.Duration('r', "refresh", 15*time.Second, "refresh interval") + debug = fs.Bool('d', "debug", false, "log debug information") + ) + + os.Setenv("MY_PROGRAM_REFRESH", "3s") + + err := ff.Parse(fs, []string{}, + ff.WithEnvVarPrefix("MY_PROGRAM"), + ) + + fmt.Printf("err=%v\n", err) + fmt.Printf("listen=%v\n", *listen) + fmt.Printf("refresh=%v\n", *refresh) + fmt.Printf("debug=%v\n", *debug) + + // Output: + // err= + // listen=localhost:8080 + // refresh=3s + // debug=false +} + +func ExampleParse_config() { + fs := ff.NewFlags("myprogram") + var ( + listen = fs.StringLong("listen", "localhost:8080", "listen address") + refresh = fs.Duration('r', "refresh", 15*time.Second, "refresh interval") + debug = fs.Bool('d', "debug", false, "log debug information") + _ = fs.String('c', "config", "", "path to config file") + ) + + f, _ := os.CreateTemp("", "ExampleParse_config") + defer func() { f.Close(); os.Remove(f.Name()) }() + fmt.Fprint(f, ` + debug + listen localhost:9999 + `) + + err := ff.Parse(fs, []string{"-c", f.Name()}, + ff.WithConfigFileFlag("config"), + ff.WithConfigFileParser(ff.PlainParser), + ) + + fmt.Printf("err=%v\n", err) + fmt.Printf("listen=%v\n", *listen) + fmt.Printf("refresh=%v\n", *refresh) + fmt.Printf("debug=%v\n", *debug) + + // Output: + // err= + // listen=localhost:9999 + // refresh=15s + // debug=true +} + +func ExampleParse_stdlib() { + fs := flag.NewFlagSet("myprogram", flag.ContinueOnError) + var ( + listen = fs.String("listen", "localhost:8080", "listen address") + refresh = fs.Duration("refresh", 15*time.Second, "refresh interval") + debug = fs.Bool("debug", false, "log debug information") + ) + + err := ff.Parse(fs, []string{"--debug", "-refresh=2s", "-listen", "localhost:9999"}) + + fmt.Printf("err=%v\n", err) + fmt.Printf("listen=%v\n", *listen) + fmt.Printf("refresh=%v\n", *refresh) + fmt.Printf("debug=%v\n", *debug) + + // Output: + // err= + // listen=localhost:9999 + // refresh=2s + // debug=true +} + +func ExampleParse_help() { + fs := ff.NewFlags("myprogram") + var ( + listen = fs.StringLong("listen", "localhost:8080", "listen address") + refresh = fs.DurationLong("refresh", 15*time.Second, "refresh interval") + debug = fs.BoolLong("debug", false, "log debug information") + ) + + err := ff.Parse(fs, []string{"-h"}) + + fmt.Printf("err=%v\n", err) + fmt.Printf("listen=%v\n", *listen) + fmt.Printf("refresh=%v\n", *refresh) + fmt.Printf("debug=%v\n", *debug) + + // Output: + // err=parse args: flag: help requested + // listen=localhost:8080 + // refresh=15s + // debug=false +} diff --git a/examples/basicflags/basicflags.go b/examples/basicflags/basicflags.go new file mode 100644 index 0000000..06780e9 --- /dev/null +++ b/examples/basicflags/basicflags.go @@ -0,0 +1,43 @@ +package main + +import ( + "errors" + "fmt" + "os" + "time" + + "github.com/peterbourgon/ff/v4" + "github.com/peterbourgon/ff/v4/ffhelp" +) + +func main() { + fs := ff.NewFlags("basicflags") + var ( + config = fs.String('c', "config", "", "config file") + delta = fs.Duration('d', "delta", time.Second, "value for `∆` parameter") + epsilon = fs.IntLong("epsilon", 32, "value for `ε` parameter") + urls = fs.StringSet('u', "url", "remote URL (repeatable)") + verbose = fs.Bool('v', "verbose", false, "verbose logging") + ) + + err := ff.Parse(fs, os.Args[1:], + ff.WithEnvVarPrefix("BASICFLAGS"), // try `env BASICFLAGS_DELTA=33ms basicflags` + ff.WithConfigFileFlag("config"), // try providing a file with `delta 33ms` + ff.WithConfigFileParser(ff.PlainParser), + ) + switch { + case errors.Is(err, ff.ErrHelp): + fmt.Fprintf(os.Stderr, "%s\n", ffhelp.Flags(fs)) + os.Exit(0) + case err != nil: + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + fmt.Printf("config: %q\n", *config) + fmt.Printf("delta: %s\n", *delta) + fmt.Printf("epsilon: %d\n", *epsilon) + fmt.Printf("urls: %v\n", *urls) + fmt.Printf("verbose: %v\n", *verbose) + fmt.Printf("fs.GetArgs: %v\n", fs.GetArgs()) +} diff --git a/examples/objectctl/cmd/objectctl/main.go b/examples/objectctl/cmd/objectctl/main.go new file mode 100644 index 0000000..c7d8ad0 --- /dev/null +++ b/examples/objectctl/cmd/objectctl/main.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + + "github.com/peterbourgon/ff/v4" + "github.com/peterbourgon/ff/v4/examples/objectctl/pkg/createcmd" + "github.com/peterbourgon/ff/v4/examples/objectctl/pkg/deletecmd" + "github.com/peterbourgon/ff/v4/examples/objectctl/pkg/listcmd" + "github.com/peterbourgon/ff/v4/examples/objectctl/pkg/objectapi" + "github.com/peterbourgon/ff/v4/examples/objectctl/pkg/rootcmd" + "github.com/peterbourgon/ff/v4/ffhelp" +) + +func main() { + var ( + ctx = context.Background() + args = os.Args[1:] + stdin = os.Stdin + stdout = os.Stdout + stderr = os.Stderr + err = exec(ctx, args, stdin, stdout, stderr) + ) + switch { + case err == nil, errors.Is(err, ff.ErrHelp), errors.Is(err, ff.ErrNoExec): + // no problem + case err != nil: + fmt.Fprintf(stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func exec(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (err error) { + var ( + root = rootcmd.New(stdout, stderr) + _ = createcmd.New(root) + _ = deletecmd.New(root) + _ = listcmd.New(root) + ) + + defer func() { + if err != nil { + fmt.Fprintf(stderr, "\n%s\n", ffhelp.Command(root.Command)) + } + }() + + if err := root.Command.Parse(args); err != nil { + return fmt.Errorf("parse: %w", err) + } + + client, err := objectapi.NewClient(root.Token) + if err != nil { + return fmt.Errorf("construct API client: %w", err) + } + + root.Client = client + + if err := root.Command.Run(ctx); err != nil { + return fmt.Errorf("run: %w", err) + } + + return nil +} diff --git a/examples/objectctl/cmd/objectctl/main_test.go b/examples/objectctl/cmd/objectctl/main_test.go new file mode 100644 index 0000000..8caeb26 --- /dev/null +++ b/examples/objectctl/cmd/objectctl/main_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/peterbourgon/ff/v4" + "github.com/peterbourgon/ff/v4/examples/objectctl/pkg/objectapi" + "github.com/peterbourgon/ff/v4/fftest" +) + +func TestExec(t *testing.T) { + t.Parallel() + + type testcase struct { + name string + args []string + wantErr error + wantStdout string + wantStderr string + } + + testcases := []testcase{ + { + name: "no args", + wantStderr: rootUsage, + wantErr: ff.ErrNoExec, + }, + { + name: "-h", + args: []string{"-h"}, + wantStderr: rootUsage, + wantErr: ff.ErrHelp, + }, + { + name: "list", + args: []string{"list"}, + wantStderr: listUsage, + wantErr: objectapi.ErrUnauthorized, + }, + { + name: "list -h", + args: []string{"list", "-h"}, + wantStderr: listUsage, + wantErr: ff.ErrHelp, + }, + { + name: "list --token=SECRET", + args: []string{"list", "--token=SECRET"}, + wantStdout: listOutput, + }, + } + + for _, test := range testcases { + t.Run(test.name, func(t *testing.T) { + var ( + ctx = context.Background() + stdin = strings.NewReader("") + stdout = &bytes.Buffer{} + stderr = &bytes.Buffer{} + haveErr = exec(ctx, test.args, stdin, stdout, stderr) + ) + + if test.wantErr == nil { + if haveErr != nil { + t.Fatalf("error: want none, have %v", haveErr) + } + } + + if test.wantErr != nil { + if !errors.Is(haveErr, test.wantErr) { + t.Fatalf("error: want %v, have %v", test.wantErr, haveErr) + } + } + + { + want := strings.TrimSpace(test.wantStdout) + have := strings.TrimSpace(stdout.String()) + if want != have { + t.Errorf("stdout:\n%s", fftest.DiffString(want, have)) + } + } + + { + want := strings.TrimSpace(test.wantStderr) + have := strings.TrimSpace(stderr.String()) + if want != have { + t.Errorf("stderr:\n%s", fftest.DiffString(want, have)) + } + } + }) + } +} + +const rootUsage = ` +objectctl -- control objects + +USAGE + objectctl [FLAGS] ... + +SUBCOMMANDS + create create or overwrite an object + delete delete an object + list list available objects + +FLAGS + --token STRING secret token for object API + -v, --verbose log verbose output +` + +const listUsage = ` +list -- list available objects + +USAGE + objectctl list [FLAGS] + +FLAGS + -a, --atime include last access time of each object + +FLAGS (objectctl) + --token STRING secret token for object API + -v, --verbose log verbose output +` + +const listOutput = ` +KEY VALUE +aardvark A nocturnal burrowing mammal with long ears, a tubular snout, and a long extensible tongue. +apple The fruit of any of certain other species of tree of the same genus. +beach The shore of a body of water, especially when sandy or pebbly. +carillon A stationary set of chromatically tuned bells in a tower. +` diff --git a/examples/objectctl/pkg/createcmd/create.go b/examples/objectctl/pkg/createcmd/create.go new file mode 100644 index 0000000..08139d1 --- /dev/null +++ b/examples/objectctl/pkg/createcmd/create.go @@ -0,0 +1,61 @@ +package createcmd + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/peterbourgon/ff/v4" + "github.com/peterbourgon/ff/v4/examples/objectctl/pkg/rootcmd" + "github.com/peterbourgon/ff/v4/ffval" +) + +type CreateConfig struct { + *rootcmd.RootConfig + Overwrite bool + Flags *ff.CoreFlags + Command *ff.Command +} + +func New(rootConfig *rootcmd.RootConfig) *CreateConfig { + var cfg CreateConfig + cfg.RootConfig = rootConfig + cfg.Flags = ff.NewFlags("create").SetParent(cfg.RootConfig.Flags) + cfg.Flags.AddFlag(ff.CoreFlagConfig{ + LongName: "overwrite", + Value: ffval.NewValue(&cfg.Overwrite), + Usage: "overwrite an existing object", + NoDefault: true, + }) + cfg.Command = &ff.Command{ + Name: "create", + Usage: "objectctl create [FLAGS] ", + ShortHelp: "create or overwrite an object", + Flags: cfg.Flags, + Exec: cfg.Exec, + } + cfg.RootConfig.Command.Subcommands = append(cfg.RootConfig.Command.Subcommands, cfg.Command) + return &cfg +} + +func (cfg *CreateConfig) Exec(ctx context.Context, args []string) error { + if len(args) < 2 { + return errors.New("create requires at least 2 args") + } + + var ( + key = args[0] + value = strings.Join(args[1:], " ") + err = cfg.Client.Create(ctx, key, value, cfg.Overwrite) + ) + if err != nil { + return err + } + + if cfg.Verbose { + fmt.Fprintf(cfg.Stderr, "create %q OK\n", key) + } + + return nil +} diff --git a/examples/objectctl/pkg/deletecmd/delete.go b/examples/objectctl/pkg/deletecmd/delete.go new file mode 100644 index 0000000..0985f7d --- /dev/null +++ b/examples/objectctl/pkg/deletecmd/delete.go @@ -0,0 +1,57 @@ +package deletecmd + +import ( + "context" + "errors" + "fmt" + + "github.com/peterbourgon/ff/v4" + "github.com/peterbourgon/ff/v4/examples/objectctl/pkg/rootcmd" + "github.com/peterbourgon/ff/v4/ffval" +) + +type DeleteConfig struct { + *rootcmd.RootConfig + Force bool + Flags *ff.CoreFlags + Command *ff.Command +} + +func New(parent *rootcmd.RootConfig) *DeleteConfig { + var cfg DeleteConfig + cfg.RootConfig = parent + cfg.Flags = ff.NewFlags("delete").SetParent(parent.Flags) + cfg.Flags.AddFlag(ff.CoreFlagConfig{ + LongName: "force", + Value: ffval.NewValue(&cfg.Force), + Usage: "force delete", + NoDefault: true, + }) + cfg.Command = &ff.Command{ + Name: "delete", + Usage: "objectctl delete [FLAGS] ", + ShortHelp: "delete an object", + Flags: cfg.Flags, + Exec: cfg.Exec, + } + cfg.RootConfig.Command.Subcommands = append(cfg.RootConfig.Command.Subcommands, cfg.Command) + return &cfg +} + +func (cfg *DeleteConfig) Exec(ctx context.Context, args []string) error { + if len(args) < 1 { + return errors.New("delete requires at least 1 arg") + } + + key := args[0] + existed, err := cfg.Client.Delete(ctx, key, cfg.Force) + if err != nil { + return err + } + + if cfg.Verbose { + fmt.Fprintf(cfg.Stderr, "delete %q OK (existed %v)\n", key, existed) + } + + return nil +} diff --git a/examples/objectctl/pkg/listcmd/list.go b/examples/objectctl/pkg/listcmd/list.go new file mode 100644 index 0000000..2fb6057 --- /dev/null +++ b/examples/objectctl/pkg/listcmd/list.go @@ -0,0 +1,74 @@ +package listcmd + +import ( + "context" + "fmt" + "text/tabwriter" + "time" + + "github.com/peterbourgon/ff/v4" + "github.com/peterbourgon/ff/v4/examples/objectctl/pkg/rootcmd" + "github.com/peterbourgon/ff/v4/ffval" +) + +type Config struct { + *rootcmd.RootConfig + WithAccessTimes bool + Command *ff.Command + Flags *ff.CoreFlags +} + +func New(parent *rootcmd.RootConfig) *Config { + var cfg Config + cfg.RootConfig = parent + cfg.Flags = ff.NewFlags("list").SetParent(parent.Flags) + cfg.Flags.AddFlag(ff.CoreFlagConfig{ + ShortName: 'a', + LongName: "atime", + Value: ffval.NewValue(&cfg.WithAccessTimes), + Usage: "include last access time of each object", + NoDefault: true, + }) + + cfg.Command = &ff.Command{ + Name: "list", + Usage: "objectctl list [FLAGS]", + ShortHelp: "list available objects", + Flags: cfg.Flags, + Exec: cfg.Exec, + } + cfg.RootConfig.Command.Subcommands = append(cfg.RootConfig.Command.Subcommands, cfg.Command) + return &cfg +} + +func (cfg *Config) Exec(ctx context.Context, _ []string) error { + objects, err := cfg.Client.List(ctx) + if err != nil { + return err + } + + if cfg.Verbose { + fmt.Fprintf(cfg.Stderr, "object count: %d\n", len(objects)) + } + + if len(objects) <= 0 { + return nil + } + + tw := tabwriter.NewWriter(cfg.Stdout, 0, 2, 2, ' ', 0) + if cfg.WithAccessTimes { + fmt.Fprintf(tw, "KEY\tVALUE\tATIME\n") + } else { + fmt.Fprintf(tw, "KEY\tVALUE\n") + } + for _, object := range objects { + if cfg.WithAccessTimes { + fmt.Fprintf(tw, "%s\t%s\t%s\n", object.Key, object.Value, object.Access.Format(time.RFC3339)) + } else { + fmt.Fprintf(tw, "%s\t%s\n", object.Key, object.Value) + } + } + tw.Flush() + + return nil +} diff --git a/ffcli/examples/objectctl/pkg/objectapi/client.go b/examples/objectctl/pkg/objectapi/client.go similarity index 85% rename from ffcli/examples/objectctl/pkg/objectapi/client.go rename to examples/objectctl/pkg/objectapi/client.go index 6abd35b..9c320b6 100644 --- a/ffcli/examples/objectctl/pkg/objectapi/client.go +++ b/examples/objectctl/pkg/objectapi/client.go @@ -3,6 +3,7 @@ package objectapi import ( "context" "errors" + "sort" "time" ) @@ -48,6 +49,8 @@ func (c *Client) List(ctx context.Context) ([]Object, error) { // // +var ErrUnauthorized = errors.New("unauthorized") + type mockServer struct { token string objects map[string]Object @@ -62,7 +65,7 @@ func newMockServer() *mockServer { func (s *mockServer) create(token, key, value string, overwrite bool) error { if token != s.token { - return errors.New("not authorized") + return ErrUnauthorized } if _, ok := s.objects[key]; ok && !overwrite { @@ -78,9 +81,9 @@ func (s *mockServer) create(token, key, value string, overwrite bool) error { return nil } -func (s *mockServer) delete(token, key string, force bool) (existed bool, err error) { +func (s *mockServer) delete(token, key string, force bool) (existed bool, _ error) { if token != s.token { - return false, errors.New("not authorized") + return false, ErrUnauthorized } _, ok := s.objects[key] @@ -90,7 +93,7 @@ func (s *mockServer) delete(token, key string, force bool) (existed bool, err er func (s *mockServer) list(token string) ([]Object, error) { if token != s.token { - return nil, errors.New("not authorized") + return nil, ErrUnauthorized } result := make([]Object, 0, len(s.objects)) @@ -98,10 +101,19 @@ func (s *mockServer) list(token string) ([]Object, error) { result = append(result, obj) } + sort.Slice(result, func(i, j int) bool { + return result[i].Key < result[j].Key + }) + return result, nil } var defaultObjects = map[string]Object{ + "aardvark": { + Key: "aardvark", + Value: "A nocturnal burrowing mammal with long ears, a tubular snout, and a long extensible tongue.", + Access: mustParseTime(time.RFC3339, "2019-03-15T17:18:19Z"), + }, "apple": { Key: "apple", Value: "The fruit of any of certain other species of tree of the same genus.", diff --git a/examples/objectctl/pkg/rootcmd/root.go b/examples/objectctl/pkg/rootcmd/root.go new file mode 100644 index 0000000..30bc029 --- /dev/null +++ b/examples/objectctl/pkg/rootcmd/root.go @@ -0,0 +1,41 @@ +package rootcmd + +import ( + "io" + + "github.com/peterbourgon/ff/v4" + "github.com/peterbourgon/ff/v4/examples/objectctl/pkg/objectapi" + "github.com/peterbourgon/ff/v4/ffval" +) + +type RootConfig struct { + Stdout io.Writer + Stderr io.Writer + Token string + Verbose bool + Client *objectapi.Client + Flags *ff.CoreFlags + Command *ff.Command +} + +func New(stdout, stderr io.Writer) *RootConfig { + var cfg RootConfig + cfg.Stdout = stdout + cfg.Stderr = stderr + cfg.Flags = ff.NewFlags("objectctl") + cfg.Flags.StringVar(&cfg.Token, 0, "token", "", "secret token for object API") + cfg.Flags.AddFlag(ff.CoreFlagConfig{ + ShortName: 'v', + LongName: "verbose", + Value: ffval.NewValue(&cfg.Verbose), + Usage: "log verbose output", + NoDefault: true, + }) + cfg.Command = &ff.Command{ + Name: "objectctl", + ShortHelp: "control objects", + Usage: "objectctl [FLAGS] ...", + Flags: cfg.Flags, + } + return &cfg +} diff --git a/examples/textctl/textctl.go b/examples/textctl/textctl.go new file mode 100644 index 0000000..70812f2 --- /dev/null +++ b/examples/textctl/textctl.go @@ -0,0 +1,76 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/peterbourgon/ff/v4" + "github.com/peterbourgon/ff/v4/ffhelp" +) + +// textctl is a simple application where all commands are built up in func main. +// It demonstrates how to declare commands, how to wire them into a command +// tree, and how to give subcommands access to parent command flags. + +func main() { + rootFlags := ff.NewFlags("textctl") + verbose := rootFlags.Bool('v', "verbose", false, "increase log verbosity") + rootCmd := &ff.Command{ + Name: "textctl", + Usage: "textctl [FLAGS] ", + Flags: rootFlags, + } + + repeatFlags := ff.NewFlags("repeat").SetParent(rootFlags) // SetParent allows repeatFlags access to rootFlags + n := repeatFlags.IntShort('n', 3, "how many times to repeat") + repeatCmd := &ff.Command{ + Name: "repeat", + Usage: "textctl repeat [-n TIMES] ", + ShortHelp: "repeatedly print the argument to stdout", + Flags: repeatFlags, + Exec: func(_ context.Context, args []string) error { // defining Exec inline allows it to access the e.g. verbose flag, above + if n := len(args); n != 1 { + return fmt.Errorf("repeat requires exactly 1 argument, but you provided %d", n) + } + if *verbose { + fmt.Fprintf(os.Stderr, "repeat: n=%d\n", *n) + fmt.Fprintf(os.Stderr, "repeat: will generate %dB of output\n", (*n)*len(args[0])) + } + for i := 0; i < *n; i++ { + fmt.Fprintf(os.Stdout, "%s\n", args[0]) + } + return nil + }, + } + rootCmd.Subcommands = append(rootCmd.Subcommands, repeatCmd) // add the repeat command underneath the root comand + + countCmd := &ff.Command{ + Name: "count", + Usage: "textctl count [ ...]", + ShortHelp: "count the number of bytes in the arguments", + Flags: ff.NewFlags("count").SetParent(rootFlags), // count has no flags itself, but it should still be able to parse root flags + Exec: func(_ context.Context, args []string) error { + if *verbose { + fmt.Fprintf(os.Stderr, "count: argument count %d\n", len(args)) + } + var n int + for _, arg := range args { + n += len(arg) + } + fmt.Fprintf(os.Stdout, "%d\n", n) + return nil + }, + } + rootCmd.Subcommands = append(rootCmd.Subcommands, countCmd) // add the count command underneath the root command + + err := rootCmd.ParseAndRun(context.Background(), os.Args[1:]) + if errors.Is(err, ff.ErrHelp) || errors.Is(err, ff.ErrNoExec) { + fmt.Fprintf(os.Stderr, "\n%s\n", ffhelp.Command(rootCmd)) + os.Exit(0) + } else if err != nil { + fmt.Fprintf(os.Stderr, "error: %s\n", err) + os.Exit(1) + } +} diff --git a/ffcli/README.md b/ffcli/README.md deleted file mode 100644 index aee199e..0000000 --- a/ffcli/README.md +++ /dev/null @@ -1,153 +0,0 @@ -# ffcli [![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/github.com/peterbourgon/ff/v3/ffcli) - -ffcli stands for flags-first command line interface, -and provides an opinionated way to build CLIs. - -## Rationale - -Popular CLI frameworks like [spf13/cobra][cobra], [urfave/cli][urfave], or -[alecthomas/kingpin][kingpin] tend to have extremely large APIs, to support a -large number of "table stakes" features. - -[cobra]: https://github.com/spf13/cobra -[urfave]: https://github.com/urfave/cli -[kingpin]: https://github.com/alecthomas/kingpin - -This package is intended to be a lightweight alternative to those packages. In -contrast to them, the API surface area of package ffcli is very small, with the -immediate goal of being intuitive and productive, and the long-term goal of -supporting commandline applications that are substantially easier to understand -and maintain. - -To support these goals, the package is concerned only with the core mechanics of -defining a command tree, parsing flags, and selecting a command to run. It does -not intend to be a one-stop-shop for everything your commandline application -needs. Features like tab completion or colorized output are orthogonal to -command tree parsing, and should be easy to provide on top of ffcli. - -Finally, this package follows in the philosophy of its parent package ff, or -"flags-first". Flags, and more specifically the Go stdlib flag.FlagSet, should -be the primary mechanism of getting configuration from the execution environment -into your program. The affordances provided by package ff, including environment -variable and config file parsing, are also available in package ffcli. Support -for other flag packages is a non-goal. - - -## Goals - -- Absolute minimum usable API -- Prefer using existing language features/patterns/abstractions whenever possible -- Enable integration-style testing of CLIs with mockable dependencies -- No global state - -## Non-goals - -- All conceivably useful features -- Integration with flag packages other than [package flag][flag] and [ff][ff] - -[flag]: https://golang.org/pkg/flag -[ff]: https://github.com/peterbourgon/ff - -## Usage - -The core of the package is the [ffcli.Command][command]. Here is the simplest -possible example of an ffcli program. - -[command]: https://godoc.org/github.com/peterbourgon/ff/ffcli#Command - -```go -import ( - "context" - "os" - - "github.com/peterbourgon/ff/v3/ffcli" -) - -func main() { - root := &ffcli.Command{ - Exec: func(ctx context.Context, args []string) error { - println("hello world") - return nil - }, - } - - root.ParseAndRun(context.Background(), os.Args[1:]) -} -``` - -Most CLIs use flags and arguments to control behavior. Here is a command which -takes a string to repeat as an argument, and the number of times to repeat it as -a flag. - -```go -fs := flag.NewFlagSet("repeat", flag.ExitOnError) -n := fs.Int("n", 3, "how many times to repeat") - -root := &ffcli.Command{ - ShortUsage: "repeat [-n times] ", - ShortHelp: "Repeatedly print the argument to stdout.", - FlagSet: fs, - Exec: func(ctx context.Context, args []string) error { - if nargs := len(args); nargs != 1 { - return fmt.Errorf("repeat requires exactly 1 argument, but you provided %d", nargs) - } - for i := 0; i < *n; i++ { - fmt.Fprintln(os.Stdout, args[0]) - } - return nil - }, -} - -if err := root.ParseAndRun(context.Background(), os.Args[1:]); err != nil { - log.Fatal(err) -} -``` - -Each command may have subcommands, allowing you to build a command tree. - -```go -var ( - rootFlagSet = flag.NewFlagSet("textctl", flag.ExitOnError) - verbose = rootFlagSet.Bool("v", false, "increase log verbosity") - repeatFlagSet = flag.NewFlagSet("textctl repeat", flag.ExitOnError) - n = repeatFlagSet.Int("n", 3, "how many times to repeat") -) - -repeat := &ffcli.Command{ - Name: "repeat", - ShortUsage: "textctl repeat [-n times] ", - ShortHelp: "Repeatedly print the argument to stdout.", - FlagSet: repeatFlagSet, - Exec: func(_ context.Context, args []string) error { ... }, -} - -count := &ffcli.Command{ - Name: "count", - ShortUsage: "textctl count [ ...]", - ShortHelp: "Count the number of bytes in the arguments.", - Exec: func(_ context.Context, args []string) error { ... }, -} - -root := &ffcli.Command{ - ShortUsage: "textctl [flags] ", - FlagSet: rootFlagSet, - Subcommands: []*ffcli.Command{repeat, count}, -} - -if err := root.ParseAndRun(context.Background(), os.Args[1:]); err != nil { - log.Fatal(err) -} -``` - -ParseAndRun can also be split into distinct Parse and Run phases, allowing you -to perform two-phase setup or initialization of e.g. API clients that require -user-supplied configuration. - -## Examples - -See [the examples directory][examples]. If you'd like an example of a specific -type of program structure, or a CLI that satisfies a specific requirement, -please [file an issue][issue]. - -[examples]: https://github.com/peterbourgon/ff/tree/master/ffcli/examples -[issue]: https://github.com/peterbourgon/ff/issues/new diff --git a/ffcli/command.go b/ffcli/command.go deleted file mode 100644 index 2484d1c..0000000 --- a/ffcli/command.go +++ /dev/null @@ -1,285 +0,0 @@ -package ffcli - -import ( - "context" - "errors" - "flag" - "fmt" - "strings" - "text/tabwriter" - - "github.com/peterbourgon/ff/v3" -) - -// Command combines a main function with a flag.FlagSet, and zero or more -// sub-commands. A commandline program can be represented as a declarative tree -// of commands. -type Command struct { - // Name of the command. Used for sub-command matching, and as a replacement - // for Usage, if no Usage string is provided. Required for sub-commands, - // optional for the root command. - Name string - - // ShortUsage string for this command. Consumed by the DefaultUsageFunc and - // printed at the top of the help output. Recommended but not required. - // Should be one line of the form - // - // cmd [flags] subcmd [flags] [ ...] - // - // If it's not provided, the DefaultUsageFunc will use Name instead. - // Optional, but recommended. - ShortUsage string - - // ShortHelp is printed next to the command name when it appears as a - // sub-command, in the help output of its parent command. Optional, but - // recommended. - ShortHelp string - - // LongHelp is consumed by the DefaultUsageFunc and printed in the help - // output, after ShortUsage and before flags. Typically a paragraph or more - // of prose-like text, providing more explicit context and guidance than - // what is implied by flags and arguments. Optional. - LongHelp string - - // UsageFunc generates a complete usage output, written to the io.Writer - // returned by FlagSet.Output() when the -h flag is passed. The function is - // invoked with its corresponding command, and its output should reflect the - // command's short usage, short help, and long help strings, subcommands, - // and available flags. Optional; if not provided, a suitable, compact - // default is used. - UsageFunc func(c *Command) string - - // FlagSet associated with this command. Optional, but if none is provided, - // an empty FlagSet will be defined and attached during the parse phase, so - // that the -h flag works as expected. - FlagSet *flag.FlagSet - - // Options provided to ff.Parse when parsing arguments for this command. - // Optional. - Options []ff.Option - - // Subcommands accessible underneath (i.e. after) this command. Optional. - Subcommands []*Command - - // A successful Parse populates these unexported fields. - selected *Command // the command itself (if terminal) or a subcommand - args []string // args that should be passed to Run, if any - - // Exec is invoked if this command has been determined to be the terminal - // command selected by the arguments provided to Parse or ParseAndRun. The - // args passed to Exec are the args left over after flags parsing. Optional. - // - // If Exec returns flag.ErrHelp, then Run (or ParseAndRun) will behave as if - // -h were passed and emit the complete usage output. - // - // If Exec is nil, and this command is identified as the terminal command, - // then Parse, Run, and ParseAndRun will all return NoExecError. Callers may - // check for this error and print e.g. help or usage text to the user, in - // effect treating some commands as just collections of subcommands, rather - // than being invocable themselves. - Exec func(ctx context.Context, args []string) error -} - -// Parse the commandline arguments for this command and all sub-commands -// recursively, defining flags along the way. If Parse returns without an error, -// the terminal command has been successfully identified, and may be invoked by -// calling Run. -// -// If the terminal command identified by Parse doesn't define an Exec function, -// then Parse will return NoExecError. -func (c *Command) Parse(args []string) error { - if c.selected != nil { - return nil - } - - if c.FlagSet == nil { - c.FlagSet = flag.NewFlagSet(c.Name, flag.ExitOnError) - } - - if c.UsageFunc == nil { - c.UsageFunc = DefaultUsageFunc - } - - c.FlagSet.Usage = func() { - fmt.Fprintln(c.FlagSet.Output(), c.UsageFunc(c)) - } - - if err := ff.Parse(c.FlagSet, args, c.Options...); err != nil { - return err - } - - c.args = c.FlagSet.Args() - if len(c.args) > 0 { - for _, subcommand := range c.Subcommands { - if strings.EqualFold(c.args[0], subcommand.Name) { - c.selected = subcommand - return subcommand.Parse(c.args[1:]) - } - } - } - - c.selected = c - - if c.Exec == nil { - return NoExecError{Command: c} - } - - return nil -} - -// Run selects the terminal command in a command tree previously identified by a -// successful call to Parse, and calls that command's Exec function with the -// appropriate subset of commandline args. -// -// If the terminal command previously identified by Parse doesn't define an Exec -// function, then Run will return NoExecError. -func (c *Command) Run(ctx context.Context) (err error) { - var ( - unparsed = c.selected == nil - terminal = c.selected == c && c.Exec != nil - noop = c.selected == c && c.Exec == nil - ) - - defer func() { - if terminal && errors.Is(err, flag.ErrHelp) { - c.FlagSet.Usage() - } - }() - - switch { - case unparsed: - return ErrUnparsed - case terminal: - return c.Exec(ctx, c.args) - case noop: - return NoExecError{Command: c} - default: - return c.selected.Run(ctx) - } -} - -// ParseAndRun is a helper function that calls Parse and then Run in a single -// invocation. It's useful for simple command trees that don't need two-phase -// setup. -func (c *Command) ParseAndRun(ctx context.Context, args []string) error { - if err := c.Parse(args); err != nil { - return err - } - - if err := c.Run(ctx); err != nil { - return err - } - - return nil -} - -// -// -// - -// ErrUnparsed is returned by Run if Parse hasn't been called first. -var ErrUnparsed = errors.New("command tree is unparsed, can't run") - -// NoExecError is returned if the terminal command selected during the parse -// phase doesn't define an Exec function. -type NoExecError struct { - Command *Command -} - -// Error implements the error interface. -func (e NoExecError) Error() string { - return fmt.Sprintf("terminal command (%s) doesn't define an Exec function", e.Command.Name) -} - -// -// -// - -// DefaultUsageFunc is the default UsageFunc used for all commands -// if no custom UsageFunc is provided. -func DefaultUsageFunc(c *Command) string { - var b strings.Builder - - if c.ShortHelp != "" { - fmt.Fprintf(&b, "DESCRIPTION\n") - fmt.Fprintf(&b, " %s\n", c.ShortHelp) - fmt.Fprintf(&b, "\n") - } - - fmt.Fprintf(&b, "USAGE\n") - if c.ShortUsage != "" { - fmt.Fprintf(&b, " %s\n", c.ShortUsage) - } else { - fmt.Fprintf(&b, " %s\n", c.Name) - } - fmt.Fprintf(&b, "\n") - - if c.LongHelp != "" { - fmt.Fprintf(&b, "%s\n\n", c.LongHelp) - } - - if len(c.Subcommands) > 0 { - fmt.Fprintf(&b, "SUBCOMMANDS\n") - tw := tabwriter.NewWriter(&b, 0, 2, 2, ' ', 0) - for _, subcommand := range c.Subcommands { - fmt.Fprintf(tw, " %s\t%s\n", subcommand.Name, subcommand.ShortHelp) - } - tw.Flush() - fmt.Fprintf(&b, "\n") - } - - if countFlags(c.FlagSet) > 0 { - fmt.Fprintf(&b, "FLAGS\n") - tw := tabwriter.NewWriter(&b, 0, 2, 2, ' ', 0) - c.FlagSet.VisitAll(func(f *flag.Flag) { - space := " " - if isBoolFlag(f) { - space = "=" - } - - // If the help text contains backticks, - // e.g. "foo `bar` baz"`, we'll get: - // - // argname = "bar" - // usage = "foo bar baz" - // - // Otherwise, it's an educated guess for a placeholder, - // or an empty string if one couldn't be determined. - argname, usage := flag.UnquoteUsage(f) - - // For the argument name printed in the help, - // the order of preference is: - // - // 1. the default value - // 2. the back-quoted name from the help text - // 3. the '...' placeholder - var def string - switch { - case f.DefValue != "": - def = f.DefValue - case argname != "": - def = argname - default: - def = "..." - } - - fmt.Fprintf(tw, " -%s%s%s\t%s\n", f.Name, space, def, usage) - }) - tw.Flush() - fmt.Fprintf(&b, "\n") - } - - return strings.TrimSpace(b.String()) + "\n" -} - -func countFlags(fs *flag.FlagSet) (n int) { - fs.VisitAll(func(*flag.Flag) { n++ }) - return n -} - -func isBoolFlag(f *flag.Flag) bool { - b, ok := f.Value.(interface { - IsBoolFlag() bool - }) - return ok && b.IsBoolFlag() -} diff --git a/ffcli/command_test.go b/ffcli/command_test.go deleted file mode 100644 index 5637d59..0000000 --- a/ffcli/command_test.go +++ /dev/null @@ -1,611 +0,0 @@ -package ffcli_test - -import ( - "bytes" - "context" - "errors" - "flag" - "fmt" - "io" - "log" - "reflect" - "strings" - "testing" - "time" - - "github.com/peterbourgon/ff/v3/ffcli" - "github.com/peterbourgon/ff/v3/fftest" -) - -func TestCommandRun(t *testing.T) { - t.Parallel() - - for _, testcase := range []struct { - name string - args []string - rootvars fftest.Vars - rootran bool - rootargs []string - foovars fftest.Vars - fooran bool - fooargs []string - barvars fftest.Vars - barran bool - barargs []string - }{ - { - name: "root", - rootran: true, - }, - { - name: "root flags", - args: []string{"-s", "123", "-b"}, - rootvars: fftest.Vars{S: "123", B: true}, - rootran: true, - }, - { - name: "root args", - args: []string{"hello"}, - rootran: true, - rootargs: []string{"hello"}, - }, - { - name: "root flags args", - args: []string{"-i=123", "hello world"}, - rootvars: fftest.Vars{I: 123}, - rootran: true, - rootargs: []string{"hello world"}, - }, - { - name: "root flags -- args", - args: []string{"-f", "1.23", "--", "hello", "world"}, - rootvars: fftest.Vars{F: 1.23}, - rootran: true, - rootargs: []string{"hello", "world"}, - }, - { - name: "root foo", - args: []string{"foo"}, - fooran: true, - }, - { - name: "root flags foo", - args: []string{"-s", "OK", "-d", "10m", "foo"}, - rootvars: fftest.Vars{S: "OK", D: 10 * time.Minute}, - fooran: true, - }, - { - name: "root flags foo flags", - args: []string{"-s", "OK", "-d", "10m", "foo", "-s", "Yup"}, - rootvars: fftest.Vars{S: "OK", D: 10 * time.Minute}, - foovars: fftest.Vars{S: "Yup"}, - fooran: true, - }, - { - name: "root flags foo flags args", - args: []string{"-f=0.99", "foo", "-f", "1.01", "verb", "noun", "adjective adjective"}, - rootvars: fftest.Vars{F: 0.99}, - foovars: fftest.Vars{F: 1.01}, - fooran: true, - fooargs: []string{"verb", "noun", "adjective adjective"}, - }, - { - name: "root flags foo args", - args: []string{"-f=0.99", "foo", "abc", "def", "ghi"}, - rootvars: fftest.Vars{F: 0.99}, - fooran: true, - fooargs: []string{"abc", "def", "ghi"}, - }, - { - name: "root bar -- args", - args: []string{"bar", "--", "argument", "list"}, - barran: true, - barargs: []string{"argument", "list"}, - }, - } { - t.Run(testcase.name, func(t *testing.T) { - foofs, foovars := fftest.Pair() - var fooargs []string - var fooran bool - foo := &ffcli.Command{ - Name: "foo", - FlagSet: foofs, - Exec: func(_ context.Context, args []string) error { fooran, fooargs = true, args; return nil }, - } - - barfs, barvars := fftest.Pair() - var barargs []string - var barran bool - bar := &ffcli.Command{ - Name: "bar", - FlagSet: barfs, - Exec: func(_ context.Context, args []string) error { barran, barargs = true, args; return nil }, - } - - rootfs, rootvars := fftest.Pair() - var rootargs []string - var rootran bool - root := &ffcli.Command{ - FlagSet: rootfs, - Subcommands: []*ffcli.Command{foo, bar}, - Exec: func(_ context.Context, args []string) error { rootran, rootargs = true, args; return nil }, - } - - err := root.ParseAndRun(context.Background(), testcase.args) - assertNoError(t, err) - fftest.Compare(t, &testcase.rootvars, rootvars) - assertBool(t, testcase.rootran, rootran) - assertStringSlice(t, testcase.rootargs, rootargs) - fftest.Compare(t, &testcase.foovars, foovars) - assertBool(t, testcase.fooran, fooran) - assertStringSlice(t, testcase.fooargs, fooargs) - fftest.Compare(t, &testcase.barvars, barvars) - assertBool(t, testcase.barran, barran) - assertStringSlice(t, testcase.barargs, barargs) - }) - } -} - -func TestHelpUsage(t *testing.T) { - t.Parallel() - - for _, testcase := range []struct { - name string - usageFunc func(*ffcli.Command) string - exec func(context.Context, []string) error - args []string - output string - }{ - { - name: "nil", - args: []string{"-h"}, - output: defaultUsageFuncOutput, - }, - { - name: "DefaultUsageFunc", - usageFunc: ffcli.DefaultUsageFunc, - args: []string{"-h"}, - output: defaultUsageFuncOutput, - }, - { - name: "custom usage", - usageFunc: func(*ffcli.Command) string { return "🍰" }, - args: []string{"-h"}, - output: "🍰\n", - }, - { - name: "ErrHelp", - usageFunc: func(*ffcli.Command) string { return "👹" }, - exec: func(context.Context, []string) error { return flag.ErrHelp }, - output: "👹\n", - }, - } { - t.Run(testcase.name, func(t *testing.T) { - fs, _ := fftest.Pair() - var buf bytes.Buffer - fs.SetOutput(&buf) - - command := &ffcli.Command{ - Name: "TestHelpUsage", - ShortUsage: "TestHelpUsage [flags] ", - ShortHelp: "Some short help.", - LongHelp: "Some long help.", - FlagSet: fs, - UsageFunc: testcase.usageFunc, - Exec: testcase.exec, - } - - err := command.ParseAndRun(context.Background(), testcase.args) - assertErrorIs(t, flag.ErrHelp, err) - assertMultilineString(t, testcase.output, buf.String()) - }) - } -} - -func TestNestedOutput(t *testing.T) { - t.Parallel() - - for _, testcase := range []struct { - name string - args []string - wantErr error - wantOutput string - }{ - { - name: "root without args", - args: []string{}, - wantErr: flag.ErrHelp, - wantOutput: "root usage func\n", - }, - { - name: "root with args", - args: []string{"abc", "def ghi"}, - wantErr: flag.ErrHelp, - wantOutput: "root usage func\n", - }, - { - name: "root help", - args: []string{"-h"}, - wantErr: flag.ErrHelp, - wantOutput: "root usage func\n", - }, - { - name: "foo without args", - args: []string{"foo"}, - wantOutput: "foo: ''\n", - }, - { - name: "foo with args", - args: []string{"foo", "alpha", "beta"}, - wantOutput: "foo: 'alpha beta'\n", - }, - { - name: "foo help", - args: []string{"foo", "-h"}, - wantErr: flag.ErrHelp, - wantOutput: "foo usage func\n", // only one instance of usage string - }, - { - name: "foo bar without args", - args: []string{"foo", "bar"}, - wantErr: flag.ErrHelp, - wantOutput: "bar usage func\n", - }, - { - name: "foo bar with args", - args: []string{"foo", "bar", "--", "baz quux"}, - wantErr: flag.ErrHelp, - wantOutput: "bar usage func\n", - }, - { - name: "foo bar help", - args: []string{"foo", "bar", "--help"}, - wantErr: flag.ErrHelp, - wantOutput: "bar usage func\n", - }, - } { - t.Run(testcase.name, func(t *testing.T) { - var ( - rootfs = flag.NewFlagSet("root", flag.ContinueOnError) - foofs = flag.NewFlagSet("foo", flag.ContinueOnError) - barfs = flag.NewFlagSet("bar", flag.ContinueOnError) - buf bytes.Buffer - ) - rootfs.SetOutput(&buf) - foofs.SetOutput(&buf) - barfs.SetOutput(&buf) - - barExec := func(_ context.Context, args []string) error { - return flag.ErrHelp - } - - bar := &ffcli.Command{ - Name: "bar", - FlagSet: barfs, - UsageFunc: func(*ffcli.Command) string { return "bar usage func" }, - Exec: barExec, - } - - fooExec := func(_ context.Context, args []string) error { - fmt.Fprintf(&buf, "foo: '%s'\n", strings.Join(args, " ")) - return nil - } - - foo := &ffcli.Command{ - Name: "foo", - FlagSet: foofs, - UsageFunc: func(*ffcli.Command) string { return "foo usage func" }, - Subcommands: []*ffcli.Command{bar}, - Exec: fooExec, - } - - rootExec := func(_ context.Context, args []string) error { - return flag.ErrHelp - } - - root := &ffcli.Command{ - FlagSet: rootfs, - UsageFunc: func(*ffcli.Command) string { return "root usage func" }, - Subcommands: []*ffcli.Command{foo}, - Exec: rootExec, - } - - err := root.ParseAndRun(context.Background(), testcase.args) - if want, have := testcase.wantErr, err; !errors.Is(have, want) { - t.Errorf("error: want %v, have %v", want, have) - } - if want, have := testcase.wantOutput, buf.String(); want != have { - t.Errorf("output: want %q, have %q", want, have) - } - }) - } -} - -func TestIssue57(t *testing.T) { - t.Parallel() - - for _, testcase := range []struct { - args []string - parseErrAs any - parseErrIs error - parseErrStr string - runErrAs any - runErrIs error - runErrStr string - }{ - { - args: []string{}, - parseErrAs: &ffcli.NoExecError{}, - runErrAs: &ffcli.NoExecError{}, - }, - { - args: []string{"-h"}, - parseErrIs: flag.ErrHelp, - runErrIs: ffcli.ErrUnparsed, - }, - { - args: []string{"bar"}, - parseErrAs: &ffcli.NoExecError{}, - runErrAs: &ffcli.NoExecError{}, - }, - { - args: []string{"bar", "-h"}, - parseErrAs: flag.ErrHelp, - runErrAs: ffcli.ErrUnparsed, - }, - { - args: []string{"bar", "-undefined"}, - parseErrStr: "error parsing commandline arguments: flag provided but not defined: -undefined", - runErrIs: ffcli.ErrUnparsed, - }, - { - args: []string{"bar", "baz"}, - }, - { - args: []string{"bar", "baz", "-h"}, - parseErrIs: flag.ErrHelp, - runErrIs: ffcli.ErrUnparsed, - }, - { - args: []string{"bar", "baz", "-also.undefined"}, - parseErrStr: "error parsing commandline arguments: flag provided but not defined: -also.undefined", - runErrIs: ffcli.ErrUnparsed, - }, - } { - t.Run(strings.Join(append([]string{"foo"}, testcase.args...), " "), func(t *testing.T) { - fs := flag.NewFlagSet("·", flag.ContinueOnError) - fs.SetOutput(io.Discard) - - var ( - baz = &ffcli.Command{Name: "baz", FlagSet: fs, Exec: func(_ context.Context, args []string) error { return nil }} - bar = &ffcli.Command{Name: "bar", FlagSet: fs, Subcommands: []*ffcli.Command{baz}} - foo = &ffcli.Command{Name: "foo", FlagSet: fs, Subcommands: []*ffcli.Command{bar}} - ) - - var ( - parseErr = foo.Parse(testcase.args) - runErr = foo.Run(context.Background()) - ) - - if testcase.parseErrAs != nil { - if want, have := &testcase.parseErrAs, parseErr; !errors.As(have, want) { - t.Errorf("Parse: want %v, have %v", want, have) - } - } - - if testcase.parseErrIs != nil { - if want, have := testcase.parseErrIs, parseErr; !errors.Is(have, want) { - t.Errorf("Parse: want %v, have %v", want, have) - } - } - - if testcase.parseErrStr != "" { - if want, have := testcase.parseErrStr, parseErr.Error(); want != have { - t.Errorf("Parse: want %q, have %q", want, have) - } - } - - if testcase.runErrAs != nil { - if want, have := &testcase.runErrAs, runErr; !errors.As(have, want) { - t.Errorf("Run: want %v, have %v", want, have) - } - } - - if testcase.runErrIs != nil { - if want, have := testcase.runErrIs, runErr; !errors.Is(have, want) { - t.Errorf("Run: want %v, have %v", want, have) - } - } - - if testcase.runErrStr != "" { - if want, have := testcase.runErrStr, runErr.Error(); want != have { - t.Errorf("Run: want %q, have %q", want, have) - } - } - - var ( - noParseErr = testcase.parseErrAs == nil && testcase.parseErrIs == nil && testcase.parseErrStr == "" - noRunErr = testcase.runErrAs == nil && testcase.runErrIs == nil && testcase.runErrStr == "" - ) - if noParseErr && noRunErr { - if parseErr != nil { - t.Errorf("Parse: unexpected error: %v", parseErr) - } - if runErr != nil { - t.Errorf("Run: unexpected error: %v", runErr) - } - } - }) - } -} - -func TestDefaultUsageFuncFlagHelp(t *testing.T) { - t.Parallel() - - for _, testcase := range []struct { - name string // name of test case - def string // default value, if any - help string // help text for flag - want string // expected usage text - }{ - { - name: "plain text", - help: "does stuff", - want: "-x string does stuff", - }, - { - name: "placeholder", - help: "reads from `file` instead of stdout", - want: "-x file reads from file instead of stdout", - }, - { - name: "default", - def: "www", - help: "path to output directory", - want: "-x www path to output directory", - }, - { - name: "default with placeholder", - def: "www", - help: "path to output `directory`", - want: "-x www path to output directory", - }, - } { - testcase := testcase - t.Run(testcase.name, func(t *testing.T) { - t.Parallel() - - fset := flag.NewFlagSet(t.Name(), flag.ContinueOnError) - fset.String("x", testcase.def, testcase.help) - - usage := ffcli.DefaultUsageFunc(&ffcli.Command{ - FlagSet: fset, - }) - - // Discard everything before the FLAGS section. - _, flagUsage, ok := strings.Cut(usage, "\nFLAGS\n") - if !ok { - t.Fatalf("FLAGS section not found in:\n%s", usage) - } - - assertMultilineString(t, - strings.TrimSpace(testcase.want), - strings.TrimSpace(flagUsage)) - }) - } -} - -func ExampleCommand_Parse_then_Run() { - // Assume our CLI will use some client that requires a token. - type FooClient struct { - token string - } - - // That client would have a constructor. - NewFooClient := func(token string) (*FooClient, error) { - if token == "" { - return nil, fmt.Errorf("token required") - } - return &FooClient{token: token}, nil - } - - // We define the token in the root command's FlagSet. - var ( - rootFlagSet = flag.NewFlagSet("mycommand", flag.ExitOnError) - token = rootFlagSet.String("token", "", "API token") - ) - - // Create a placeholder client, initially nil. - var client *FooClient - - // Commands can reference and use it, because by the time their Exec - // function is invoked, the client will be constructed. - foo := &ffcli.Command{ - Name: "foo", - Exec: func(context.Context, []string) error { - fmt.Printf("subcommand foo can use the client: %v", client) - return nil - }, - } - - root := &ffcli.Command{ - FlagSet: rootFlagSet, - Subcommands: []*ffcli.Command{foo}, - } - - // Call Parse first, to populate flags and select a terminal command. - if err := root.Parse([]string{"-token", "SECRETKEY", "foo"}); err != nil { - log.Fatalf("Parse failure: %v", err) - } - - // After a successful Parse, we can construct a FooClient with the token. - var err error - client, err = NewFooClient(*token) - if err != nil { - log.Fatalf("error constructing FooClient: %v", err) - } - - // Then call Run, which will select the foo subcommand and invoke it. - if err := root.Run(context.Background()); err != nil { - log.Fatalf("Run failure: %v", err) - } - - // Output: - // subcommand foo can use the client: &{SECRETKEY} -} - -func assertNoError(t *testing.T, err error) { - t.Helper() - if err != nil { - t.Fatal(err) - } -} - -func assertErrorIs(t *testing.T, want, have error) { - t.Helper() - if !errors.Is(have, want) { - t.Fatalf("want %v, have %v", want, have) - } -} - -func assertMultilineString(t *testing.T, want, have string) { - t.Helper() - if want != have { - t.Fatalf("\nwant:\n%s\n\nhave:\n%s\n", want, have) - } -} - -func assertBool(t *testing.T, want, have bool) { - t.Helper() - if want != have { - t.Fatalf("want %v, have %v", want, have) - } -} - -func assertStringSlice(t *testing.T, want, have []string) { - t.Helper() - if len(want) == 0 && len(have) == 0 { - return // consider []string{} and []string(nil) equivalent - } - if !reflect.DeepEqual(want, have) { - t.Fatalf("want %#+v, have %#+v", want, have) - } -} - -var defaultUsageFuncOutput = strings.TrimSpace(` -DESCRIPTION - Some short help. - -USAGE - TestHelpUsage [flags] - -Some long help. - -FLAGS - -b=false bool - -d 0s time.Duration - -f 0 float64 - -i 0 int - -s string string - -x ... collection of strings (repeatable) -`) + "\n\n" diff --git a/ffcli/doc.go b/ffcli/doc.go deleted file mode 100644 index 237edea..0000000 --- a/ffcli/doc.go +++ /dev/null @@ -1,5 +0,0 @@ -// Package ffcli is for building declarative commandline applications. -// -// See the README at https://github.com/peterbourgon/ff/tree/master/ffcli -// for more information. -package ffcli diff --git a/ffcli/examples/objectctl/cmd/objectctl/main.go b/ffcli/examples/objectctl/cmd/objectctl/main.go deleted file mode 100644 index ec47ba1..0000000 --- a/ffcli/examples/objectctl/cmd/objectctl/main.go +++ /dev/null @@ -1,48 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - - "github.com/peterbourgon/ff/v3/ffcli" - "github.com/peterbourgon/ff/v3/ffcli/examples/objectctl/pkg/createcmd" - "github.com/peterbourgon/ff/v3/ffcli/examples/objectctl/pkg/deletecmd" - "github.com/peterbourgon/ff/v3/ffcli/examples/objectctl/pkg/listcmd" - "github.com/peterbourgon/ff/v3/ffcli/examples/objectctl/pkg/objectapi" - "github.com/peterbourgon/ff/v3/ffcli/examples/objectctl/pkg/rootcmd" -) - -func main() { - var ( - out = os.Stdout - rootCommand, rootConfig = rootcmd.New() - createCommand = createcmd.New(rootConfig, out) - deleteCommand = deletecmd.New(rootConfig, out) - listCommand = listcmd.New(rootConfig, out) - ) - - rootCommand.Subcommands = []*ffcli.Command{ - createCommand, - deleteCommand, - listCommand, - } - - if err := rootCommand.Parse(os.Args[1:]); err != nil { - fmt.Fprintf(os.Stderr, "error during Parse: %v\n", err) - os.Exit(1) - } - - client, err := objectapi.NewClient(rootConfig.Token) - if err != nil { - fmt.Fprintf(os.Stderr, "error constructing object API client: %v\n", err) - os.Exit(1) - } - - rootConfig.Client = client - - if err := rootCommand.Run(context.Background()); err != nil { - fmt.Fprintf(os.Stderr, "%v\n", err) - os.Exit(1) - } -} diff --git a/ffcli/examples/objectctl/pkg/createcmd/create.go b/ffcli/examples/objectctl/pkg/createcmd/create.go deleted file mode 100644 index d3702fc..0000000 --- a/ffcli/examples/objectctl/pkg/createcmd/create.go +++ /dev/null @@ -1,62 +0,0 @@ -package createcmd - -import ( - "context" - "errors" - "flag" - "fmt" - "io" - "strings" - - "github.com/peterbourgon/ff/v3/ffcli" - "github.com/peterbourgon/ff/v3/ffcli/examples/objectctl/pkg/rootcmd" -) - -// Config for the create subcommand, including a reference to the API client. -type Config struct { - rootConfig *rootcmd.Config - out io.Writer - overwrite bool -} - -// New returns a usable ffcli.Command for the create subcommand. -func New(rootConfig *rootcmd.Config, out io.Writer) *ffcli.Command { - cfg := Config{ - rootConfig: rootConfig, - out: out, - } - - fs := flag.NewFlagSet("objectctl create", flag.ExitOnError) - fs.BoolVar(&cfg.overwrite, "overwrite", false, "overwrite existing object, if it exists") - rootConfig.RegisterFlags(fs) - - return &ffcli.Command{ - Name: "create", - ShortUsage: "objectctl create [flags] ", - ShortHelp: "Create or overwrite an object", - FlagSet: fs, - Exec: cfg.Exec, - } -} - -// Exec function for this command. -func (c *Config) Exec(ctx context.Context, args []string) error { - if len(args) < 2 { - return errors.New("create requires at least 2 args") - } - - var ( - key = args[0] - value = strings.Join(args[1:], " ") - err = c.rootConfig.Client.Create(ctx, key, value, c.overwrite) - ) - if err != nil { - return err - } - - if c.rootConfig.Verbose { - fmt.Fprintf(c.out, "create %q OK\n", key) - } - - return nil -} diff --git a/ffcli/examples/objectctl/pkg/deletecmd/delete.go b/ffcli/examples/objectctl/pkg/deletecmd/delete.go deleted file mode 100644 index 94743d5..0000000 --- a/ffcli/examples/objectctl/pkg/deletecmd/delete.go +++ /dev/null @@ -1,59 +0,0 @@ -package deletecmd - -import ( - "context" - "errors" - "flag" - "fmt" - "io" - - "github.com/peterbourgon/ff/v3/ffcli" - "github.com/peterbourgon/ff/v3/ffcli/examples/objectctl/pkg/rootcmd" -) - -// Config for the delete subcommand, including a reference to the API client. -type Config struct { - rootConfig *rootcmd.Config - out io.Writer - force bool -} - -// New returns a usable ffcli.Command for the delete subcommand. -func New(rootConfig *rootcmd.Config, out io.Writer) *ffcli.Command { - cfg := Config{ - rootConfig: rootConfig, - out: out, - } - - fs := flag.NewFlagSet("objectctl delete", flag.ExitOnError) - rootConfig.RegisterFlags(fs) - - return &ffcli.Command{ - Name: "delete", - ShortUsage: "objectctl delete ", - ShortHelp: "Delete an object", - FlagSet: fs, - Exec: cfg.Exec, - } -} - -// Exec function for this command. -func (c *Config) Exec(ctx context.Context, args []string) error { - if len(args) < 1 { - return errors.New("delete requires at least 1 arg") - } - - var ( - key = args[0] - existed, err = c.rootConfig.Client.Delete(ctx, key, c.force) - ) - if err != nil { - return err - } - - if c.rootConfig.Verbose { - fmt.Fprintf(c.out, "delete %q OK (existed %v)\n", key, existed) - } - - return nil -} diff --git a/ffcli/examples/objectctl/pkg/listcmd/list.go b/ffcli/examples/objectctl/pkg/listcmd/list.go deleted file mode 100644 index 5d4b243..0000000 --- a/ffcli/examples/objectctl/pkg/listcmd/list.go +++ /dev/null @@ -1,75 +0,0 @@ -package listcmd - -import ( - "context" - "flag" - "fmt" - "io" - "text/tabwriter" - "time" - - "github.com/peterbourgon/ff/v3/ffcli" - "github.com/peterbourgon/ff/v3/ffcli/examples/objectctl/pkg/rootcmd" -) - -// Config for the list subcommand, including a reference -// to the global config, for access to global flags. -type Config struct { - rootConfig *rootcmd.Config - out io.Writer - withAccessTimes bool -} - -// New creates a new ffcli.Command for the list subcommand. -func New(rootConfig *rootcmd.Config, out io.Writer) *ffcli.Command { - cfg := Config{ - rootConfig: rootConfig, - out: out, - } - - fs := flag.NewFlagSet("objectctl list", flag.ExitOnError) - fs.BoolVar(&cfg.withAccessTimes, "a", false, "include last access time of each object") - rootConfig.RegisterFlags(fs) - - return &ffcli.Command{ - Name: "list", - ShortUsage: "objectctl list [flags] []", - ShortHelp: "List available objects", - FlagSet: fs, - Exec: cfg.Exec, - } -} - -// Exec function for this command. -func (c *Config) Exec(ctx context.Context, _ []string) error { - objects, err := c.rootConfig.Client.List(ctx) - if err != nil { - return fmt.Errorf("error executing list: %w", err) - } - - if len(objects) <= 0 { - fmt.Fprintf(c.out, "no objects\n") - return nil - } - - if c.rootConfig.Verbose { - fmt.Fprintf(c.out, "object count: %d\n", len(objects)) - } - - tw := tabwriter.NewWriter(c.out, 0, 2, 2, ' ', 0) - if c.withAccessTimes { - fmt.Fprintf(tw, "KEY\tVALUE\tATIME\n") - } else { - fmt.Fprintf(tw, "KEY\tVALUE\n") - } - for _, object := range objects { - if c.withAccessTimes { - fmt.Fprintf(tw, "%s\t%s\t%s\n", object.Key, object.Value, object.Access.Format(time.RFC3339)) - } else { - fmt.Fprintf(tw, "%s\t%s\n", object.Key, object.Value) - } - } - tw.Flush() - - return nil -} diff --git a/ffcli/examples/objectctl/pkg/rootcmd/root.go b/ffcli/examples/objectctl/pkg/rootcmd/root.go deleted file mode 100644 index 329b7b3..0000000 --- a/ffcli/examples/objectctl/pkg/rootcmd/root.go +++ /dev/null @@ -1,51 +0,0 @@ -package rootcmd - -import ( - "context" - "flag" - - "github.com/peterbourgon/ff/v3/ffcli" - "github.com/peterbourgon/ff/v3/ffcli/examples/objectctl/pkg/objectapi" -) - -// Config for the root command, including flags and types that should be -// available to each subcommand. -type Config struct { - Token string - Verbose bool - - Client *objectapi.Client -} - -// New constructs a usable ffcli.Command and an empty Config. The config's token -// and verbose fields will be set after a successful parse. The caller must -// initialize the config's object API client field. -func New() (*ffcli.Command, *Config) { - var cfg Config - - fs := flag.NewFlagSet("objectctl", flag.ExitOnError) - cfg.RegisterFlags(fs) - - return &ffcli.Command{ - Name: "objectctl", - ShortUsage: "objectctl [flags] [flags] [...]", - FlagSet: fs, - Exec: cfg.Exec, - }, &cfg -} - -// RegisterFlags registers the flag fields into the provided flag.FlagSet. This -// helper function allows subcommands to register the root flags into their -// flagsets, creating "global" flags that can be passed after any subcommand at -// the commandline. -func (c *Config) RegisterFlags(fs *flag.FlagSet) { - fs.StringVar(&c.Token, "token", "", "secret token for object API") - fs.BoolVar(&c.Verbose, "v", false, "log verbose output") -} - -// Exec function for this command. -func (c *Config) Exec(context.Context, []string) error { - // The root command has no meaning, so if it gets executed, - // display the usage text to the user instead. - return flag.ErrHelp -} diff --git a/ffcli/examples/textctl/textctl.go b/ffcli/examples/textctl/textctl.go deleted file mode 100644 index 201daa9..0000000 --- a/ffcli/examples/textctl/textctl.go +++ /dev/null @@ -1,74 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "os" - - "github.com/peterbourgon/ff/v3/ffcli" -) - -// textctl is a simple applications in which all commands are built up in func -// main. It demonstrates how to declare minimal commands, how to wire them -// together into a command tree, and one way to allow subcommands access to -// flags set in parent commands. - -func main() { - var ( - rootFlagSet = flag.NewFlagSet("textctl", flag.ExitOnError) - verbose = rootFlagSet.Bool("v", false, "increase log verbosity") - repeatFlagSet = flag.NewFlagSet("textctl repeat", flag.ExitOnError) - n = repeatFlagSet.Int("n", 3, "how many times to repeat") - ) - - repeat := &ffcli.Command{ - Name: "repeat", - ShortUsage: "textctl repeat [-n times] ", - ShortHelp: "Repeatedly print the argument to stdout.", - FlagSet: repeatFlagSet, - Exec: func(_ context.Context, args []string) error { - if n := len(args); n != 1 { - return fmt.Errorf("repeat requires exactly 1 argument, but you provided %d", n) - } - if *verbose { - fmt.Fprintf(os.Stderr, "repeat: will generate %dB of output\n", (*n)*len(args[0])) - } - for i := 0; i < *n; i++ { - fmt.Fprintf(os.Stdout, "%s\n", args[0]) - } - return nil - }, - } - - count := &ffcli.Command{ - Name: "count", - ShortUsage: "count [ ...]", - ShortHelp: "Count the number of bytes in the arguments.", - Exec: func(_ context.Context, args []string) error { - if *verbose { - fmt.Fprintf(os.Stderr, "count: argument count %d\n", len(args)) - } - var n int - for _, arg := range args { - n += len(arg) - } - fmt.Fprintf(os.Stdout, "%d\n", n) - return nil - }, - } - - root := &ffcli.Command{ - ShortUsage: "textctl [flags] ", - FlagSet: rootFlagSet, - Subcommands: []*ffcli.Command{repeat, count}, - Exec: func(context.Context, []string) error { - return flag.ErrHelp - }, - } - - if err := root.ParseAndRun(context.Background(), os.Args[1:]); err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - os.Exit(1) - } -} diff --git a/ffenv/ffenv.go b/ffenv/ffenv.go new file mode 100644 index 0000000..f6d0514 --- /dev/null +++ b/ffenv/ffenv.go @@ -0,0 +1,65 @@ +// Package ffenv provides an .env config file paser. +package ffenv + +import ( + "bufio" + "errors" + "fmt" + "io" + "strconv" + "strings" +) + +// Parse is a parser for .env files. Each line is tokenized on the first `=` +// character. The first token is interpreted as the env var representation of +// the flag name, and the second token is interpreted as the value. Both tokens +// are trimmed of leading and trailing whitespace. If the value is "double +// quoted", control characters like `\n` are expanded. Lines beginning with `#` +// are interpreted as comments. End-of-line comments are not supported. +// +// The parser respects the [ff.WithEnvVarPrefix] option. For example, if parse +// is called with an env var prefix MYPROG, then both FOO=bar and MYPROG_FOO=bar +// would set a flag named foo. +func Parse(r io.Reader, set func(name, value string) error) error { + s := bufio.NewScanner(r) + for s.Scan() { + line := strings.TrimSpace(s.Text()) + if line == "" { + continue // skip empties + } + + if line[0] == '#' { + continue // skip comments + } + + index := strings.IndexRune(line, '=') + if index < 0 { + return fmt.Errorf("%w: %s", ErrInvalidLine, line) + } + + var ( + name = strings.TrimSpace(line[:index]) + value = strings.TrimSpace(line[index+1:]) + ) + + if len(name) <= 0 { + return fmt.Errorf("%w: %s", ErrInvalidLine, line) + } + + if len(value) <= 0 { + return fmt.Errorf("%w: %s", ErrInvalidLine, line) + } + + if unquoted, err := strconv.Unquote(value); err == nil { + value = unquoted + } + + if err := set(name, value); err != nil { + return err + } + } + return nil +} + +// ErrInvalidLine is returned when the parser encounters an invalid line. +var ErrInvalidLine = errors.New("invalid line") diff --git a/env_parser_test.go b/ffenv/ffenv_test.go similarity index 59% rename from env_parser_test.go rename to ffenv/ffenv_test.go index 9f34e54..57dd0c1 100644 --- a/env_parser_test.go +++ b/ffenv/ffenv_test.go @@ -1,12 +1,13 @@ -package ff_test +package ffenv_test import ( "path/filepath" "testing" "time" - "github.com/peterbourgon/ff/v3" - "github.com/peterbourgon/ff/v3/fftest" + "github.com/peterbourgon/ff/v4" + "github.com/peterbourgon/ff/v4/ffenv" + "github.com/peterbourgon/ff/v4/fftest" ) func TestEnvFileParser(t *testing.T) { @@ -32,7 +33,7 @@ func TestEnvFileParser(t *testing.T) { }, { file: "testdata/prefix-undef.env", - opts: []ff.Option{ff.WithEnvVarPrefix("MYPROG"), ff.WithIgnoreUndefined(true)}, + opts: []ff.Option{ff.WithEnvVarPrefix("MYPROG"), ff.WithConfigIgnoreUndefinedFlags()}, want: fftest.Vars{S: "bango", I: 9}, }, { @@ -41,11 +42,11 @@ func TestEnvFileParser(t *testing.T) { }, { file: "testdata/no-value.env", - want: fftest.Vars{WantParseErrorString: "invalid line: D="}, + want: fftest.Vars{WantParseErrorIs: ffenv.ErrInvalidLine}, }, { file: "testdata/spaces.env", - want: fftest.Vars{X: []string{"1", "2", "3", "4", "5", " 6", " 7 ", " 8 "}}, + want: fftest.Vars{X: []string{"1", "2", "3", "4", "5", " 6", " 7 ", " 8 ", "9"}}, }, { file: "testdata/newlines.env", @@ -55,12 +56,23 @@ func TestEnvFileParser(t *testing.T) { file: "testdata/capitalization.env", want: fftest.Vars{S: "hello", I: 12345}, }, + { + file: "testdata/comments.env", + want: fftest.Vars{S: "abc # def"}, + }, } { t.Run(filepath.Base(testcase.file), func(t *testing.T) { - testcase.opts = append(testcase.opts, ff.WithConfigFile(testcase.file), ff.WithConfigFileParser(ff.EnvParser)) - fs, vars := fftest.Pair() - vars.ParseError = ff.Parse(fs, []string{}, testcase.opts...) - fftest.Compare(t, &testcase.want, vars) + testcase.opts = append(testcase.opts, + ff.WithConfigFile(testcase.file), + ff.WithConfigFileParser(ffenv.Parse), + ) + for _, constr := range fftest.DefaultConstructors { + t.Run(constr.Name, func(t *testing.T) { + fs, vars := constr.Make(fftest.Vars{}) + vars.ParseError = ff.Parse(fs, []string{}, testcase.opts...) + fftest.Compare(t, &testcase.want, vars) + }) + } }) } } diff --git a/testdata/basic.env b/ffenv/testdata/basic.env similarity index 100% rename from testdata/basic.env rename to ffenv/testdata/basic.env diff --git a/testdata/capitalization.env b/ffenv/testdata/capitalization.env similarity index 100% rename from testdata/capitalization.env rename to ffenv/testdata/capitalization.env diff --git a/ffenv/testdata/comments.env b/ffenv/testdata/comments.env new file mode 100644 index 0000000..276a426 --- /dev/null +++ b/ffenv/testdata/comments.env @@ -0,0 +1,6 @@ +# comment 1 + #comment 2 + # comment 3 + +# S will be set to `abc # def` +S=abc # def diff --git a/testdata/empty.env b/ffenv/testdata/empty.env similarity index 100% rename from testdata/empty.env rename to ffenv/testdata/empty.env diff --git a/testdata/newlines.env b/ffenv/testdata/newlines.env similarity index 100% rename from testdata/newlines.env rename to ffenv/testdata/newlines.env diff --git a/testdata/no-value.env b/ffenv/testdata/no-value.env similarity index 100% rename from testdata/no-value.env rename to ffenv/testdata/no-value.env diff --git a/testdata/prefix-undef.env b/ffenv/testdata/prefix-undef.env similarity index 100% rename from testdata/prefix-undef.env rename to ffenv/testdata/prefix-undef.env diff --git a/testdata/prefix.env b/ffenv/testdata/prefix.env similarity index 100% rename from testdata/prefix.env rename to ffenv/testdata/prefix.env diff --git a/testdata/quotes.env b/ffenv/testdata/quotes.env similarity index 100% rename from testdata/quotes.env rename to ffenv/testdata/quotes.env diff --git a/testdata/spaces.env b/ffenv/testdata/spaces.env similarity index 75% rename from testdata/spaces.env rename to ffenv/testdata/spaces.env index e9ef4fe..5b5d8d0 100644 --- a/testdata/spaces.env +++ b/ffenv/testdata/spaces.env @@ -2,7 +2,8 @@ X = 1 X= 2 X =3 X= 4 -X = 5 + X = 5 X=" 6" X= " 7 " X = " 8 " + X = 9 diff --git a/ffhelp/doc.go b/ffhelp/doc.go new file mode 100644 index 0000000..985b6a9 --- /dev/null +++ b/ffhelp/doc.go @@ -0,0 +1,2 @@ +// Package ffhelp provides tools to produce help text for flags and commands. +package ffhelp diff --git a/ffhelp/flag.go b/ffhelp/flag.go new file mode 100644 index 0000000..f8b8203 --- /dev/null +++ b/ffhelp/flag.go @@ -0,0 +1,156 @@ +package ffhelp + +import ( + "fmt" + "io" + "strings" + + "github.com/peterbourgon/ff/v4" +) + +// Flag wraps [ff.Flag] to implement [fmt.Formatter]. It's relatively low-level. +// Most consumers are probably better served by higher-level helpers. +type Flag struct{ ff.Flag } + +// WrapFlag lets you write ffhelp.WrapFlag(f) instead of ffhelp.Flag{Flag: f}. +func WrapFlag(f ff.Flag) Flag { return Flag{Flag: f} } + +// FormatFlag is a simple helper to format a flag in a single motion. +func FormatFlag(f ff.Flag, format string) string { return fmt.Sprintf(format, Flag{Flag: f}) } + +// Format implements [fmt.Formatter] with support for the following verbs. +// +// VERB DESCRIPTION EXAMPLE +// %s short and long name, comma delimited "f, foo" +// %+s like %s with hyphen prefixes "-f, --foo" +// %#+s like %+s with empty short names padded " --foo" +// %v like %s with placeholder suffix "f, foo STR" +// %+v like %+s with placeholder suffix "-f, --foo STR" +// %#+v like %#+s with placeholder suffix " --foo STR" +// %n short name "f" +// %+n short name with one-hyphen prefix "-f" +// %l long name "foo" +// %+l long name with two-hyphen prefix "--foo" +// %u usage text "foo parameter" +// %k placeholder "STR" +// %d default value "bar" +// +// See the tests for more complete examples. +func (f Flag) Format(s fmt.State, verb rune) { + if f.Flag == nil { + fmt.Fprintf(s, "%%!%c", verb) + return + } + + switch verb { + case 's', 'v', 'x': + addHyphens := s.Flag('+') + addPadding := addHyphens && s.Flag('#') + short, haveShort := f.GetShortName() + long, haveLong := f.GetLongName() + + var shortstr string + if haveShort { + switch { + case addHyphens: + shortstr = "-" + string(short) + case !addHyphens: + shortstr = string(short) + } + } + + if haveLong && addHyphens { + long = "--" + long + } + + switch { + case haveShort && haveLong: + fmt.Fprintf(s, "%s, %s", shortstr, long) + case haveShort && !haveLong: + fmt.Fprintf(s, "%s", shortstr) + case !haveShort && haveLong && addPadding: + fmt.Fprintf(s, " %s", long) + case !haveShort && haveLong && !addPadding: + fmt.Fprintf(s, "%s", long) + } + + if verb == 'v' { + if p := f.GetPlaceholder(); p != "" { + fmt.Fprintf(s, " %s", p) + } + } + + case 'n': + short, ok := f.GetShortName() + switch { + case !ok: + // + case s.Flag('+'): + io.WriteString(s, "-"+string(short)) + default: + io.WriteString(s, string(short)) + } + + case 'l': + long, ok := f.GetLongName() + switch { + case !ok: + // + case s.Flag('+'): + io.WriteString(s, "--"+long) + default: + io.WriteString(s, long) + } + + case 'd': + io.WriteString(s, f.GetDefault()) + + case 'u': + io.WriteString(s, f.GetUsage()) + + case 'k': + io.WriteString(s, f.GetPlaceholder()) + } +} + +// +// +// + +// FlagSpec represents a single-line help text for an [ff.Flag]. That line +// consists of two parts: the spec, which is a fixed-width formatted description +// of the flag names and placeholder; and the usage, which is a combination of +// the usage string and the default value (if non-empty). +type FlagSpec struct { + Flag ff.Flag + Spec string // "-f, --foo STRING" + Usage string // "value of foo parameter (default: bar)" +} + +// MakeFlagSpec produces a [FlagSpec] from an [ff.Flag]. +func MakeFlagSpec(f ff.Flag) FlagSpec { + ff := Flag{f} + + spec := fmt.Sprintf("%#+v", ff) + if sf, ok := f.(interface{ IsStdFlag() bool }); ok && sf.IsStdFlag() { + spec = strings.Replace(spec, "--", "-", 1) + spec = strings.TrimSpace(spec) + } + + usage := fmt.Sprintf("%u", ff) + if def := f.GetDefault(); def != "" { + usage = fmt.Sprintf("%s (default: %s)", usage, def) + } + + return FlagSpec{ + Flag: f, + Spec: spec, + Usage: usage, + } +} + +// String returns a tab-delimited and newline-terminated string containing the +// spec and the usage. It's intended to be written to a [tabwriter.Writer]. +func (fs FlagSpec) String() string { + return fmt.Sprintf("%s\t%s\n", fs.Spec, fs.Usage) +} diff --git a/ffhelp/flag_test.go b/ffhelp/flag_test.go new file mode 100644 index 0000000..98040f1 --- /dev/null +++ b/ffhelp/flag_test.go @@ -0,0 +1,120 @@ +package ffhelp_test + +import ( + "fmt" + "testing" + "time" + + "github.com/peterbourgon/ff/v4" + "github.com/peterbourgon/ff/v4/ffhelp" + "github.com/peterbourgon/ff/v4/ffval" +) + +func TestFlagFormat(t *testing.T) { + t.Parallel() + + fs := ff.NewFlags("flags") + + var foo ffhelp.Flag + if f, err := fs.AddFlag(ff.CoreFlagConfig{ + ShortName: 'f', + LongName: "foo", + Value: ffval.NewValueDefault(new(string), "hello world"), + Usage: "usage text", + Placeholder: "STR", + }); err != nil { + t.Fatal(err) + } else { + foo = ffhelp.WrapFlag(f) + } + + var bar ffhelp.Flag + if f, err := fs.AddFlag(ff.CoreFlagConfig{ + ShortName: 'b', + Value: ffval.NewValueDefault(new(bool), false), + }); err != nil { + t.Fatal(err) + } else { + bar = ffhelp.WrapFlag(f) + } + + var baz ffhelp.Flag + if f, err := fs.AddFlag(ff.CoreFlagConfig{ + LongName: "baz", + Value: ffval.NewValueDefault(new(bool), true), + Placeholder: "XX", + }); err != nil { + t.Fatal(err) + } else { + baz = ffhelp.WrapFlag(f) + } + + for _, testcase := range []struct { + format, wantFoo, wantBar, wantBaz string + }{ + {format: `%s`, wantFoo: `f, foo`, wantBar: `b`, wantBaz: `baz`}, + {format: `%+s`, wantFoo: `-f, --foo`, wantBar: `-b`, wantBaz: `--baz`}, + {format: `%#+s`, wantFoo: `-f, --foo`, wantBar: `-b`, wantBaz: ` --baz`}, + {format: `%v`, wantFoo: `f, foo STR`, wantBar: `b`, wantBaz: `baz XX`}, + {format: `%+v`, wantFoo: `-f, --foo STR`, wantBar: `-b`, wantBaz: `--baz XX`}, + {format: `%#+v`, wantFoo: `-f, --foo STR`, wantBar: `-b`, wantBaz: ` --baz XX`}, + {format: `%n`, wantFoo: `f`, wantBar: `b`, wantBaz: ``}, + {format: `%+n`, wantFoo: `-f`, wantBar: `-b`, wantBaz: ``}, + {format: `%l`, wantFoo: `foo`, wantBar: ``, wantBaz: `baz`}, + {format: `%+l`, wantFoo: `--foo`, wantBar: ``, wantBaz: `--baz`}, + {format: `%d`, wantFoo: `hello world`, wantBar: `false`, wantBaz: `true`}, + {format: `%u`, wantFoo: `usage text`, wantBar: ``, wantBaz: ``}, + {format: `%k`, wantFoo: `STR`, wantBar: ``, wantBaz: `XX`}, + } { + t.Run(testcase.format, func(t *testing.T) { + if want, have := testcase.wantFoo, fmt.Sprintf(testcase.format, foo); want != have { + t.Errorf("foo: want '%s', have '%s'", want, have) + } + if want, have := testcase.wantBar, fmt.Sprintf(testcase.format, bar); want != have { + t.Errorf("bar: want '%s', have '%s'", want, have) + } + if want, have := testcase.wantBaz, fmt.Sprintf(testcase.format, baz); want != have { + t.Errorf("baz: want '%s', have '%s'", want, have) + } + }) + } + + t.Run("Empties", func(t *testing.T) { + fs := ff.NewFlags(t.Name()) + + fooFlag, _ := fs.AddFlag(ff.CoreFlagConfig{ + LongName: "foo", + Value: new(ffval.Int), + Usage: "foo value", + NoPlaceholder: true, + }) + + foo := ffhelp.WrapFlag(fooFlag) + + if want, have := "", fmt.Sprintf("%k", foo); want != have { + t.Errorf("foo: Placeholder (%%k): want '%s', have '%s'", want, have) + } + + if want, have := "0", fmt.Sprintf("%d", foo); want != have { + t.Errorf("foo: Default: (%%d): want '%s', have '%s'", want, have) + } + + barFlag, _ := fs.AddFlag(ff.CoreFlagConfig{ + ShortName: 'b', + LongName: "bar", + Value: ffval.NewValueDefault(new(time.Duration), time.Second), + Usage: "bar value", + NoDefault: true, + }) + + bar := ffhelp.WrapFlag(barFlag) + + if want, have := "DURATION", fmt.Sprintf("%k", bar); want != have { + t.Errorf("bar: Placeholder (%%k): want '%s', have '%s'", want, have) + } + + if want, have := "", fmt.Sprintf("%d", bar); want != have { + t.Errorf("bar: Default: (%%d): want '%s', have '%s'", want, have) + } + }) +} diff --git a/ffhelp/help.go b/ffhelp/help.go new file mode 100644 index 0000000..7a68311 --- /dev/null +++ b/ffhelp/help.go @@ -0,0 +1,98 @@ +package ffhelp + +import ( + "bytes" + "fmt" + "io" + + "github.com/peterbourgon/ff/v4" +) + +// Help represents help output for a flag set, command, etc. +type Help []Section + +// Flags returns a default [Help] value representing fs. If details are +// provided, they're included as a single untitled section before any FLAGS +// section(s). +// +// This function is meant as reasonable default for most users, and as an +// example. Callers who want different help output should implement their own +// [Help] value constructors like this one. +func Flags(fs ff.Flags, details ...string) Help { + var help Help + help = append(help, NewUntitledSection(fs.GetName())) + if len(details) > 0 { + help = append(help, NewUntitledSection(details...)) + } + help = append(help, NewFlagsSections(fs)...) + return help +} + +// Command returns a standard [Help] for the given command. +// +// This function is meant as reasonable default for most users, and as an +// example. Callers who want different help output should implement their own +// [Help] value constructors like this one. +func Command(cmd *ff.Command) Help { + var help Help + + if selected := cmd.GetSelected(); selected != nil { + cmd = selected + } + + commandTitle := cmd.Name + if cmd.ShortHelp != "" { + commandTitle = fmt.Sprintf("%s -- %s", commandTitle, cmd.ShortHelp) + } + help = append(help, NewUntitledSection(commandTitle)) + + if cmd.Usage != "" { + help = append(help, NewSection("USAGE", cmd.Usage)) + } + + if cmd.LongHelp != "" { + help = append(help, NewUntitledSection(cmd.LongHelp)) + } + + if len(cmd.Subcommands) > 0 { + help = append(help, NewSubcommandsSection(cmd.Subcommands)) + } + + help = append(help, NewFlagsSections(cmd.Flags)...) + + return help +} + +// WriteTo implements [io.WriterTo]. +func (h Help) WriteTo(w io.Writer) (n int64, _ error) { + if len(h) <= 0 { + return 0, nil + } + + for i, s := range h { + if i > 0 { + nn, err := fmt.Fprintf(w, "\n") + if err != nil { + return n, err + } + n += int64(nn) + } + + nn, err := s.WriteTo(w) // always ends in \n + if err != nil { + return n, err + } + n += int64(nn) + } + + return n, nil +} + +// String implements [fmt.Stringer]. +func (h Help) String() string { + var buf bytes.Buffer + if _, err := h.WriteTo(&buf); err != nil { + return fmt.Sprintf("%%!ERROR<%v>", err) + } + return buf.String() +} diff --git a/ffhelp/help_test.go b/ffhelp/help_test.go new file mode 100644 index 0000000..af1e7d6 --- /dev/null +++ b/ffhelp/help_test.go @@ -0,0 +1,105 @@ +package ffhelp_test + +import ( + "strings" + "testing" + + "github.com/peterbourgon/ff/v4" + "github.com/peterbourgon/ff/v4/ffhelp" + "github.com/peterbourgon/ff/v4/fftest" +) + +func TestFlagsHelp(t *testing.T) { + t.Parallel() + + t.Run("basic", func(t *testing.T) { + fs := ff.NewFlags("fftest") + fs.Duration('d', "dur", 0, "duration flag") + fs.String('s', "str", "", "string flag") + + want := strings.TrimSpace(testFlagsHelpBasic) + have := strings.TrimSpace(ffhelp.Flags(fs).String()) + if want != have { + t.Errorf("\n%s", fftest.DiffString(want, have)) + } + }) + + t.Run("summary", func(t *testing.T) { + fs := ff.NewFlags("fftest") + fs.Duration('d', "dur", 0, "duration flag") + fs.String('s', "str", "", "string flag") + + want := strings.TrimSpace(testFlagsHelpSummary) + have := strings.TrimSpace(ffhelp.Flags(fs).String()) + if want != have { + t.Errorf("\n%s", fftest.DiffString(want, have)) + } + }) + + t.Run("details", func(t *testing.T) { + fs := ff.NewFlags("fftest") + fs.Duration('d', "dur", 0, "duration flag") + fs.String('s', "str", "", "string flag") + + want := strings.TrimSpace(testFlagsHelpDetails) + have := strings.TrimSpace(ffhelp.Flags(fs, strings.TrimSpace(loremIpsum)).String()) + if want != have { + t.Errorf("\n%s", fftest.DiffString(want, have)) + } + }) +} + +var testFlagsHelpBasic = ` +fftest + +FLAGS + -d, --dur DURATION duration flag (default: 0s) + -s, --str STRING string flag +` + +var testFlagsHelpSummary = ` +fftest + +FLAGS + -d, --dur DURATION duration flag (default: 0s) + -s, --str STRING string flag +` + +var testFlagsHelpDetails = ` +fftest + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam diam eros, +vestibulum at pulvinar vulputate, vehicula id lacus. Class aptent taciti +sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. +Mauris venenatis felis orci, ac consectetur mi molestie ac. Integer pharetra +pharetra odio. Maecenas metus eros, viverra eget efficitur ut, feugiat in +tortor. Quisque elit nibh, rhoncus in posuere et, bibendum non turpis. +Maecenas eget dui malesuada, pretium tellus quis, bibendum felis. Duis erat +enim, faucibus id auctor ac, ornare sed metus. + +FLAGS + -d, --dur DURATION duration flag (default: 0s) + -s, --str STRING string flag +` + +func TestFlagsHelp_OnlyLong(t *testing.T) { + t.Parallel() + + fs := ff.NewFlags("fftest") + fs.BoolLong("alpha", false, "alpha usage") + fs.BoolLong("beta", false, "beta usage") + + want := strings.TrimSpace(testFlagsHelpOnlyLong) + have := strings.TrimSpace(ffhelp.Flags(fs).String()) + if want != have { + t.Errorf("\n%s", fftest.DiffString(want, have)) + } +} + +const testFlagsHelpOnlyLong = ` +fftest + +FLAGS + --alpha alpha usage (default: false) + --beta beta usage (default: false) +` diff --git a/ffhelp/section.go b/ffhelp/section.go new file mode 100644 index 0000000..9a0339d --- /dev/null +++ b/ffhelp/section.go @@ -0,0 +1,278 @@ +package ffhelp + +import ( + "bytes" + "fmt" + "io" + "strings" + "text/tabwriter" + + "github.com/peterbourgon/ff/v4" +) + +// DefaultLinePrefix is used by [Section] constructors in this package. +var DefaultLinePrefix = " " + +// Section describes a single block of help text. A section typically begins +// with a typically uppercase TITLE, and contains one or more lines of content, +// which are typically indented by LinePrefix. +type Section struct { + // Title of the section, typically UPPERCASE. + Title string + + // Lines in the section. Lines should be formatted for the user device, i.e. + // long lines should be split into multiple lines. Each line is prefixed + // with LinePrefix before being rendered. + Lines []string + + // LinePrefix is prefixed to each line when the section is rendered. + LinePrefix string + + // LineColumns indicates that each line is a tab-delimited set of fields, + // and therefore will be rendered in a columnar format via text/tabwriter. + LineColumns bool +} + +// WriteTo implements [io.WriterTo], always ending with a newline. +func (s Section) WriteTo(w io.Writer) (n int64, _ error) { + if s.Title != "" { + nn, err := fmt.Fprint(w, ensureNewline(s.Title)) + if err != nil { + return n, err + } + n += int64(nn) + } + + dst, flush := w, func() error { return nil } + if s.LineColumns { + tab := newTabWriter(w) + dst, flush = tab, tab.Flush + } + + for _, line := range s.Lines { + nn, err := fmt.Fprint(dst, ensureNewline(s.LinePrefix+line)) + if err != nil { + return n, err + } + n += int64(nn) + } + if err := flush(); err != nil { + return n, err + } + + return n, nil +} + +// String returns a multi-line string representing the section. +func (s Section) String() string { + var buf bytes.Buffer + if _, err := s.WriteTo(&buf); err != nil { + return fmt.Sprintf("%%!ERROR<%v>", err) + } + return buf.String() +} + +// NewSection returns a section with the given title and lines, using +// [DefaultLinePrefix]. +func NewSection(title string, lines ...string) Section { + return Section{ + Title: title, + Lines: lines, + LinePrefix: DefaultLinePrefix, + } +} + +// NewUntitledSection returns a section with no title and the given lines, with +// no line prefix. +func NewUntitledSection(lines ...string) Section { + return Section{ + Lines: lines, + } +} + +// NewFlagsSection returns a single FLAGS section representing every flag +// available to fs. Each flag is rendered via [FlagSpec]. +func NewFlagsSection(fs ff.Flags) Section { + ss := newFlagSections(flagSectionsConfig{Flags: fs, SingleSection: true}) + if len(ss) != 1 { + panic(fmt.Errorf("expected 1 section, got %d", len(ss))) + } + return ss[0] +} + +// NewFlagsSections returns FLAGS section(s) representing every flag available +// to fs. Flags are grouped into sections according to their parent flag set. +// Each flag is rendered via [FlagSpec]. +func NewFlagsSections(fs ff.Flags) []Section { + return newFlagSections(flagSectionsConfig{Flags: fs, SharedAlignment: true}) +} + +// NewSubcommandsSection returns a SUBCOMMANDS section containing one line for +// every subcommand in the slice. Lines consist of the subcommand name and the +// ShortHelp for that subcommand, in a columnar format. +func NewSubcommandsSection(subcommands []*ff.Command) Section { + var lines []string + for _, sc := range subcommands { + lines = append(lines, fmt.Sprintf("%s\t%s\n", sc.Name, sc.ShortHelp)) + } + if len(lines) <= 0 { + lines = append(lines, "(no subcommands)") + } + return Section{ + Title: "SUBCOMMANDS", + Lines: lines, + LinePrefix: DefaultLinePrefix, + LineColumns: true, + } +} + +// +// +// + +type flagSectionsConfig struct { + Flags ff.Flags + SingleSection bool // treat all flags as belonging to the base flag set + AlwaysSubtitle bool // add the flag set name to every section title + SharedAlignment bool // use the same column spacing across all sections +} + +func newFlagSections(cfg flagSectionsConfig) []Section { + var ( + index = map[string][]ff.Flag{} + order = []string{} + ) + cfg.Flags.WalkFlags(func(f ff.Flag) error { + var parent string + if cfg.SingleSection { + parent = cfg.Flags.GetName() + } else { + parent = f.GetFlags().GetName() + } + if _, ok := index[parent]; !ok { + order = append(order, parent) + } + index[parent] = append(index[parent], f) + return nil + }) + + var ( + buffer = &bytes.Buffer{} + tab = newTabWriter(buffer) + flushOne func() error + flushAll func() error + ) + if cfg.SharedAlignment { + flushOne = func() error { return nil } + flushAll = tab.Flush + } else { + flushOne = tab.Flush + flushAll = func() error { return nil } + } + + for _, name := range order { + flags := index[name] + if len(flags) <= 0 { + continue + } + for _, f := range flags { + fmt.Fprint(tab, MakeFlagSpec(f).String()) + } + if err := flushOne(); err != nil { + panic(err) + } + } + if err := flushAll(); err != nil { + panic(err) + } + + var ( + lines = splitLines(buffer.String()) + sections = []*Section{} + ) + for i, name := range order { + flags := index[name] + if len(flags) <= 0 { + continue + } + + if len(lines) < len(flags) { + panic(fmt.Errorf("%s: flag count %d, remaining section line count %d", name, len(flags), len(lines))) + } + + sectionLines := lines[:len(flags)] + if len(sectionLines) <= 0 { + panic(fmt.Errorf("%s: flag count %d, section line count 0", name, len(flags))) + } + + title := "FLAGS" + if cfg.AlwaysSubtitle || i > 0 { + title = fmt.Sprintf("%s (%s)", title, name) + } + + sections = append(sections, &Section{ + Title: title, + Lines: sectionLines, + LinePrefix: DefaultLinePrefix, + }) + + lines = lines[len(flags):] + } + + var ( + mindexOne = -1 + mindexAll = -1 + ) + for _, s := range sections { + for _, line := range s.Lines { + var index int + for index < len(line) && line[index] == ' ' { + index++ + } + switch { + case mindexOne < 0 || index < mindexOne: + mindexOne = index + case mindexAll < 0 || index < mindexAll: + mindexAll = index + } + } + if mindexOne > 0 && !cfg.SharedAlignment { + for i := range s.Lines { + s.Lines[i] = s.Lines[i][mindexOne:] + } + } + } + if mindexAll > 0 && cfg.SharedAlignment { + for _, s := range sections { + for i := range s.Lines { + s.Lines[i] = s.Lines[i][mindexOne:] + } + } + } + + flat := make([]Section, len(sections)) + for i := range sections { + flat[i] = *sections[i] + } + + return flat +} + +func newTabWriter(w io.Writer) *tabwriter.Writer { + return tabwriter.NewWriter(w, 0, 0, 3, ' ', 0) +} + +func ensureNewline(s string) string { + return strings.TrimSuffix(s, "\n") + "\n" +} + +func splitLines(s string) []string { + var res []string + for _, line := range strings.Split(s, "\n") { + if line == "" { + continue + } + res = append(res, line) + } + return res +} diff --git a/ffhelp/section_test.go b/ffhelp/section_test.go new file mode 100644 index 0000000..85bffbe --- /dev/null +++ b/ffhelp/section_test.go @@ -0,0 +1,246 @@ +package ffhelp_test + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/peterbourgon/ff/v4" + "github.com/peterbourgon/ff/v4/ffhelp" + "github.com/peterbourgon/ff/v4/fftest" +) + +func TestSection_Flags(t *testing.T) { + t.Parallel() + + t.Run("default", func(t *testing.T) { + fs, _ := fftest.CoreConstructor.Make(fftest.Vars{A: true}) + want := strings.TrimSpace(coreFlagsDefaultFlagsSection) + have := strings.TrimSpace(ffhelp.NewFlagsSection(fs).String()) + if want != have { + t.Errorf("\n%s", fftest.DiffString(want, have)) + } + }) +} + +var coreFlagsDefaultFlagsSection = strings.TrimSpace(` +FLAGS + -s, --str STRING string + -i, --int INT int (default: 0) + -f, --flt FLOAT64 float64 (default: 0) + -a, --aflag BOOL bool a (default: true) + -b, --bflag bool b (default: false) + -c, --cflag bool c (default: false) + -d, --dur DURATION time.Duration (default: 0s) + -x, --xxx STR collection of strings (repeatable) +`) + +// +// +// + +func TestSection_StdFlags(t *testing.T) { + t.Parallel() + + fs, _ := fftest.StdConstructor.Make(fftest.Vars{A: true}) + want := strings.TrimSpace(stdFlagsSectionHelp) + have := strings.TrimSpace(ffhelp.Flags(fs).String()) + if want != have { + t.Errorf("\n%s", fftest.DiffString(want, have)) + } +} + +var stdFlagsSectionHelp = ` +fftest + +FLAGS + -a BOOL bool a (default: true) + -b bool b (default: false) + -c bool c (default: false) + -d DURATION time.Duration (default: 0s) + -f FLOAT64 float64 (default: 0) + -i INT int (default: 0) + -s STRING string + -x STRING collection of strings (repeatable) +` + +// +// +// + +func TestSections_Command(t *testing.T) { + t.Parallel() + + t.Run("unparsed", func(t *testing.T) { + testcmd := makeTestCommand(t) + want := strings.TrimSpace(testCommandRootHelp) + have := strings.TrimSpace(ffhelp.Command(testcmd).String()) + if want != have { + t.Errorf("\n%s", fftest.DiffString(want, have)) + } + }) + + for _, test := range []struct { + name string + args []string + want string + }{ + { + name: "no args", + args: []string{}, + want: testCommandRootHelp, + }, + { + name: "-h", + args: []string{"-h"}, + want: testCommandRootHelp, + }, + { + name: "--help", + args: []string{"--help"}, + want: testCommandRootHelp, + }, + { + name: "-v foo", + args: []string{"-v", "foo"}, + want: testCommandFooHelp, + }, + { + name: "-v foo bar --alpha=9", + args: []string{"-v", "foo", "bar", "--alpha=9"}, + want: testCommandBarHelp, + }, + } { + t.Run(test.name, func(t *testing.T) { + testcmd := makeTestCommand(t) + err := testcmd.ParseAndRun(context.Background(), test.args) + switch { + case err == nil, errors.Is(err, ff.ErrHelp), errors.Is(err, ff.ErrNoExec): + // ok + default: + t.Fatal(err) + } + + want := strings.TrimSpace(test.want) + have := strings.TrimSpace(ffhelp.Command(testcmd).String()) + if want != have { + t.Errorf("\n%s", fftest.DiffString(want, have)) + } + }) + } +} + +var testCommandRootHelp = ` +testcmd + +USAGE + testcmd [FLAGS] ... + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam diam eros, +vestibulum at pulvinar vulputate, vehicula id lacus. Class aptent taciti +sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. +Mauris venenatis felis orci, ac consectetur mi molestie ac. Integer pharetra +pharetra odio. Maecenas metus eros, viverra eget efficitur ut, feugiat in +tortor. Quisque elit nibh, rhoncus in posuere et, bibendum non turpis. +Maecenas eget dui malesuada, pretium tellus quis, bibendum felis. Duis erat +enim, faucibus id auctor ac, ornare sed metus. + +SUBCOMMANDS + foo the foo subcommand + +FLAGS + -v, --verbose verbose logging (default: false) + --config-file STRING config file +` + +var testCommandFooHelp = ` +foo -- the foo subcommand + +USAGE + foo [FLAGS] ... + +SUBCOMMANDS + bar the bar subcommand + +FLAGS + -a, --alpha INT alpha integer (default: 10) + -b, --beta beta boolean (default: false) + +FLAGS (root) + -v, --verbose verbose logging (default: false) + --config-file STRING config file +` + +var testCommandBarHelp = strings.ReplaceAll(` +bar -- the bar subcommand + +USAGE + bar [FLAGS] ... + +FLAGS + -d, --delta δ delta #δ# duration (default: 3s) + -e, --epsilon FLOAT64 epsilon float (default: 3.21) + +FLAGS (foo) + -a, --alpha INT alpha integer (default: 10) + -b, --beta beta boolean (default: false) + +FLAGS (root) + -v, --verbose verbose logging (default: false) + --config-file STRING config file +`, "#", "`") + +// +// +// + +func makeTestCommand(t *testing.T) *ff.Command { + t.Helper() + + rootFlags := ff.NewFlags("root") + rootFlags.Bool('v', "verbose", false, "verbose logging") + rootFlags.String(0, "config-file", "", "config file") + rootCommand := &ff.Command{ + Name: "testcmd", + Usage: "testcmd [FLAGS] ...", + LongHelp: strings.TrimSpace(loremIpsum), + Flags: rootFlags, + } + + fooFlags := ff.NewFlags("foo").SetParent(rootFlags) + fooFlags.Int('a', "alpha", 10, "alpha integer") + fooFlags.Bool('b', "beta", false, "beta boolean") + fooCommand := &ff.Command{ + Name: "foo", + Usage: "foo [FLAGS] ...", + ShortHelp: "the foo subcommand", + Flags: fooFlags, + } + rootCommand.Subcommands = append(rootCommand.Subcommands, fooCommand) + + barFlags := ff.NewFlags("bar").SetParent(fooFlags) + barFlags.Duration('d', "delta", 3*time.Second, "delta `δ` duration") + barFlags.Float64('e', "epsilon", 3.21, "epsilon float") + barCommand := &ff.Command{ + Name: "bar", + Usage: "bar [FLAGS] ...", + ShortHelp: "the bar subcommand", + Flags: barFlags, + } + fooCommand.Subcommands = append(fooCommand.Subcommands, barCommand) + + return rootCommand +} + +var loremIpsum = ` +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam diam eros, +vestibulum at pulvinar vulputate, vehicula id lacus. Class aptent taciti +sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. +Mauris venenatis felis orci, ac consectetur mi molestie ac. Integer pharetra +pharetra odio. Maecenas metus eros, viverra eget efficitur ut, feugiat in +tortor. Quisque elit nibh, rhoncus in posuere et, bibendum non turpis. +Maecenas eget dui malesuada, pretium tellus quis, bibendum felis. Duis erat +enim, faucibus id auctor ac, ornare sed metus. +` diff --git a/ffjson/ffjson.go b/ffjson/ffjson.go new file mode 100644 index 0000000..183f8ed --- /dev/null +++ b/ffjson/ffjson.go @@ -0,0 +1,40 @@ +// Package ffjson provides a JSON config file paser. +package ffjson + +import ( + "encoding/json" + "io" + + "github.com/peterbourgon/ff/v4/internal/ffdata" +) + +// Parse is a helper function that uses a default parser. +func Parse(r io.Reader, set func(name, value string) error) error { + return (&Parser{}).Parse(r, set) +} + +// Parser collects parameters for the JSON config file parser. +type Parser struct { + // Delimiter is used when concatenating nested node keys into a flag name. + // The default delimiter is ".". + Delimiter string +} + +// Parse a JSON document from the provided io.Reader, using the provided set +// function to set flag values. Flag names are derived from the node names and +// their key/value pairs. +func (p Parser) Parse(r io.Reader, set func(name, value string) error) error { + if p.Delimiter == "" { + p.Delimiter = "." + } + + d := json.NewDecoder(r) + d.UseNumber() // required for stringifying values + + var m map[string]interface{} + if err := d.Decode(&m); err != nil { + return err + } + + return ffdata.TraverseMap(m, p.Delimiter, set) +} diff --git a/ffjson/ffjson_test.go b/ffjson/ffjson_test.go new file mode 100644 index 0000000..467596a --- /dev/null +++ b/ffjson/ffjson_test.go @@ -0,0 +1,53 @@ +package ffjson_test + +import ( + "io" + "testing" + "time" + + "github.com/peterbourgon/ff/v4" + "github.com/peterbourgon/ff/v4/ffjson" + "github.com/peterbourgon/ff/v4/fftest" +) + +func TestParser(t *testing.T) { + t.Parallel() + + testcases := fftest.TestCases{ + { + Name: "empty input", + ConfigFile: "testdata/empty.json", + Want: fftest.Vars{}, + }, + { + Name: "basic KV pairs", + ConfigFile: "testdata/basic.json", + Want: fftest.Vars{S: "s", I: 10, B: true, D: 5 * time.Second}, + }, + { + Name: "value arrays", + ConfigFile: "testdata/value_arrays.json", + Want: fftest.Vars{S: "bb", I: 12, B: true, D: 5 * time.Second, X: []string{"a", "B", "👍"}}, + }, + { + Name: "bad JSON file", + ConfigFile: "testdata/bad.json", + Want: fftest.Vars{WantParseErrorIs: io.ErrUnexpectedEOF}, + }, + { + Name: "nested with '.'", + ConfigFile: "testdata/nested.json", + Constructors: []fftest.Constructor{fftest.NewNestedConstructor(".")}, + Want: fftest.Vars{S: "foo bar", I: 34, X: []string{"alpha", "beta", "delta"}}, + }, + { + Name: "nested with '-'", + ConfigFile: "testdata/nested.json", + Constructors: []fftest.Constructor{fftest.NewNestedConstructor("-")}, + Options: []ff.Option{ff.WithConfigFileParser(ffjson.Parser{Delimiter: "-"}.Parse)}, + Want: fftest.Vars{S: "foo bar", I: 34, X: []string{"alpha", "beta", "delta"}}, + }, + } + + testcases.Run(t, ff.WithConfigFileParser(ffjson.Parse)) +} diff --git a/testdata/bad.json b/ffjson/testdata/bad.json similarity index 100% rename from testdata/bad.json rename to ffjson/testdata/bad.json diff --git a/testdata/basic.json b/ffjson/testdata/basic.json similarity index 100% rename from testdata/basic.json rename to ffjson/testdata/basic.json diff --git a/testdata/empty.json b/ffjson/testdata/empty.json similarity index 100% rename from testdata/empty.json rename to ffjson/testdata/empty.json diff --git a/ffjson/testdata/nested.json b/ffjson/testdata/nested.json new file mode 100644 index 0000000..b194f74 --- /dev/null +++ b/ffjson/testdata/nested.json @@ -0,0 +1,17 @@ +{ + "foo": { + "bar": { + "s": "foo bar" + } + }, + "nested": { + "i": 34 + }, + "x": { + "value": [ + "alpha", + "beta", + "delta" + ] + } +} diff --git a/testdata/value_arrays.json b/ffjson/testdata/value_arrays.json similarity index 100% rename from testdata/value_arrays.json rename to ffjson/testdata/value_arrays.json diff --git a/fftest/constructor.go b/fftest/constructor.go new file mode 100644 index 0000000..0492c30 --- /dev/null +++ b/fftest/constructor.go @@ -0,0 +1,94 @@ +package fftest + +import ( + "flag" + "fmt" + "strings" + + "github.com/peterbourgon/ff/v4" + "github.com/peterbourgon/ff/v4/ffval" +) + +// Constructor produces a flag set, and a set of vars managed by that flag set. +type Constructor struct { + // Name of the constructor, used in test names. + Name string + + // Make should return a flag set and vars structure, where each value in the + // vars structure is updated by a corresponding flag in the flag set. The + // default value for each flag should be taken from the def parameter. + Make func(def Vars) (ff.Flags, *Vars) +} + +// CoreConstructor produces a core flag set, with both short and long flag names +// for each value. +var CoreConstructor = Constructor{ + Name: "core", + Make: func(def Vars) (ff.Flags, *Vars) { + var v Vars + fs := ff.NewFlags("fftest") + fs.StringVar(&v.S, 's', "str", def.S, "string") + fs.IntVar(&v.I, 'i', "int", def.I, "int") + fs.Float64Var(&v.F, 'f', "flt", def.F, "float64") + fs.BoolVar(&v.A, 'a', "aflag", def.A, "bool a") + fs.BoolVar(&v.B, 'b', "bflag", def.B, "bool b") + fs.BoolVar(&v.C, 'c', "cflag", def.C, "bool c") + fs.DurationVar(&v.D, 'd', "dur", def.D, "time.Duration") + fs.AddFlag(ff.CoreFlagConfig{ShortName: 'x', LongName: "xxx", Placeholder: "STR", Usage: "collection of strings (repeatable)", Value: ffval.NewList(&v.X)}) + return fs, &v + }, +} + +// StdConstructor produces a stdlib flag set adapter. +var StdConstructor = Constructor{ + Name: "std", + Make: func(def Vars) (ff.Flags, *Vars) { + var v Vars + fs := flag.NewFlagSet("fftest", flag.ContinueOnError) + fs.StringVar(&v.S, "s", def.S, "string") + fs.IntVar(&v.I, "i", def.I, "int") + fs.Float64Var(&v.F, "f", def.F, "float64") + fs.BoolVar(&v.A, "a", def.A, "bool a") + fs.BoolVar(&v.B, "b", def.B, "bool b") + fs.BoolVar(&v.C, "c", def.C, "bool c") + fs.DurationVar(&v.D, "d", def.D, "time.Duration") + fs.Var(ffval.NewList(&v.X), "x", "collection of strings (repeatable)") + return ff.NewStdFlags(fs), &v + }, +} + +// DefaultConstructors are used for test cases that don't specify constructors. +var DefaultConstructors = []Constructor{ + CoreConstructor, + StdConstructor, +} + +// NewNestedConstructor returns a constructor where flags have specific +// hierarchical names delimited by the provided delim. This is useful for +// testing config file formats that allow nested configuration. +func NewNestedConstructor(delim string) Constructor { + return Constructor{ + Name: fmt.Sprintf("nested delimiter '%s'", delim), + Make: func(def Vars) (ff.Flags, *Vars) { + var ( + skey = strings.Join([]string{"foo", "bar", "s"}, delim) + ikey = strings.Join([]string{"nested", "i"}, delim) + fkey = strings.Join([]string{"nested", "f"}, delim) + akey = strings.Join([]string{"nested", "a"}, delim) + bkey = strings.Join([]string{"nested", "b"}, delim) + ckey = strings.Join([]string{"nested", "c"}, delim) + xkey = strings.Join([]string{"x", "value"}, delim) + ) + var v Vars + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.StringVar(&v.S, skey, def.S, "string var") + fs.IntVar(&v.I, ikey, def.I, "int var") + fs.Float64Var(&v.F, fkey, def.F, "float64 var") + fs.BoolVar(&v.A, akey, def.A, "bool var a") + fs.BoolVar(&v.B, bkey, def.B, "bool var b") + fs.BoolVar(&v.C, ckey, def.C, "bool var c") + fs.Var(ffval.NewList(&v.X), xkey, "x var") + return ff.NewStdFlags(fs), &v + }, + } +} diff --git a/fftest/doc.go b/fftest/doc.go index c2533c4..fab15e3 100644 --- a/fftest/doc.go +++ b/fftest/doc.go @@ -1,2 +1,2 @@ -// Package fftest provides unit test helpers. +// Package fftest provides tools for testing flag sets. package fftest diff --git a/fftest/helpers.go b/fftest/helpers.go new file mode 100644 index 0000000..7495f97 --- /dev/null +++ b/fftest/helpers.go @@ -0,0 +1,153 @@ +package fftest + +import ( + "strings" +) + +// DiffString produces a git-like diff of two multi-line strings. +func DiffString(a, b string) string { + var ( + chunks = diffChunks(strings.Split(a, "\n"), strings.Split(b, "\n")) + lines = make([]string, 0, len(chunks)) + ) + for _, c := range chunks { + lines = append(lines, c.String()) + } + return strings.Join(lines, "\n") +} + +// diffChunks adapted from github.com/kylelemons/godebug. +func diffChunks(a, b []string) []chunk { + var ( + alen = len(a) + blen = len(b) + maxPath = alen + blen + ) + if maxPath == 0 { + return nil + } + + var ( + v = make([]int, 2*maxPath+1) + vs = make([][]int, 0, 8) + + save = func() { + dup := make([]int, len(v)) + copy(dup, v) + vs = append(vs, dup) + } + ) + + d := func() int { + var d int + for d = 0; d <= maxPath; d++ { + for diag := -d; diag <= d; diag += 2 { // k + var aindex int // x + switch { + case diag == -d: + aindex = v[maxPath-d+1] + 0 + case diag == d: + aindex = v[maxPath+d-1] + 1 + case v[maxPath+diag+1] > v[maxPath+diag-1]: + aindex = v[maxPath+diag+1] + 0 + default: + aindex = v[maxPath+diag-1] + 1 + } + bindex := aindex - diag // y + for aindex < alen && bindex < blen && a[aindex] == b[bindex] { + aindex++ + bindex++ + } + v[maxPath+diag] = aindex + if aindex >= alen && bindex >= blen { + save() + return d + } + } + save() + } + return d + }() + if d == 0 { + return nil + } + + var ( + chunks = make([]chunk, d+1) + x, y = alen, blen + ) + for d := d; d > 0; d-- { + var ( + endpoint = vs[d] + diag = x - y + insert = diag == -d || (diag != d && endpoint[maxPath+diag-1] < endpoint[maxPath+diag+1]) + x1 = endpoint[maxPath+diag] + kk int + x0 int + y0 int + xM int + ) + if insert { + kk = diag + 1 + x0 = endpoint[maxPath+kk] + y0 = x0 - kk + xM = x0 + } else { + kk = diag - 1 + x0 = endpoint[maxPath+kk] + y0 = x0 - kk + xM = x0 + 1 + } + + var c chunk + if insert { + c.added = b[y0:][:1] + } else { + c.deleted = a[x0:][:1] + } + if xM < x1 { + c.equal = a[xM:][:x1-xM] + } + + chunks[d] = c + x, y = x0, y0 + } + + if x > 0 { + chunks[0].equal = a[:x] + } + + if chunks[0].empty() { + chunks = chunks[1:] + } + + if len(chunks) == 0 { + return nil + } + + return chunks +} + +type chunk struct { + added []string + deleted []string + equal []string +} + +func (c *chunk) empty() bool { + return len(c.added) == 0 && len(c.deleted) == 0 && len(c.equal) == 0 +} + +func (c *chunk) String() string { + var lines []string + for _, s := range c.added { + lines = append(lines, "+ "+s) + } + for _, s := range c.deleted { + lines = append(lines, "- "+s) + } + for _, s := range c.equal { + lines = append(lines, " "+s) + } + return strings.Join(lines, "\n") +} diff --git a/fftest/tempfile.go b/fftest/tempfile.go deleted file mode 100644 index dd6f7f4..0000000 --- a/fftest/tempfile.go +++ /dev/null @@ -1,26 +0,0 @@ -package fftest - -import ( - "math/rand" - "os" - "path/filepath" - "strconv" - "testing" -) - -// TempFile returns the filename of a temporary file that has been created with -// the provided content. The file is created in t.TempDir(), which is -// automatically removed when the test finishes. -func TempFile(t *testing.T, content string) string { - t.Helper() - - filename := filepath.Join(t.TempDir(), strconv.Itoa(rand.Int())) - - if err := os.WriteFile(filename, []byte(content), 0o0600); err != nil { - t.Fatal(err) - } - - t.Logf("created %s", filename) - - return filename -} diff --git a/fftest/testcase.go b/fftest/testcase.go new file mode 100644 index 0000000..637dda1 --- /dev/null +++ b/fftest/testcase.go @@ -0,0 +1,81 @@ +package fftest + +import ( + "os" + "testing" + + "github.com/peterbourgon/ff/v4" +) + +// TestCases are a collection of test cases that can be run as a group. +type TestCases []TestCase + +// Run the test cases in order. +func (tcs TestCases) Run(t *testing.T, options ...ff.Option) { + t.Helper() + for _, tc := range tcs { + t.Run(tc.Name, func(t *testing.T) { + t.Helper() + tc.Run(t, options...) + }) + } +} + +// TestCase describes a specific [ff.Parse] test scenario. +type TestCase struct { + Name string + Constructors []Constructor + Default Vars + ConfigFile string + Environment map[string]string + Args []string + Options []ff.Option + Want Vars +} + +// Run the test case. +func (tc *TestCase) Run(t *testing.T, options ...ff.Option) { + t.Helper() + + // The test case options are the most specific, and so the highest priority. + opts := tc.Options + + // The options passed to run are the next-highest priority. Options are + // evaluated first-to-last, and later options override earlier options, so + // lower-priority options should come before higher-priority options. + opts = append(options, opts...) + + // Default options have lowest priority, and so are first in the list. + if tc.ConfigFile != "" { + opts = append( + []ff.Option{ff.WithConfigFile(tc.ConfigFile), ff.WithConfigFileParser(ff.PlainParser)}, + opts..., + ) + } + + // If there are any environment variables, set them before running the + // tests, and reset them afterwards. Note that this means test cases cannot + // be run in parallel. + if len(tc.Environment) > 0 { + for k, v := range tc.Environment { + defer os.Setenv(k, os.Getenv(k)) + os.Setenv(k, v) + } + } + + // If no constructors were explicitly specified, use the defaults. + if len(tc.Constructors) <= 0 { + tc.Constructors = DefaultConstructors + } + + // Run the test case for each constructor. + for _, constr := range tc.Constructors { + t.Run(constr.Name, func(t *testing.T) { + t.Helper() + fs, vars := constr.Make(tc.Default) + vars.ParseError = ff.Parse(fs, tc.Args, opts...) + vars.Args = fs.GetArgs() + Compare(t, &tc.Want, vars) + }) + } +} diff --git a/fftest/tests.go b/fftest/tests.go new file mode 100644 index 0000000..77d49c9 --- /dev/null +++ b/fftest/tests.go @@ -0,0 +1,115 @@ +package fftest + +import ( + "errors" + "testing" + "unicode/utf8" + + "github.com/peterbourgon/ff/v4" + "github.com/peterbourgon/ff/v4/ffhelp" +) + +// TestFlags checks the core invariants of a flag set and its flags. The +// provided flag set should contain at least two flags, and calling parse with +// the provided args should succeed. +func TestFlags(t *testing.T, fs ff.Flags, args []string) { + t.Helper() + + if fs.GetName() == "" { + t.Errorf("GetName: empty") + } + + if fs.IsParsed() { + t.Errorf("IsParsed: initially true") + } + + if args := fs.GetArgs(); len(args) != 0 { + t.Errorf("GetArgs: initially non-empty (%v)", args) + } + + var flags []ff.Flag + if err := fs.WalkFlags(func(f ff.Flag) error { + flags = append(flags, f) + return nil + }); err != nil { + t.Fatalf("WalkFlags: error: %v", err) + } + if n := len(flags); n < 2 { + t.Fatalf("WalkFlags: need at least 2 flags, have %d", n) + } + + var count int + errEarlyReturn := errors.New("early return") + if err := fs.WalkFlags(func(f ff.Flag) error { + count++ + return errEarlyReturn + }); !errors.Is(err, errEarlyReturn) { + t.Errorf("WalkFlags: received error (%v) not errors.Is with returned error (%v)", err, errEarlyReturn) + } + if count != 1 { + t.Errorf("WalkFlags: should have walked 1 flag, have %d", count) + } + + if _, ok := fs.GetFlag(""); ok { + t.Errorf("GetFlag: passing an empty string returned ok=true") + } + + for i, f := range flags { + var ( + name = ffhelp.FormatFlag(f, "%s") + short, haveShort = f.GetShortName() + long, haveLong = f.GetLongName() + ) + + if !haveShort && !haveLong { + t.Errorf("flag (%d/%d) has neither short nor long name", i+1, len(flags)) + continue + } + + if haveShort && (short == 0 || short == utf8.RuneError) { + t.Errorf("%s: GetShortName: returned invalid rune (%x) with ok=true", name, short) + haveShort = false + } + + if haveLong && (long == "") { + t.Errorf("%s: GetLongName: returned empty string with ok=true", name) + haveLong = false + } + + if f.IsSet() { + t.Errorf("%s: IsSet: returned true before parse", name) + } + + if f.GetFlags() == nil { + t.Errorf("%s: GetFlags: returned nil", name) + } + + if f.GetDefault() != f.GetValue() { + t.Errorf("%s: GetDefault (%q) != GetValue (%q) before being set", name, f.GetDefault(), f.GetValue()) + } + + if haveShort { + if ff, ok := fs.GetFlag(string(short)); !ok { + t.Errorf("%s: GetFlag(%s): returned ok=false", name, string(short)) + } else if ff != f { + t.Errorf("%s: GetFlag(%s): returned different flag (%s)", name, string(short), ffhelp.FormatFlag(ff, "%s")) + } + } + + if haveLong { + if ff, ok := fs.GetFlag(long); !ok { + t.Errorf("%s: GetFlag(%s): returned ok=false", name, long) + } else if ff != f { + t.Errorf("%s: GetFlag(%s): returned different flag (%s)", name, long, ffhelp.FormatFlag(ff, "%s")) + } + } + } + + if err := ff.Parse(fs, args); err != nil { + t.Fatalf("Parse: error: %v", err) + } + + if !fs.IsParsed() { + t.Errorf("IsParsed: returned false after successful parse") + } +} diff --git a/fftest/vars.go b/fftest/vars.go index fe2e89a..29e9573 100644 --- a/fftest/vars.go +++ b/fftest/vars.go @@ -2,70 +2,20 @@ package fftest import ( "errors" - "flag" - "fmt" "reflect" "strings" "testing" "time" ) -// Pair defines and returns an empty flag set and vars assigned to it. -func Pair() (*flag.FlagSet, *Vars) { - fs := flag.NewFlagSet("fftest", flag.ContinueOnError) - vars := DefaultVars(fs) - return fs, vars -} - -// DefaultVars registers a predefined set of variables to the flag set. -// Tests can call parse on the flag set with a variety of flags, config files, -// and env vars, and check the resulting effect on the vars. -func DefaultVars(fs *flag.FlagSet) *Vars { - var v Vars - fs.StringVar(&v.S, "s", "", "string") - fs.IntVar(&v.I, "i", 0, "int") - fs.Float64Var(&v.F, "f", 0., "float64") - fs.BoolVar(&v.B, "b", false, "bool") - fs.DurationVar(&v.D, "d", 0*time.Second, "time.Duration") - fs.Var(&v.X, "x", "collection of strings (repeatable)") - return &v -} - -// NonzeroDefaultVars is like DefaultVars, but provides each primitive flag with -// a nonzero default value. This is useful for tests that explicitly provide a -// zero value for the type. -func NonzeroDefaultVars(fs *flag.FlagSet) *Vars { - var v Vars - fs.StringVar(&v.S, "s", "foo", "string") - fs.IntVar(&v.I, "i", 123, "int") - fs.Float64Var(&v.F, "f", 9.99, "float64") - fs.BoolVar(&v.B, "b", true, "bool") - fs.DurationVar(&v.D, "d", 3*time.Hour, "time.Duration") - fs.Var(&v.X, "x", "collection of strings (repeatable)") - return &v -} - -// NestedDefaultVars is similar to DefaultVars, but uses nested flag names. -func NestedDefaultVars(delimiter string) func(fs *flag.FlagSet) *Vars { - return func(fs *flag.FlagSet) *Vars { - var v Vars - fs.StringVar(&v.S, fmt.Sprintf("foo%ss", delimiter), "", "string") - fs.IntVar(&v.I, fmt.Sprintf("bar%[1]snested%[1]si", delimiter), 0, "int") - fs.Float64Var(&v.F, fmt.Sprintf("bar%[1]snested%[1]sf", delimiter), 0., "float64") - fs.BoolVar(&v.B, fmt.Sprintf("foo%sb", delimiter), false, "bool") - fs.Var(&v.X, fmt.Sprintf("baz%[1]snested%[1]sx", delimiter), "collection of strings (repeatable)") - return &v - } -} - // Vars are a common set of variables used for testing. type Vars struct { - S string - I int - F float64 - B bool - D time.Duration - X StringSlice + S string // flag name `s` + I int // flag name `i` + F float64 // flag name `f` + A, B, C bool // flag name `a`, `b`, `c` + D time.Duration // flag name `d` + X []string // flag name `x` // ParseError should be assigned as the result of Parse in tests. ParseError error @@ -79,6 +29,9 @@ type Vars struct { // it can specify part of that error string here. The Compare // helper will look for it using strings.Contains. WantParseErrorString string + + // Args left over after a successful parse. + Args []string } // Compare one set of vars with another @@ -106,7 +59,7 @@ func Compare(t *testing.T, want, have *Vars) { } if have.ParseError != nil { - t.Errorf("error: %v", have.ParseError) + t.Errorf("parse error: %v", have.ParseError) } if want.S != have.S { @@ -118,32 +71,25 @@ func Compare(t *testing.T, want, have *Vars) { if want.F != have.F { t.Errorf("var F: want %f, have %f", want.F, have.F) } + if want.A != have.A { + t.Errorf("var A: want %v, have %v", want.A, have.A) + } if want.B != have.B { t.Errorf("var B: want %v, have %v", want.B, have.B) } + if want.C != have.C { + t.Errorf("var C: want %v, have %v", want.C, have.C) + } if want.D != have.D { t.Errorf("var D: want %s, have %s", want.D, have.D) } if !reflect.DeepEqual(want.X, have.X) { t.Errorf("var X: want %v, have %v", want.X, have.X) } -} -// StringSlice is a flag.Value that collects each Set string -// into a slice, allowing for repeated flags. -type StringSlice []string - -// Set implements flag.Value and appends the string to the slice. -func (ss *StringSlice) Set(s string) error { - (*ss) = append(*ss, s) - return nil -} - -// String implements flag.Value and returns the list of -// strings, or "..." if no strings have been added. -func (ss *StringSlice) String() string { - if len(*ss) <= 0 { - return "..." + if len(want.Args) > 0 { + if !reflect.DeepEqual(want.Args, have.Args) { + t.Errorf("post-parse args: want %v, have %v", want.Args, have.Args) + } } - return strings.Join(*ss, ", ") } diff --git a/fftoml/fftoml.go b/fftoml/fftoml.go index 9425e2d..5747610 100644 --- a/fftoml/fftoml.go +++ b/fftoml/fftoml.go @@ -2,84 +2,36 @@ package fftoml import ( - "fmt" "io" - "github.com/pelletier/go-toml" - "github.com/peterbourgon/ff/v3/internal" + "github.com/pelletier/go-toml/v2" + "github.com/peterbourgon/ff/v4/internal/ffdata" ) -// Parser is a parser for TOML file format. Flags and their values are read -// from the key/value pairs defined in the config file. -func Parser(r io.Reader, set func(name, value string) error) error { - return New().Parse(r, set) +// Parse is a helper function that uses a default parser. +func Parse(r io.Reader, set func(name, value string) error) error { + return (&Parser{}).Parse(r, set) } -// ConfigFileParser is a parser for the TOML file format. Flags and their values -// are read from the key/value pairs defined in the config file. -// Nested tables and keys are concatenated with a delimiter to derive the -// relevant flag name. -type ConfigFileParser struct { - delimiter string +// Parser collects parameters for the TOML config file parser. +type Parser struct { + // Delimiter is used when concatenating nested node keys into a flag name. + // The default delimiter is ".". + Delimiter string } -// New constructs and configures a ConfigFileParser using the provided options. -func New(opts ...Option) (c ConfigFileParser) { - c.delimiter = "." - for _, opt := range opts { - opt(&c) +// Parse a TOML document from the provided io.Reader, using the provided set +// function to set flag values. Flag names are derived from the node names and +// their key/value pairs. +func (p Parser) Parse(r io.Reader, set func(name, value string) error) error { + if p.Delimiter == "" { + p.Delimiter = "." } - return c -} -// Parse parses the provided io.Reader as a TOML file and uses the provided set function -// to set flag names derived from the tables names and their key/value pairs. -func (c ConfigFileParser) Parse(r io.Reader, set func(name, value string) error) error { var m map[string]any if err := toml.NewDecoder(r).Decode(&m); err != nil { - return ParseError{Inner: err} - } - - if err := internal.TraverseMap(m, c.delimiter, set); err != nil { - return ParseError{Inner: err} + return err } - return nil -} - -// Option is a function which changes the behavior of the TOML config file parser. -type Option func(*ConfigFileParser) - -// WithTableDelimiter is an option which configures a delimiter -// used to prefix table names onto keys when constructing -// their associated flag name. -// The default delimiter is "." -// -// For example, given the following TOML -// -// [section.subsection] -// value = 10 -// -// Parse will match to a flag with the name `-section.subsection.value` by default. -// If the delimiter is "-", Parse will match to `-section-subsection-value` instead. -func WithTableDelimiter(d string) Option { - return func(c *ConfigFileParser) { - c.delimiter = d - } -} - -// ParseError wraps all errors originating from the TOML parser. -type ParseError struct { - Inner error -} - -// Error implenents the error interface. -func (e ParseError) Error() string { - return fmt.Sprintf("error parsing TOML config: %v", e.Inner) -} - -// Unwrap implements the errors.Wrapper interface, allowing errors.Is and -// errors.As to work with ParseErrors. -func (e ParseError) Unwrap() error { - return e.Inner + return ffdata.TraverseMap(m, p.Delimiter, set) } diff --git a/fftoml/fftoml_test.go b/fftoml/fftoml_test.go index 187d6aa..49aa4ee 100644 --- a/fftoml/fftoml_test.go +++ b/fftoml/fftoml_test.go @@ -1,35 +1,27 @@ package fftoml_test import ( - "flag" - "fmt" - "reflect" - "strings" "testing" "time" - "github.com/peterbourgon/ff/v3" - "github.com/peterbourgon/ff/v3/fftest" - "github.com/peterbourgon/ff/v3/fftoml" + "github.com/peterbourgon/ff/v4" + "github.com/peterbourgon/ff/v4/fftest" + "github.com/peterbourgon/ff/v4/fftoml" ) func TestParser(t *testing.T) { t.Parallel() - for _, testcase := range []struct { - name string - file string - want fftest.Vars - }{ + testcases := fftest.TestCases{ { - name: "empty input", - file: "testdata/empty.toml", - want: fftest.Vars{}, + Name: "empty input", + ConfigFile: "testdata/empty.toml", + Want: fftest.Vars{}, }, { - name: "basic KV pairs", - file: "testdata/basic.toml", - want: fftest.Vars{ + Name: "basic KV pairs", + ConfigFile: "testdata/basic.toml", + Want: fftest.Vars{ S: "s", I: 10, F: 3.14e10, @@ -39,67 +31,25 @@ func TestParser(t *testing.T) { }, }, { - name: "bad TOML file", - file: "testdata/bad.toml", - want: fftest.Vars{WantParseErrorString: "keys cannot contain { character"}, + Name: "bad TOML file", + ConfigFile: "testdata/bad.toml", + Want: fftest.Vars{WantParseErrorString: "invalid character at start of key"}, + }, + { + Name: "nested with '.'", + ConfigFile: "testdata/table.toml", + Default: fftest.Vars{I: 999}, + Constructors: []fftest.Constructor{fftest.NewNestedConstructor(".")}, + Want: fftest.Vars{S: "a string", I: 999, F: 1.23, X: []string{"one", "two", "three"}}, + }, + { + Name: "nested with '-'", + ConfigFile: "testdata/table.toml", + Constructors: []fftest.Constructor{fftest.NewNestedConstructor("-")}, + Options: []ff.Option{ff.WithConfigFileParser(fftoml.Parser{Delimiter: "-"}.Parse)}, + Want: fftest.Vars{S: "a string", F: 1.23, X: []string{"one", "two", "three"}}, }, - } { - t.Run(testcase.name, func(t *testing.T) { - fs, vars := fftest.Pair() - vars.ParseError = ff.Parse(fs, []string{}, - ff.WithConfigFile(testcase.file), - ff.WithConfigFileParser(fftoml.Parser), - ) - fftest.Compare(t, &testcase.want, vars) - }) } -} - -func TestParser_WithTables(t *testing.T) { - t.Parallel() - - for _, delim := range []string{ - ".", - "-", - } { - t.Run(fmt.Sprintf("delim=%q", delim), func(t *testing.T) { - var ( - skey = strings.Join([]string{"string", "key"}, delim) - fkey = strings.Join([]string{"float", "nested", "key"}, delim) - xkey = strings.Join([]string{"strings", "nested", "key"}, delim) - sval string - fval float64 - xval fftest.StringSlice - ) - - fs := flag.NewFlagSet("fftest", flag.ContinueOnError) - { - fs.StringVar(&sval, skey, "xxx", "string") - fs.Float64Var(&fval, fkey, 999, "float64") - fs.Var(&xval, xkey, "strings") - } - - parseConfig := fftoml.New(fftoml.WithTableDelimiter(delim)) - - if err := ff.Parse(fs, []string{}, - ff.WithConfigFile("testdata/table.toml"), - ff.WithConfigFileParser(parseConfig.Parse), - ); err != nil { - t.Fatal(err) - } - - if want, have := "a string", sval; want != have { - t.Errorf("string key: want %q, have %q", want, have) - } - - if want, have := 1.23, fval; want != have { - t.Errorf("float nested key: want %v, have %v", want, have) - } - - if want, have := (fftest.StringSlice{"one", "two", "three"}), xval; !reflect.DeepEqual(want, have) { - t.Errorf("strings nested key: want %v, have %v", want, have) - } - }) - } + testcases.Run(t, ff.WithConfigFileParser(fftoml.Parse)) } diff --git a/fftoml/testdata/table.toml b/fftoml/testdata/table.toml index cfd6905..1402b3f 100644 --- a/fftoml/testdata/table.toml +++ b/fftoml/testdata/table.toml @@ -1,7 +1,7 @@ -[string] -key = "a string" -[float] -[float.nested] -key = 1.23 -[strings.nested] -key = ["one", "two", "three"] +[nested] +f = 1.23 +[foo] +[foo.bar] +s = "a string" +[x] +value = ["one", "two", "three"] diff --git a/ffval/doc.go b/ffval/doc.go new file mode 100644 index 0000000..a963fa8 --- /dev/null +++ b/ffval/doc.go @@ -0,0 +1,14 @@ +// Package ffval provides common flag value types and helpers. +// +// The types defined by this package implement [flag.Value], and are intended to +// be used as values in an [ff.CoreFlagConfig]. +// +// [Value] represents a single instance of any type T that can be parsed from a +// string. The package defines a set of values for common underlying types, like +// [Bool], [String], [Duration], etc. +// +// [List] and [UniqueList] represent a sequence of values of type T, where each +// call to Set (potentially) adds the value to the end of the list. The package +// defines a small set of lists for common underlying types, like [IntList], +// [StringSet], etc. +package ffval diff --git a/ffval/lists.go b/ffval/lists.go new file mode 100644 index 0000000..b32bd2d --- /dev/null +++ b/ffval/lists.go @@ -0,0 +1,274 @@ +package ffval + +import ( + "flag" + "fmt" + "reflect" + "strings" +) + +// DefaultStringFunc is used by [List] and [UniqueList] if no StringFunc is +// explicitly provided. Each value is rendered to a string via [fmt.Sprint], and +// the strings are joined via [strings.Join] with a separator of ", ". +func DefaultStringFunc[T any](vals []T) string { + strs := make([]string, len(vals)) + for i := range vals { + strs[i] = fmt.Sprint(vals[i]) + } + return strings.Join(strs, ", ") +} + +// List is a generic [flag.Value] that represents an ordered list of values. +// Every call to Set adds the successfully parsed value to the end of the list. +// To prevent duplicate values, see [UniqueList]. +type List[T any] struct { + // ParseFunc parses a string to the type T. If no ParseFunc is provided, and + // T is a supported [ValueType], then a default ParseFunc will be assigned + // lazily. If no ParseFunc is provided, and T is not a supported + // [ValueType], then most method calls will panic. + ParseFunc func(string) (T, error) + + // Pointer is the actual slice of type T which is managed and updated by the + // list. If no Pointer is provided, a new slice is allocated lazily. For + // this reason, callers should generally access the pointer via GetPointer, + // rather than reading the field directly. + Pointer *[]T + + // StringFunc is used by the String method to transform the underlying slice + // of T to a string. If no StringFunc is provided, [DefaultStringFunc] is + // used. + StringFunc func([]T) string + + initialized bool + isSet bool +} + +var _ flag.Value = (*List[any])(nil) + +// NewList returns a list of underlying [ValueType] T, which updates the given +// pointer ptr when set. +func NewList[T ValueType](ptr *[]T) *List[T] { + v := &List[T]{ + Pointer: ptr, + } + v.initialize() + return v +} + +// NewListParser returns a list of any type T that can be parsed from a string. +// +// This constructor is intended as a convenience function for tests; consumers +// who want to provide a parser are probably better served by constructing a +// list directly, so that they can also provide other fields in a single motion. +func NewListParser[T any](parseFunc func(string) (T, error)) *List[T] { + v := &List[T]{ + ParseFunc: parseFunc, + } + v.initialize() + return v +} + +func (v *List[T]) initialize() { + if v.initialized { + return + } + + if v.ParseFunc == nil { + var zero T + valueType := reflect.TypeOf(zero) + parse, ok := defaultParseFuncs[valueType] + if !ok { + panic(fmt.Errorf("%s: unsupported value type", valueType.String())) + } + pf, ok := parse.(func(string) (T, error)) + if !ok { + panic(fmt.Errorf("%s: invalid default parse func (%T)", valueType.String(), parse)) + } + v.ParseFunc = pf + } + + if v.Pointer == nil { + v.Pointer = &([]T{}) + } + + if v.StringFunc == nil { + v.StringFunc = DefaultStringFunc[T] + } + + v.initialized = true +} + +// Set parses the given string, and appends the successfully parsed value to the +// list. Duplicates are permitted. +func (v *List[T]) Set(s string) error { + v.initialize() + + value, err := v.ParseFunc(s) + if err != nil { + return err + } + + *v.Pointer = append(*v.Pointer, value) + v.isSet = true + return nil +} + +// Get the current list of values. +func (v *List[T]) Get() []T { + v.initialize() + return *v.Pointer +} + +// GetPointer returns a pointer to the underlying slice of T. +func (v *List[T]) GetPointer() *[]T { + v.initialize() + return v.Pointer +} + +// Reset the list of values to its default (empty) state. +func (v *List[T]) Reset() error { + v.initialize() + *v.Pointer = (*v.Pointer)[:0] + v.isSet = false + return nil +} + +// String returns a string representation of the list of values. +func (v *List[T]) String() string { + v.initialize() + return v.StringFunc(*v.Pointer) +} + +// +// +// + +// UniqueList is a [List] that doesn't allow duplicate values. +type UniqueList[T comparable] struct { + // ParseFunc parses a string to the type T. If no ParseFunc is provided, and + // T is a supported [ValueType], then a default ParseFunc will be assigned + // lazily. If no ParseFunc is provided, and T is not a supported + // [ValueType], then most method calls will panic. + ParseFunc func(string) (T, error) + + // Pointer is the actual slice of type T which is managed and updated by the + // list. If no Pointer is provided, a new slice is allocated lazily. + Pointer *[]T + + // StringFunc is used by the String method to transform the underlying slice + // of T to a string. If no StringFunc is provided, [DefaultStringFunc] is + // used. + StringFunc func([]T) string + + // ErrDuplicate is returned by Set when it detects a duplicate value. By + // default, ErrDuplicate is nil, so duplicate values are silently dropped. + ErrDuplicate error + + initialized bool + isSet bool +} + +var _ flag.Value = (*UniqueList[any])(nil) + +// NewUniqueList returns a unique list of underlying [ValueType] T, which +// updates the given pointer ptr when set. +func NewUniqueList[T ValueType](ptr *[]T) *UniqueList[T] { + v := &UniqueList[T]{ + Pointer: ptr, + } + v.initialize() + return v +} + +// NewUniqueListParser returns a unique list of any comparable type T that can +// be parsed from a string. +// +// This constructor is intended as a convenience function for tests; consumers +// who want to provide a parser are probably better served by constructing a +// unique list directly, so that they can also provide other fields in a single +// motion. +func NewUniqueListParser[T comparable](parseFunc func(string) (T, error)) *UniqueList[T] { + v := &UniqueList[T]{ + ParseFunc: parseFunc, + } + v.initialize() + return v +} + +func (v *UniqueList[T]) initialize() { + if v.initialized { + return + } + + if v.ParseFunc == nil { + var zero T + valueType := reflect.TypeOf(zero) + parse, ok := defaultParseFuncs[valueType] + if !ok { + panic(fmt.Errorf("%s: unsupported value type", valueType.String())) + } + pf, ok := parse.(func(string) (T, error)) + if !ok { + panic(fmt.Errorf("%s: invalid default parse func (%T)", valueType.String(), parse)) + } + v.ParseFunc = pf + } + + if v.Pointer == nil { + v.Pointer = &([]T{}) + } + + if v.StringFunc == nil { + v.StringFunc = DefaultStringFunc[T] + } + + v.initialized = true +} + +// Set parses the given string, and appends the successfully parsed value to the +// list. If the value already exists in the list, Set returns the UniqueList's +// ErrDuplicate field, which is nil by default. +func (v *UniqueList[T]) Set(s string) error { + v.initialize() + + value, err := v.ParseFunc(s) + if err != nil { + return err + } + + for _, existing := range *(v.Pointer) { + if value == existing { + return v.ErrDuplicate + } + } + + *v.Pointer = append(*v.Pointer, value) + v.isSet = true + return nil +} + +// Get the current list of values. +func (v *UniqueList[T]) Get() []T { + v.initialize() + return *v.Pointer +} + +// GetPointer returns a pointer to the underlying slice of T. +func (v *UniqueList[T]) GetPointer() *[]T { + v.initialize() + return v.Pointer +} + +// Reset the list of values to its default (empty) state. +func (v *UniqueList[T]) Reset() error { + v.initialize() + *v.Pointer = (*v.Pointer)[:0] + v.isSet = false + return nil +} + +// String returns a string representation of the list of values. +func (v *UniqueList[T]) String() string { + v.initialize() + return v.StringFunc(*v.Pointer) +} diff --git a/ffval/lists_test.go b/ffval/lists_test.go new file mode 100644 index 0000000..1c8a9e7 --- /dev/null +++ b/ffval/lists_test.go @@ -0,0 +1,142 @@ +package ffval_test + +import ( + "errors" + "fmt" + "reflect" + "testing" + + "github.com/peterbourgon/ff/v4/ffval" +) + +func TestLists_zero(t *testing.T) { + t.Parallel() + + t.Run("List[int]", func(t *testing.T) { + var val ffval.List[int] + + if want, have := (*[]int)(nil), val.Pointer; want != have { + t.Errorf("Pointer: want %#+v, have %#+v", want, have) // nil Pointer is lazy-initialized + } + + if want, have := []int{}, val.Get(); !reflect.DeepEqual(want, have) { + t.Errorf("GetPointer: want %v, have %v", want, have) + } + + if val.Pointer == nil { + t.Fatalf("Pointer: still nil after GetPointer") + } + + if want, have := "", val.String(); want != have { + t.Errorf("String: want %q, have %q", want, have) + } + + if want, have := []int{}, val.Get(); !reflect.DeepEqual(want, have) { + t.Errorf("Get: want %#+v, have %#+v", want, have) + } + + if err := val.Set("123"); err != nil { + t.Fatalf("Set(123): %v", err) + } + + if err := val.Set("123"); err != nil { + t.Fatalf("Set(123): %v", err) + } + + if want, have := "123, 123", val.String(); want != have { + t.Errorf("String: want %q, have %q", want, have) + } + + if want, have := []int{123, 123}, val.Get(); !reflect.DeepEqual(want, have) { + t.Errorf("Get: want %v, have %v", want, have) + } + + if want, have := []int{123, 123}, *val.Pointer; !reflect.DeepEqual(want, have) { + t.Errorf("Pointer: want %v, have %v", want, have) + } + }) + + t.Run("UniqueList[int]", func(t *testing.T) { + var val ffval.UniqueList[int] + + if want, have := (*[]int)(nil), val.Pointer; want != have { + t.Errorf("Pointer: want %#+v, have %#+v", want, have) // nil Pointer is lazy-initialized + } + + if want, have := []int{}, val.Get(); !reflect.DeepEqual(want, have) { + t.Errorf("GetPointer: want %v, have %v", want, have) + } + + if val.Pointer == nil { + t.Fatalf("Pointer: still nil after GetPointer") + } + + if want, have := "", val.String(); want != have { + t.Errorf("String: want %q, have %q", want, have) + } + + if want, have := []int{}, val.Get(); !reflect.DeepEqual(want, have) { + t.Errorf("Get: want %#+v, have %#+v", want, have) + } + + if err := val.Set("123"); err != nil { + t.Fatalf("Set(123): %v", err) + } + + if err := val.Set("456"); err != nil { + t.Fatalf("Set(456): %v", err) + } + + if err := val.Set("123"); err != nil { + t.Fatalf("Set(123): %v", err) + } + + val.ErrDuplicate = fmt.Errorf("dupe") + if err := val.Set("123"); !errors.Is(err, val.ErrDuplicate) { + t.Fatalf("Set(123): want %v, have %v", val.ErrDuplicate, err) + } + + if want, have := "123, 456", val.String(); want != have { + t.Errorf("String: want %q, have %q", want, have) + } + + if want, have := []int{123, 456}, val.Get(); !reflect.DeepEqual(want, have) { + t.Errorf("Get: want %v, have %v", want, have) + } + + if want, have := []int{123, 456}, *val.Pointer; !reflect.DeepEqual(want, have) { + t.Errorf("Pointer: want %v, have %v", want, have) + } + }) +} + +func TestLists_reset(t *testing.T) { + t.Parallel() + + var list ffval.StringList + var set ffval.StringSet + + for _, s := range []string{"a", "a", "b", "c"} { + list.Set(s) + set.Set(s) + } + + if want, have := "a, a, b, c", list.String(); want != have { + t.Errorf("StringList: want %q, have %q", want, have) + } + + if want, have := "a, b, c", set.String(); want != have { + t.Errorf("StringSet: want %q, have %q", want, have) + } + + list.Reset() + set.Reset() + + if want, have := "", list.String(); want != have { + t.Errorf("StringList: want %q, have %q", want, have) + } + + if want, have := "", set.String(); want != have { + t.Errorf("StringSet: want %q, have %q", want, have) + } +} diff --git a/ffval/types.go b/ffval/types.go new file mode 100644 index 0000000..047f51d --- /dev/null +++ b/ffval/types.go @@ -0,0 +1,177 @@ +package ffval + +import ( + "fmt" + "reflect" + "strconv" + "time" + "unicode/utf8" +) + +// ValueType is a generic type constraint for a specific set of primitive types +// that are natively supported by this package. Each of them has a default +// parser, which will be used if a parser is not explicitly provided by the +// user. This permits the zero value of corresponding generic types to be +// useful, which in turn allows this package to provide common and useful types +// like [Bool], [String], [StringSet], etc. +type ValueType interface { + bool | int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64 | float32 | float64 | string | complex64 | complex128 | time.Duration | ffbyte | ffrune +} + +// +// +// + +// Bool is a flag value representing a bool. +// Values are parsed by [strconv.ParseBool]. +type Bool = Value[bool] + +// Int is a flag value representing an int. +// Values are parsed by [strconv.Atoi]. +type Int = Value[int] + +// Int8 is a flag value representing an int8. +// Values are parsed by [strconv.ParseInt]. +type Int8 = Value[int8] + +// Int16 is a flag value representing an int16. +// Values are parsed by [strconv.ParseInt]. +type Int16 = Value[int16] + +// Int32 is a flag value representing an int32. +// Values are parsed by [strconv.ParseInt]. +type Int32 = Value[int32] + +// Int64 is a flag value representing an int64. +// Values are parsed by [strconv.ParseInt]. +type Int64 = Value[int64] + +// Uint is a flag value representing a uint. +// Values are parsed by [strconv.ParseUint]. +type Uint = Value[uint] + +// Uint8 is a flag value representing a uint8. +// Values are parsed by [strconv.ParseUint]. +type Uint8 = Value[uint8] + +// Uint16 is a flag value representing a uint16. +// Values are parsed by [strconv.ParseUint]. +type Uint16 = Value[uint16] + +// Uint32 is a flag value representing a uint32. +// Values are parsed by [strconv.ParseUint]. +type Uint32 = Value[uint32] + +// Uint64 is a flag value representing a uint64. +// Values are parsed by [strconv.ParseUint]. +type Uint64 = Value[uint64] + +// Float32 is a flag value representing a float32. +// Values are parsed by [strconv.ParseFloat]. +type Float32 = Value[float32] + +// Float64 is a flag value representing a float64. +// Values are parsed by [strconv.ParseFloat]. +type Float64 = Value[float64] + +// String is a flag value representing a string. +type String = Value[string] + +// Complex64 is a flag value representing a complex64. +// Values are parsed by [strconv.ParseComplex]. +type Complex64 = Value[complex64] + +// Complex128 is a flag value representing a complex128. +// Values are parsed by [strconv.ParseComplex]. +type Complex128 = Value[complex128] + +// Byte is a flag value representing a byte. Values are parsed with a +// custom function that expects a string containing a single byte. +type Byte = Value[ffbyte] + +// Rune is a flag value representing a rune. Values are parsed with a +// custom function that expects a string containing a single valid rune. +type Rune = Value[ffrune] + +// Duration is a flag value representing a [time.Duration]. +// Values are parsed by [time.ParseDuration]. +type Duration = Value[time.Duration] + +// +// +// + +// BoolList is a [List] of bools. Duplicates are permitted. +type BoolList = List[bool] + +// BoolSet is a [UniqueList] of bools. Duplicates are silently dropped. +type BoolSet = UniqueList[bool] + +// IntList is a [List] of ints. Duplicates are permitted. +type IntList = List[int] + +// IntSet is a [UniqueList] of ints. Duplicates are silently dropped. +type IntSet = UniqueList[int] + +// StringList a [List] of strings. Duplicates are permitted. +type StringList = List[string] + +// StringSet is a [UniqueList] of strings. Duplicates are silently dropped. +type StringSet = UniqueList[string] + +// +// +// + +var defaultParseFuncs = map[reflect.Type]any{ + reflect.TypeOf(*new(bool)): strconv.ParseBool, + reflect.TypeOf(*new(int)): strconv.Atoi, + reflect.TypeOf(*new(int8)): func(s string) (int8, error) { v, err := strconv.ParseInt(s, 0, 8); return int8(v), err }, + reflect.TypeOf(*new(int16)): func(s string) (int16, error) { v, err := strconv.ParseInt(s, 0, 16); return int16(v), err }, + reflect.TypeOf(*new(int32)): func(s string) (int32, error) { v, err := strconv.ParseInt(s, 0, 32); return int32(v), err }, + reflect.TypeOf(*new(int64)): func(s string) (int64, error) { v, err := strconv.ParseInt(s, 0, 64); return int64(v), err }, + reflect.TypeOf(*new(uint)): func(s string) (uint, error) { v, err := strconv.ParseUint(s, 0, 64); return uint(v), err }, + reflect.TypeOf(*new(uint8)): func(s string) (uint8, error) { v, err := strconv.ParseUint(s, 0, 8); return uint8(v), err }, + reflect.TypeOf(*new(uint16)): func(s string) (uint16, error) { v, err := strconv.ParseUint(s, 0, 16); return uint16(v), err }, + reflect.TypeOf(*new(uint32)): func(s string) (uint32, error) { v, err := strconv.ParseUint(s, 0, 32); return uint32(v), err }, + reflect.TypeOf(*new(uint64)): func(s string) (uint64, error) { v, err := strconv.ParseUint(s, 0, 64); return uint64(v), err }, + reflect.TypeOf(*new(float32)): func(s string) (float32, error) { v, err := strconv.ParseFloat(s, 32); return float32(v), err }, + reflect.TypeOf(*new(float64)): func(s string) (float64, error) { v, err := strconv.ParseFloat(s, 64); return float64(v), err }, + reflect.TypeOf(*new(string)): func(s string) (string, error) { return s, nil }, + reflect.TypeOf(*new(complex64)): func(s string) (complex64, error) { v, err := strconv.ParseComplex(s, 64); return complex64(v), err }, + reflect.TypeOf(*new(complex128)): func(s string) (complex128, error) { v, err := strconv.ParseComplex(s, 128); return complex128(v), err }, + reflect.TypeOf(*new(ffbyte)): parseByte, + reflect.TypeOf(*new(ffrune)): parseRune, + reflect.TypeOf(*new(time.Duration)): time.ParseDuration, +} + +// byte aliases uint8, but we want to distinguish them when parsing. +type ffbyte byte + +func parseByte(s string) (ffbyte, error) { + if b, err := strconv.ParseUint(s, 0, 8); err == nil { + return ffbyte(b), nil + } + + if b := []byte(s); len(b) == 1 { + return ffbyte(b[0]), nil + } + + return 0, fmt.Errorf("invalid string %q", s) +} + +// rune aliases int32, but we want to distinguish them when parsing. +type ffrune rune + +func parseRune(s string) (ffrune, error) { + if n := utf8.RuneCountInString(s); n != 1 { + return 0, fmt.Errorf("invalid string: want 1 rune, have %d", n) + } + + r, _ := utf8.DecodeRuneInString(s) + if r == utf8.RuneError { + return 0, fmt.Errorf("invalid string: invalid rune") + } + + return ffrune(r), nil +} diff --git a/ffval/value.go b/ffval/value.go new file mode 100644 index 0000000..df08197 --- /dev/null +++ b/ffval/value.go @@ -0,0 +1,151 @@ +package ffval + +import ( + "flag" + "fmt" + "reflect" +) + +// Value is a generic [flag.Value] that can be set from a string. +// +// Most consumers should probably not need to use a value directly, and can +// instead use one of the specific value types defined by this package, like +// [String] or [Duration]. +type Value[T any] struct { + // ParseFunc parses a string to the type T. If no ParseFunc is provided, and + // T is a supported [ValueType], then a default ParseFunc will be assigned + // lazily. If no ParseFunc is provided, and T is not a supported + // [ValueType], then most method calls will panic. + ParseFunc func(string) (T, error) + + // Pointer is the actual instance of the type T which is managed and updated + // by the value. If no Pointer is provided, a new T is allocated lazily. For + // this reason, callers should generally access the pointer via GetPointer, + // rather than reading the field directly. + Pointer *T + + // Default value, which is the zero value of the type T by default. + Default T + + initialized bool + isSet bool +} + +var _ flag.Value = (*Value[any])(nil) + +// NewValue returns a [Value] of underlying [ValueType] T, which updates the +// given pointer ptr when set, and which has a default value of the zero value +// of the type T. +func NewValue[T ValueType](ptr *T) *Value[T] { + var zero T + return NewValueDefault(ptr, zero) +} + +// NewValueDefault returns a value of underlying [ValueType] T, which updates +// the given pointer ptr when set, and which has the given default value def. +func NewValueDefault[T ValueType](ptr *T, def T) *Value[T] { + v := &Value[T]{ + Pointer: ptr, + Default: def, + } + v.initialize() + return v +} + +// NewValueParser returns a value for any type T that can be parsed from a +// string. +// +// This constructor is intended as a convenience function for tests; consumers +// who want to provide a parser are probably better served by constructing a +// value directly, so that they can also provide other fields in a single +// motion. +func NewValueParser[T any](parseFunc func(string) (T, error)) *Value[T] { + v := &Value[T]{ + ParseFunc: parseFunc, + } + v.initialize() + return v +} + +func (v *Value[T]) initialize() { + if v.initialized { + return + } + + if v.ParseFunc == nil { + var zero T + valueType := reflect.TypeOf(zero) + parse, ok := defaultParseFuncs[valueType] + if !ok { + panic(fmt.Errorf("%s: unsupported value type", valueType.String())) + } + pf, ok := parse.(func(string) (T, error)) + if !ok { + panic(fmt.Errorf("%s: invalid default parse func (%T)", valueType.String(), parse)) + } + v.ParseFunc = pf + } + + if v.Pointer == nil { + v.Pointer = new(T) + } + + *v.Pointer = v.Default + + v.initialized = true +} + +// Set the value by parsing the given string. +func (v *Value[T]) Set(s string) error { + v.initialize() + + val, err := v.ParseFunc(s) + if err != nil { + return fmt.Errorf("parse error: %w", err) + } + + *v.Pointer = val + v.isSet = true + return nil +} + +// Get the current value. +func (v *Value[T]) Get() T { + v.initialize() + return *v.Pointer +} + +// GetPointer returns a pointer to the underlying instance of type T which is +// managed by this value. +func (v *Value[T]) GetPointer() *T { + v.initialize() + return v.Pointer +} + +// Reset the value to its default state. +func (v *Value[T]) Reset() error { + v.initialize() + *v.Pointer = v.Default + v.isSet = false + return nil +} + +// String returns a string representation of the value returned by Get. +func (v *Value[T]) String() string { + return fmt.Sprint(v.Get()) +} + +// IsSet returns true if the value has been explicitly set. +func (v Value[T]) IsSet() bool { + return v.isSet +} + +// IsBoolFlag returns true if the underlying type T is bool. +func (v Value[T]) IsBoolFlag() bool { + switch x := any(v.Default); x.(type) { + case bool: + return true + default: + return false + } +} diff --git a/ffval/value_test.go b/ffval/value_test.go new file mode 100644 index 0000000..fdb65c5 --- /dev/null +++ b/ffval/value_test.go @@ -0,0 +1,257 @@ +package ffval_test + +import ( + "flag" + "fmt" + "strconv" + "testing" + + "github.com/peterbourgon/ff/v4" + "github.com/peterbourgon/ff/v4/ffval" +) + +func TestValue_zero(t *testing.T) { + t.Parallel() + + t.Run("Int", func(t *testing.T) { + var val ffval.Int + + if want, have := (*int)(nil), val.Pointer; want != have { + t.Errorf("Pointer: want %#+v, have %#+v", want, have) // nil Pointer is lazy-initialized + } + + if val.GetPointer() == nil { + t.Fatalf("GetPointer: nil") + } + + if val.Pointer == nil { + t.Fatalf("Pointer: still nil after GetPointer") + } + + if want, have := "0", val.String(); want != have { + t.Errorf("String: want %q, have %q", want, have) + } + + if want, have := 0, val.Get(); want != have { + t.Errorf("Get: want %v, have %v", want, have) + } + + if err := val.Set("123"); err != nil { + t.Fatalf("Set(123): %v", err) + } + + if want, have := "123", val.String(); want != have { + t.Errorf("String: want %q, have %q", want, have) + } + + if want, have := 123, val.Get(); want != have { + t.Errorf("Get: want %v, have %v", want, have) + } + + if want, have := 123, *val.Pointer; want != have { + t.Errorf("Pointer: want %v, have %v", want, have) + } + }) +} + +func TestValue_reset(t *testing.T) { + t.Parallel() + + t.Run("String", func(t *testing.T) { + val := ffval.Value[string]{ + ParseFunc: func(s string) (string, error) { return s, nil }, + Default: "zombo.com", + } + + if want, have := "zombo.com", val.Get(); want != have { + t.Errorf("Get: want %q, have %q", want, have) + } + + if err := val.Set("party.pizza"); err != nil { + t.Fatalf("Set: %v", err) + } + + if want, have := "party.pizza", val.Get(); want != have { + t.Errorf("Get: want %q, have %q", want, have) + } + + if err := val.Reset(); err != nil { + t.Fatalf("Reset: %v", err) + } + + if want, have := "zombo.com", val.Get(); want != have { + t.Errorf("Get: want %q, have %q", want, have) + } + }) +} + +func TestValue_constructors(t *testing.T) { + t.Parallel() + + t.Run("NewValueParser float64", func(t *testing.T) { + val := ffval.NewValueParser(func(s string) (float64, error) { + return strconv.ParseFloat(s, 64) + }) + + if want, have := 0.0, val.Get(); want != have { + t.Errorf("Get: want %v, have %v", want, have) + } + + if err := val.Set("1.23"); err != nil { + t.Fatalf("Set(1.23): %v", err) + } + + if want, have := 1.23, val.Get(); want != have { + t.Errorf("Get: want %v, have %v", want, have) + } + }) + + t.Run("NewValueParser uintptr", func(t *testing.T) { + val := ffval.NewValueParser(func(s string) (uintptr, error) { + u, err := strconv.ParseUint(s, 10, 64) + return uintptr(u), err + }) + + if want, have := uintptr(0), val.Get(); want != have { + t.Errorf("Get: want %v, have %v", want, have) + } + + if err := val.Set("12345678"); err != nil { + t.Fatalf("Set: %v", err) + } + + if want, have := uintptr(12345678), val.Get(); want != have { + t.Errorf("Get: want %v, have %v", want, have) + } + }) +} + +func TestValue_types(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + value flag.Value + good []string + bad []string + }{ + { + value: new(ffval.Bool), + good: []string{"1", "true", "TRUE", "True", "T", "t", "0", "false", "FALSE", "False", "F", "f"}, + bad: []string{"", "yes", "2", "no"}, + }, + { + value: new(ffval.Int), + good: []string{"0", "1", "-2", "123"}, + bad: []string{"", "1e3", "999999999999999999999999", "0b01", "0o4", "0x9", "0xEf", "0XA0"}, + }, + { + value: new(ffval.Int8), + good: []string{"0", "1", "-2", "0b01", "0o2", "0x03", "0X04", "0xa"}, + bad: []string{"", "1e3", "xxx", "32768", "0xfa", "0XAF"}, + }, + { + value: new(ffval.Int16), + good: []string{"0", "1", "-2", "0b01", "0o2", "0x03", "0xfa", "0XAF"}, + bad: []string{"", "1e3", "xxx", "99999"}, + }, + { + value: new(ffval.Int32), + good: []string{"0", "1", "-2", "0b01", "0o2", "0x03", "0xfa", "0XAF"}, + bad: []string{"", "1e3", "xxx", "123456789012345"}, + }, + { + value: new(ffval.Int64), + good: []string{"0", "1", "-2", "0b01", "0o2", "0x03", "0xfa", "0XAF"}, + bad: []string{"", "1e3", "xxx", "999999999999999999999999"}, + }, + { + value: new(ffval.Uint), + good: []string{"0", "1", "0b01", "0o2", "0x03", "0xfa", "0XAF"}, + bad: []string{"", "1e2", "xxx", "-3"}, + }, + { + value: new(ffval.Uint8), + good: []string{"0", "1", "255", "0b01", "0o2", "0x03", "0xfa", "0XAF"}, + bad: []string{"", "1e2", "xxx", "-4", "256"}, + }, + { + value: new(ffval.Uint16), + good: []string{"0", "1", "65535", "0b01", "0o2", "0x03", "0xfa", "0XAF"}, + bad: []string{"", "1e2", "xxx", "-5", "65536"}, + }, + { + value: new(ffval.Uint32), + good: []string{"0", "1", "4294967295", "0b01", "0o2", "0x03", "0xfa", "0XAF"}, + bad: []string{"", "1e2", "xxx", "-6", "4294967296"}, + }, + { + value: new(ffval.Uint64), + good: []string{"0", "1", "18446744073709551615", "0b01", "0o2", "0x03", "0xfa", "0XAF"}, + bad: []string{"", "1e2", "xxx", "-7", "18446744073709551616"}, + }, + { + value: new(ffval.Float32), + good: []string{"0", "-1", "-2.34", "5.6", "1e3"}, + bad: []string{"", "xxx", "1e100", "1e500"}, + }, + { + value: new(ffval.Float64), + good: []string{"0", "-1", "-2.34", "5.6", "1e3", "1e100"}, + bad: []string{"", "xxx", "1e500"}, + }, + { + value: new(ffval.String), + good: []string{"", "1", "hello", "🙂"}, + bad: []string{}, + }, + { + value: new(ffval.Complex64), + good: []string{"1", "(0)", "Inf", "+Inf", "-inf", "0.1i", "0x0p+012345i"}, + bad: []string{"", " ", "i", "1e309i", "2e307"}, + }, + { + value: new(ffval.Complex128), + good: []string{"1", "(0)", "Inf", "+Inf", "-inf", "0.1i", "0x0p+012345i", "2e307"}, + bad: []string{"", " ", "i", "1e309i"}, + }, + { + value: new(ffval.Byte), + good: []string{"0", " ", "00", "0x0", "0x00", "1", "0xc", "0x3", "0xf", "0Xff", "0xFF", "0b0100", "0o5", "0b0100_0001"}, + bad: []string{"", "-1", "FG", "xF", "259", "0b1111_1111_1111"}, + }, + { + value: new(ffval.Rune), + good: []string{"1", " ", "(", "😀"}, + bad: []string{"", "00", "👨‍👨‍👧‍👦"}, + }, + { + value: new(ffval.Duration), + good: []string{"12ns", "34ms", "5h6m", "127h"}, + bad: []string{"", " ", "123", "3.21"}, + }, + } { + t.Run(fmt.Sprintf("%T", test.value), func(t *testing.T) { + fs := ff.NewFlags(t.Name()) + fs.Value('v', "value", test.value, "usage string") + + for _, s := range test.good { + if err := test.value.Set(s); err != nil { + t.Errorf("%T: %q: %v", test.value, s, err) + } + } + + for _, s := range test.bad { + if err := test.value.Set(s); err == nil { + t.Errorf("%T: %q: want error, have none", test.value, s) + } + } + }) + } + + t.Run("bool", func(t *testing.T) { + var b ffval.Bool + if want, have := true, b.IsBoolFlag(); want != have { + t.Errorf("%T: IsBoolFlag: want %v, have %v", b, want, have) + } + }) +} diff --git a/ffyaml/ffyaml.go b/ffyaml/ffyaml.go index 3f86aba..05e4975 100644 --- a/ffyaml/ffyaml.go +++ b/ffyaml/ffyaml.go @@ -3,20 +3,19 @@ package ffyaml import ( "errors" - "fmt" "io" - "github.com/peterbourgon/ff/v3/internal" + "github.com/peterbourgon/ff/v4/internal/ffdata" "gopkg.in/yaml.v2" ) -// Parser is a helper function that uses a default ParseConfig. -func Parser(r io.Reader, set func(name, value string) error) error { - return (&ParseConfig{}).Parse(r, set) +// Parse is a helper function that uses a default parser. +func Parse(r io.Reader, set func(name, value string) error) error { + return (&Parser{}).Parse(r, set) } -// ParseConfig collects parameters for the YAML config file parser. -type ParseConfig struct { +// Parser collects parameters for the YAML config file parser. +type Parser struct { // Delimiter is used when concatenating nested node keys into a flag name. // The default delimiter is ".". Delimiter string @@ -25,35 +24,15 @@ type ParseConfig struct { // Parse a YAML document from the provided io.Reader, using the provided set // function to set flag values. Flag names are derived from the node names and // their key/value pairs. -func (pc *ParseConfig) Parse(r io.Reader, set func(name, value string) error) error { - if pc.Delimiter == "" { - pc.Delimiter = "." +func (p Parser) Parse(r io.Reader, set func(name, value string) error) error { + if p.Delimiter == "" { + p.Delimiter = "." } var m map[string]interface{} if err := yaml.NewDecoder(r).Decode(&m); err != nil && !errors.Is(err, io.EOF) { - return ParseError{Inner: err} + return err } - if err := internal.TraverseMap(m, pc.Delimiter, set); err != nil { - return ParseError{Inner: err} - } - - return nil -} - -// ParseError wraps all errors originating from the YAML parser. -type ParseError struct { - Inner error -} - -// Error implenents the error interface. -func (e ParseError) Error() string { - return fmt.Sprintf("error parsing YAML config: %v", e.Inner) -} - -// Unwrap implements the errors.Wrapper interface, allowing errors.Is and -// errors.As to work with ParseErrors. -func (e ParseError) Unwrap() error { - return e.Inner + return ffdata.TraverseMap(m, p.Delimiter, set) } diff --git a/ffyaml/ffyaml_test.go b/ffyaml/ffyaml_test.go index 16aed9b..2d34836 100644 --- a/ffyaml/ffyaml_test.go +++ b/ffyaml/ffyaml_test.go @@ -1,104 +1,92 @@ package ffyaml_test import ( - "flag" "os" "testing" "time" - "github.com/peterbourgon/ff/v3" - "github.com/peterbourgon/ff/v3/fftest" - "github.com/peterbourgon/ff/v3/ffyaml" + "github.com/peterbourgon/ff/v4" + "github.com/peterbourgon/ff/v4/fftest" + "github.com/peterbourgon/ff/v4/ffyaml" ) func TestParser(t *testing.T) { t.Parallel() - for _, testcase := range []struct { - vars func(*flag.FlagSet) *fftest.Vars - name string - file string - miss bool // AllowMissingConfigFiles - want fftest.Vars - }{ + testcases := fftest.TestCases{ { - name: "empty", - file: "testdata/empty.yaml", - want: fftest.Vars{}, + Name: "empty", + ConfigFile: "testdata/empty.yaml", + Want: fftest.Vars{}, }, { - name: "basic KV pairs", - file: "testdata/basic.yaml", - want: fftest.Vars{S: "hello", I: 10, B: true, D: 5 * time.Second, F: 3.14}, + Name: "basic KV pairs", + ConfigFile: "testdata/basic.yaml", + Want: fftest.Vars{S: "hello", I: 10, B: true, D: 5 * time.Second, F: 3.14}, }, { - name: "invalid prefix", - file: "testdata/invalid_prefix.yaml", - want: fftest.Vars{WantParseErrorString: "found character that cannot start any token"}, + Name: "invalid prefix", + ConfigFile: "testdata/invalid_prefix.yaml", + Want: fftest.Vars{WantParseErrorString: "found character that cannot start any token"}, }, { - vars: fftest.NonzeroDefaultVars, - name: "no value for s", - file: "testdata/no_value_s.yaml", - want: fftest.Vars{S: "", I: 123, F: 9.99, B: true, D: 3 * time.Hour}, + Name: "no value for s", + Default: fftest.Vars{S: "xxx", I: 123, F: 9.99}, + ConfigFile: "testdata/no_value_s.yaml", + Want: fftest.Vars{S: "", I: 123, F: 9.99}, }, { - vars: fftest.NonzeroDefaultVars, - name: "no value for i", - file: "testdata/no_value_i.yaml", - want: fftest.Vars{WantParseErrorString: "parse error"}, + Name: "no value for i", + Default: fftest.Vars{S: "xxx", I: 123, F: 9.99}, + ConfigFile: "testdata/no_value_i.yaml", + Want: fftest.Vars{WantParseErrorString: "parse error"}, }, { - name: "basic arrays", - file: "testdata/basic_array.yaml", - want: fftest.Vars{S: "c", X: []string{"a", "b", "c"}}, + Name: "basic arrays", + ConfigFile: "testdata/basic_array.yaml", + Want: fftest.Vars{S: "c", X: []string{"a", "b", "c"}}, }, { - name: "multiline arrays", - file: "testdata/multi_line_array.yaml", - want: fftest.Vars{S: "c", X: []string{"d", "e", "f"}}, + Name: "multiline arrays", + ConfigFile: "testdata/multi_line_array.yaml", + Want: fftest.Vars{S: "c", X: []string{"d", "e", "f"}}, }, { - name: "line break arrays", - file: "testdata/line_break_array.yaml", - want: fftest.Vars{X: []string{"first string", "second string", "third"}}, + Name: "line break arrays", + ConfigFile: "testdata/line_break_array.yaml", + Want: fftest.Vars{X: []string{"first string", "second string", "third"}}, }, { - name: "unquoted strings in arrays", - file: "testdata/unquoted_string_array.yaml", - want: fftest.Vars{X: []string{"one", "two", "three"}}, + Name: "unquoted strings in arrays", + ConfigFile: "testdata/unquoted_string_array.yaml", + Want: fftest.Vars{X: []string{"one", "two", "three"}}, }, { - name: "missing config file allowed", - file: "testdata/this_file_does_not_exist.yaml", - miss: true, - want: fftest.Vars{}, + Name: "missing config file allowed", + ConfigFile: "testdata/this_file_does_not_exist.yaml", + Options: []ff.Option{ff.WithConfigAllowMissingFile()}, + Want: fftest.Vars{}, }, { - name: "missing config file not allowed", - file: "testdata/this_file_does_not_exist.yaml", - miss: false, - want: fftest.Vars{WantParseErrorIs: os.ErrNotExist}, + Name: "missing config file not allowed", + ConfigFile: "testdata/this_file_does_not_exist.yaml", + Want: fftest.Vars{WantParseErrorIs: os.ErrNotExist}, }, { - name: "nested nodes", - file: "testdata/nested.yaml", - vars: fftest.NestedDefaultVars("."), - want: fftest.Vars{S: "a string", B: true, I: 123, F: 1.23, X: []string{"one", "two", "three"}}, + Name: "nested with '.'", + ConfigFile: "testdata/nested.yaml", + Constructors: []fftest.Constructor{fftest.NewNestedConstructor(".")}, + Want: fftest.Vars{S: "a string", F: 1.23, B: true, X: []string{"one", "two", "three"}}, + }, + { + Name: "nested with '-'", + ConfigFile: "testdata/nested.yaml", + Default: fftest.Vars{A: true}, + Constructors: []fftest.Constructor{fftest.NewNestedConstructor("-")}, + Options: []ff.Option{ff.WithConfigFileParser(ffyaml.Parser{Delimiter: "-"}.Parse)}, + Want: fftest.Vars{S: "a string", F: 1.23, A: true, B: true, X: []string{"one", "two", "three"}}, }, - } { - t.Run(testcase.name, func(t *testing.T) { - if testcase.vars == nil { - testcase.vars = fftest.DefaultVars - } - fs := flag.NewFlagSet("fftest", flag.ContinueOnError) - vars := testcase.vars(fs) - vars.ParseError = ff.Parse(fs, []string{}, - ff.WithConfigFile(testcase.file), - ff.WithConfigFileParser(ffyaml.Parser), - ff.WithAllowMissingConfigFile(testcase.miss), - ) - fftest.Compare(t, &testcase.want, vars) - }) } + + testcases.Run(t, ff.WithConfigFileParser(ffyaml.Parse)) } diff --git a/ffyaml/testdata/nested.yaml b/ffyaml/testdata/nested.yaml index 0b18c00..86cc34d 100644 --- a/ffyaml/testdata/nested.yaml +++ b/ffyaml/testdata/nested.yaml @@ -1,13 +1,11 @@ foo: - s: a string + bar: + s: a string +nested: b: true -bar: - nested: - i: 123 - f: 1.23 -baz: - nested: - x: - - one - - two - - three + f: 1.23 +x: + value: + - one + - two + - three diff --git a/flags.go b/flags.go new file mode 100644 index 0000000..fe88b4e --- /dev/null +++ b/flags.go @@ -0,0 +1,113 @@ +package ff + +import ( + "flag" + + "github.com/peterbourgon/ff/v4/ffval" +) + +// Flags describes a collection of flags, typically associated with a specific +// command (or sub-command) executed by an end user. +// +// Any valid Flags can be provided to [Parse], or used as the Flags field in a +// [Command]. This allows custom flag set implementations to take advantage of +// the primary features of this module. +// +// Implementations are not expected to be safe for concurrent use by multiple +// goroutines. +type Flags interface { + // GetName should return the name of the flag set. + GetName() string + + // Parse should parse the provided args against the flag set, setting flags + // as appropriate, and saving leftover args to be returned by GetArgs. The + // provided args shouldn't include the program name: callers should pass + // os.Args[1:], not os.Args. + Parse(args []string) error + + // IsParsed should return true if the flag set was successfully parsed. + IsParsed() bool + + // WalkFlags should call the given fn for each flag known to the flag set. + // Note that this may include flags that are actually defined in different + // "parent" flag sets. If fn returns an error, WalkFlags should immediately + // return that error. + WalkFlags(fn func(Flag) error) error + + // GetFlag should find and return the first flag known to the flag set with + // the given name. The name should always be compared against valid flag + // long names. If name is a single valid rune, it should also be compared + // against valid flag short names. Note that this may return a flag that is + // actually defined in a different "parent" flag set. + GetFlag(name string) (Flag, bool) + + // GetArgs should return the args left over after a successful call to + // parse. If parse has not yet been called successfully, it should return an + // empty (or nil) slice. + GetArgs() []string +} + +// Flag describes a single runtime configuration parameter, defined within a set +// of flags, and with a value that's parsed from a string. +// +// Implementations are not expected to be safe for concurrent use by multiple +// goroutines. +type Flag interface { + // GetFlags should return the set of flags in which this flag is defined. + // It's primarily used for help output. + GetFlags() Flags + + // GetShortName should return the short name for this flag, if one is + // defined. A short name is always a single character (rune) which is + // typically parsed with a single leading - hyphen. + GetShortName() (rune, bool) + + // GetLongName should return the long name for this flag, if one is defined. + // A long name is always a non-empty string which is typically parsed with + // two leading -- hyphens. + GetLongName() (string, bool) + + // GetPlaceholder should return a string that can be used as a placeholder + // for the flag value in help output. For example, a placeholder for a + // string flag might be STRING. An empty placeholder is valid. + GetPlaceholder() string + + // GetUsage should return a short description of the flag, which can be + // included in the help output on the same line as the flag name(s). For + // example, the usage string for a timeout flag used in an HTTP client might + // be "timeout for outgoing HTTP requests". An empty usage string is valid, + // but not recommended. + GetUsage() string + + // GetDefault should return the default value of the flag as a string. + GetDefault() string + + // SetValue should parse the provided string into the appropriate type for + // the flag, and set the flag to that parsed value. + SetValue(string) error + + // GetValue should return the current value of the flag as a string. If no + // value has been set, it should return the default value. + GetValue() string + + // IsSet should return true if SetValue has been called successfully. + IsSet() bool +} + +// Resetter may optionally be implemented by [Flags]. +type Resetter interface { + // Reset should revert the flag set to its initial state, including all + // flags defined in the flag set. If reset returns successfully, the flag + // set should be as if it were newly constructed: IsParsed should return + // false, GetArgs should return an empty slice, etc. + Reset() error +} + +// IsBoolFlagger is used to identify flag values representing booleans. +type IsBoolFlagger interface{ IsBoolFlag() bool } + +var ( + _ flag.Value = (*ffval.Value[any])(nil) + _ Resetter = (*ffval.Value[any])(nil) + _ IsBoolFlagger = (*ffval.Value[any])(nil) +) diff --git a/go.mod b/go.mod index e0dfa50..60108a5 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ -module github.com/peterbourgon/ff/v3 +module github.com/peterbourgon/ff/v4 -go 1.18 +go 1.20 require ( - github.com/pelletier/go-toml v1.9.5 + github.com/pelletier/go-toml/v2 v2.0.9 gopkg.in/yaml.v2 v2.4.0 ) diff --git a/go.sum b/go.sum index b9c5b36..90beaf7 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,21 @@ -github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= -github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0= +github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/hack/lint-parallel-tests.bash b/hack/lint-parallel-tests similarity index 99% rename from hack/lint-parallel-tests.bash rename to hack/lint-parallel-tests index f5cdbf9..5682348 100755 --- a/hack/lint-parallel-tests.bash +++ b/hack/lint-parallel-tests @@ -11,4 +11,3 @@ then echo FAIL: not all tests call t.Parallel exit 1 fi - diff --git a/hack/run-action-tests b/hack/run-action-tests new file mode 100755 index 0000000..71f27ce --- /dev/null +++ b/hack/run-action-tests @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +set -o pipefail + +function test_workflow { cat .github/workflows/test.yaml ; } +function run_commands { yq '.jobs.test.steps[] | select(.name == "Run *") | .run' ; } + +test_workflow | run_commands | while read COMMAND +do + echo ${COMMAND} + TEMPFILE=$(mktemp) + eval ${COMMAND} >${TEMPFILE} \ + && { rm ${TEMPFILE}; } \ + || { cat ${TEMPFILE}; rm ${TEMPFILE}; exit 1; } +done diff --git a/helpers.go b/helpers.go new file mode 100644 index 0000000..a98d1db --- /dev/null +++ b/helpers.go @@ -0,0 +1,46 @@ +package ff + +import ( + "fmt" + "strings" + "unicode/utf8" +) + +func newFlagError(f Flag, err error) error { + return fmt.Errorf("%s: %w", getNameString(f), err) +} + +func isValidShortName(short rune) bool { + var ( + isZero = short == 0 + isError = short == utf8.RuneError + isValid = !isZero && !isError + ) + return isValid +} + +func isValidLongName(long string) bool { + return long != "" +} + +func getNameStrings(f Flag) []string { + var names []string + if short, ok := f.GetShortName(); ok { + names = append(names, string(short)) + } + if long, ok := f.GetLongName(); ok { + names = append(names, long) + } + return names +} + +func getNameString(f Flag) string { + var names []string + if short, ok := f.GetShortName(); ok { + names = append(names, "-"+string(short)) + } + if long, ok := f.GetLongName(); ok { + names = append(names, "--"+long) + } + return strings.Join(names, ", ") +} diff --git a/internal/doc.go b/internal/doc.go deleted file mode 100644 index e144ef5..0000000 --- a/internal/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package internal provides private helpers used by various module packages. -package internal diff --git a/internal/ffdata/doc.go b/internal/ffdata/doc.go new file mode 100644 index 0000000..c049740 --- /dev/null +++ b/internal/ffdata/doc.go @@ -0,0 +1,2 @@ +// Package ffdata provides data-related helpers for ff packages. +package ffdata diff --git a/internal/traverse_map.go b/internal/ffdata/traverse_map.go similarity index 98% rename from internal/traverse_map.go rename to internal/ffdata/traverse_map.go index 410a60e..d811ca2 100644 --- a/internal/traverse_map.go +++ b/internal/ffdata/traverse_map.go @@ -1,4 +1,4 @@ -package internal +package ffdata import ( "encoding/json" diff --git a/internal/traverse_map_test.go b/internal/ffdata/traverse_map_test.go similarity index 94% rename from internal/traverse_map_test.go rename to internal/ffdata/traverse_map_test.go index 08de9b3..e66bb70 100644 --- a/internal/traverse_map_test.go +++ b/internal/ffdata/traverse_map_test.go @@ -1,11 +1,11 @@ -package internal_test +package ffdata_test import ( "encoding/json" "strings" "testing" - "github.com/peterbourgon/ff/v3/internal" + "github.com/peterbourgon/ff/v4/internal/ffdata" ) func TestTraverseMap(t *testing.T) { @@ -122,7 +122,7 @@ func TestTraverseMap(t *testing.T) { return nil } - if err := internal.TraverseMap(test.M, test.Delim, observe); err != nil { + if err := ffdata.TraverseMap(test.M, test.Delim, observe); err != nil { t.Fatal(err) } diff --git a/json_parser.go b/json_parser.go deleted file mode 100644 index 546ef5f..0000000 --- a/json_parser.go +++ /dev/null @@ -1,62 +0,0 @@ -package ff - -import ( - "encoding/json" - "fmt" - "io" - - "github.com/peterbourgon/ff/v3/internal" -) - -// JSONParser is a helper function that uses a default JSONParseConfig. -func JSONParser(r io.Reader, set func(name, value string) error) error { - return (&JSONParseConfig{}).Parse(r, set) -} - -// JSONParseConfig collects parameters for the JSON config file parser. -type JSONParseConfig struct { - // Delimiter is used when concatenating nested node keys into a flag name. - // The default delimiter is ".". - Delimiter string -} - -// Parse a JSON document from the provided io.Reader, using the provided set -// function to set flag values. Flag names are derived from the node names and -// their key/value pairs. -func (pc *JSONParseConfig) Parse(r io.Reader, set func(name, value string) error) error { - if pc.Delimiter == "" { - pc.Delimiter = "." - } - - d := json.NewDecoder(r) - d.UseNumber() // required for stringifying values - - var m map[string]interface{} - if err := d.Decode(&m); err != nil { - return JSONParseError{Inner: err} - } - - if err := internal.TraverseMap(m, pc.Delimiter, set); err != nil { - return JSONParseError{Inner: err} - } - - return nil -} - -// JSONParseError wraps all errors originating from the JSONParser. -// -// DEPRECATED: callers should test the inner error instead of this wrapper. -type JSONParseError struct { - Inner error -} - -// Error implenents the error interface. -func (e JSONParseError) Error() string { - return fmt.Sprintf("error parsing JSON config: %v", e.Inner) -} - -// Unwrap implements the errors.Wrapper interface, allowing errors.Is and -// errors.As to work with JSONParseErrors. -func (e JSONParseError) Unwrap() error { - return e.Inner -} diff --git a/json_parser_test.go b/json_parser_test.go deleted file mode 100644 index 39a1b90..0000000 --- a/json_parser_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package ff_test - -import ( - "io" - "testing" - "time" - - "github.com/peterbourgon/ff/v3" - "github.com/peterbourgon/ff/v3/fftest" -) - -func TestJSONParser(t *testing.T) { - t.Parallel() - - for _, testcase := range []struct { - name string - args []string - file string - want fftest.Vars - }{ - { - name: "empty input", - args: []string{}, - file: "testdata/empty.json", - want: fftest.Vars{}, - }, - { - name: "basic KV pairs", - args: []string{}, - file: "testdata/basic.json", - want: fftest.Vars{S: "s", I: 10, B: true, D: 5 * time.Second}, - }, - { - name: "value arrays", - args: []string{}, - file: "testdata/value_arrays.json", - want: fftest.Vars{S: "bb", I: 12, B: true, D: 5 * time.Second, X: []string{"a", "B", "👍"}}, - }, - { - name: "bad JSON file", - args: []string{}, - file: "testdata/bad.json", - want: fftest.Vars{WantParseErrorIs: io.ErrUnexpectedEOF}, - }, - } { - t.Run(testcase.name, func(t *testing.T) { - fs, vars := fftest.Pair() - vars.ParseError = ff.Parse(fs, testcase.args, - ff.WithConfigFile(testcase.file), - ff.WithConfigFileParser(ff.JSONParser), - ) - fftest.Compare(t, &testcase.want, vars) - }) - } -} diff --git a/options.go b/options.go new file mode 100644 index 0000000..52d227e --- /dev/null +++ b/options.go @@ -0,0 +1,135 @@ +package ff + +import ( + "embed" + "io" + iofs "io/fs" +) + +// Option controls some aspect of parsing behavior. +type Option func(*ParseContext) + +// ParseContext receives and maintains parse options. +type ParseContext struct { + envVarEnabled bool + envVarPrefix string + envVarSplit string + + configFileName string + configFlagName string + configParseFunc ConfigFileParseFunc + configOpenFunc func(string) (iofs.File, error) + configAllowMissingFile bool + configIgnoreUndefinedFlags bool +} + +// ConfigFileParseFunc is a function that consumes the provided reader as a config +// file, and calls the provided set function for every name=value pair it +// discovers. +type ConfigFileParseFunc func(r io.Reader, set func(name, value string) error) error + +// WithConfigFile tells [Parse] to read the provided filename as a config file. +// Requires [WithConfigFileParser], and overrides [WithConfigFileFlag]. +// +// Because config files should generally be user-specifiable, this option should +// rarely be used; prefer [WithConfigFileFlag]. +func WithConfigFile(filename string) Option { + return func(pc *ParseContext) { + pc.configFileName = filename + } +} + +// WithConfigFileFlag tells [Parse] to treat the flag with the given name as a +// config file. The flag name must be defined in the flag set consumed by parse. +// Requires [WithConfigFileParser], and is overridden by [WithConfigFile]. +// +// To specify a default config file, provide it as the default value of the +// corresponding flag. +func WithConfigFileFlag(flagname string) Option { + return func(pc *ParseContext) { + pc.configFlagName = flagname + } +} + +// WithConfigFileParser tells [Parse] how to interpret a config file. This +// option must be explicitly provided in order to parse config files. +// +// By default, no config file parser is defined, and config files are ignored. +func WithConfigFileParser(pf ConfigFileParseFunc) Option { + return func(pc *ParseContext) { + pc.configParseFunc = pf + } +} + +// WithConfigAllowMissingFile tells [Parse] to ignore config files that are +// specified but don't exist. +// +// By default, missing config files result in a parse error. +func WithConfigAllowMissingFile() Option { + return func(pc *ParseContext) { + pc.configAllowMissingFile = true + } +} + +// WithConfigIgnoreUndefinedFlags tells [Parse] to ignore flags in config files +// which are not defined in the parsed flag set. This option only applies to +// flags in config files. +// +// By default, undefined flags in config files result in a parse error. +func WithConfigIgnoreUndefinedFlags() Option { + return func(pc *ParseContext) { + pc.configIgnoreUndefinedFlags = true + } +} + +// WithEnvVars tells [Parse] to set flags from environment variables. Flags are +// matched to environment variables by capitalizing the flag name, and replacing +// separator characters like periods or hyphens with underscores. +// +// By default, flags are not parsed from environment variables at all. +func WithEnvVars() Option { + return func(pc *ParseContext) { + pc.envVarEnabled = true + } +} + +// WithEnvVarPrefix is like [WithEnvVars], but only considers environment +// variables beginning with the given prefix followed by an underscore. That +// prefix (and underscore) are removed before matching the env var key to a flag +// name. For example, the env var prefix `MYPROG` would mean that the env var +// `MYPROG_FOO` matches a flag named `foo`. +// +// By default, flags are not parsed from environment variables at all. +func WithEnvVarPrefix(prefix string) Option { + return func(pc *ParseContext) { + pc.envVarEnabled = true + pc.envVarPrefix = prefix + } +} + +// WithEnvVarSplit tells [Parse] to split environment variable values on the +// given delimiter, and to set the flag multiple times, once for each delimited +// token. Values produced in this way are not trimmed of whitespace. +// +// For example, `FOO=a,b,c` might cause a flag named `foo` to receive a single +// call to Set with the value `a,b,c`. If WithEnvVarSplit is provided as an +// option, with a delimiter of `,`, then that flag would receive three separate +// calls to Set with the strings `a`, `b`, and `c`. +// +// By default, no splitting of environment variable values occurs. +func WithEnvVarSplit(delimiter string) Option { + return func(pc *ParseContext) { + pc.envVarEnabled = true + pc.envVarSplit = delimiter + } +} + +// WithFilesystem tells [Parse] to use the provided filesystem when accessing +// files on disk, typically when reading a config file. +// +// By default, the host filesystem is used, via [os.Open]. +func WithFilesystem(fs embed.FS) Option { + return func(pc *ParseContext) { + pc.configOpenFunc = fs.Open + } +} diff --git a/parse.go b/parse.go index 4735951..ea575fa 100644 --- a/parse.go +++ b/parse.go @@ -1,7 +1,7 @@ package ff import ( - "embed" + "bufio" "errors" "flag" "fmt" @@ -11,311 +11,303 @@ import ( "strings" ) -// ConfigFileParser interprets the config file represented by the reader -// and calls the set function for each parsed flag pair. -type ConfigFileParser func(r io.Reader, set func(name, value string) error) error - -// Parse the flags in the flag set from the provided (presumably commandline) -// args. Additional options may be provided to have Parse also read from a -// config file, and/or environment variables, in that priority order. -func Parse(fs *flag.FlagSet, args []string, options ...Option) error { - var c Context - for _, option := range options { - option(&c) +// Parse the flag set with the provided args. [Option] values can be used to +// influence parse behavior. For example, options exist to read flags from +// environment variables, config files, etc. +// +// The fs parameter must be of type [Flags] or [*flag.FlagSet]. Any other type +// will result in an error. +func Parse(fs any, args []string, options ...Option) error { + switch reified := fs.(type) { + case Flags: + return parseFlags(reified, args, options...) + case *flag.FlagSet: + return parseFlags(NewStdFlags(reified), args, options...) + default: + return fmt.Errorf("unsupported flag set %T", fs) } +} - flag2env := map[*flag.Flag]string{} - env2flag := map[string]*flag.Flag{} - fs.VisitAll(func(f *flag.Flag) { - var key string - key = strings.ToUpper(f.Name) - key = flagNameToEnvVar.Replace(key) - key = maybePrefix(c.envVarPrefix != "", key, c.envVarPrefix) - env2flag[key] = f - flag2env[f] = key - }) - - // First priority: commandline flags (explicit user preference). - - if err := fs.Parse(args); err != nil { - return fmt.Errorf("error parsing commandline arguments: %w", err) +func parseFlags(fs Flags, args []string, options ...Option) error { + // The parse context manages options. + var pc ParseContext + for _, option := range options { + option(&pc) } - provided := map[string]bool{} - fs.Visit(func(f *flag.Flag) { - provided[f.Name] = true - }) - - // Second priority: environment variables (session). - - if c.readEnvVars { - var visitErr error - fs.VisitAll(func(f *flag.Flag) { - if visitErr != nil { - return - } - - if provided[f.Name] { - return - } - - key, ok := flag2env[f] - if !ok { - panic(fmt.Errorf("%s: invalid flag/env mapping", f.Name)) - } - - value := os.Getenv(key) - if value == "" { - return - } - - for _, v := range maybeSplit(value, c.envVarSplit) { - if err := fs.Set(f.Name, v); err != nil { - visitErr = fmt.Errorf("error setting flag %q from environment variable %q: %w", f.Name, key, err) - return + // Index valid flags by env var key, to support .env config files (below). + env2flag := map[string]Flag{} + { + if err := fs.WalkFlags(func(f Flag) error { + for _, name := range getNameStrings(f) { + key := getEnvVarKey(name, pc.envVarPrefix) + if existing, ok := env2flag[key]; ok { + return fmt.Errorf("%s: %w (%s)", getNameString(f), ErrDuplicateFlag, getNameString(existing)) } + env2flag[key] = f } - }) - if visitErr != nil { - return fmt.Errorf("error parsing environment variables: %w", visitErr) + return nil + }); err != nil { + return err } } - fs.Visit(func(f *flag.Flag) { - provided[f.Name] = true - }) - - // Third priority: config file (host). - - var configFile string - if c.configFileVia != nil { - configFile = *c.configFileVia + // After each stage of parsing, record the flags that have been provided. + // Subsequent lower-priority stages can't set these already-provided flags. + var provided flagSetSlice + markProvided := func() { + fs.WalkFlags(func(f Flag) error { + if f.IsSet() { + provided.add(f) + } + return nil + }) } - if configFile == "" && c.configFileFlagName != "" { - if f := fs.Lookup(c.configFileFlagName); f != nil { - configFile = f.Value.String() + // First priority: the commandline, i.e. the user. + { + if err := fs.Parse(args); err != nil { + return fmt.Errorf("parse args: %w", err) } - } - if c.configFileOpenFunc == nil { - c.configFileOpenFunc = func(s string) (iofs.File, error) { - return os.Open(s) - } + markProvided() } - var ( - haveConfigFile = configFile != "" - haveParser = c.configFileParser != nil - parseConfigFile = haveConfigFile && haveParser - ) - if parseConfigFile { - f, err := c.configFileOpenFunc(configFile) - switch { - case err == nil: - defer f.Close() - if err := c.configFileParser(f, func(name, value string) error { - if provided[name] { + // Second priority: the environment, i.e. the session. + { + if pc.envVarEnabled { + if err := fs.WalkFlags(func(f Flag) error { + // If the flag has already been set, we can't do anything. + if provided.has(f) { return nil } - var ( - f1 = fs.Lookup(name) - f2 = env2flag[name] - f *flag.Flag - ) - switch { - case f1 == nil && f2 == nil && c.ignoreUndefined: - return nil - case f1 == nil && f2 == nil && !c.ignoreUndefined: - return fmt.Errorf("config file flag %q not defined in flag set", name) - case f1 != nil && f2 == nil: - f = f1 - case f1 == nil && f2 != nil: - f = f2 - case f1 != nil && f2 != nil && f1 == f2: - f = f1 - case f1 != nil && f2 != nil && f1 != f2: - return fmt.Errorf("config file flag %q ambiguous: matches %s and %s", name, f1.Name, f2.Name) - } - - if provided[f.Name] { - return nil - } - - if err := fs.Set(f.Name, value); err != nil { - return fmt.Errorf("error setting flag %q from config file: %w", name, err) + // Look in the environment for each of the flag names. + for _, name := range getNameStrings(f) { + // Transform the flag name to an env var key. + key := getEnvVarKey(name, pc.envVarPrefix) + + // Look up the value from the environment. + val := os.Getenv(key) + if val == "" { + continue + } + + // The value may need to be split. + vals := []string{val} + if pc.envVarSplit != "" { + vals = strings.Split(val, pc.envVarSplit) + } + + // Set the flag to the value(s). + for _, v := range vals { + if err := f.SetValue(v); err != nil { + return fmt.Errorf("%s=%q: %w", key, val, err) + } + } } return nil }); err != nil { - return err + return fmt.Errorf("parse environment: %w", err) } + } - case errors.Is(err, iofs.ErrNotExist) && c.allowMissingConfigFile: - // no problem + markProvided() + } - default: - return err + // Third priority: the config file, i.e. the host. + { + // First, prefer an explicit filename string. + var configFile string + if pc.configFileName != "" { + configFile = pc.configFileName } - } - fs.Visit(func(f *flag.Flag) { - provided[f.Name] = true - }) + // Next, check the flag name. + if configFile == "" && pc.configFlagName != "" { + if f, ok := fs.GetFlag(pc.configFlagName); ok { + configFile = f.GetValue() + } + } - return nil -} + // If they didn't provide an open func, set the default. + if pc.configOpenFunc == nil { + pc.configOpenFunc = func(s string) (iofs.File, error) { + return os.Open(s) + } + } -// Context contains private fields used during parsing. -type Context struct { - configFileVia *string - configFileFlagName string - configFileParser ConfigFileParser - configFileOpenFunc func(string) (iofs.File, error) - allowMissingConfigFile bool - readEnvVars bool - envVarPrefix string - envVarSplit string - ignoreUndefined bool -} + // Config files require both a filename and a parser. + var ( + haveConfigFile = configFile != "" + haveParser = pc.configParseFunc != nil + parseConfigFile = haveConfigFile && haveParser + ) + if parseConfigFile { + configFile, err := pc.configOpenFunc(configFile) + switch { + case err == nil: + defer configFile.Close() + if err := pc.configParseFunc(configFile, func(name, value string) error { + // The parser calls us with a name=value pair. We want to + // allow the name to be either the actual flag name, or its + // env var representation (to support .env files). + var ( + setFlag, fromSet = fs.GetFlag(name) + envFlag, fromEnv = env2flag[name] + target Flag + ) + switch { + case fromSet: + target = setFlag + case !fromSet && fromEnv: + target = envFlag + case !fromSet && !fromEnv && pc.configIgnoreUndefinedFlags: + return nil + case !fromSet && !fromEnv && !pc.configIgnoreUndefinedFlags: + return fmt.Errorf("%s: %w", name, ErrUnknownFlag) + } + + // If the flag was already provided by commandline args or + // env vars, then don't set it again. But be sure to allow + // config files to specify the same flag multiple times. + if provided.has(target) { + return nil + } + + if err := target.SetValue(value); err != nil { + return fmt.Errorf("%s: %w", name, err) + } -// Option controls some aspect of Parse behavior. -type Option func(*Context) + return nil + }); err != nil { + return fmt.Errorf("parse config file: %w", err) + } -// WithConfigFile tells Parse to read the provided filename as a config file. -// Requires WithConfigFileParser, and overrides WithConfigFileFlag. Because -// config files should generally be user-specifiable, this option should rarely -// be used; prefer WithConfigFileFlag. -func WithConfigFile(filename string) Option { - return WithConfigFileVia(&filename) -} + case errors.Is(err, iofs.ErrNotExist) && pc.configAllowMissingFile: + // no problem -// WithConfigFileVia tells Parse to read the provided filename as a config file. -// Requires WithConfigFileParser, and overrides WithConfigFileFlag. This is -// useful for sharing a single root level flag for config files among multiple -// ffcli subcommands. -func WithConfigFileVia(filename *string) Option { - return func(c *Context) { - c.configFileVia = filename - } -} + default: + return err + } + } -// WithConfigFileFlag tells Parse to treat the flag with the given name as a -// config file. Requires WithConfigFileParser, and is overridden by -// WithConfigFile. -// -// To specify a default config file, provide it as the default value of the -// corresponding flag. See also: WithAllowMissingConfigFile. -func WithConfigFileFlag(flagname string) Option { - return func(c *Context) { - c.configFileFlagName = flagname + markProvided() } -} -// WithConfigFileParser tells Parse how to interpret the config file provided -// via WithConfigFile or WithConfigFileFlag. -func WithConfigFileParser(p ConfigFileParser) Option { - return func(c *Context) { - c.configFileParser = p - } + return nil } -// WithAllowMissingConfigFile tells Parse to permit the case where a config file -// is specified but doesn't exist. // -// By default, missing config files cause Parse to fail. -func WithAllowMissingConfigFile(allow bool) Option { - return func(c *Context) { - c.allowMissingConfigFile = allow - } -} - -// WithEnvVarNoPrefix is an alias for WithEnvVars. // -// DEPRECATED: prefer WithEnvVars. -var WithEnvVarNoPrefix = WithEnvVars - -// WithEnvVars tells Parse to set flags from environment variables. Flag -// names are matched to environment variables by capitalizing the flag name, and -// replacing separator characters like periods or hyphens with underscores. // -// By default, flags are not set from environment variables at all. -func WithEnvVars() Option { - return func(c *Context) { - c.readEnvVars = true - } -} -// WithEnvVarPrefix is like WithEnvVars, but only considers environment -// variables beginning with the given prefix followed by an underscore. That -// prefix (and underscore) are removed before matching to flag names. This -// option is also respected by the EnvParser config file parser. +// PlainParser is a parser for config files in an extremely simple format. Each +// line is tokenized as a single key/value pair. The first space-delimited token +// in the line is interpreted as the flag name, and the rest of the line is +// interpreted as the flag value. // -// By default, flags are not set from environment variables at all. -func WithEnvVarPrefix(prefix string) Option { - return func(c *Context) { - c.readEnvVars = true - c.envVarPrefix = prefix - } -} +// Any leading hyphens on the flag name are ignored. Lines with a flag name but +// no value are interpreted as booleans, and the value is set to true. +// +// Flag values are trimmed of leading and trailing whitespace, but are otherwise +// unmodified. In particular, values are not quote-unescaped, and control +// characters like \n are not evaluated and instead passed through as literals. +// +// Comments are supported via "#". End-of-line comments require a space between +// the end of the line and the "#" character. +// +// An example config file follows. +// +// # this is a full-line comment +// timeout 250ms # this is an end-of-line comment +// foo abc def # set foo to `abc def` +// foo 12345678 # repeated flags result in repeated calls to Set +// bar "abc def" # set bar to `"abc def"`, including quotes +// baz x\ny # set baz to `x\ny`, passing \n literally +// verbose # equivalent to `verbose true` +func PlainParser(r io.Reader, set func(name, value string) error) error { + s := bufio.NewScanner(r) + for s.Scan() { + line := strings.TrimSpace(s.Text()) + if line == "" { + continue // skip empties + } -// WithEnvVarSplit tells Parse to split environment variables on the given -// delimiter, and to make a call to Set on the corresponding flag with each -// split token. -func WithEnvVarSplit(delimiter string) Option { - return func(c *Context) { - c.envVarSplit = delimiter - } -} + if line[0] == '#' { + continue // skip comments + } -// WithIgnoreUndefined tells Parse to ignore undefined flags that it encounters -// in config files. By default, if Parse encounters an undefined flag in a -// config file, it will return an error. Note that this setting does not apply -// to undefined flags passed as arguments. -func WithIgnoreUndefined(ignore bool) Option { - return func(c *Context) { - c.ignoreUndefined = ignore - } -} + var ( + name string + value string + index = strings.IndexRune(line, ' ') + ) + if index < 0 { + name, value = line, "true" // boolean option + } else { + name, value = line[:index], strings.TrimSpace(line[index:]) + } -// WithFilesystem tells Parse to use the provided filesystem when accessing -// files on disk, for example when reading a config file. By default, the host -// filesystem is used, via [os.Open]. -func WithFilesystem(fs embed.FS) Option { - return func(c *Context) { - c.configFileOpenFunc = fs.Open + if i := strings.Index(value, " #"); i >= 0 { + value = strings.TrimSpace(value[:i]) + } + + if err := set(name, value); err != nil { + return err + } } + return s.Err() } -var flagNameToEnvVar = strings.NewReplacer( +// +// +// + +var envVarSeparators = strings.NewReplacer( "-", "_", ".", "_", "/", "_", ) -func maybePrefix(doPrefix bool, key string, prefix string) string { - if doPrefix { - key = strings.ToUpper(prefix) + "_" + key - } +func getEnvVarKey(flagName, envVarPrefix string) string { + var key string + key = flagName + key = strings.TrimLeft(key, "-") + key = strings.ToUpper(key) + key = envVarSeparators.Replace(key) + key = maybePrefix(key, envVarPrefix) return key } -func maybeSplit(value, split string) []string { - if split == "" { - return []string{value} +func maybePrefix(key string, prefix string) string { + if prefix != "" { + key = strings.ToUpper(prefix) + "_" + key } - return strings.Split(value, split) + return key } -// StringConversionError was returned by config file parsers in certain cases. // -// DEPRECATED: this error is no longer returned by anything. -type StringConversionError struct { - Value interface{} +// +// + +type flagSetSlice []Flag + +func (fss *flagSetSlice) add(f Flag) { + for _, ff := range *fss { + if f == ff { + return + } + } + *fss = append(*fss, f) } -// Error implements the error interface. -func (e StringConversionError) Error() string { - return fmt.Sprintf("couldn't convert %q (type %T) to string", e.Value, e.Value) +func (fss *flagSetSlice) has(f Flag) bool { + for _, ff := range *fss { + if f == ff { + return true + } + } + return false } diff --git a/parse_test.go b/parse_test.go index b90420d..9936aed 100644 --- a/parse_test.go +++ b/parse_test.go @@ -1,326 +1,354 @@ package ff_test import ( - "context" "embed" - "flag" - "os" "testing" "time" - "github.com/peterbourgon/ff/v3" - "github.com/peterbourgon/ff/v3/ffcli" - "github.com/peterbourgon/ff/v3/fftest" + "github.com/peterbourgon/ff/v4" + "github.com/peterbourgon/ff/v4/fftest" ) //go:embed testdata/*.conf var testdataConfigFS embed.FS -func TestParseBasics(t *testing.T) { +func TestParse(t *testing.T) { t.Parallel() - for _, testcase := range []struct { - name string - env map[string]string - file string - args []string - opts []ff.Option - want fftest.Vars - }{ + testcases := fftest.TestCases{ { - name: "empty", - args: []string{}, - want: fftest.Vars{}, + Name: "empty", + Want: fftest.Vars{}, }, { - name: "args only", - args: []string{"-s", "foo", "-i", "123", "-b", "-d", "24m"}, - want: fftest.Vars{S: "foo", I: 123, B: true, D: 24 * time.Minute}, + Name: "args", + Args: []string{"-s", "foo", "-i", "123", "-b", "-d", "24m"}, + Want: fftest.Vars{S: "foo", I: 123, B: true, D: 24 * time.Minute}, }, + { - name: "file only", - file: "testdata/1.conf", - want: fftest.Vars{S: "bar", I: 99, B: true, D: time.Hour}, + Name: "file only", + ConfigFile: "testdata/1.conf", + Want: fftest.Vars{S: "bar", I: 99, B: true, D: time.Hour}, }, { - name: "env only", - env: map[string]string{"TEST_PARSE_S": "baz", "TEST_PARSE_F": "0.99", "TEST_PARSE_D": "100s"}, - opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, - want: fftest.Vars{S: "baz", F: 0.99, D: 100 * time.Second}, + Name: "env only", + Environment: map[string]string{"TEST_PARSE_S": "baz", "TEST_PARSE_F": "0.99", "TEST_PARSE_D": "100s"}, + Options: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, + Want: fftest.Vars{S: "baz", F: 0.99, D: 100 * time.Second}, }, { - name: "file args", - file: "testdata/2.conf", - args: []string{"-s", "foo", "-i", "1234"}, - want: fftest.Vars{S: "foo", I: 1234, D: 3 * time.Second}, + Name: "file args", + ConfigFile: "testdata/2.conf", + Args: []string{"-s", "foo", "-i", "1234"}, + Want: fftest.Vars{S: "foo", I: 1234, D: 3 * time.Second}, }, { - name: "env args", - env: map[string]string{"TEST_PARSE_S": "should be overridden", "TEST_PARSE_B": "true"}, - args: []string{"-s", "explicit wins", "-i", "7"}, - opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, - want: fftest.Vars{S: "explicit wins", I: 7, B: true}, + Name: "env args", + Environment: map[string]string{"TEST_PARSE_S": "should be overridden", "TEST_PARSE_B": "true"}, + Args: []string{"-s", "explicit wins", "-i", "7"}, + Options: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, + Want: fftest.Vars{S: "explicit wins", I: 7, B: true}, }, { - name: "file env", - env: map[string]string{"TEST_PARSE_S": "env takes priority", "TEST_PARSE_B": "true"}, - file: "testdata/3.conf", - opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, - want: fftest.Vars{S: "env takes priority", I: 99, B: true, D: 34 * time.Second}, + Name: "file env", + ConfigFile: "testdata/3.conf", + Environment: map[string]string{"TEST_PARSE_S": "env takes priority", "TEST_PARSE_B": "true"}, + Options: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, + Want: fftest.Vars{S: "env takes priority", I: 99, B: true, D: 34 * time.Second}, }, { - name: "file env args", - file: "testdata/4.conf", - env: map[string]string{"TEST_PARSE_S": "from env", "TEST_PARSE_I": "300", "TEST_PARSE_F": "0.15", "TEST_PARSE_B": "true"}, - args: []string{"-s", "from arg", "-i", "100"}, - opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, - want: fftest.Vars{S: "from arg", I: 100, F: 0.15, B: true, D: time.Minute}, + Name: "file env args", + ConfigFile: "testdata/4.conf", + Environment: map[string]string{"TEST_PARSE_S": "from env", "TEST_PARSE_I": "300", "TEST_PARSE_F": "0.15", "TEST_PARSE_B": "true"}, + Args: []string{"-s", "from arg", "-i", "100"}, + Options: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, + Want: fftest.Vars{S: "from arg", I: 100, F: 0.15, B: true, D: time.Minute}, }, { - name: "repeated args", - args: []string{"-s", "foo", "-s", "bar", "-d", "1m", "-d", "1h", "-x", "1", "-x", "2", "-x", "3"}, - want: fftest.Vars{S: "bar", D: time.Hour, X: []string{"1", "2", "3"}}, + Name: "repeated args", + Args: []string{"-s", "foo", "-s", "bar", "-d", "1m", "-d", "1h", "-x", "1", "-x", "2", "-x", "3"}, + Want: fftest.Vars{S: "bar", D: time.Hour, X: []string{"1", "2", "3"}}, }, { - name: "priority repeats", - env: map[string]string{"TEST_PARSE_S": "s.env", "TEST_PARSE_X": "x.env.1"}, - file: "testdata/5.conf", - args: []string{"-s", "s.arg.1", "-s", "s.arg.2", "-x", "x.arg.1", "-x", "x.arg.2"}, - opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, - want: fftest.Vars{S: "s.arg.2", X: []string{"x.arg.1", "x.arg.2"}}, // highest prio wins and no others are called + Name: "file repeats", + ConfigFile: "testdata/5.conf", + Want: fftest.Vars{S: "s.file.2", X: []string{"x.file.1", "x.file.2"}}, }, { - name: "PlainParser solo bool", - file: "testdata/solo_bool.conf", - want: fftest.Vars{S: "x", B: true}, + Name: "priority repeats", + ConfigFile: "testdata/5.conf", + Environment: map[string]string{"TEST_PARSE_S": "s.env", "TEST_PARSE_X": "x.env.1"}, + Args: []string{"-s", "s.arg.1", "-s", "s.arg.2", "-x", "x.arg.1", "-x", "x.arg.2"}, + Options: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, + Want: fftest.Vars{S: "s.arg.2", X: []string{"x.arg.1", "x.arg.2"}}, // highest prio wins and no others are called }, { - name: "PlainParser string with spaces", - file: "testdata/spaces.conf", - want: fftest.Vars{S: "i am the very model of a modern major general"}, + Name: "WithEnvVars", + Environment: map[string]string{"S": "xxx", "F": "9.87"}, + Options: []ff.Option{ff.WithEnvVars()}, + Want: fftest.Vars{S: "xxx", F: 9.87}, }, { - name: "default comma behavior", - env: map[string]string{"TEST_PARSE_S": "one,two,three", "TEST_PARSE_X": "one,two,three"}, - opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, - want: fftest.Vars{S: "one,two,three", X: []string{"one,two,three"}}, + Name: "WithEnvVars prefix", + Environment: map[string]string{"TEST_PARSE_S": "foo", "S": "bar"}, + Options: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, + Want: fftest.Vars{S: "foo"}, }, { - name: "WithEnvVarSplit", - env: map[string]string{"TEST_PARSE_S": "one,two,three", "TEST_PARSE_X": "one,two,three"}, - opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE"), ff.WithEnvVarSplit(",")}, - want: fftest.Vars{S: "three", X: []string{"one", "two", "three"}}, + Name: "WithEnvVars no prefix", + Environment: map[string]string{"TEST_PARSE_S": "foo", "S": "bar"}, + Options: []ff.Option{ff.WithEnvVars()}, + Want: fftest.Vars{S: "bar"}, }, { - name: "WithEnvVars", - env: map[string]string{"TEST_PARSE_S": "foo", "S": "bar"}, - opts: []ff.Option{ff.WithEnvVars()}, - want: fftest.Vars{S: "bar"}, + Name: "WithEnvVarSplit", + Environment: map[string]string{"TEST_PARSE_S": "one,two,three", "TEST_PARSE_X": "one,two,three"}, + Options: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE"), ff.WithEnvVarSplit(",")}, + Want: fftest.Vars{S: "three", X: []string{"one", "two", "three"}}, }, { - name: "WithIgnoreUndefined env", - env: map[string]string{"TEST_PARSE_UNDEFINED": "one", "TEST_PARSE_S": "one"}, - opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE"), ff.WithIgnoreUndefined(true)}, - want: fftest.Vars{S: "one"}, + Name: "env default comma behavior", + Environment: map[string]string{"TEST_PARSE_S": "one,two,three", "TEST_PARSE_X": "one,two,three"}, + Options: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, + Want: fftest.Vars{S: "one,two,three", X: []string{"one,two,three"}}, }, { - name: "WithIgnoreUndefined file true", - file: "testdata/undefined.conf", - opts: []ff.Option{ff.WithIgnoreUndefined(true)}, - want: fftest.Vars{S: "one"}, + Name: "env var split comma whitespace", + Environment: map[string]string{"TEST_PARSE_S": "one, two, three ", "TEST_PARSE_X": "one, two, three "}, + Options: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE"), ff.WithEnvVarSplit(",")}, + Want: fftest.Vars{S: " three ", X: []string{"one", " two", " three "}}, }, + } + + testcases.Run(t) +} + +func TestParse_CoreFlags(t *testing.T) { + t.Parallel() + + testcases := fftest.TestCases{ { - name: "WithIgnoreUndefined file false", - file: "testdata/undefined.conf", - opts: []ff.Option{ff.WithIgnoreUndefined(false)}, - want: fftest.Vars{WantParseErrorString: "config file flag"}, + Name: "long args", + Constructors: []fftest.Constructor{fftest.CoreConstructor}, + Args: []string{"--str=foo", "--int", "123", "--bflag", "-d", "13m"}, + Want: fftest.Vars{S: "foo", I: 123, B: true, D: 13 * time.Minute}, }, { - name: "env var split comma whitespace", - env: map[string]string{"TEST_PARSE_S": "one, two, three ", "TEST_PARSE_X": "one, two, three "}, - opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE"), ff.WithEnvVarSplit(",")}, - want: fftest.Vars{S: " three ", X: []string{"one", " two", " three "}}, + Name: "-b only", + Constructors: []fftest.Constructor{fftest.CoreConstructor}, + Args: []string{`-b`}, + Want: fftest.Vars{B: true}, }, { - name: "WithEnvVars", - env: map[string]string{"S": "xxx", "F": "9.87"}, - opts: []ff.Option{ff.WithEnvVars()}, - want: fftest.Vars{S: "xxx", F: 9.87}, + Name: "--str abc", + Constructors: []fftest.Constructor{fftest.CoreConstructor}, + Args: []string{`--str`, `abc`}, + Want: fftest.Vars{S: "abc"}, }, { - name: "WithEnvVarNoPrefix", // make sure alias works - env: map[string]string{"S": "xxx", "F": "9.87"}, - opts: []ff.Option{ff.WithEnvVarNoPrefix()}, - want: fftest.Vars{S: "xxx", F: 9.87}, + Name: "-s xxx", + Constructors: []fftest.Constructor{fftest.CoreConstructor}, + Args: []string{`-s`, `xxx`}, + Want: fftest.Vars{S: "xxx"}, }, { - name: "WithFilesystem testdata/1.conf", - opts: []ff.Option{ff.WithFilesystem(testdataConfigFS), ff.WithConfigFile("testdata/1.conf"), ff.WithConfigFileParser(ff.PlainParser)}, - want: fftest.Vars{S: "bar", I: 99, B: true, D: 1 * time.Hour}, + Name: "-s=xxx", + Constructors: []fftest.Constructor{fftest.CoreConstructor}, + Args: []string{`-s=xxx`}, + Want: fftest.Vars{S: "=xxx"}, }, - } { - t.Run(testcase.name, func(t *testing.T) { - if testcase.file != "" { - testcase.opts = append(testcase.opts, ff.WithConfigFile(testcase.file), ff.WithConfigFileParser(ff.PlainParser)) - } - - if len(testcase.env) > 0 { - for k, v := range testcase.env { - defer os.Setenv(k, os.Getenv(k)) - os.Setenv(k, v) - } - } - - fs, vars := fftest.Pair() - vars.ParseError = ff.Parse(fs, testcase.args, testcase.opts...) - fftest.Compare(t, &testcase.want, vars) - }) - } -} - -func TestParseIssue16(t *testing.T) { - t.Parallel() - - for _, testcase := range []struct { - name string - data string - want string - }{ { - name: "hash in value", - data: "s bar#baz", - want: "bar#baz", + Name: "-str=xxx", + Constructors: []fftest.Constructor{fftest.CoreConstructor}, + Args: []string{`-str=xxx`}, + Want: fftest.Vars{S: `tr=xxx`}, }, { - name: "EOL comment with space", - data: "s bar # baz", - want: "bar", + Name: "-s -b", + Constructors: []fftest.Constructor{fftest.CoreConstructor}, + Args: []string{`-s`, `-b`}, + Want: fftest.Vars{S: "-b"}, }, { - name: "EOL comment no space", - data: "s bar #baz", - want: "bar", + Name: "-a -b -c", + Constructors: []fftest.Constructor{fftest.CoreConstructor}, + Args: []string{`-a`, `-b`, `-c`}, + Want: fftest.Vars{A: true, B: true, C: true}, }, { - name: "only comment with space", - data: "# foo bar\n", - want: "", + Name: "-ab -c", + Constructors: []fftest.Constructor{fftest.CoreConstructor}, + Args: []string{`-ab`, `-c`}, + Want: fftest.Vars{A: true, B: true, C: true}, }, { - name: "only comment no space", - data: "#foo bar\n", - want: "", + Name: "-ab -sfoo -bc", + Constructors: []fftest.Constructor{fftest.CoreConstructor}, + Args: []string{`-ab`, `-sfoo`, `-bc`}, + Want: fftest.Vars{A: true, B: true, C: true, S: "foo"}, + }, + { + Name: "-absfoo -c", + Constructors: []fftest.Constructor{fftest.CoreConstructor}, + Args: []string{`-absfoo`, `-c`}, + Want: fftest.Vars{A: true, B: true, C: true, S: "foo"}, + }, + { + Name: "-acs foo -b", + Constructors: []fftest.Constructor{fftest.CoreConstructor}, + Args: []string{`-acs`, `foo`, `-b`}, + Want: fftest.Vars{A: true, B: true, C: true, S: "foo"}, + }, + { + Name: "-a true -b false -c true", + Constructors: []fftest.Constructor{fftest.CoreConstructor}, + Args: []string{`-a`, `true`, `-b`, `false`, `-c`, `true`}, + Want: fftest.Vars{A: true, Args: []string{`true`, `-b`, `false`, `-c`, `true`}}, + }, + { + Name: "--str foo -h", + Constructors: []fftest.Constructor{fftest.CoreConstructor}, + Args: []string{`--str`, `foo`, `-h`}, + Want: fftest.Vars{S: "foo", WantParseErrorIs: ff.ErrHelp}, + }, + { + Name: "--str foo --help -b", + Constructors: []fftest.Constructor{fftest.CoreConstructor}, + Args: []string{`--str`, `foo`, `--help`, `-b`}, + Want: fftest.Vars{S: "foo", B: false, WantParseErrorIs: ff.ErrHelp}, + }, + { + Name: "-s foo -f 1.23", + Constructors: []fftest.Constructor{fftest.CoreConstructor}, + Args: []string{"-s", "foo", "-f", "1.23"}, + Want: fftest.Vars{S: "foo", F: 1.23}, + }, + { + Name: "-a true -b true", + Constructors: []fftest.Constructor{fftest.CoreConstructor}, + Args: []string{"-a", "true", "-b", "true"}, + Want: fftest.Vars{A: true, B: false, Args: []string{"true", "-b", "true"}}, + }, + { + Name: "--aflag true --cflag true", + Constructors: []fftest.Constructor{fftest.CoreConstructor}, + Args: []string{"--aflag", "true", "--cflag", "true"}, + Want: fftest.Vars{A: true, B: false, C: true, Args: []string{}}, + }, + { + Name: "-a --bflag=false", + Constructors: []fftest.Constructor{fftest.CoreConstructor}, + Default: fftest.Vars{A: true, B: true}, + Args: []string{"-a", "--bflag=false"}, + Want: fftest.Vars{A: true, B: false, C: false, Args: []string{}}, + }, + { + Name: "-a false", + Constructors: []fftest.Constructor{fftest.CoreConstructor}, + Default: fftest.Vars{A: true, B: true}, + Args: []string{"-a", "false"}, + Want: fftest.Vars{A: true, B: true, C: false, Args: []string{"false"}}, }, - } { - t.Run(testcase.name, func(t *testing.T) { - filename := fftest.TempFile(t, testcase.data) - - fs, vars := fftest.Pair() - vars.ParseError = ff.Parse(fs, []string{}, - ff.WithConfigFile(filename), - ff.WithConfigFileParser(ff.PlainParser), - ) - - want := fftest.Vars{S: testcase.want} - fftest.Compare(t, &want, vars) - }) } + + testcases.Run(t) } -func TestParseConfigFile(t *testing.T) { +func TestParse_StdFlagSetAdapter(t *testing.T) { t.Parallel() - for _, testcase := range []struct { - name string - missing bool - allowMissing bool - parseError error - }{ + testcases := fftest.TestCases{ { - name: "has config file", + Name: "-singledash space values", + Constructors: []fftest.Constructor{fftest.StdConstructor}, + Args: []string{"-s", "foo", "-f", "1.23"}, + Want: fftest.Vars{S: "foo", F: 1.23}, }, { - name: "config file missing", - missing: true, - parseError: os.ErrNotExist, + Name: "-singledash space values bool", + Constructors: []fftest.Constructor{fftest.StdConstructor}, + Args: []string{"-a", "true", "-b", "true"}, + Want: fftest.Vars{A: true, B: true}, }, { - name: "config file missing + allow missing", - missing: true, - allowMissing: true, + Name: "--doubledash space values bool", + Constructors: []fftest.Constructor{fftest.StdConstructor}, + Args: []string{"--a", "true", "--c", "true"}, + Want: fftest.Vars{A: true, C: true}, + }, + { + Name: "bool default true set false", + Constructors: []fftest.Constructor{fftest.StdConstructor}, + Default: fftest.Vars{A: true, B: true}, + Args: []string{"-a", "-b=false"}, + Want: fftest.Vars{A: true, B: false, C: false}, + }, + { + Name: "bool default true set false with spaces", + Constructors: []fftest.Constructor{fftest.StdConstructor}, + Default: fftest.Vars{A: true, B: true}, + Args: []string{"-a", "false"}, + Want: fftest.Vars{A: false, B: true, C: false}, }, - } { - t.Run(testcase.name, func(t *testing.T) { - filename := "dummy" - if !testcase.missing { - filename = fftest.TempFile(t, "") - } - - options := []ff.Option{ff.WithConfigFile(filename), ff.WithConfigFileParser(ff.PlainParser)} - if testcase.allowMissing { - options = append(options, ff.WithAllowMissingConfigFile(true)) - } - - fs, vars := fftest.Pair() - vars.ParseError = ff.Parse(fs, []string{}, options...) - - want := fftest.Vars{WantParseErrorIs: testcase.parseError} - fftest.Compare(t, &want, vars) - }) } + + testcases.Run(t) } -func TestParseConfigFileVia(t *testing.T) { +func TestParse_PlainParser(t *testing.T) { t.Parallel() - var ( - rootFS = flag.NewFlagSet("root", flag.ContinueOnError) - config = rootFS.String("config-file", "", "") - i = rootFS.Int("i", 0, "") - s = rootFS.String("s", "", "") - subFS = flag.NewFlagSet("subcommand", flag.ContinueOnError) - d = subFS.Duration("d", time.Second, "") - b = subFS.Bool("b", false, "") - ) - - subCommand := &ffcli.Command{ - Name: "subcommand", - FlagSet: subFS, - Options: []ff.Option{ - ff.WithConfigFileParser(ff.PlainParser), - ff.WithConfigFileVia(config), - ff.WithIgnoreUndefined(true), - }, - Exec: func(ctx context.Context, args []string) error { return nil }, - } - - root := &ffcli.Command{ - Name: "root", - FlagSet: rootFS, - Options: []ff.Option{ - ff.WithConfigFileParser(ff.PlainParser), - ff.WithConfigFileFlag("config-file"), - ff.WithIgnoreUndefined(true), - }, - Exec: func(ctx context.Context, args []string) error { return nil }, - Subcommands: []*ffcli.Command{subCommand}, - } - - err := root.ParseAndRun(context.Background(), []string{"-config-file", "testdata/1.conf", "subcommand", "-b"}) - if err != nil { - t.Fatal(err) + testcases := fftest.TestCases{ + { + Name: "solo bool", + ConfigFile: "testdata/solo_bool.conf", + Want: fftest.Vars{S: "x", B: true}, + }, + { + Name: "string with spaces", + ConfigFile: "testdata/spaces.conf", + Want: fftest.Vars{S: "i am the very model of a modern major general"}, + }, + { + Name: "comments", + ConfigFile: "testdata/comments.conf", + Want: fftest.Vars{X: []string{ + "foo#bar", + "foo# bar", + "foo", + "foo", + `"foo#bar"#baz`, + `"foo#bar"`, + `"foo`, + }}, + }, + { + Name: "newlines", + ConfigFile: "testdata/newlines.conf", + Want: fftest.Vars{X: []string{ + `hello\nworld\n`, + `"hello\nworld\n"`, + }}, + }, + { + Name: "WithConfigIgnoreUndefined not set", + ConfigFile: "testdata/undefined.conf", + Want: fftest.Vars{WantParseErrorIs: ff.ErrUnknownFlag}, + }, + { + Name: "WithConfigIgnoreUndefined is set", + ConfigFile: "testdata/undefined.conf", + Options: []ff.Option{ff.WithConfigIgnoreUndefinedFlags()}, + Want: fftest.Vars{S: "one"}, + }, + { + Name: "WithFilesystem", + ConfigFile: "testdata/1.conf", + Options: []ff.Option{ff.WithFilesystem(testdataConfigFS)}, + Want: fftest.Vars{S: "bar", I: 99, B: true, D: 1 * time.Hour}, + }, } - if want, have := time.Hour, *d; want != have { - t.Errorf("d: want %v, have %v", want, have) - } - if want, have := true, *b; want != have { - t.Errorf("b: want %v, have %v", want, have) - } - if want, have := "bar", *s; want != have { - t.Errorf("s: want %q, have %q", want, have) - } - if want, have := 99, *i; want != have { - t.Errorf("i: want %d, have %d", want, have) - } + testcases.Run(t) } diff --git a/plain_parser.go b/plain_parser.go deleted file mode 100644 index 682fcfe..0000000 --- a/plain_parser.go +++ /dev/null @@ -1,46 +0,0 @@ -package ff - -import ( - "bufio" - "io" - "strings" -) - -// PlainParser is a parser for config files in an extremely simple format. Each -// line is tokenized as a single key/value pair. The first whitespace-delimited -// token in the line is interpreted as the flag name, and all remaining tokens -// are interpreted as the value. Any leading hyphens on the flag name are -// ignored. -func PlainParser(r io.Reader, set func(name, value string) error) error { - s := bufio.NewScanner(r) - for s.Scan() { - line := strings.TrimSpace(s.Text()) - if line == "" { - continue // skip empties - } - - if line[0] == '#' { - continue // skip comments - } - - var ( - name string - value string - index = strings.IndexRune(line, ' ') - ) - if index < 0 { - name, value = line, "true" // boolean option - } else { - name, value = line[:index], strings.TrimSpace(line[index:]) - } - - if i := strings.Index(value, " #"); i >= 0 { - value = strings.TrimSpace(value[:i]) - } - - if err := set(name, value); err != nil { - return err - } - } - return nil -} diff --git a/testdata/comments.conf b/testdata/comments.conf new file mode 100644 index 0000000..f9e740c --- /dev/null +++ b/testdata/comments.conf @@ -0,0 +1,12 @@ +#s this should not be set +# i 123 # this should also not be set + +# EOL comments require a space before the # + +x foo#bar # <- this adds `foo#bar` +x foo# bar # <- this adds `foo# bar` +x foo #bar # <- this adds `foo` +x foo # bar # <- this adds `foo` +x "foo#bar"#baz # <- this adds `"foo#bar"#baz` +x "foo#bar" #baz # <- this adds `"foo#bar"` +x "foo #bar"#baz # <- this adds `"foo` diff --git a/testdata/newlines.conf b/testdata/newlines.conf new file mode 100644 index 0000000..49f44f3 --- /dev/null +++ b/testdata/newlines.conf @@ -0,0 +1,2 @@ +x hello\nworld\n # \n is escaped, not evaluated +x "hello\nworld\n" # same thing here