pax_global_header00006660000000000000000000000064144562424700014522gustar00rootroot0000000000000052 comment=e267c41b1d149b5151fb637d65f53b6e833448fd ff-3.4.0/000077500000000000000000000000001445624247000121215ustar00rootroot00000000000000ff-3.4.0/.github/000077500000000000000000000000001445624247000134615ustar00rootroot00000000000000ff-3.4.0/.github/workflows/000077500000000000000000000000001445624247000155165ustar00rootroot00000000000000ff-3.4.0/.github/workflows/test.yml000066400000000000000000000020251445624247000172170ustar00rootroot00000000000000on: 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 ./... ff-3.4.0/.gitignore000066400000000000000000000004231445624247000141100ustar00rootroot00000000000000# Binaries for programs and plugins *.exe *.dll *.so *.dylib # Test binary, build with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 .glide/ ff-3.4.0/LICENSE000066400000000000000000000261351445624247000131350ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS 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 "{}" 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 file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. 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. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ff-3.4.0/README.md000066400000000000000000000077361445624247000134150ustar00rootroot00000000000000# 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 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). 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. ## Usage Define a flag.FlagSet in your func main. ```go import ( "flag" "os" "time" "github.com/peterbourgon/ff/v3" ) 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. ```go err := ff.Parse(fs, os.Args[1:], ff.WithEnvVarPrefix("MY_PROGRAM"), ff.WithConfigFileFlag("config"), ff.WithConfigFileParser(ff.PlainParser), ) ``` 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.) 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`. Finally, if a `-config` file is specified, the example will try to parse it using the PlainParser, which expects files in this format. ``` listen-addr localhost:8080 refresh 30s debug true ``` You could also use the JSONParser, which expects a JSON object. ```json { "listen-addr": "localhost:8080", "refresh": "30s", "debug": true } ``` Or, you could write your own config file parser. ```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 ``` ## Flags and env vars One common use case is to allow configuration from both flags and env vars. ```go package main import ( "flag" "fmt" "os" "github.com/peterbourgon/ff/v3" ) 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) } ``` ``` $ env PORT=9090 myservice port 9090, debug false $ env PORT=9090 DEBUG=1 myservice -port=1234 port 1234, debug true ``` ## Error handling 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. [90]: https://github.com/peterbourgon/ff/issues/90 ff-3.4.0/doc.go000066400000000000000000000006451445624247000132220ustar00rootroot00000000000000// Package ff is a flags-first helper package for configuring programs. // // 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. // // See the README at https://github.com/peterbourgon/ff for more information. package ff ff-3.4.0/env_parser.go000066400000000000000000000025651445624247000146240ustar00rootroot00000000000000package 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 } ff-3.4.0/env_parser_test.go000066400000000000000000000031361445624247000156560ustar00rootroot00000000000000package ff_test import ( "path/filepath" "testing" "time" "github.com/peterbourgon/ff/v3" "github.com/peterbourgon/ff/v3/fftest" ) func TestEnvFileParser(t *testing.T) { t.Parallel() for _, testcase := range []struct { file string opts []ff.Option want fftest.Vars }{ { file: "testdata/empty.env", want: fftest.Vars{}, }, { file: "testdata/basic.env", want: fftest.Vars{S: "bar", I: 99, B: true, D: time.Hour}, }, { file: "testdata/prefix.env", opts: []ff.Option{ff.WithEnvVarPrefix("MYPROG")}, want: fftest.Vars{S: "bingo", I: 123}, }, { file: "testdata/prefix-undef.env", opts: []ff.Option{ff.WithEnvVarPrefix("MYPROG"), ff.WithIgnoreUndefined(true)}, want: fftest.Vars{S: "bango", I: 9}, }, { file: "testdata/quotes.env", want: fftest.Vars{S: "", I: 32, X: []string{"1", "2 2", "3 3 3"}}, }, { file: "testdata/no-value.env", want: fftest.Vars{WantParseErrorString: "invalid line: D="}, }, { file: "testdata/spaces.env", want: fftest.Vars{X: []string{"1", "2", "3", "4", "5", " 6", " 7 ", " 8 "}}, }, { file: "testdata/newlines.env", want: fftest.Vars{S: "one\ntwo\nthree\n\n", X: []string{`A\nB\n\n`}}, }, { file: "testdata/capitalization.env", want: fftest.Vars{S: "hello", I: 12345}, }, } { 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) }) } } ff-3.4.0/ffcli/000077500000000000000000000000001445624247000132045ustar00rootroot00000000000000ff-3.4.0/ffcli/README.md000066400000000000000000000116661445624247000144750ustar00rootroot00000000000000# 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 ff-3.4.0/ffcli/command.go000066400000000000000000000175071445624247000151630ustar00rootroot00000000000000package 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() } ff-3.4.0/ffcli/command_test.go000066400000000000000000000362101445624247000162120ustar00rootroot00000000000000package 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" ff-3.4.0/ffcli/doc.go000066400000000000000000000002731445624247000143020ustar00rootroot00000000000000// 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 ff-3.4.0/ffcli/examples/000077500000000000000000000000001445624247000150225ustar00rootroot00000000000000ff-3.4.0/ffcli/examples/objectctl/000077500000000000000000000000001445624247000167735ustar00rootroot00000000000000ff-3.4.0/ffcli/examples/objectctl/cmd/000077500000000000000000000000001445624247000175365ustar00rootroot00000000000000ff-3.4.0/ffcli/examples/objectctl/cmd/objectctl/000077500000000000000000000000001445624247000215075ustar00rootroot00000000000000ff-3.4.0/ffcli/examples/objectctl/cmd/objectctl/main.go000066400000000000000000000023631445624247000227660ustar00rootroot00000000000000package 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) } } ff-3.4.0/ffcli/examples/objectctl/pkg/000077500000000000000000000000001445624247000175545ustar00rootroot00000000000000ff-3.4.0/ffcli/examples/objectctl/pkg/createcmd/000077500000000000000000000000001445624247000215035ustar00rootroot00000000000000ff-3.4.0/ffcli/examples/objectctl/pkg/createcmd/create.go000066400000000000000000000025541445624247000233030ustar00rootroot00000000000000package 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 } ff-3.4.0/ffcli/examples/objectctl/pkg/deletecmd/000077500000000000000000000000001445624247000215025ustar00rootroot00000000000000ff-3.4.0/ffcli/examples/objectctl/pkg/deletecmd/delete.go000066400000000000000000000023231445624247000232730ustar00rootroot00000000000000package 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 } ff-3.4.0/ffcli/examples/objectctl/pkg/listcmd/000077500000000000000000000000001445624247000212135ustar00rootroot00000000000000ff-3.4.0/ffcli/examples/objectctl/pkg/listcmd/list.go000066400000000000000000000033611445624247000225200ustar00rootroot00000000000000package 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 } ff-3.4.0/ffcli/examples/objectctl/pkg/objectapi/000077500000000000000000000000001445624247000215145ustar00rootroot00000000000000ff-3.4.0/ffcli/examples/objectctl/pkg/objectapi/client.go000066400000000000000000000054611445624247000233270ustar00rootroot00000000000000package objectapi import ( "context" "errors" "time" ) // Object is meant to be a domain object for a theoretical object store. type Object struct { Key string Value string Access time.Time } // Client is meant to model an SDK client for a theoretical object store API. // Because we're only using it for demo purposes, it embeds a mock server with // fixed data. type Client struct { token string server *mockServer } // NewClient is meant to model a constructor for the SDK client. func NewClient(token string) (*Client, error) { return &Client{ token: token, server: newMockServer(), }, nil } // Create is some bit of functionality. func (c *Client) Create(ctx context.Context, key, value string, overwrite bool) error { return c.server.create(c.token, key, value, overwrite) } // Delete is some bit of functionality. func (c *Client) Delete(ctx context.Context, key string, force bool) (existed bool, err error) { return c.server.delete(c.token, key, force) } // List is some bit of functionality. func (c *Client) List(ctx context.Context) ([]Object, error) { return c.server.list(c.token) } // // // type mockServer struct { token string objects map[string]Object } func newMockServer() *mockServer { return &mockServer{ token: "SECRET", objects: defaultObjects, } } func (s *mockServer) create(token, key, value string, overwrite bool) error { if token != s.token { return errors.New("not authorized") } if _, ok := s.objects[key]; ok && !overwrite { return errors.New("object already exists") } s.objects[key] = Object{ Key: key, Value: value, Access: time.Now(), } return nil } func (s *mockServer) delete(token, key string, force bool) (existed bool, err error) { if token != s.token { return false, errors.New("not authorized") } _, ok := s.objects[key] delete(s.objects, key) return ok, nil } func (s *mockServer) list(token string) ([]Object, error) { if token != s.token { return nil, errors.New("not authorized") } result := make([]Object, 0, len(s.objects)) for _, obj := range s.objects { result = append(result, obj) } return result, nil } var defaultObjects = map[string]Object{ "apple": { Key: "apple", Value: "The fruit of any of certain other species of tree of the same genus.", Access: mustParseTime(time.RFC3339, "2019-03-15T15:01:00Z"), }, "beach": { Key: "beach", Value: "The shore of a body of water, especially when sandy or pebbly.", Access: mustParseTime(time.RFC3339, "2019-04-20T12:21:30Z"), }, "carillon": { Key: "carillon", Value: "A stationary set of chromatically tuned bells in a tower.", Access: mustParseTime(time.RFC3339, "2019-07-04T23:59:59Z"), }, } func mustParseTime(layout string, value string) time.Time { t, err := time.Parse(layout, value) if err != nil { panic(err) } return t } ff-3.4.0/ffcli/examples/objectctl/pkg/rootcmd/000077500000000000000000000000001445624247000212235ustar00rootroot00000000000000ff-3.4.0/ffcli/examples/objectctl/pkg/rootcmd/root.go000066400000000000000000000027621445624247000225440ustar00rootroot00000000000000package 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 } ff-3.4.0/ffcli/examples/textctl/000077500000000000000000000000001445624247000165115ustar00rootroot00000000000000ff-3.4.0/ffcli/examples/textctl/textctl.go000066400000000000000000000037451445624247000205400ustar00rootroot00000000000000package 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) } } ff-3.4.0/fftest/000077500000000000000000000000001445624247000134145ustar00rootroot00000000000000ff-3.4.0/fftest/doc.go000066400000000000000000000000751445624247000145120ustar00rootroot00000000000000// Package fftest provides unit test helpers. package fftest ff-3.4.0/fftest/tempfile.go000066400000000000000000000010621445624247000155470ustar00rootroot00000000000000package 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 } ff-3.4.0/fftest/vars.go000066400000000000000000000113171445624247000147210ustar00rootroot00000000000000package 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 // ParseError should be assigned as the result of Parse in tests. ParseError error // If a test case expects an input to generate a parse error, // it can specify that error here. The Compare helper will // look for it using errors.Is. WantParseErrorIs error // If a test case expects an input to generate a parse error, // it can specify part of that error string here. The Compare // helper will look for it using strings.Contains. WantParseErrorString string } // Compare one set of vars with another // and t.Error on any difference. func Compare(t *testing.T, want, have *Vars) { t.Helper() if want.WantParseErrorIs != nil || want.WantParseErrorString != "" { if want.WantParseErrorIs != nil && have.ParseError == nil { t.Errorf("want error (%v), have none", want.WantParseErrorIs) } if want.WantParseErrorString != "" && have.ParseError == nil { t.Errorf("want error (%q), have none", want.WantParseErrorString) } if want.WantParseErrorIs == nil && want.WantParseErrorString == "" && have.ParseError != nil { t.Errorf("want clean parse, have error (%v)", have.ParseError) } if want.WantParseErrorIs != nil && have.ParseError != nil && !errors.Is(have.ParseError, want.WantParseErrorIs) { t.Errorf("want wrapped error (%#+v), have error (%#+v)", want.WantParseErrorIs, have.ParseError) } if want.WantParseErrorString != "" && have.ParseError != nil && !strings.Contains(have.ParseError.Error(), want.WantParseErrorString) { t.Errorf("want error string (%q), have error (%v)", want.WantParseErrorString, have.ParseError) } return } if have.ParseError != nil { t.Errorf("error: %v", have.ParseError) } if want.S != have.S { t.Errorf("var S: want %q, have %q", want.S, have.S) } if want.I != have.I { t.Errorf("var I: want %d, have %d", want.I, have.I) } if want.F != have.F { t.Errorf("var F: want %f, have %f", want.F, have.F) } if want.B != have.B { t.Errorf("var B: want %v, have %v", want.B, have.B) } 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 "..." } return strings.Join(*ss, ", ") } ff-3.4.0/fftoml/000077500000000000000000000000001445624247000134105ustar00rootroot00000000000000ff-3.4.0/fftoml/fftoml.go000066400000000000000000000046421445624247000152340ustar00rootroot00000000000000// Package fftoml provides a TOML config file paser. package fftoml import ( "fmt" "io" "github.com/pelletier/go-toml" "github.com/peterbourgon/ff/v3/internal" ) // 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) } // 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 } // New constructs and configures a ConfigFileParser using the provided options. func New(opts ...Option) (c ConfigFileParser) { c.delimiter = "." for _, opt := range opts { opt(&c) } 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 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 } ff-3.4.0/fftoml/fftoml_test.go000066400000000000000000000044401445624247000162670ustar00rootroot00000000000000package 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" ) func TestParser(t *testing.T) { t.Parallel() for _, testcase := range []struct { name string file string want fftest.Vars }{ { name: "empty input", file: "testdata/empty.toml", want: fftest.Vars{}, }, { name: "basic KV pairs", file: "testdata/basic.toml", want: fftest.Vars{ S: "s", I: 10, F: 3.14e10, B: true, D: 5 * time.Second, X: []string{"1", "a", "๐Ÿ‘"}, }, }, { name: "bad TOML file", file: "testdata/bad.toml", want: fftest.Vars{WantParseErrorString: "keys cannot contain { character"}, }, } { 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) } }) } } ff-3.4.0/fftoml/testdata/000077500000000000000000000000001445624247000152215ustar00rootroot00000000000000ff-3.4.0/fftoml/testdata/bad.toml000066400000000000000000000000011445624247000166330ustar00rootroot00000000000000{ff-3.4.0/fftoml/testdata/basic.toml000066400000000000000000000001041445624247000171720ustar00rootroot00000000000000s = "s" i = 10 f = 3.14e10 b = true d = "5s" x = ["1", "a", "๐Ÿ‘"] ff-3.4.0/fftoml/testdata/empty.toml000066400000000000000000000000011445624247000172430ustar00rootroot00000000000000 ff-3.4.0/fftoml/testdata/table.toml000066400000000000000000000001531445624247000172040ustar00rootroot00000000000000[string] key = "a string" [float] [float.nested] key = 1.23 [strings.nested] key = ["one", "two", "three"] ff-3.4.0/ffyaml/000077500000000000000000000000001445624247000133775ustar00rootroot00000000000000ff-3.4.0/ffyaml/ffyaml.go000066400000000000000000000030421445624247000152030ustar00rootroot00000000000000// Package ffyaml provides a YAML config file parser. package ffyaml import ( "errors" "fmt" "io" "github.com/peterbourgon/ff/v3/internal" "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) } // ParseConfig collects parameters for the YAML config file parser. type ParseConfig struct { // Delimiter is used when concatenating nested node keys into a flag name. // The default delimiter is ".". Delimiter string } // 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 = "." } var m map[string]interface{} if err := yaml.NewDecoder(r).Decode(&m); err != nil && !errors.Is(err, io.EOF) { return ParseError{Inner: 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 } ff-3.4.0/ffyaml/ffyaml_test.go000066400000000000000000000052011445624247000162410ustar00rootroot00000000000000package ffyaml_test import ( "flag" "os" "testing" "time" "github.com/peterbourgon/ff/v3" "github.com/peterbourgon/ff/v3/fftest" "github.com/peterbourgon/ff/v3/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 }{ { name: "empty", file: "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: "invalid prefix", file: "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}, }, { vars: fftest.NonzeroDefaultVars, name: "no value for i", file: "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: "multiline arrays", file: "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: "unquoted strings in arrays", file: "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 not allowed", file: "testdata/this_file_does_not_exist.yaml", miss: false, 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"}}, }, } { 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) }) } } ff-3.4.0/ffyaml/testdata/000077500000000000000000000000001445624247000152105ustar00rootroot00000000000000ff-3.4.0/ffyaml/testdata/basic.yaml000066400000000000000000000000441445624247000171530ustar00rootroot00000000000000s: hello i: 10 b: true d: 5s f: 3.14ff-3.4.0/ffyaml/testdata/basic_array.yaml000066400000000000000000000000461445624247000203530ustar00rootroot00000000000000s: ['a', 'b', 'c'] x: ['a', 'b', 'c'] ff-3.4.0/ffyaml/testdata/empty.yaml000066400000000000000000000000001445624247000172200ustar00rootroot00000000000000ff-3.4.0/ffyaml/testdata/invalid_prefix.yaml000066400000000000000000000000171445624247000210750ustar00rootroot00000000000000 i: 123 s: foo ff-3.4.0/ffyaml/testdata/line_break_array.yaml000066400000000000000000000000571445624247000213670ustar00rootroot00000000000000x: ["first string", "second string", "third"] ff-3.4.0/ffyaml/testdata/multi_line_array.yaml000066400000000000000000000000521445624247000214300ustar00rootroot00000000000000s: - a - b - c x: - d - e - f ff-3.4.0/ffyaml/testdata/nested.yaml000066400000000000000000000001771445624247000173630ustar00rootroot00000000000000foo: s: a string b: true bar: nested: i: 123 f: 1.23 baz: nested: x: - one - two - three ff-3.4.0/ffyaml/testdata/no_value_i.yaml000066400000000000000000000000241445624247000202100ustar00rootroot00000000000000s: woozlewozzle i: ff-3.4.0/ffyaml/testdata/no_value_s.yaml000066400000000000000000000000121445624247000202170ustar00rootroot00000000000000s: i: 123 ff-3.4.0/ffyaml/testdata/unquoted_string_array.yaml000066400000000000000000000000241445624247000225200ustar00rootroot00000000000000x: [one, two, three]ff-3.4.0/go.mod000066400000000000000000000001711445624247000132260ustar00rootroot00000000000000module github.com/peterbourgon/ff/v3 go 1.18 require ( github.com/pelletier/go-toml v1.9.5 gopkg.in/yaml.v2 v2.4.0 ) ff-3.4.0/go.sum000066400000000000000000000010271445624247000132540ustar00rootroot00000000000000github.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= 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= ff-3.4.0/hack/000077500000000000000000000000001445624247000130275ustar00rootroot00000000000000ff-3.4.0/hack/lint-parallel-tests.bash000077500000000000000000000005701445624247000175730ustar00rootroot00000000000000#!/usr/bin/env bash set -o pipefail function for_all_test_files { find . -name '*_test.go' ; } function first_line_of_test { xargs -n1 awk '/^func Test/{getline; print FILENAME ":" NR-1 " " $0}' ; } function not_parallel { grep -v 't.Parallel()' ; } if for_all_test_files | first_line_of_test | not_parallel then echo FAIL: not all tests call t.Parallel exit 1 fi ff-3.4.0/internal/000077500000000000000000000000001445624247000137355ustar00rootroot00000000000000ff-3.4.0/internal/doc.go000066400000000000000000000001371445624247000150320ustar00rootroot00000000000000// Package internal provides private helpers used by various module packages. package internal ff-3.4.0/internal/traverse_map.go000066400000000000000000000030301445624247000167500ustar00rootroot00000000000000package internal import ( "encoding/json" "fmt" "strconv" ) // TraverseMap recursively walks the given map, calling set for each value. If the // value is a slice, set is called for each element of the slice. The keys of // nested maps are joined with the given delimiter. func TraverseMap(m map[string]any, delimiter string, set func(name, value string) error) error { return traverseMap("", m, delimiter, set) } func traverseMap(key string, val any, delimiter string, set func(name, value string) error) error { switch v := val.(type) { case string: return set(key, v) case json.Number: return set(key, v.String()) case uint64: return set(key, strconv.FormatUint(v, 10)) case int: return set(key, strconv.Itoa(v)) case int64: return set(key, strconv.FormatInt(v, 10)) case float64: return set(key, strconv.FormatFloat(v, 'g', -1, 64)) case bool: return set(key, strconv.FormatBool(v)) case nil: return set(key, "") case []any: for _, v := range v { if err := traverseMap(key, v, delimiter, set); err != nil { return err } } case map[string]any: for k, v := range v { if key != "" { k = key + delimiter + k } if err := traverseMap(k, v, delimiter, set); err != nil { return err } } case map[any]any: for k, v := range v { ks := fmt.Sprint(k) if key != "" { ks = key + delimiter + ks } if err := traverseMap(ks, v, delimiter, set); err != nil { return err } } default: return fmt.Errorf("couldn't convert %q (type %T) to string", val, val) } return nil } ff-3.4.0/internal/traverse_map_test.go000066400000000000000000000046271445624247000200240ustar00rootroot00000000000000package internal_test import ( "encoding/json" "strings" "testing" "github.com/peterbourgon/ff/v3/internal" ) func TestTraverseMap(t *testing.T) { t.Parallel() tests := []struct { Name string M map[string]any Delim string Want map[string]struct{} }{ { Name: "single values", M: map[string]any{ "s": "foo", "i": 123, "i64": int64(123), "u": uint64(123), "f": 1.23, "jn": json.Number("123"), "b": true, "nil": nil, }, Delim: ".", Want: map[string]struct{}{ "s=foo": {}, "i=123": {}, "i64=123": {}, "u=123": {}, "f=1.23": {}, "jn=123": {}, "b=true": {}, "nil=": {}, }, }, { Name: "slices", M: map[string]any{ "is": []any{1, 2, 3}, "ss": []any{"a", "b", "c"}, "bs": []any{true, false}, "as": []any{"a", 1, true}, }, Want: map[string]struct{}{ "is=1": {}, "is=2": {}, "is=3": {}, "ss=a": {}, "ss=b": {}, "ss=c": {}, "bs=true": {}, "bs=false": {}, "as=a": {}, "as=1": {}, "as=true": {}, }, }, { Name: "nested maps", M: map[string]any{ "m": map[string]any{ "s": "foo", "m2": map[string]any{ "i": 123, }, }, }, Delim: ".", Want: map[string]struct{}{ "m.s=foo": {}, "m.m2.i=123": {}, }, }, { Name: "nested maps with '-' delimiter", M: map[string]any{ "m": map[string]any{ "s": "foo", "m2": map[string]any{ "i": 123, }, }, }, Delim: "-", Want: map[string]struct{}{ "m-s=foo": {}, "m-m2-i=123": {}, }, }, { Name: "nested map[any]any", M: map[string]any{ "m": map[any]any{ "m2": map[string]any{ "i": 999, }, }, }, Delim: ".", Want: map[string]struct{}{ "m.m2.i=999": {}, }, }, } for _, test := range tests { t.Run(test.Name, func(t *testing.T) { observe := func(name, value string) error { key := name + "=" + value if _, ok := test.Want[key]; !ok { t.Errorf("set(%s, %s): unexpected call to set", name, value) } delete(test.Want, key) return nil } if err := internal.TraverseMap(test.M, test.Delim, observe); err != nil { t.Fatal(err) } for key := range test.Want { name, value, _ := strings.Cut(key, "=") t.Errorf("set(%s, %s): expected but did not occur", name, value) } }) } } ff-3.4.0/json_parser.go000066400000000000000000000030631445624247000147770ustar00rootroot00000000000000package 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. 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 } ff-3.4.0/json_parser_test.go000066400000000000000000000022021445624247000160300ustar00rootroot00000000000000package 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) }) } } ff-3.4.0/parse.go000066400000000000000000000207531445624247000135710ustar00rootroot00000000000000package ff import ( "embed" "errors" "flag" "fmt" "io" iofs "io/fs" "os" "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) } 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) } 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 } } }) if visitErr != nil { return fmt.Errorf("error parsing environment variables: %w", visitErr) } } 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 } if configFile == "" && c.configFileFlagName != "" { if f := fs.Lookup(c.configFileFlagName); f != nil { configFile = f.Value.String() } } if c.configFileOpenFunc == nil { c.configFileOpenFunc = func(s string) (iofs.File, error) { return os.Open(s) } } 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] { 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) } return nil }); err != nil { return err } case errors.Is(err, iofs.ErrNotExist) && c.allowMissingConfigFile: // no problem default: return err } } fs.Visit(func(f *flag.Flag) { provided[f.Name] = true }) return nil } // 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 } // Option controls some aspect of Parse behavior. type Option func(*Context) // 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) } // 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 } } // 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 } } // 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 } } // 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. // // 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 } } // 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 } } // 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 } } // 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 } } var flagNameToEnvVar = strings.NewReplacer( "-", "_", ".", "_", "/", "_", ) func maybePrefix(doPrefix bool, key string, prefix string) string { if doPrefix { key = strings.ToUpper(prefix) + "_" + key } return key } func maybeSplit(value, split string) []string { if split == "" { return []string{value} } return strings.Split(value, split) } // StringConversionError was returned by config file parsers in certain cases. // // DEPRECATED: this error is no longer returned by anything. type StringConversionError struct { Value interface{} } // 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) } ff-3.4.0/parse_test.go000066400000000000000000000220161445624247000146220ustar00rootroot00000000000000package 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" ) //go:embed testdata/*.conf var testdataConfigFS embed.FS func TestParseBasics(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 }{ { name: "empty", args: []string{}, 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: "file only", file: "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: "file args", file: "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: "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 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: "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: "PlainParser solo bool", file: "testdata/solo_bool.conf", want: fftest.Vars{S: "x", B: true}, }, { name: "PlainParser string with spaces", file: "testdata/spaces.conf", want: fftest.Vars{S: "i am the very model of a modern major general"}, }, { 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: "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", env: map[string]string{"TEST_PARSE_S": "foo", "S": "bar"}, opts: []ff.Option{ff.WithEnvVars()}, want: fftest.Vars{S: "bar"}, }, { 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: "WithIgnoreUndefined file true", file: "testdata/undefined.conf", opts: []ff.Option{ff.WithIgnoreUndefined(true)}, want: fftest.Vars{S: "one"}, }, { name: "WithIgnoreUndefined file false", file: "testdata/undefined.conf", opts: []ff.Option{ff.WithIgnoreUndefined(false)}, want: fftest.Vars{WantParseErrorString: "config file flag"}, }, { 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: "WithEnvVars", env: map[string]string{"S": "xxx", "F": "9.87"}, opts: []ff.Option{ff.WithEnvVars()}, want: fftest.Vars{S: "xxx", F: 9.87}, }, { 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: "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}, }, } { 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: "EOL comment with space", data: "s bar # baz", want: "bar", }, { name: "EOL comment no space", data: "s bar #baz", want: "bar", }, { name: "only comment with space", data: "# foo bar\n", want: "", }, { name: "only comment no space", data: "#foo bar\n", want: "", }, } { 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) }) } } func TestParseConfigFile(t *testing.T) { t.Parallel() for _, testcase := range []struct { name string missing bool allowMissing bool parseError error }{ { name: "has config file", }, { name: "config file missing", missing: true, parseError: os.ErrNotExist, }, { name: "config file missing + allow missing", missing: true, allowMissing: true, }, } { 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) }) } } func TestParseConfigFileVia(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) } 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) } } ff-3.4.0/plain_parser.go000066400000000000000000000020121445624247000151220ustar00rootroot00000000000000package 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 } ff-3.4.0/testdata/000077500000000000000000000000001445624247000137325ustar00rootroot00000000000000ff-3.4.0/testdata/1.conf000066400000000000000000000000261445624247000147370ustar00rootroot00000000000000s bar i 99 b true d 1hff-3.4.0/testdata/2.conf000066400000000000000000000000351445624247000147400ustar00rootroot00000000000000 s should be overridden d 3sff-3.4.0/testdata/3.conf000066400000000000000000000000401445624247000147350ustar00rootroot00000000000000s bar i 99 d 34s # comment line ff-3.4.0/testdata/4.conf000066400000000000000000000000461445624247000147440ustar00rootroot00000000000000s from file i 200 # comment d 1m f 2.3ff-3.4.0/testdata/5.conf000066400000000000000000000000551445624247000147450ustar00rootroot00000000000000s s.file.1 s s.file.2 x x.file.1 x x.file.2 ff-3.4.0/testdata/bad.json000066400000000000000000000000011445624247000153420ustar00rootroot00000000000000{ff-3.4.0/testdata/basic.env000066400000000000000000000000271445624247000155240ustar00rootroot00000000000000S=bar I=99 B=true D=1h ff-3.4.0/testdata/basic.json000066400000000000000000000000731445624247000157060ustar00rootroot00000000000000{ "s": "s", "i": 10, "b": true, "d": "5s" }ff-3.4.0/testdata/capitalization.env000066400000000000000000000000201445624247000174470ustar00rootroot00000000000000s=hello i=12345 ff-3.4.0/testdata/empty.env000066400000000000000000000000001445624247000155700ustar00rootroot00000000000000ff-3.4.0/testdata/empty.json000066400000000000000000000000021445624247000157530ustar00rootroot00000000000000{}ff-3.4.0/testdata/newlines.env000066400000000000000000000000431445624247000162650ustar00rootroot00000000000000S="one\ntwo\nthree\n\n" X=A\nB\n\n ff-3.4.0/testdata/no-value.env000066400000000000000000000000271445624247000161710ustar00rootroot00000000000000I=32 D= S=this is fine ff-3.4.0/testdata/prefix-undef.env000066400000000000000000000000651445624247000170410ustar00rootroot00000000000000 MYPROG_I=9 OTHERPREFIX_B=true MYPROG_S=bango D=32m ff-3.4.0/testdata/prefix.env000066400000000000000000000000361445624247000157400ustar00rootroot00000000000000 MYPROG_S=bingo MYPROG_I=123 ff-3.4.0/testdata/quotes.env000066400000000000000000000000401445624247000157560ustar00rootroot00000000000000S="" X=1 X=2 2 X="3 3 3" I="32" ff-3.4.0/testdata/solo_bool.conf000066400000000000000000000000051445624247000165630ustar00rootroot00000000000000b s xff-3.4.0/testdata/spaces.conf000066400000000000000000000000571445624247000160610ustar00rootroot00000000000000s i am the very model of a modern major generalff-3.4.0/testdata/spaces.env000066400000000000000000000000721445624247000157210ustar00rootroot00000000000000X = 1 X= 2 X =3 X= 4 X = 5 X=" 6" X= " 7 " X = " 8 " ff-3.4.0/testdata/undefined.conf000066400000000000000000000000371445624247000165420ustar00rootroot00000000000000undef undefined variable s one ff-3.4.0/testdata/value_arrays.json000066400000000000000000000002021445624247000173140ustar00rootroot00000000000000{ "s": ["a", "bb"], "i":["10","11","12"], "b": [ false, true ], "d": ["10m", "5s"], "x": ["a" , "B", "๐Ÿ‘"] }