pax_global_header00006660000000000000000000000064147465210260014521gustar00rootroot0000000000000052 comment=1e6fd027cc68a365db6390d24758262fc28d5b9a mybase-1.1.0/000077500000000000000000000000001474652102600130005ustar00rootroot00000000000000mybase-1.1.0/.github/000077500000000000000000000000001474652102600143405ustar00rootroot00000000000000mybase-1.1.0/.github/workflows/000077500000000000000000000000001474652102600163755ustar00rootroot00000000000000mybase-1.1.0/.github/workflows/tests.yml000066400000000000000000000021221474652102600202570ustar00rootroot00000000000000name: Tests on: [push, pull_request] env: GOVERSION: "1.22" jobs: test: name: Check code quality and run tests if: "!contains(github.event.head_commit.message, '[ci skip]')" runs-on: ubuntu-latest steps: - name: Set up Go uses: actions/setup-go@v5 with: go-version: ${{env.GOVERSION}} - name: Check out code uses: actions/checkout@v4 - name: Run gofmt run: test -z "$(gofmt -s -d *.go 2>&1)" - name: Run golint run: go install golang.org/x/lint/golint@latest && golint -set_exit_status - name: Run go vet run: go vet - name: Run govulncheck run: go install golang.org/x/vuln/cmd/govulncheck@latest && govulncheck -show verbose ./... - name: Run tests run: go test -v -coverprofile=coverage.out -covermode=count - name: Report coverage env: COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: go install github.com/mattn/goveralls@latest && goveralls -coverprofile=coverage.out -service=github continue-on-error: true mybase-1.1.0/LICENSE000066400000000000000000000261351474652102600140140ustar00rootroot00000000000000 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. mybase-1.1.0/NOTICE000066400000000000000000000010761474652102600137100ustar00rootroot00000000000000Copyright 2025 Skeema LLC and the Skeema authors 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. mybase-1.1.0/README.md000066400000000000000000000103131474652102600142550ustar00rootroot00000000000000# mybase [![build status](https://img.shields.io/github/actions/workflow/status/skeema/mybase/tests.yml?branch=main)](https://github.com/skeema/mybase/actions) [![godoc](https://img.shields.io/badge/godoc-reference-blue.svg)](https://godoc.org/github.com/skeema/mybase) [![latest release](https://img.shields.io/github/release/skeema/mybase.svg)](https://github.com/skeema/mybase/releases) A light-weight Golang framework for building command-line applications, with MySQL-like option handling ## Features * Options may be provided via POSIX-style CLI flags (long or short) and/or ini-style option files * Intentionally does *not* support the golang flag package's single-dash long args (e.g. "-bar" is not equivalent to "--bar") * Multiple option files may be used, with cascading overrides * Ability to determine which source provided any given option (e.g. CLI vs a specific option file vs default value) * Supports command suites / subcommands, including nesting * Extensible to other option file formats/sources via a simple one-method interface * Automatic help/usage flags and subcommands * Few external dependencies ## Motivation Unlike other Go CLI packages, mybase attempts to provide MySQL-like option parsing on the [command-line](http://dev.mysql.com/doc/refman/5.6/en/command-line-options.html) and in [option files](http://dev.mysql.com/doc/refman/5.6/en/option-files.html). In brief, this means: * In option names, underscores are automatically converted to dashes. * Boolean options may have their value omitted to mean true ("--foo" means "--foo=true"). Meanwhile, falsey values include "off", "false", and "0". * Boolean option names may be [modified](http://dev.mysql.com/doc/refman/5.6/en/option-modifiers.html) by a prefix of "skip-" or "disable-" to negate the option ("--skip-foo" is equivalent to "--foo=false") * If an option name is prefixed with "loose-", it isn't an error if the option doesn't exist; it will just be ignored. This allows for backwards-compatible / cross-version option files. * The -h short option is *not* mapped to help (instead help uses -? for its short option). This allows -h to be used for --host if desired. * String-type short options may be configured to require arg (format "-u root" with a space) or have optional arg (format "-psecret" with no space, or "-p" alone if no arg / using default value or boolean value). * Boolean short options may be combined ("-bar" will mean "-b -a -r" if all three are boolean options). Full compatibility with MySQL's option semantics is not guaranteed. Please open a GitHub issue if you encounter specific incompatibilities. MySQL is a trademark of Oracle Corp. ## Status mybase has reached v1 and now has a stable API with backwards-compatibility guarantee. However, documentation, generic examples, and more thorough test coverage still need to be written. For now, see the [Skeema codebase](http://github.com/skeema/skeema) for a canonical example using all features of mybase. ### Future development The following features are **not** yet implemented, but are planned for future releases: * Env vars as an option source * Additional ways to get config option values: floating-point, IP address, bool count of repeated option * API for re-reading all option files that have changed * Command aliases Unit test coverage of mybase is still incomplete; code coverage is currently around 68%. This will be improved in future releases. ## Credits Created and maintained by [@evanelias](https://github.com/evanelias). Additional [contributions](https://github.com/skeema/mybase/graphs/contributors) by: * [@zls0424](https://github.com/zls0424) * [@gusgins](https://github.com/gusgins) ## License **Copyright 2025 Skeema LLC and the Skeema authors** ```text 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. ``` mybase-1.1.0/cli.go000066400000000000000000000157341474652102600141100ustar00rootroot00000000000000package mybase import ( "errors" "fmt" "strings" ) // CommandLine stores state relating to executing an application. type CommandLine struct { InvokedAs string // How the bin was invoked; e.g. os.Args[0] Command *Command // Which command (or subcommand) is being executed OptionValues map[string]string // Option values parsed from the command-line ArgValues []string // Positional arg values (does not include InvokedAs or Command.Name) } // OptionValue returns the value for the requested option if it was specified // on the command-line. This is satisfies the OptionValuer interface, allowing // Config to use the command-line as the highest-priority option provider. func (cli *CommandLine) OptionValue(optionName string) (string, bool) { value, ok := cli.OptionValues[optionName] return value, ok } func (cli *CommandLine) parseLongArg(arg string, args *[]string, longOptionIndex map[string]*Option) error { key, value, hasValue, loose := NormalizeOptionToken(arg) opt, found := longOptionIndex[key] if !found { if loose { return nil } return OptionNotDefinedError{key, "CLI"} } // Use returned hasValue boolean instead of comparing value to "", since "" may // be set explicitly (--some-opt='') or implicitly (--skip-some-bool-opt) and // both of those cases treat hasValue=true if !hasValue { if opt.RequireValue { // Value required: slurp next arg to allow format "--foo bar" in addition to "--foo=bar" if len(*args) == 0 || strings.HasPrefix((*args)[0], "-") { return OptionMissingValueError{opt.Name, "CLI"} } value = (*args)[0] *args = (*args)[1:] } else if opt.Type == OptionTypeBool { // Boolean without value is treated as true value = "1" } } else if value == "" && opt.Type == OptionTypeString { // Convert empty strings into quote-wrapped empty strings, so that callers // may differentiate between bare "--foo" vs "--foo=" if desired, by using // Config.GetRaw(). Meanwhile Config.Get and most other getters strip // surrounding quotes, so this does not break anything. value = "''" } cli.OptionValues[opt.Name] = value return nil } func (cli *CommandLine) parseShortArgs(arg string, args *[]string, shortOptionIndex map[rune]*Option) error { runeList := []rune(arg) var done bool for len(runeList) > 0 && !done { short := runeList[0] runeList = runeList[1:] var value string opt, found := shortOptionIndex[short] if !found { return OptionNotDefinedError{string(short), "CLI"} } // Consume value. Depending on the option, value may be supplied as chars immediately following // this one, or after a space as next arg on CLI. if len(runeList) > 0 && opt.Type != OptionTypeBool { // "-xvalue", only supported for non-bools value = string(runeList) done = true } else if opt.RequireValue { // "-x value", only supported if opt requires a value if len(*args) > 0 && !strings.HasPrefix((*args)[0], "-") { value = (*args)[0] *args = (*args)[1:] } else { return OptionMissingValueError{opt.Name, "CLI"} } } else { // "-xyz", parse x as a valueless option and loop again to parse y (and possibly z) as separate shorthand options if opt.Type == OptionTypeBool { value = "1" // booleans handle lack of value as being true, whereas other types keep it as empty string } } cli.OptionValues[opt.Name] = value } return nil } func (cli *CommandLine) String() string { // Don't reveal the actual command-line value, since it may contain something // sensitive (even though it shouldn't!) return "command line" } // ParseCLI parses the command-line to generate a CommandLine, which // stores which (sub)command was used, named option values, and positional arg // values. The CommandLine will then be wrapped in a Config for returning. // // The supplied cmd should typically be a root Command (one with nil // ParentCommand), but this is not a requirement. // // The supplied args should match format of os.Args; i.e. args[0] // should contain the program name. func ParseCLI(cmd *Command, args []string) (*Config, error) { if len(args) == 0 { return nil, errors.New("ParseCLI: No command-line supplied") } cli := &CommandLine{ Command: cmd, InvokedAs: args[0], OptionValues: make(map[string]string), ArgValues: make([]string, 0), } args = args[1:] // Index options by shorthand longOptionIndex := cmd.Options() shortOptionIndex := make(map[rune]*Option, len(longOptionIndex)) for name, opt := range longOptionIndex { if opt.Shorthand != 0 { if _, already := shortOptionIndex[opt.Shorthand]; already { panic(fmt.Errorf("Command %s defines multiple conflicting options with short-form -%c", cmd.Name, opt.Shorthand)) } shortOptionIndex[opt.Shorthand] = longOptionIndex[name] } } var noMoreOptions bool // Iterate over the cli args and process each in turn for len(args) > 0 { arg := args[0] args = args[1:] switch { // option terminator case arg == "--": noMoreOptions = true // long option case len(arg) > 2 && arg[0:2] == "--" && !noMoreOptions: if err := cli.parseLongArg(arg[2:], &args, longOptionIndex); err != nil { return nil, err } // short option(s) -- multiple bools may be combined into one case len(arg) > 1 && arg[0] == '-' && !noMoreOptions: if err := cli.parseShortArgs(arg[1:], &args, shortOptionIndex); err != nil { return nil, err } // first positional arg is command name if the current command is a command suite case len(cli.Command.SubCommands) > 0: command, validCommand := cli.Command.SubCommands[arg] if !validCommand { return nil, fmt.Errorf("Unknown command \"%s\"", arg) } cli.Command = command // Add the options of the new command into our maps. Any name conflicts // intentionally override parent versions. for name, opt := range command.options { longOptionIndex[name] = command.options[name] if opt.Shorthand != 0 { shortOptionIndex[opt.Shorthand] = command.options[name] } } // supplying help or version as first positional arg to a non-command-suite: // treat as if supplied as option instead case len(cli.ArgValues) == 0 && (arg == "help" || arg == "version"): if err := cli.parseLongArg(arg, &args, longOptionIndex); err != nil { return nil, err } // superfluous positional arg case len(cli.ArgValues) >= len(cli.Command.args): return nil, fmt.Errorf("Extra command-line arg \"%s\" supplied; command %s takes a max of %d args", arg, cli.Command.Name, len(cli.Command.args)) // positional arg default: cli.ArgValues = append(cli.ArgValues, arg) } } if _, helpWanted := cli.OptionValues["help"]; !helpWanted && len(cli.ArgValues) < cli.Command.minArgs() { return nil, fmt.Errorf("Too few positional args supplied on command line; command %s requires at least %d args", cli.Command.Name, cli.Command.minArgs()) } // If no command supplied on a command suite, redirect to help subcommand if len(cli.Command.SubCommands) > 0 { cli.Command = cli.Command.SubCommands["help"] } return NewConfig(cli), nil } mybase-1.1.0/command.go000066400000000000000000000311301474652102600147430ustar00rootroot00000000000000package mybase import ( "fmt" "os" "runtime" "sort" "strings" "github.com/mitchellh/go-wordwrap" terminal "golang.org/x/term" ) // CommandHandler is a function that can be associated with a Command as a // callback which implements the command's logic. type CommandHandler func(*Config) error // Command can represent either a command suite (program with subcommands), a // subcommand of another command suite, a stand-alone program without // subcommands, or an arbitrarily nested command suite. type Command struct { Name string // Command name, as used in CLI Summary string // Short description text. If ParentCommand is nil, represents version instead. Description string // Long (multi-line) description/help text WebDocURL string // Optional URL for online documentation for this specific command SubCommands map[string]*Command // Index of sub-commands ParentCommand *Command // What command this is a sub-command of, or nil if this is the top level Handler CommandHandler // Callback for processing command. Ignored if len(SubCommands) > 0. options map[string]*Option // Command-specific options args []*Option // command-speciifc positional args. Ignored if len(SubCommands) > 0. } // NewCommand creates a standalone command, ie one that does not take sub- // commands of its own. // If this will be a top-level command (no parent), supply a version string // in place of summary. func NewCommand(name, summary, description string, handler CommandHandler) *Command { cmd := &Command{ Name: name, Summary: summary, Description: description, Handler: handler, } cmd.AddOptions("global", StringOption("help", '?', "", "Display usage information for the specified command").ValueOptional(), BoolOption("version", 0, false, "Display program version"), ) return cmd } // NewCommandSuite creates a Command that will have sub-commands. // If this will be a top-level command (no parent), supply a version string // in place of summary. func NewCommandSuite(name, summary, description string) *Command { cmd := &Command{ Name: name, Description: description, Summary: summary, SubCommands: make(map[string]*Command), options: make(map[string]*Option), } // Add help subcommand, and equivalently as an option helpCmd := &Command{ Name: "help", Description: "Display usage information", Summary: `Display usage information`, Handler: helpHandler, } helpCmd.AddArg("command", "", false) // Add version subcommand, and equivalently as an option versionCmd := &Command{ Name: "version", Description: "Display program version", Summary: `Display program version`, Handler: versionHandler, } cmd.AddSubCommand(versionCmd) cmd.AddSubCommand(helpCmd) cmd.AddOptions("global", BoolOption("version", 0, false, "Display program version"), StringOption("help", '?', "", "Display usage information for the specified command").ValueOptional(), ) return cmd } // AddSubCommand adds a subcommand to a command suite. func (cmd *Command) AddSubCommand(subCmd *Command) { if cmd.SubCommands == nil || cmd.Handler != nil { panic(fmt.Errorf("AddSubCommand: Parent command %s was not created as a CommandSuite", cmd.Name)) } subCmd.ParentCommand = cmd cmd.SubCommands[subCmd.Name] = subCmd delete(subCmd.SubCommands, "version") // non-top-level command suites don't need version as command } // AddArg adds a positional arg to a Command. If requireValue is false, this arg // is considered optional and its defaultValue will be used if omitted. func (cmd *Command) AddArg(name, defaultValue string, requireValue bool) { // Validate the arg. Panic if there's a problem, since this is indicative of // programmer error. for _, arg := range cmd.args { // Cannot add two args with same name (TODO: add support for arg slurping into a slice) if arg.Name == name { panic(fmt.Errorf("Cannot add arg %s to command %s: prior arg already has that name", name, cmd.Name)) } // Cannot add a required arg if optional args are already present if requireValue && !arg.RequireValue { panic(fmt.Errorf("Cannot add required arg %s to command %s: prior arg %s is optional", name, cmd.Name, arg.Name)) } } if defaultValue != "" && requireValue { panic(fmt.Errorf("Cannot add required arg %s to command %s: required args cannot have a default value", name, cmd.Name)) } arg := &Option{ Name: name, Type: OptionTypeString, Default: defaultValue, RequireValue: requireValue, } cmd.args = append(cmd.args, arg) } // AddOption adds an Option to a Command. Options represent flags/settings // which can be supplied via the command-line or an options file. func (cmd *Command) AddOption(opt *Option) { if cmd.options == nil { cmd.options = make(map[string]*Option) } cmd.options[opt.Name] = opt } // AddOptions adds any number of Options to a Command, also setting the Group // field of all the options to the supplied string. func (cmd *Command) AddOptions(group string, opts ...*Option) { for _, opt := range opts { opt.Group = group cmd.AddOption(opt) } } // Options returns a map of options for this command, recursively merged with // its parent command. In cases of conflicts, sub-command options override their // parents / grandparents / etc. The returned map is always a copy, so // modifications to the map itself will not affect the original cmd.options. // This method does not include positional args in its return value. func (cmd *Command) Options() (optMap map[string]*Option) { if cmd.ParentCommand == nil { optMap = make(map[string]*Option, len(cmd.options)) } else { optMap = cmd.ParentCommand.Options() } for name := range cmd.options { optMap[name] = cmd.options[name] } return optMap } // OptionValue returns the default value of the option with name optionName. // This is satisfies the OptionValuer interface, and allows a Config to use // a Command as the lowest-priority option provider in order to return an // option's default value. func (cmd *Command) OptionValue(optionName string) (string, bool) { options := cmd.Options() opt, ok := options[optionName] if !ok { // See if the optionName actually refers to a positional arg, and if so, // return the proper default. This is needed for patterns like // Config.GetIntOrDefault which assume they can get defaults by calling // OptionValue on a Command. for _, arg := range cmd.args { if arg.Name == optionName { return arg.Default, true } } return "", false } return opt.Default, true } // Usage returns help instructions for a Command. func (cmd *Command) Usage() { fmt.Printf("\nUsage: %s\n\n", cmd.Invocation()) lineLen := 80 if stdinFd := int(os.Stderr.Fd()); terminal.IsTerminal(stdinFd) { lineLen, _, _ = terminal.GetSize(stdinFd) if lineLen < 80 { lineLen = 80 } else if lineLen > 180 { lineLen = 160 } else if lineLen > 120 { lineLen -= 20 } } // Avoid extra blank lines on Windows when output matches full line length if runtime.GOOS == "windows" { lineLen-- } fmt.Printf("%s\n", wordwrap.WrapString(cmd.Description, uint(lineLen))) if len(cmd.SubCommands) > 0 { fmt.Println("\nCommands:") var maxLen int names := make([]string, 0, len(cmd.SubCommands)) for name := range cmd.SubCommands { names = append(names, name) if len(name) > maxLen { maxLen = len(name) } } sort.Strings(names) for _, name := range names { fmt.Printf(" %*s %s\n", -1*maxLen, name, cmd.SubCommands[name].Summary) } } allOptions := cmd.Options() var maxLen int for _, opt := range allOptions { if nameLen := len(opt.usageName()); nameLen > maxLen { maxLen = nameLen } } for _, grp := range cmd.OptionGroups() { groupName := grp.Name if groupName == "" && cmd.ParentCommand != nil { groupName = cmd.Name } title := fmt.Sprintf("%s Options", strings.Title(groupName)) fmt.Printf("\n%s:\n", strings.TrimSpace(title)) for _, opt := range grp.Options { fmt.Print(opt.Usage(maxLen)) } } if webDocs := cmd.WebDocText(); webDocs != "" { fmt.Printf("\n%s\n\n", wordwrap.WrapString(webDocs, uint(lineLen))) } } // Invocation returns command-line help for invoking a command with its args. func (cmd *Command) Invocation() string { invocation := cmd.Name current := cmd for current.ParentCommand != nil { current = current.ParentCommand invocation = fmt.Sprintf("%s %s", current.Name, invocation) } return fmt.Sprintf("%s []%s", invocation, cmd.argUsage()) } // OptionGroups is a helper to return a pre-sorted list of groups of options. // The groups are ordered such that the unnamed group is first, and globals are // last; any additional groups are in the middle, in alphabetical order. The // options within each group are also sorted in alphabetical order. Hidden // options are omitted, since OptionGroup values are intended only for // generation of usage/help text. func (cmd *Command) OptionGroups() []OptionGroup { nameless := []*Option{} global := []*Option{} others := make(map[string][]*Option) allOptions := cmd.Options() for _, opt := range allOptions { if opt.HiddenOnCLI { continue } if opt.Group == "" { nameless = append(nameless, opt) } else if opt.Group == "global" { global = append(global, opt) } else { if others[opt.Group] == nil { others[opt.Group] = []*Option{opt} } else { others[opt.Group] = append(others[opt.Group], opt) } } } var ret []OptionGroup if len(nameless) > 0 { ret = append(ret, *newOptionGroup("", nameless)) } otherNames := make([]string, 0, len(others)) for groupName := range others { otherNames = append(otherNames, groupName) } sort.Strings(otherNames) for _, groupName := range otherNames { ret = append(ret, *newOptionGroup(groupName, others[groupName])) } if len(global) > 0 { ret = append(ret, *newOptionGroup("global", global)) } return ret } // WebDocText returns a string with descriptive help text linking to the online // documentation for this command suite or subcommand. If this command doesn't // have a doc URL, but an ancestor command suite does, a URL will be constructed // incorporating this command's name into the URL path. If this command and its // ancestors all lack doc URLs, an empty string is returned. func (cmd *Command) WebDocText() string { noun := "command" if len(cmd.SubCommands) > 0 { noun = "command suite" } var subPath string cur := cmd for cur.WebDocURL == "" && cur.ParentCommand != nil { subPath = fmt.Sprintf("/%s%s", cur.Name, subPath) cur = cur.ParentCommand } if cur.WebDocURL == "" { return "" } fullURL := fmt.Sprintf("%s%s", cur.WebDocURL, subPath) return fmt.Sprintf("Complete documentation for this %s is available online: %s", noun, fullURL) } // Root returns the top-level ancestor of this cmd -- that is, it climbs the // parent hierarchy until it finds a command with a nil ParentCommand func (cmd *Command) Root() *Command { result := cmd for result.ParentCommand != nil { result = result.ParentCommand } return result } // HasArg returns true if cmd has a named arg called name. func (cmd *Command) HasArg(name string) bool { for _, arg := range cmd.args { if arg.Name == name { return true } } return false } func (cmd *Command) minArgs() int { // If we hit an optional arg at slice position n, this means there // were n required args prior to the optional arg. for n, arg := range cmd.args { if !arg.RequireValue { return n } } // If all args are required, the min arg count is the number of args. return len(cmd.args) } func (cmd *Command) argUsage() string { if len(cmd.SubCommands) > 0 { return " " } var usage string var optionalArgs int for _, arg := range cmd.args { if arg.RequireValue { usage += fmt.Sprintf(" <%s>", arg.Name) } else { usage += fmt.Sprintf(" [<%s>", arg.Name) optionalArgs++ } } return usage + strings.Repeat("]", optionalArgs) } func helpHandler(cfg *Config) error { forCommand := cfg.CLI.Command if forCommand.Name == "help" && forCommand.ParentCommand != nil { forCommand = forCommand.ParentCommand } var forCommandName string if len(cfg.CLI.ArgValues) > 0 { forCommandName = unquote(cfg.CLI.ArgValues[0]) } if len(forCommand.SubCommands) > 0 && forCommandName != "" { var ok bool if forCommand, ok = forCommand.SubCommands[forCommandName]; !ok { return fmt.Errorf("Unknown command \"%s\"", forCommandName) } } forCommand.Usage() return nil } func versionHandler(cfg *Config) error { cmd := cfg.CLI.Command.Root() version := cmd.Summary if version == "" { version = "not specified" } fmt.Println(cmd.Name, "version", version) return nil } mybase-1.1.0/command_test.go000066400000000000000000000131751474652102600160130ustar00rootroot00000000000000package mybase import ( "fmt" "strings" "testing" ) func TestCommandInvocation(t *testing.T) { single := simpleCommand() expected := "mycommand [] []" if actual := single.Invocation(); actual != expected { t.Errorf("Incorrect result from Invocation() for simple command: expected=%q, actual=%q", expected, actual) } suite := simpleCommandSuite() expected = "mycommand [] " if actual := suite.Invocation(); actual != expected { t.Errorf("Incorrect result from Invocation() for command suite root: expected=%q, actual=%q", expected, actual) } subOne := suite.SubCommands["one"] expected = "mycommand one []" if actual := subOne.Invocation(); actual != expected { t.Errorf("Incorrect result from Invocation() for subcommand one: expected=%q, actual=%q", expected, actual) } subTwo := suite.SubCommands["two"] expected = "mycommand two [] []" if actual := subTwo.Invocation(); actual != expected { t.Errorf("Incorrect result from Invocation() for subcommand two: expected=%q, actual=%q", expected, actual) } } func TestCommandOptionGroups(t *testing.T) { cmd := simpleCommand() cmd.AddOptions("global", StringOption("another", 0, "", "dummy description"), ) cmd.AddOptions("wontshow", StringOption("alsohidden", 0, "", "dummy description").Hidden(), ) cmd.AddOptions("widgets", StringOption("weight", 0, "", "dummy description"), StringOption("size", 0, "", "dummy description"), ) actual := cmd.OptionGroups() expectedGroupNames := []string{"", "widgets", "global"} expectedOptionNames := [][]string{ {"bool1", "bool2", "hasshort", "truthybool", "visible"}, {"size", "weight"}, {"another", "help", "version"}, } if len(actual) != len(expectedGroupNames) { t.Fatalf("Expected %d option groups; instead found %d", len(expectedGroupNames), len(actual)) } for i, grp := range actual { if grp.Name != expectedGroupNames[i] { t.Errorf("Expected group[%d] to have name %q; instead found %q", i, expectedGroupNames[i], grp.Name) } if len(grp.Options) != len(expectedOptionNames[i]) { t.Errorf("Expected group[%d] to have %d options; instead found %d", i, len(expectedOptionNames[i]), len(grp.Options)) } else { for j, opt := range grp.Options { if opt.Name != expectedOptionNames[i][j] { t.Errorf("Expected group[%d].Options[%d] to be %q, instead found %q", i, j, expectedOptionNames[i][j], opt.Name) } } } } } func TestWebDocText(t *testing.T) { single := simpleCommand() actual := single.WebDocText() if strings.Contains(actual, "command suite") { t.Errorf("Unexpected reference to command suite in non-suite web doc text: %q", actual) } if !strings.HasSuffix(actual, single.WebDocURL) { t.Errorf("Expected web doc link to be %q, but full text did not have that suffix: %q", single.WebDocURL, actual) } single.WebDocURL = "" if actual := single.WebDocText(); actual != "" { t.Errorf("Expected single command with no web doc to return empty string; instead found %q", actual) } suite := simpleCommandSuite() actual = suite.WebDocText() if !strings.Contains(actual, "command suite") { t.Errorf("Unexpectedly lacking reference to command suite in web doc text: %q", actual) } if !strings.HasSuffix(actual, suite.WebDocURL) { t.Errorf("Expected web doc link to be %q, but full text did not have that suffix: %q", suite.WebDocURL, actual) } subOne := suite.SubCommands["one"] actual = subOne.WebDocText() if strings.Contains(actual, "command suite") { t.Errorf("Unexpected reference to command suite in non-suite web doc text: %q", actual) } if !strings.HasSuffix(actual, fmt.Sprintf("%s/%s", suite.WebDocURL, subOne.Name)) { t.Errorf("Expected web doc link to be %q, but full text did not have that suffix: %q", subOne.WebDocURL, actual) } } // simpleCommand returns a standalone command for testing purposes func simpleCommand() *Command { cmd := NewCommand("mycommand", "summary", "description", nil) cmd.AddOption(StringOption("visible", 0, "", "dummy description")) cmd.AddOption(StringOption("hidden", 0, "somedefault", "dummy description").Hidden()) cmd.AddOption(StringOption("hasshort", 's', "", "dummy description")) cmd.AddOption(BoolOption("bool1", 'b', false, "dummy description")) cmd.AddOption(BoolOption("bool2", 'B', false, "dummy description")) cmd.AddOption(BoolOption("truthybool", 0, true, "dummy description")) cmd.AddArg("required", "", true) cmd.AddArg("optional", "hello", false) cmd.WebDocURL = "https://www.indexhint.com/test/cmddoc" return cmd } // simpleCommandSuite returns a command suite for testing purposes func simpleCommandSuite() *Command { suite := NewCommandSuite("mycommand", "summary", "description") suite.AddOption(StringOption("visible", 0, "", "dummy description")) suite.AddOption(StringOption("hidden", 0, "somedefault", "dummy description").Hidden()) suite.AddOption(StringOption("hasshort", 's', "", "dummy description")) suite.AddOption(BoolOption("bool1", 'b', false, "dummy description")) suite.AddOption(BoolOption("bool2", 'B', false, "dummy description")) suite.AddOption(BoolOption("truthybool", 0, true, "dummy description")) suite.WebDocURL = "https://www.indexhint.com/test/suitedoc" cmd1 := NewCommand("one", "summary", "description", nil) cmd1.AddOption(StringOption("visible", 0, "newdefault", "dummy description")) // changed default cmd1.AddOption(StringOption("hidden", 0, "somedefault", "dummy description")) // no longer hidden cmd1.AddOption(StringOption("newopt", 'n', "", "dummy description")) suite.AddSubCommand(cmd1) cmd2 := NewCommand("two", "summary", "description", nil) cmd2.AddArg("optional", "hello", false) suite.AddSubCommand(cmd2) return suite } mybase-1.1.0/config.go000066400000000000000000000563551474652102600146120ustar00rootroot00000000000000package mybase import ( "fmt" "os" "path/filepath" "regexp" "strconv" "strings" ) // OptionValuer should be implemented by anything that can parse and return // user-supplied values for options. If the struct has a value corresponding // to the given optionName, it should return the value along with a true value // for ok. If the struct does not have a value for the given optionName, it // should return "", false. type OptionValuer interface { OptionValue(optionName string) (value string, ok bool) } // StringMapValues is the most trivial possible implementation of the // OptionValuer interface: it just maps option name strings to option value // strings. type StringMapValues map[string]string // OptionValue satisfies the OptionValuer interface, allowing StringMapValues // to be an option source for Config methods. func (source StringMapValues) OptionValue(optionName string) (string, bool) { val, ok := source[optionName] return val, ok } func (source StringMapValues) String() string { return "runtime override" } // Config represents a list of sources for option values -- the command-line // plus zero or more option files, or any other source implementing the // OptionValuer interface. type Config struct { CLI *CommandLine // Parsed command-line IsTest bool // true if Config generated from test logic, false otherwise LooseFileOptions bool // enable to ignore unknown options in all Files runtimeOverrides StringMapValues // Highest-priority option value overrides sources []OptionValuer // Sources of option values, excluding CLI, Command, runtimeOverrides; higher indexes override lower indexes unifiedValues map[string]string // Precomputed cache of option name => value unifiedSources map[string]OptionValuer // Precomputed cache of option name => which source supplied it dirty bool // true if source list has changed, meaning next access needs to recompute caches } // NewConfig creates a Config object, given a CommandLine and any arbitrary // number of other OptionValuer option sources. The order of sources matters: // in case of conflicts (multiple sources providing the same option value), // later sources override earlier sources. The CommandLine always overrides // other sources, and should not be supplied redundantly via sources. func NewConfig(cli *CommandLine, sources ...OptionValuer) *Config { return &Config{ CLI: cli, runtimeOverrides: StringMapValues(make(map[string]string)), sources: sources, dirty: true, } } // Clone returns a shallow copy of a Config. The copy will point to the same // CLI value and sources values, but the sources slice itself will be a new // slice, meaning that a caller can add sources without impacting the original // Config's source list. func (cfg *Config) Clone() *Config { sourcesCopy := make([]OptionValuer, len(cfg.sources)) copy(sourcesCopy, cfg.sources) runtimeOverridesCopy := StringMapValues(make(map[string]string, len(cfg.runtimeOverrides))) for rtoName, rtoValue := range cfg.runtimeOverrides { runtimeOverridesCopy[rtoName] = rtoValue } return &Config{ CLI: cfg.CLI, IsTest: cfg.IsTest, LooseFileOptions: cfg.LooseFileOptions, runtimeOverrides: runtimeOverridesCopy, sources: sourcesCopy, dirty: true, } } // AddSource adds a new OptionValuer to cfg. It will override previously-added // sources, with the exception of the CommandLine, which always takes // precedence. func (cfg *Config) AddSource(source OptionValuer) { cfg.sources = append(cfg.sources, source) cfg.dirty = true } // HandleCommand executes the CommandHandler callback associated with the // Command that was parsed on the CommandLine. func (cfg *Config) HandleCommand() error { // Handle --help if supplied as an option instead of as a subcommand // (Note that format "command help []" is already parsed properly into help command) if forCommandName, helpWanted := cfg.CLI.OptionValues["help"]; helpWanted { // command --help displays help for command // vs // command --help displays help for subcommand cfg.CLI.ArgValues = []string{forCommandName} return helpHandler(cfg) } // Handle --version if supplied as an option instead of as a subcommand if cfg.CLI.OptionValues["version"] == "1" { return versionHandler(cfg) } return cfg.CLI.Command.Handler(cfg) } // Sources returns a slice of OptionValuer values used as option sources for // cfg. The result is ordered from lowest-priority to highest-priority. func (cfg *Config) Sources() []OptionValuer { allSources := make([]OptionValuer, 1, len(cfg.sources)+2) // Lowest-priority source is the current command, which returns default values // for any valid option allSources[0] = cfg.CLI.Command // Next come cfg.sources, which are already ordered from lowest priority to highest priority allSources = append(allSources, cfg.sources...) // Finally, at highest priorities are options provided on the command-line, // and then runtime overrides allSources = append(allSources, cfg.CLI, cfg.runtimeOverrides) return allSources } // rebuild iterates over all sources, to construct a single cached key-value // lookup map. This improves performance of subsequent option value lookups. func (cfg *Config) rebuild() { options := cfg.CLI.Command.Options() cfg.unifiedValues = make(map[string]string, len(options)+len(cfg.CLI.Command.args)) cfg.unifiedSources = make(map[string]OptionValuer, len(options)+len(cfg.CLI.Command.args)) // Iterate over positional CLI args. These have highest precedence of all, and // are treated as a special-case (not placed in sources and work differently // than normal options, since they cannot appear in option files) for pos, arg := range cfg.CLI.Command.args { if pos < len(cfg.CLI.ArgValues) { // supplied on CLI cfg.unifiedSources[arg.Name] = cfg.CLI cfg.unifiedValues[arg.Name] = cfg.CLI.ArgValues[pos] delete(options, arg.Name) // shadow any normal option that has same name } else { // not supplied on CLI - using default value // In this case we intentionally DON'T shadow any normal option with same // name, since a supplied option should override an unsupplied arg default. cfg.unifiedSources[arg.Name] = cfg.CLI.Command cfg.unifiedValues[arg.Name] = arg.Default } } // Iterate over all options, and set them in our maps for tracking values and sources. // We go in reverse order to start at highest priority and break early when a value is found. allSources := cfg.Sources() for name := range options { var found bool for n := len(allSources) - 1; n >= 0 && !found; n-- { source := allSources[n] if value, ok := source.OptionValue(name); ok { cfg.unifiedValues[name] = value cfg.unifiedSources[name] = source found = true } } if !found { // If not even the Command provides a value, something is horribly wrong. panic(fmt.Errorf("Assertion failed: Iterated over option %s not provided by command %s", name, cfg.CLI.Command.Name)) } } cfg.dirty = false } func (cfg *Config) rebuildIfDirty() { if cfg.dirty { cfg.rebuild() } } // MarkDirty causes the config to rebuild itself on next option lookup. This // is only needed in situations where a source is known to have changed since // the previous lookup. // // Deprecated: Callers should prefer using SetRuntimeOverride, instead of // directly manipulating a source and then calling MarkDirty. func (cfg *Config) MarkDirty() { cfg.dirty = true } // SetRuntimeOverride sets an override value for the supplied option name. // This value takes precedence over all sources, including option values that // were supplied on the CLI. The supplied name must correspond to a known option // in cfg, otherwise this method panics. func (cfg *Config) SetRuntimeOverride(name, value string) { var optionExists bool if cfg.dirty { optionExists = (cfg.FindOption(name) != nil) } else { _, optionExists = cfg.unifiedSources[name] } if !optionExists { panic(fmt.Errorf("Assertion failed: option %s does not exist", name)) } cfg.runtimeOverrides[name] = value if !cfg.dirty { // Instead of marking the config as dirty and rebuilding it lazily, we can // just set the value right away, since runtime overrides are always the // highest priority source. cfg.unifiedValues[name] = value cfg.unifiedSources[name] = cfg.runtimeOverrides } } // Changed returns true if the specified option name has been set, and its // set value (after unquoting) differs from the option's default value. func (cfg *Config) Changed(name string) bool { if !cfg.Supplied(name) { return false } opt := cfg.FindOption(name) // Note that opt cannot be nil here, so no need to check. If the name didn't // correspond to an existing option, the previous call to Supplied panics. return (unquote(cfg.unifiedValues[name]) != opt.Default) } // Supplied returns true if the specified option name has been set by some // configuration source, or false if not. // // Note that Supplied returns true even if some source has set the option to a // value *equal to its default value*. If you want to check if an option // *differs* from its default value (the more common situation), use Changed. As // an example, imagine that one source sets an option to a non-default value, // but some other higher-priority source explicitly sets it back to its default // value. In this case, Supplied returns true but Changed returns false. func (cfg *Config) Supplied(name string) bool { source := cfg.Source(name) switch source.(type) { case *Command: return false default: return true } } // SuppliedWithValue returns true if the specified option name has been set by // some configuration source AND had a value specified, even if that value was // a blank string or empty value. For example, this returns true even for // --foo="" or --foo= on a command line, or foo="" or foo= in an option file. // Returns false for bare --foo on CLI or bare foo in an option file. // This method is only usable on OptionTypeString options with !RequireValue. // Panics if the supplied option name does not meet those requirements. func (cfg *Config) SuppliedWithValue(name string) bool { opt := cfg.FindOption(name) if opt.Type != OptionTypeString || opt.RequireValue { panic(fmt.Errorf("Assertion failed: SuppliedWithValue called on wrong kind of option %s", name)) } if !cfg.Supplied(name) { return false } // Hacky: this relies on logic in both CommandLine.parseLongArg() and // File.Parse(), which store empty strings as "''" (vs bareword valueless // options as ""). This makes it possible to differentiate between the two // using Config.GetRaw(), since it does not strip quotes like Config.Get(). return cfg.GetRaw(name) != "" } // OnCLI returns true if the specified option name was set on the command-line, // or false otherwise. If the option does not exist, panics to indicate // programmer error. func (cfg *Config) OnCLI(name string) bool { return cfg.Source(name) == cfg.CLI } // Source returns the OptionValuer that provided the specified option. If the // option does not exist, panics to indicate programmer error. func (cfg *Config) Source(name string) OptionValuer { cfg.rebuildIfDirty() source, ok := cfg.unifiedSources[name] if !ok { panic(fmt.Errorf("Assertion failed: option %s does not exist", name)) } return source } // FindOption returns an Option by name. It first searches the current command // hierarchy, but if it fails to find the option there, it then searches all // other command hierarchies as well. This makes it suitable for use in parsing // option files, which may refer to options that aren't relevant to the current // command but exist in some other command. // Returns nil if no option with that name can be found anywhere. func (cfg *Config) FindOption(name string) *Option { myOptions := cfg.CLI.Command.Options() if opt, ok := myOptions[name]; ok { return opt } for _, arg := range cfg.CLI.Command.args { // args are option-like, but stored differently if arg.Name == name { return arg } } var helper func(*Command) *Option helper = func(cmd *Command) *Option { if opt, ok := cmd.options[name]; ok { return opt } for _, arg := range cmd.args { if arg.Name == name { return arg } } for _, sub := range cmd.SubCommands { opt := helper(sub) if opt != nil { return opt } } return nil } return helper(cfg.CLI.Command.Root()) } // GetRaw returns an option's value as-is as a string. If the option is not set, // its default value will be returned. Panics if the option does not exist, // since this is indicative of programmer error, not runtime error. func (cfg *Config) GetRaw(name string) string { cfg.rebuildIfDirty() value, ok := cfg.unifiedValues[name] if !ok { panic(fmt.Errorf("Assertion failed: called Get on unknown option %s", name)) } return value } // Get returns an option's value as a string. If the entire value is wrapped // in quotes (single, double, or backticks) they will be stripped, and // escaped quotes or backslashes within the string will be unescaped. If the // option is not set, its default value will be returned. Panics if the option // does not exist, since this is indicative of programmer error, not runtime // error. func (cfg *Config) Get(name string) string { value := cfg.GetRaw(name) return unquote(value) } // GetAllowEnvVar works like Get, but with additional support for ENV variables: // If the option value begins with $, it will be replaced with the value of the // corresponding environment variable, or an empty string if that variable is // not set. // Environment value lookups only occur if the option value came from a source // other than the CLI, since it is assumed that shells already handle the CLI // use-case appropriately. The value must also either not be quote-wrapped, or // be wrapped in double-quotes. (This way, literal values that just happen to // begin with a dollar sign may be expressed by wrapping a string in single- // quotes.) // Note that this method does NOT perform full variable interpolation: env // vars may not be present mid-string, nor can the form ${varname} be used. func (cfg *Config) GetAllowEnvVar(name string) string { unquoted, quote := trimQuotes(cfg.GetRaw(name)) if len(unquoted) < 2 || unquoted[0] != '$' || quote == '\'' || quote == '`' || cfg.OnCLI(name) { return unquoted } return os.Getenv(unquoted[1:]) } // GetSlice returns an option's value as a slice of strings, splitting on // the provided delimiter. Delimiters contained inside quoted values have no // effect, nor do backslash-escaped delimiters. Quote-wrapped tokens will have // their surrounding quotes stripped in the returned value. Leading and trailing // whitespace in any token will be stripped. Empty values will be removed. // // unwrapFullValue determines how an entirely-quoted-wrapped option value is // treated: if true, a fully quote-wrapped option value will be unquoted before // being parsed for delimiters. If false, a fully-quote-wrapped option value // will be treated as a single token, resulting in a one-element slice. func (cfg *Config) GetSlice(name string, delimiter rune, unwrapFullValue bool) []string { var value string if unwrapFullValue { value = cfg.Get(name) } else { value = cfg.GetRaw(name) } return splitValueIntoSlice(value, delimiter) } // GetSliceAllowEnvVar works like a combination of GetAllowEnvVar and GetSlice: // if the configured value is of form $FOO, and the $FOO environment variable // stores a comma-separated list of values, the list will be split using the // supplied delimiter. // Options can either be set to literal lists (as per GetSlice) or to a single // env variable name (as per GetAllowEnvVar), but not a combination. In other // words, if an option value is set to "a,$FOO,b" then this will not expand // $FOO. // unwrapFullValue only applies to values which aren't set via env vars. func (cfg *Config) GetSliceAllowEnvVar(name string, delimiter rune, unwrapFullValue bool) []string { raw := cfg.GetRaw(name) unquoted, quote := trimQuotes(raw) var value string if len(unquoted) >= 2 && unquoted[0] == '$' && quote != '\'' && quote != '`' && !cfg.OnCLI(name) { value = os.Getenv(unquoted[1:]) } else if unwrapFullValue { value = unquoted } else { value = raw } return splitValueIntoSlice(value, delimiter) } func splitValueIntoSlice(value string, delimiter rune) []string { tokens := []string{} var startToken int var inQuote rune var escapeNext bool for n, c := range value + string(delimiter) { if escapeNext && n < len(value) { escapeNext = false continue } switch c { case '\\': escapeNext = true case delimiter: if inQuote == 0 || n == len(value) { token := strings.TrimSpace(unquote(value[startToken:n])) if token != "" { tokens = append(tokens, token) } startToken = n + 1 } case '\'', '"', '`': if inQuote > 0 { inQuote = 0 } else { inQuote = c } } } return tokens } // GetBool returns an option's value as a bool. If the option is not set, its // default value will be returned. Panics if the flag does not exist. func (cfg *Config) GetBool(name string) bool { return BoolValue(cfg.Get(name)) } // GetInt returns an option's value as an int. If an error occurs in parsing // the value as an int, it is returned as the second return value. Panics if // the option does not exist. func (cfg *Config) GetInt(name string) (int, error) { return strconv.Atoi(cfg.Get(name)) } // GetIntOrDefault is like GetInt, but returns the option's default value if // parsing the supplied value as an int fails. Panics if the option does not // exist. func (cfg *Config) GetIntOrDefault(name string) int { value, err := cfg.GetInt(name) if err != nil { defaultValue, _ := cfg.CLI.Command.OptionValue(name) value, err = strconv.Atoi(defaultValue) if err != nil { panic(fmt.Errorf("Assertion failed: default value for option %s is %s, which fails int parsing", name, defaultValue)) } } return value } // GetEnum returns an option's value as a string if it matches one of the // supplied allowed values, or its default value (which need not be supplied). // Otherwise an error is returned. Matching is case-insensitive, but the // returned value will always be of the same case as it was supplied in // allowedValues. Panics if the option does not exist. func (cfg *Config) GetEnum(name string, allowedValues ...string) (string, error) { value := strings.ToLower(cfg.Get(name)) defaultValue, _ := cfg.CLI.Command.OptionValue(name) var seenDefaultInAllowed bool for _, allowedVal := range allowedValues { if value == strings.ToLower(allowedVal) { return allowedVal, nil } if strings.ToLower(allowedVal) == strings.ToLower(defaultValue) { seenDefaultInAllowed = true } } if !seenDefaultInAllowed { if value == strings.ToLower(defaultValue) { return defaultValue, nil } allowedValues = append(allowedValues, defaultValue) } for n := range allowedValues { allowedValues[n] = fmt.Sprintf(`"%s"`, allowedValues[n]) } allAllowed := strings.Join(allowedValues, ", ") return "", fmt.Errorf("Option %s can only be set to one of these values: %s", name, allAllowed) } // GetBytes returns an option's value as a uint64 representing a number of bytes. // If the value was supplied with a suffix of K, M, or G (upper or lower case) // the returned value will automatically be multiplied by 1024, 1024^2, or // 1024^3 respectively. Suffixes may also be expressed with a trailing 'B', // e.g. 'KB' and 'K' are equivalent. // A blank string will be returned as 0, with no error. Aside from that case, // an error will be returned if the value cannot be parsed as a byte size. // Panics if the option does not exist. func (cfg *Config) GetBytes(name string) (uint64, error) { var multiplier uint64 = 1 value := strings.ToLower(cfg.Get(name)) if value == "" { return 0, nil } if value[len(value)-1] == 'b' { value = value[0 : len(value)-1] } if strings.LastIndexAny(value, "kmg") == len(value)-1 { multipliers := map[byte]uint64{ 'k': 1024, 'm': 1024 * 1024, 'g': 1024 * 1024 * 1024, } suffix := value[len(value)-1] value = value[0 : len(value)-1] multiplier = multipliers[suffix] } numVal, err := strconv.ParseUint(value, 10, 64) return numVal * multiplier, err } // GetRegexp returns an option's value as a compiled *regexp.Regexp. If the // option value isn't set (empty string), returns nil,nil. If the option value // is set but cannot be compiled as a valid regular expression, returns nil and // an error value. Panics if the named option does not exist. func (cfg *Config) GetRegexp(name string) (*regexp.Regexp, error) { value := cfg.Get(name) if value == "" { return nil, nil } re, err := regexp.Compile(value) if err != nil { return nil, fmt.Errorf("Invalid regexp for option %s: %s", name, value) } return re, nil } // GetAbsPath returns an option's value as an absolute path to a file. If the // option value is already set to an absolute path, it is returned as-is. If // the option value is set to a relative path, the result depends on where the // option was set. In an option file, a relative path will be interpreted based // on the directory containing that option file. In all other cases (command- // line, option default value, runtime override), a relative path will be // interpreted based on the working directory at the time of GetAbsPath being // called. func (cfg *Config) GetAbsPath(name string) (string, error) { value := cfg.Get(name) if value == "" || filepath.IsAbs(value) { return value, nil } if f, ok := cfg.Source(name).(*File); ok { return filepath.Join(f.Dir, value), nil } workingDir, err := os.Getwd() if err != nil { return "", err } return filepath.Join(workingDir, value), nil } // unquote takes a string, trims whitespace on both ends, and then examines // whether the entire string is wrapped in quotes. If it isn't, the string // is returned as-is after the whitespace is trimmed. Otherwise, the string // will have its wrapped quotes removed, and escaped values within the string // will be un-escaped. func unquote(input string) string { unquoted, _ := trimQuotes(input) return unquoted } // trimQuotes behaves like unquote, but also returns the quote rune that was // removed, or a zero-valued rune if no unquoting occurred. func trimQuotes(input string) (string, rune) { input = strings.TrimSpace(input) // If the string isn't quote-wrapped, return as-is. // Since the only supported quote characters are single-byte, no need to be // cautious about multi-byte chars in these conditionals. if len(input) < 2 { return input, 0 } quote := rune(input[0]) if (quote != '`' && quote != '"' && quote != '\'') || quote != rune(input[len(input)-1]) { return input, 0 } // Do a pass through the string. Store each rune in a buffer, unescaping // escaped quotes in the process. If we hit a terminating quote midway thru // the string, return the original value. (We don't unquote or unescape // anything unless the *entire* value is quoted.) var escapeNext bool var buf strings.Builder buf.Grow(len(input) - 2) for _, r := range input[1 : len(input)-1] { if r == quote && !escapeNext { // we hit an unescaped terminating quote midway in the string, meaning the // entire input is not quote-wrapped return input, 0 } if r == '\\' && !escapeNext { escapeNext = true continue } escapeNext = false buf.WriteRune(r) } return buf.String(), quote } mybase-1.1.0/config_test.go000066400000000000000000000444561474652102600156500ustar00rootroot00000000000000package mybase import ( "fmt" "os" "path/filepath" "reflect" "strings" "testing" ) func TestOptionStatus(t *testing.T) { assertOptionStatus := func(cfg *Config, name string, expectChanged, expectSupplied, expectOnCLI bool) { t.Helper() if cfg.Changed(name) != expectChanged { t.Errorf("Expected cfg.Changed(%s)==%t, but instead returned %t", name, expectChanged, !expectChanged) } if cfg.Supplied(name) != expectSupplied { t.Errorf("Expected cfg.Supplied(%s)==%t, but instead returned %t", name, expectSupplied, !expectSupplied) } if cfg.OnCLI(name) != expectOnCLI { t.Errorf("Expected cfg.OnCLI(%s)==%t, but instead returned %t", name, expectOnCLI, !expectOnCLI) } } fakeFileOptions := SimpleSource(map[string]string{ "hidden": "set off cli", "bool1": "1", }) cmd := simpleCommand() cfg := ParseFakeCLI(t, cmd, "mycommand -s 'hello world' --skip-truthybool --hidden=\"somedefault\" -B arg1", fakeFileOptions) assertOptionStatus(cfg, "visible", false, false, false) assertOptionStatus(cfg, "hidden", false, true, true) assertOptionStatus(cfg, "hasshort", true, true, true) assertOptionStatus(cfg, "bool1", true, true, false) assertOptionStatus(cfg, "bool2", true, true, true) assertOptionStatus(cfg, "truthybool", true, true, true) assertOptionStatus(cfg, "required", true, true, true) assertOptionStatus(cfg, "optional", false, false, false) // Among other things, confirm behavior of string option set to empty string cfg = ParseFakeCLI(t, cmd, "mycommand --skip-bool1 --hidden=\"\" --bool2 arg1", fakeFileOptions) assertOptionStatus(cfg, "bool1", false, true, true) assertOptionStatus(cfg, "hidden", true, true, true) assertOptionStatus(cfg, "bool2", true, true, true) if cfg.GetRaw("hidden") != "''" || cfg.Get("hidden") != "" { t.Errorf("Unexpected behavior of stringy options with empty value: GetRaw=%q, Get=%q", cfg.GetRaw("hidden"), cfg.Get("hidden")) } } func TestSuppliedWithValue(t *testing.T) { assertSuppliedWithValue := func(cfg *Config, name string, expected bool) { t.Helper() if cfg.SuppliedWithValue(name) != expected { t.Errorf("Unexpected return from SuppliedWithValue(%q): expected %t, found %t", name, expected, !expected) } } assertPanic := func(cfg *Config, name string) { t.Helper() defer func() { if recover() == nil { t.Errorf("Expected SuppliedWithValue(%q) to panic, but it did not", name) } }() cfg.SuppliedWithValue(name) } cmd := simpleCommand() cmd.AddOption(StringOption("optional1", 'y', "", "dummy description").ValueOptional()) cmd.AddOption(StringOption("optional2", 'z', "default", "dummy description").ValueOptional()) cfg := ParseFakeCLI(t, cmd, "mycommand -s 'hello world' --skip-truthybool arg1") assertPanic(cfg, "doesntexist") // panics if option does not exist assertPanic(cfg, "truthybool") // panics if option isn't string typed assertPanic(cfg, "hasshort") // panics if option value isn't optional assertSuppliedWithValue(cfg, "optional1", false) assertSuppliedWithValue(cfg, "optional2", false) cfg = ParseFakeCLI(t, cmd, "mycommand -y -z arg1") assertSuppliedWithValue(cfg, "optional1", false) assertSuppliedWithValue(cfg, "optional2", false) cfg = ParseFakeCLI(t, cmd, "mycommand -yhello --optional2 arg1") assertSuppliedWithValue(cfg, "optional1", true) assertSuppliedWithValue(cfg, "optional2", false) cfg = ParseFakeCLI(t, cmd, "mycommand --optional2= --optional1='' arg1") assertSuppliedWithValue(cfg, "optional1", true) assertSuppliedWithValue(cfg, "optional2", true) } func TestRuntimeOverride(t *testing.T) { assertOptionValue := func(c *Config, name, expected string) { t.Helper() if actual := c.Get(name); actual != expected { t.Errorf("Expected config.Get(%q) = %q, instead found %q", name, expected, actual) } } assertOnCLI := func(c *Config, name string, expected bool) { t.Helper() if actual := c.OnCLI(name); actual != expected { t.Errorf("Expected config.OnCLI(%q) = %t, instead found %t", name, expected, actual) } } assertSetRuntimeOverridePanic := func(c *Config, name string) { t.Helper() defer func() { t.Helper() if iface := recover(); iface == nil { t.Errorf("Expected SetRuntimeOverride(%q) to panic, but it did not", name) } }() c.SetRuntimeOverride(name, "foo") } cmd := simpleCommand() cmd.AddOption(StringOption("optional1", 'y', "", "dummy description").ValueOptional()) cmd.AddOption(StringOption("optional2", 'z', "default", "dummy description").ValueOptional()) cfg := ParseFakeCLI(t, cmd, "mycommand -s 'hello world' --skip-truthybool arg1") // Confirm results prior to overrides assertOptionValue(cfg, "optional1", "") assertOptionValue(cfg, "optional2", "default") assertOptionValue(cfg, "hasshort", "hello world") assertOnCLI(cfg, "hasshort", true) assertOnCLI(cfg, "optional2", false) // Confirm behavior of overrides cfg.SetRuntimeOverride("hasshort", "overridden1") cfg.SetRuntimeOverride("optional2", "overridden2") assertSetRuntimeOverridePanic(cfg, "doesnt-exist") assertOptionValue(cfg, "hasshort", "overridden1") assertOptionValue(cfg, "optional2", "overridden2") assertOnCLI(cfg, "hasshort", false) assertOnCLI(cfg, "optional2", false) // Confirm behaviors of clone, including use of a deep copy of the overrides // map, rather than a shared reference clone := cfg.Clone() assertOptionValue(clone, "optional1", "") assertOptionValue(clone, "hasshort", "overridden1") assertOptionValue(clone, "optional2", "overridden2") assertOnCLI(clone, "hasshort", false) assertOnCLI(clone, "optional2", false) assertOnCLI(clone, "truthybool", true) cfg.SetRuntimeOverride("optional2", "newval") assertOptionValue(cfg, "optional2", "newval") assertOptionValue(clone, "optional2", "overridden2") clone.SetRuntimeOverride("hasshort", "alsonew") assertOptionValue(cfg, "hasshort", "overridden1") assertOptionValue(clone, "hasshort", "alsonew") // Confirm behavior on a dirty config clone = cfg.Clone() assertSetRuntimeOverridePanic(clone, "doesnt-exist2") clone.SetRuntimeOverride("hasshort", "alsonew") assertOptionValue(clone, "hasshort", "alsonew") } func TestGetRaw(t *testing.T) { optionValues := map[string]string{ "basic": "foo", "nothing": "", "single": "'quoted'", "double": `"quoted"`, "backtick": "`quoted`", "middle": "something 'something' something", "beginning": `"something" something`, "end": "something `something`", } cfg := simpleConfig(optionValues) for name, expected := range optionValues { if found := cfg.GetRaw(name); found != expected { t.Errorf("Expected GetRaw(%s) to be %s, instead found %s", name, expected, found) } } } func TestGet(t *testing.T) { assertBasicGet := func(name, value string) { optionValues := map[string]string{ name: value, } cfg := simpleConfig(optionValues) if actual := cfg.Get(name); actual != value { t.Errorf("Expected Get(%s) to return %s, instead found %s", name, value, actual) } } assertQuotedGet := func(name, value, expected string) { optionValues := map[string]string{ name: value, } cfg := simpleConfig(optionValues) if actual := cfg.Get(name); actual != expected { t.Errorf("Expected Get(%s) to return %s, instead found %s", name, expected, actual) } } basicValues := map[string]string{ "basic": "foo", "nothing": "", "uni-start": "☃snowperson", "uni-end": "snowperson☃", "uni-both": "☃snowperson☃", "middle": "something 'something' something", "beginning": `"something" something`, "end": "something `something`", "no-escape1": `something\'s still backslashed`, "no-escape2": `'even this\'s still backslashed', they said`, "not-fully-quoted": `"hello world", I say, "more quoted text but not fully quote wrapped"`, } for name, value := range basicValues { assertBasicGet(name, value) } quotedValues := [][3]string{ {"single", "'quoted'", "quoted"}, {"double", `"quoted"`, "quoted"}, {"empty", "''", ""}, {"backtick", "`quoted`", "quoted"}, {"uni-middle", `"yay ☃ snowpeople"`, `yay ☃ snowpeople`}, {"esc-quote", `'something\'s escaped'`, `something's escaped`}, {"esc-esc", `"c:\\tacotown"`, `c:\tacotown`}, {"esc-rando", `'why\ whatevs'`, `why whatevs`}, {"esc-uni", `'escaped snowpeople \☃ oh noes'`, `escaped snowpeople ☃ oh noes`}, } for _, tuple := range quotedValues { assertQuotedGet(tuple[0], tuple[1], tuple[2]) } } func TestGetAllowEnvVar(t *testing.T) { t.Setenv("SOME_VAR", "some value") cfg := simpleConfig(map[string]string{ "int": "1", "blank": "", "working-env": "$SOME_VAR", "non-env": "SOME_VAR", "dollar-literal": "$", "unset-env-blank": "$OTHER_VAR", "quoted-working-env": `"$SOME_VAR"`, "singlequote-no-env": "'$SOME_VAR'", "backtick-no-env": "`$SOME_VAR`", "quoted-dollar-literal": `"$"`, "spaces-working-env": " $SOME_VAR\n", "spaces-quoted-working-env": ` "$SOME_VAR" `, }) testCases := map[string]string{ "int": "1", "blank": "", "working-env": "some value", "non-env": "SOME_VAR", "dollar-literal": "$", "unset-env-blank": "", "quoted-working-env": "some value", "singlequote-no-env": "$SOME_VAR", "backtick-no-env": "$SOME_VAR", "quoted-dollar-literal": "$", "spaces-working-env": "some value", "spaces-quoted-working-env": "some value", } for input, expected := range testCases { if actual := cfg.GetAllowEnvVar(input); actual != expected { t.Errorf("Expected cfg.Get(%q) to return %q, instead found %q", input, expected, actual) } } } func TestGetSlice(t *testing.T) { assertGetSlice := func(optionValue string, delimiter rune, unwrapFull bool, expected ...string) { t.Helper() if expected == nil { expected = make([]string, 0) } cfg := simpleConfig(map[string]string{"option-name": optionValue}) if actual := cfg.GetSlice("option-name", delimiter, unwrapFull); !reflect.DeepEqual(actual, expected) { t.Errorf("Expected GetSlice(\"...\", '%c', %t) on %#v to return %#v, instead found %#v", delimiter, unwrapFull, optionValue, expected, actual) } } assertGetSlice("hello", ',', false, "hello") assertGetSlice(`hello\`, ',', false, `hello\`) assertGetSlice("hello, world", ',', false, "hello", "world") assertGetSlice(`outside,"inside, ok?", also outside`, ',', false, "outside", "inside, ok?", "also outside") assertGetSlice(`escaped\,delimiter doesn\'t split, ok?`, ',', false, `escaped\,delimiter doesn\'t split`, "ok?") assertGetSlice(`quoted "mid, value" doesn\'t split, either, duh`, ',', false, `quoted "mid, value" doesn\'t split`, "either", "duh") assertGetSlice(`'escaping\'s ok to prevent early quote end', yay," ok "`, ',', false, "escaping's ok to prevent early quote end", "yay", "ok") assertGetSlice(" space delimiter", ' ', false, "space", "delimiter") assertGetSlice(`'fully wrapped in single quotes, commas still split tho, "nested\'s ok"'`, ',', false, "fully wrapped in single quotes, commas still split tho, \"nested's ok\"") assertGetSlice(`'fully wrapped in single quotes, commas still split tho, "nested\'s ok"'`, ',', true, "fully wrapped in single quotes", "commas still split tho", "nested's ok") assertGetSlice(`"'quotes',get \"tricky\", right, 'especially \\\' nested'"`, ',', true, "quotes", `get "tricky"`, "right", "especially ' nested") assertGetSlice("", ',', false) assertGetSlice(" ", ',', false) assertGetSlice(" ", ' ', false) assertGetSlice("``", ',', true) assertGetSlice(" ` ` ", ',', true) assertGetSlice(" ` ` ", ' ', true) } func TestGetSliceAllowEnvVar(t *testing.T) { assertGetSlice := func(viaEnv bool, optionValue string, unwrapFull bool, expected ...string) { t.Helper() if expected == nil { expected = make([]string, 0) } var configVal string if viaEnv { configVal = "$FOO" t.Setenv("FOO", optionValue) } else { configVal = optionValue } cfg := simpleConfig(map[string]string{"option-name": configVal}) if actual := cfg.GetSliceAllowEnvVar("option-name", ',', unwrapFull); !reflect.DeepEqual(actual, expected) { t.Errorf("Expected GetSliceAllowEnv(\"...\", ',', %t) on %#v to return %#v, instead found %#v", unwrapFull, optionValue, expected, actual) } } assertGetSlice(true, "hello", false, "hello") assertGetSlice(true, `hello\`, false, `hello\`) assertGetSlice(false, "hello", false, "hello") assertGetSlice(false, `hello\`, false, `hello\`) assertGetSlice(true, "hello, world", false, "hello", "world") assertGetSlice(false, "hello, world", false, "hello", "world") assertGetSlice(true, "'hello, world'", true, "hello, world") assertGetSlice(false, "'hello, world'", false, "hello, world") assertGetSlice(false, "'hello, world'", true, "hello", "world") } func TestGetEnum(t *testing.T) { optionValues := map[string]string{ "foo": "bar", "caps": "SHOUTING", "blank": "", } cfg := simpleConfig(optionValues) value, err := cfg.GetEnum("foo", "baw", "bar", "bat") if value != "bar" || err != nil { t.Errorf("Expected bar,nil; found %s,%s", value, err) } value, err = cfg.GetEnum("foo", "BAW", "BaR", "baT") if value != "BaR" || err != nil { t.Errorf("Expected BaR,nil; found %s,%s", value, err) } value, err = cfg.GetEnum("foo", "nope", "dope") if value != "" || err == nil { t.Errorf("Expected error, found %s,%s", value, err) } value, err = cfg.GetEnum("caps", "yelling", "shouting") if value != "shouting" || err != nil { t.Errorf("Expected shouting,nil; found %s,%s", value, err) } value, err = cfg.GetEnum("blank", "nonblank1", "nonblank2") if value != "" || err != nil { t.Errorf("Expected empty string to be allowed since it is the default value, but instead found %s,%s", value, err) } } func TestGetBytes(t *testing.T) { optionValues := map[string]string{ "simple-ok": "1234", "negative-fail": "-3", "float-fail": "4.5", "kilo1-ok": "123k", "kilo2-ok": "234K", "megs1-ok": "12M", "megs2-ok": "440mB", "gigs-ok": "4GB", "tera-fail": "55t", "blank-ok": "", } cfg := simpleConfig(optionValues) assertBytes := func(name string, expect uint64) { value, err := cfg.GetBytes(name) if err == nil && strings.HasSuffix(name, "_bad") { t.Errorf("Expected error for GetBytes(%s) but didn't find one", name) } else if err != nil && strings.HasSuffix(name, "-ok") { t.Errorf("Unexpected error for GetBytes(%s): %s", name, err) } if value != expect { t.Errorf("Expected GetBytes(%s) to return %d, instead found %d", name, expect, value) } } expected := map[string]uint64{ "simple-ok": 1234, "negative-fail": 0, "float-fail": 0, "kilo1-ok": 123 * 1024, "kilo2-ok": 234 * 1024, "megs1-ok": 12 * 1024 * 1024, "megs2-ok": 440 * 1024 * 1024, "gigs-ok": 4 * 1024 * 1024 * 1024, "tera-fail": 0, "blank-ok": 0, } for name, expect := range expected { assertBytes(name, expect) } } func TestGetRegexp(t *testing.T) { optionValues := map[string]string{ "valid": "^test", "invalid": "+++", "blank": "", } cfg := simpleConfig(optionValues) re, err := cfg.GetRegexp("valid") if err != nil { t.Errorf("Unexpected error for GetRegexp(\"valid\"): %s", err) } if re == nil || !re.MatchString("testing") { t.Error("Regexp returned by GetRegexp(\"valid\") not working as expected") } re, err = cfg.GetRegexp("invalid") if re != nil || err == nil { t.Errorf("Expected invalid regexp to return nil and err, instead returned %v, %v", re, err) } re, err = cfg.GetRegexp("blank") if re != nil || err != nil { t.Errorf("Expected blank regexp to return nil, nil; instead returned %v, %v", re, err) } } func TestGetAbsPath(t *testing.T) { wd, err := os.Getwd() if err != nil { t.Fatalf("Unexpected error getting working directory: %v", err) } defaultAbs := fmt.Sprintf("%s%cfoobar", filepath.VolumeName(wd), os.PathSeparator) cmd := NewCommand("mycommand", "summary", "description", nil) cmd.AddOption(StringOption("file1", 'x', "", "dummy description")) cmd.AddOption(StringOption("file2", 'y', "default", "dummy description")) cmd.AddOption(StringOption("file3", 'z', defaultAbs, "dummy description")) cfg := ParseFakeCLI(t, cmd, "mycommand") // Test cases for default values cases := map[string]string{ "file1": "", // Option with blank default --> return empty string "file2": filepath.Join(wd, "default"), // Option with relative default --> base off of wd "file3": defaultAbs, // Option with absolute default --> return as-is } for optionName, expected := range cases { if actual, err := cfg.GetAbsPath(optionName); actual != expected { t.Errorf("Expected GetAbsPath(%q) to return %q, instead got %q with err=%v", optionName, expected, actual, err) } } // Test cases for command-line values cfg = ParseFakeCLI(t, cmd, "mycommand --file1=foo/bar --file2="+strings.ReplaceAll(defaultAbs, `\`, `\\`)) cases = map[string]string{ "file1": filepath.Join(wd, "foo/bar"), "file2": defaultAbs, } for optionName, expected := range cases { if actual, err := cfg.GetAbsPath(optionName); actual != expected { t.Errorf("Expected GetAbsPath(%q) to return %q, instead got %q with err=%v", optionName, expected, actual, err) } } // Test cases for relative to option file cfg = ParseFakeCLI(t, cmd, "mycommand") f, err := getParsedFile(cfg, false, "file1="+defaultAbs+"\nfile2=aaa/bbb\n") if err != nil { t.Fatalf("Unexpected error getting fake parsed file: %v", err) } f.Dir = fmt.Sprintf("%s%ctmp", filepath.VolumeName(wd), os.PathSeparator) cfg.AddSource(f) cases = map[string]string{ "file1": defaultAbs, "file2": filepath.Join(f.Dir, "aaa", "bbb"), } for optionName, expected := range cases { if actual, err := cfg.GetAbsPath(optionName); actual != expected { t.Errorf("Expected GetAbsPath(%q) to return %q, instead got %q with err=%v", optionName, expected, actual, err) } } } // simpleConfig returns a stub config based on a single map of key->value string // pairs. All keys in the map will automatically be considered valid options. func simpleConfig(values map[string]string) *Config { cmd := NewCommand("test", "1.0", "this is for testing", nil) for key := range values { cmd.AddOption(StringOption(key, 0, "", key)) } cli := &CommandLine{ Command: cmd, } return NewConfig(cli, SimpleSource(values)) } mybase-1.1.0/file.go000066400000000000000000000366141474652102600142600ustar00rootroot00000000000000package mybase import ( "bufio" "errors" "fmt" "io" "io/ioutil" "log" "os" "path/filepath" "reflect" "sort" "strings" "unicode" ) // Section represents a labeled section of an option file. Option values that // precede any named section are still associated with a Section object, but // with a Name of "". type Section struct { Name string Values map[string]string // mapping of option name => value as string opts map[string]*Option // mapping of option name => option definition } // File represents a form of ini-style option file. Lines can contain // [sections], option=value, option without value (usually for bools), or // comments. type File struct { Dir string Name string IgnoreUnknownOptions bool sections []*Section sectionIndex map[string]*Section read bool parsed bool contents string selected []string ignoredOptionNames map[string]bool } // NewFile returns a value representing an option file. The arg(s) will be // joined to create a single path, so it does not matter if the path is provided // in a way that separates the dir from the base filename or not. func NewFile(paths ...string) *File { pathAndName := filepath.Join(paths...) cleanPath, err := filepath.Abs(filepath.Clean(pathAndName)) if err == nil { pathAndName = cleanPath } defaultSection := &Section{ Name: "", Values: make(map[string]string), opts: make(map[string]*Option), } return &File{ Dir: filepath.Dir(pathAndName), Name: filepath.Base(pathAndName), sections: []*Section{defaultSection}, sectionIndex: map[string]*Section{"": defaultSection}, ignoredOptionNames: make(map[string]bool), } } // Exists returns true if the file exists and is visible to the current user. func (f *File) Exists() bool { _, err := os.Stat(f.Path()) return (err == nil) } // Path returns the file's full absolute path with filename. func (f *File) Path() string { return filepath.Join(f.Dir, f.Name) } func (f *File) String() string { return f.Path() } // Write writes out the file's contents to disk. If overwrite=false and the // file already exists, an error will be returned. // Note that if overwrite=true and the file already exists, any comments // and extra whitespace in the file will be lost upon re-writing. All option // names and values will be normalized in the rewritten file. Any "loose-" // prefix option names that did not exist will not be written, and any that // did exist will have their "loose-" prefix stripped. These shortcomings will // be fixed in a future release. func (f *File) Write(overwrite bool) error { lines := make([]string, 0) for n, section := range f.sections { if section.Name != "" { lines = append(lines, fmt.Sprintf("[%s]", section.Name)) } ks := make([]string, 0, len(section.Values)) for k := range section.Values { ks = append(ks, k) } sort.Strings(ks) for _, k := range ks { // Note: section.opts[k] will be nil if the option value came from // File.SetOptionValue() and was not previously set! In this case we always // treat the opt as stringy, to avoid converting some-int=0 to skip-some-int optionIsBoolean := (section.opts[k] != nil && section.opts[k].Type == OptionTypeBool) val := section.Values[k] if (optionIsBoolean && !BoolValue(val)) || val == "''" { // false-valued boolean, or explicitly-empty-string non-boolean lines = append(lines, fmt.Sprintf("skip-%s", k)) } else if optionIsBoolean || val == "" { // true-valued boolean, or valueless (implying value-optional) non-boolean lines = append(lines, k) } else { // non-boolean with a value lines = append(lines, fmt.Sprintf("%s=%s", k, val)) } } // Append a blank line after the section, unless it was the last one, or // it was the default section and had no values if n < len(f.sections)-1 && (section.Name != "" || len(section.Values) > 0) { lines = append(lines, "") } } if len(lines) == 0 { log.Printf("Skipping write to %s due to empty configuration", f.Path()) return nil } f.contents = fmt.Sprintf("%s\n", strings.Join(lines, "\n")) f.read = true f.parsed = true flag := os.O_WRONLY | os.O_CREATE if overwrite { flag |= os.O_TRUNC } else { flag |= os.O_EXCL } osFile, err := os.OpenFile(f.Path(), flag, 0666) if err != nil { return err } n, err := osFile.Write([]byte(f.contents)) if err == nil && n < len(f.contents) { err = io.ErrShortWrite } if err1 := osFile.Close(); err == nil { err = err1 } return err } // Read loads the contents of the option file, but does not parse it. func (f *File) Read() error { file, err := os.Open(f.Path()) if err != nil { return err } defer file.Close() bytes, err := ioutil.ReadAll(file) if err != nil { return err } f.contents = string(bytes) f.read = true return nil } // Parse parses the file contents into a series of Sections. A Config object // must be supplied so that the list of valid Options is known. func (f *File) Parse(cfg *Config) error { if !f.read { if err := f.Read(); err != nil { return err } } section := f.sectionIndex[""] var lineNumber int contents := strings.TrimPrefix(f.contents, "\uFEFF") // strip utf8 BOM if present scanner := bufio.NewScanner(strings.NewReader(contents)) for scanner.Scan() { line := scanner.Text() lineNumber++ parsedLine, err := parseLine(line) if err != nil { return FileParseFormatError{ Problem: err.Error(), FilePath: f.Path(), LineNumber: lineNumber, } } switch parsedLine.kind { case lineTypeSectionHeader: section = f.getOrCreateSection(parsedLine.sectionName) case lineTypeKeyOnly, lineTypeKeyValue: if f.ignoredOptionNames[parsedLine.key] { continue } opt := cfg.FindOption(parsedLine.key) if opt == nil { if parsedLine.isLoose || f.IgnoreUnknownOptions || cfg.LooseFileOptions { continue } else { return OptionNotDefinedError{parsedLine.key, fmt.Sprintf("%s line %d", f.Path(), lineNumber)} } } if parsedLine.kind == lineTypeKeyOnly { if opt.RequireValue { return OptionMissingValueError{opt.Name, fmt.Sprintf("%s line %d", f.Path(), lineNumber)} } else if opt.Type == OptionTypeBool { // For booleans, option without value indicates option is being enabled parsedLine.value = "1" } } else if parsedLine.value == "" && opt.Type == OptionTypeString { // Convert empty strings into quote-wrapped empty strings, so that callers // may differentiate between bare "foo" vs "foo=" if desired, by using // Config.GetRaw(). Meanwhile Config.Get and most other getters strip // surrounding quotes, so this does not break anything. parsedLine.value = "''" } section.Values[parsedLine.key] = parsedLine.value section.opts[parsedLine.key] = opt } } f.parsed = true f.selected = []string{""} return scanner.Err() } // UseSection changes which section(s) of the file are used when calling // OptionValue. If multiple section names are supplied, multiple sections will // be checked by OptionValue, with sections listed first taking precedence over // subsequent ones. // Note that the default nameless section "" (i.e. lines at the top of the file // prior to a section header) is automatically appended to the end of the list. // So this section is always checked, at lowest priority, need not be // passed to this function. func (f *File) UseSection(names ...string) error { notFound := make([]string, 0) already := make(map[string]bool, len(names)) // This intentionally allocates a new []string for selected. This way, even // if there are other shallow copies of f, calling UseSection on one won't // affect the others. f.selected = make([]string, 0, len(names)+1) for _, name := range names { if already[name] { continue } already[name] = true if f.HasSection(name) { f.selected = append(f.selected, name) } else { notFound = append(notFound, name) } } if !already[""] { f.selected = append(names, "") } if len(notFound) == 0 { return nil } return fmt.Errorf("File %s missing section: %s", f.Path(), strings.Join(notFound, ", ")) } // HasSection returns true if the file has a section with the supplied name. func (f *File) HasSection(name string) bool { _, ok := f.sectionIndex[name] return ok } // SectionsWithOption returns a list of section names that set the supplied // option name. func (f *File) SectionsWithOption(optionName string) []string { result := make([]string, 0, len(f.sections)) for _, section := range f.sections { if _, ok := section.Values[optionName]; ok { result = append(result, section.Name) } } return result } // SomeSectionHasOption returns true if at least one section sets the supplied // option name. func (f *File) SomeSectionHasOption(optionName string) bool { return len(f.SectionsWithOption(optionName)) > 0 } // SectionValues returns a map of option name to raw option string values for // the supplied section name. The returned map is a copy; modifying it will not // affect the File. func (f *File) SectionValues(name string) map[string]string { section := f.sectionIndex[name] if section == nil { return map[string]string{} } result := make(map[string]string, len(section.Values)) for k, v := range section.Values { result[k] = v } return result } // OptionValue returns the value for the requested option from the option file. // Only the previously-selected section(s) of the file will be used, or the // default section "" if no section has been selected via UseSection. // Panics if the file has not yet been parsed, as this would indicate a bug. // This is satisfies the OptionValuer interface, allowing Files to be used as // an option source in Config. func (f *File) OptionValue(optionName string) (string, bool) { if !f.parsed { panic(fmt.Errorf("Call to OptionValue(\"%s\") on unparsed file %s", optionName, f.Path())) } for _, sectionName := range f.selected { section := f.sectionIndex[sectionName] if section == nil { continue } if value, ok := section.Values[optionName]; ok { return value, true } } return "", false } // SetOptionValue sets an option value in the named section. This is not // persisted to the file until Write is called on the File. This is not // guaranteed to affect any Config that is already using the File as a // Source. func (f *File) SetOptionValue(sectionName, optionName, value string) { section := f.getOrCreateSection(sectionName) section.Values[optionName] = value } // UnsetOptionValue removes an option value in the named section. This is not // persisted to the file until Write is called on the File. This is not // guaranteed to affect any Config that is already using the File as a // Source. func (f *File) UnsetOptionValue(sectionName, optionName string) { section := f.getOrCreateSection(sectionName) delete(section.Values, optionName) } // SameContents returns true if f and other have the same sections and values. // Ordering, formatting, comments, filename, and directory do not affect the // results of this comparison. Both files must be parsed by the caller prior // to calling this method, otherwise this method panics to indicate programmer // error. // This method is primarily intended for unit testing purposes. func (f *File) SameContents(other *File) bool { if !f.parsed || !other.parsed { panic(errors.New("File.SameContents called on a file that has not yet been parsed")) } if len(f.sectionIndex) != len(other.sectionIndex) { return false } for name := range f.sectionIndex { a := f.sectionIndex[name] b, ok := other.sectionIndex[name] if !ok || a.Name != b.Name { return false } if !reflect.DeepEqual(a.Values, b.Values) { return false } } return true } // IgnoreOptions causes the supplied option names to be ignored by a subsequent // call to Parse. The supplied option names do not need to exist as valid // options. // Note that if the file is later re-written, ignored options will be stripped // from the rewritten version. // Panics if the file has already been parsed, as this would indicate a bug. func (f *File) IgnoreOptions(names ...string) { if f.parsed { panic(errors.New("File.IgnoreOptions called on a file that has already been parsed")) } for _, name := range names { f.ignoredOptionNames[name] = true } } func (f *File) getOrCreateSection(name string) *Section { if s, exists := f.sectionIndex[name]; exists { return s } s := &Section{ Name: name, Values: make(map[string]string), opts: make(map[string]*Option), } f.sections = append(f.sections, s) f.sectionIndex[name] = s return s } type lineType int const ( lineTypeBlank lineType = iota lineTypeComment lineTypeSectionHeader lineTypeKeyOnly lineTypeKeyValue ) type parsedLine struct { sectionName string key string value string comment string kind lineType isLoose bool } // parseLine parses a file line into its components func parseLine(line string) (*parsedLine, error) { line = strings.TrimLeftFunc(line, unicode.IsSpace) result := new(parsedLine) if line == "" { result.kind = lineTypeBlank return result, nil } if line[0] == ';' || line[0] == '#' { result.kind = lineTypeComment result.comment = line[1:] return result, nil } if line[0] == '[' { endIndex := strings.Index(line, "]") hashIndex := strings.Index(line, "#") if endIndex == -1 || (hashIndex > -1 && hashIndex < endIndex) { return nil, errors.New("unterminated section name") } if endIndex < len(line)-1 { var after string if hashIndex > -1 { after = line[endIndex+1 : hashIndex] } else { after = line[endIndex+1:] } if len(strings.TrimSpace(after)) > 0 { return nil, errors.New("extra characters after section name") } } result.kind = lineTypeSectionHeader result.sectionName = line[1:endIndex] if hashIndex > -1 { result.comment = line[hashIndex+1:] } return result, nil } // If we get here, it's one of the key/value types var inValue, escapeNext bool var inQuote rune // Parse out any inline comment, being careful to still allow escaped hashes or // hashes inside of quoted values for n, c := range line { if escapeNext { escapeNext = false continue } if c == '#' && inQuote == 0 { result.comment = line[n+1:] line = line[0:n] break } if !inValue { switch c { case '=': inValue = true case '\'', '"', '`', '\\': return nil, fmt.Errorf("Illegal character %c in option name", c) } continue } switch c { case '\'', '"', '`': if c == inQuote { inQuote = 0 } else if inQuote == 0 { inQuote = c } case '\\': escapeNext = true } } if inQuote != 0 { return nil, errors.New("Quoted value has no terminating quote") } if escapeNext { return nil, errors.New("Value ends in a single backslash") } var hasValue bool result.key, result.value, hasValue, result.isLoose = NormalizeOptionToken(line) if hasValue { result.kind = lineTypeKeyValue } else { result.kind = lineTypeKeyOnly } return result, nil } // FileParseFormatError is an error returned when File.Parse encounters a // problem with the formatting of a file (separate from an unknown option or a // lack of a required value for an option, which are handled by other types) type FileParseFormatError struct { Problem string FilePath string LineNumber int } // Error satisfies golang's error interface. func (fpf FileParseFormatError) Error() string { return fmt.Sprintf("Parse error in %s line %d: %s", fpf.FilePath, fpf.LineNumber, fpf.Problem) } mybase-1.1.0/file_test.go000066400000000000000000000254721474652102600153170ustar00rootroot00000000000000package mybase import ( "io/ioutil" "os" "testing" ) func getParsedFile(cfg *Config, ignoreUnknownOptions bool, contents string, ignoredOpts ...string) (*File, error) { var err error file := NewFile("/tmp/fake.cnf") file.IgnoreUnknownOptions = ignoreUnknownOptions file.IgnoreOptions(ignoredOpts...) if len(contents) > 0 { file.contents = contents file.read = true err = file.Parse(cfg) } return file, err } func TestFileReadWrite(t *testing.T) { f := NewFile(os.TempDir(), "mybasetest.cnf") if f.Exists() { t.Fatalf("File at path %s unexpectedly already exists", f.Path()) } if err := f.Read(); err == nil { t.Fatal("Expected File.Read() to fail on nonexistent file, but err is nil") } contents := "foo=bar\noptional-string\n\n[mysection]\nskip-foo\nskip-safeties\nsimple-bool\n" err := ioutil.WriteFile(f.Path(), []byte(contents), 0777) if err != nil { t.Fatalf("Unable to directly write %s to set up test: %s", f.Path(), err) } defer os.Remove(f.Path()) if !f.Exists() { t.Error("Expected File.Exists() to return true, but it did not") } if err := f.Read(); err != nil { t.Fatalf("Unexpected error from File.Read(): %v", err) } if f.contents != contents { t.Errorf("Unexpected f.contents: %q", f.contents) } if !f.read { t.Error("Expected f.read to be true after calling Read(), but it is false") } cmd := NewCommand("test", "1.0", "this is for testing", nil) cmd.AddOption(StringOption("foo", 0, "", "")) cmd.AddOption(StringOption("optional-string", 0, "", "").ValueOptional()) cmd.AddOption(BoolOption("safeties", 0, true, "")) cmd.AddOption(BoolOption("simple-bool", 0, false, "")) cli := &CommandLine{ Command: cmd, } cfg := NewConfig(cli) if err := f.Parse(cfg); err != nil { t.Fatalf("Unexpected error from Parse(): %v", err) } // Non-overwrite Write should fail, but overwrite-friendly should be fine if err := f.Write(false); err == nil { t.Error("Expected Write(false) to fail, but it did not") } if err := f.Write(true); err != nil { t.Errorf("Unexpected error from Write(true): %v", err) } newContentsBytes, err := ioutil.ReadFile(f.Path()) if err != nil { t.Fatalf("Unexpected error directly re-reading file: %v", err) } if string(newContentsBytes) != contents { t.Errorf("Unexpected file contents: %q", string(newContentsBytes)) } } func TestParse(t *testing.T) { assertFileParsed := func(f *File, err error, expectedSections ...string) { t.Helper() if err != nil { t.Errorf("Expected file to parse without error, but instead found %s", err) } else if len(f.sections) != len(expectedSections) { t.Errorf("Expected file to have %d sections, but instead found %d", len(expectedSections), len(f.sections)) } else { for _, name := range expectedSections { if _, ok := f.sectionIndex[name]; !ok { t.Errorf("Expected section \"%s\" to exist, but it does not", name) } } } } assertFileValue := func(f *File, sectionName, optionName, value string) { t.Helper() if section := f.sectionIndex[sectionName]; section == nil { t.Errorf("Expected section \"%s\" to exist, but it does not", sectionName) } else if actualValue, ok := section.Values[optionName]; !ok || actualValue != value { t.Errorf("Expected section \"%s\" value of %s to be \"%s\", instead found \"%s\" (set=%t)", sectionName, optionName, value, actualValue, ok) } } cmd := NewCommand("test", "1.0", "this is for testing", nil) cmd.AddOption(StringOption("mystring", 0, "", "")) cmd.AddOption(BoolOption("mybool", 0, false, "")) cli := &CommandLine{ Command: cmd, } cfg := NewConfig(cli) f, err := getParsedFile(cfg, false, "mystring=hello\nmybool") assertFileParsed(f, err, "") assertFileValue(f, "", "mystring", "hello") assertFileValue(f, "", "mybool", "1") f, err = getParsedFile(cfg, false, "mystring = \nskip-mybool") assertFileParsed(f, err, "") assertFileValue(f, "", "mystring", "''") f, err = getParsedFile(cfg, false, "skip-mybool\n mystring = whatever \n\n\t[one] #yay\nmybool=1\n[two]\nloose-mystring=overridden\n\n\n") assertFileParsed(f, err, "", "one", "two") assertFileValue(f, "", "mystring", "whatever") assertFileValue(f, "", "mybool", "") assertFileValue(f, "one", "mybool", "1") assertFileValue(f, "two", "mystring", "overridden") AssertFileSetsOptions(t, f, "mybool", "mystring") AssertFileMissingOptions(t, f, "yay") // Test with utf8 BOM in front of contents f, err = getParsedFile(cfg, false, "\uFEFFskip-mybool\n mystring = whatever \n\n\t[one] #yay\nmybool=1\n[two]\nloose-mystring=overridden\n\n\n") assertFileParsed(f, err, "", "one", "two") assertFileValue(f, "", "mystring", "whatever") f, err = getParsedFile(cfg, false, "loose-doesntexist=foo\n\n\nmystring=`ok` ") assertFileParsed(f, err, "") assertFileValue(f, "", "mystring", "`ok`") f, err = getParsedFile(cfg, true, "errors-dont-matter=1") assertFileParsed(f, err, "") f, err = getParsedFile(cfg, false, "errors-dont-matter=1", "errors-dont-matter") assertFileParsed(f, err, "") badContents := []string{ "mystring=hello\ninvalid=fail", "mybool=true\nmystring\n", "[foo\nmybool\n", } for _, contents := range badContents { f, err = getParsedFile(cfg, false, contents) if err == nil { t.Errorf("Expected file parsing to generate error, but it did not. Contents:\n%s\n\n", f.contents) } } // Test Config.LooseFileOptions cfg.LooseFileOptions = true f, err = getParsedFile(cfg, false, "[one]\nerrors-dont-matter=1\nmystring=hello") assertFileParsed(f, err, "", "one") assertFileValue(f, "one", "mystring", "hello") } func TestFileSameContents(t *testing.T) { cmd := NewCommand("test", "1.0", "this is for testing", nil) cmd.AddOption(StringOption("mystring", 0, "", "")) cmd.AddOption(BoolOption("mybool", 0, false, "")) cli := &CommandLine{ Command: cmd, } cfg := NewConfig(cli) f1, err1 := getParsedFile(cfg, false, "skip-mybool\n mystring = whatever \n\n\t[one] #yay\nmybool=1\n[two]\nloose-mystring=overridden\n\n\n") f2, err2 := getParsedFile(cfg, false, "mystring = whatever\nskip-mybool\n[one]\nmybool=1\n[two]\nmystring=overridden\n") if err1 != nil || err2 != nil { t.Fatalf("Unexpected errors in getting parsed test files: %v / %v", err1, err2) } if !f1.SameContents(f2) { t.Error("Expected f1 and f2 to have the same contents, but they did not") } f3, err3 := getParsedFile(cfg, false, "skip-mybool\n[one]\nmybool=1\n[two]\nmystring=overridden\n") if err3 != nil { t.Fatalf("Unexpected error in getting parsed test file: %v", err3) } if f1.SameContents(f3) { t.Error("Expected f1 and f3 to have different contents, but SameContents returned true") } f3.SetOptionValue("", "mystring", "some other value") if f1.SameContents(f3) { t.Error("Expected f1 and f3 to have different contents, but SameContents returned true") } f3.SetOptionValue("", "mystring", "whatever") if !f1.SameContents(f3) { t.Error("Expected f1 and f3 to now have the same contents, but they did not") } } func TestParseLine(t *testing.T) { assertLine := func(line, sectionName, key, value, comment string, kind lineType, isLoose bool) { result, err := parseLine(line) if err != nil { t.Errorf("Unexpected error result from parsing line \"%s\": %s", line, err) return } expect := parsedLine{ sectionName: sectionName, key: key, value: value, comment: comment, kind: kind, isLoose: isLoose, } if *result != expect { t.Errorf("Result %v does not match expectation %v", *result, expect) } } assertLineHasErr := func(line string) { _, err := parseLine(line) if err == nil { t.Errorf("Expected error result from parsing line \"%s\", but no error returned", line) } } assertLine("", "", "", "", "", lineTypeBlank, false) assertLine("; comments are cool right", "", "", "", " comments are cool right", lineTypeComment, false) assertLine("#so are these", "", "", "", "so are these", lineTypeComment, false) assertLine(" [awesome] # very nice section", "awesome", "", "", " very nice section", lineTypeSectionHeader, false) assertLine("[]", "", "", "", "", lineTypeSectionHeader, false) assertLine(" [cool beans] # awesome section", "cool beans", "", "", " awesome section", lineTypeSectionHeader, false) assertLine(" foo", "", "foo", "", "", lineTypeKeyOnly, false) assertLine(" loose-foo#sup=dup'whatever'", "", "foo", "", "sup=dup'whatever'", lineTypeKeyOnly, true) assertLine("this = that = whatever # okie dokie", "", "this", "that = whatever", " okie dokie", lineTypeKeyValue, false) assertLine("loose_something=\"quoted value # ignores value's # comments\" # until after value's \"quotes\"", "", "something", "\"quoted value # ignores value's # comments\"", " until after value's \"quotes\"", lineTypeKeyValue, true) assertLine(" backticks-work = `yep working fine` ", "", "backticks-work", "`yep working fine`", "", lineTypeKeyValue, false) assertLine("foo='first' part of value only is quoted", "", "foo", "'first' part of value only is quoted", "", lineTypeKeyValue, false) assertLine("foo='first' and last parts of value are 'quoted'", "", "foo", "'first' and last parts of value are 'quoted'", "", lineTypeKeyValue, false) assertLineHasErr("[section") assertLineHasErr("[section # hmmm") assertLineHasErr("[section] lol # lolol") assertLineHasErr(`"key"="value"`) assertLineHasErr("key\\=still-key = value") assertLineHasErr(`no-terminator = "this quote does not end`) assertLineHasErr(`foo=bar\`) assertLineHasErr("foo=\"mismatched quotes`") assertLineHasErr("foo=`unbalanced`quotes`") } func TestFileSectionValues(t *testing.T) { cmd := NewCommand("test", "1.0", "this is for testing", nil) cmd.AddOption(StringOption("mystring", 0, "", "")) cmd.AddOption(BoolOption("mybool", 0, false, "")) cli := &CommandLine{ Command: cmd, } cfg := NewConfig(cli) f, err := getParsedFile(cfg, false, "skip-mybool\n mystring = whatever \n\n\t[one]\nmybool=1\n[two]\nloose-mystring=overridden\n") if err != nil { t.Fatalf("Unexpected error return from getParsedFile: %v", err) } // Non-existent section should return empty (but non-nil) map values := f.SectionValues("doesnt-exist") if len(values) != 0 || values == nil { t.Errorf("Unexpected return value from SectionValues: %v", values) } // Existing section should be returned with expected values values = f.SectionValues("one") if len(values) != 1 || values["mybool"] != "1" { t.Errorf("Unexpected return value from SectionValues: %v", values) } values = f.SectionValues("two") if len(values) != 1 || values["mystring"] != "overridden" { t.Errorf("Unexpected return value from SectionValues: %v", values) } // The returned map should be a copy -- modifying it shouldn't impact the File values["foo"] = "bar" values["mystring"] = "different value" origMap := f.sectionIndex["two"].Values if len(origMap) != 1 || origMap["mystring"] != "overridden" { t.Errorf("SectionValues unexpectedly did not return a copy of the map? Original map contents now %v", origMap) } } mybase-1.1.0/go.mod000066400000000000000000000002501474652102600141030ustar00rootroot00000000000000module github.com/skeema/mybase go 1.22 require ( github.com/mitchellh/go-wordwrap v1.0.1 golang.org/x/term v0.28.0 ) require golang.org/x/sys v0.29.0 // indirect mybase-1.1.0/go.sum000066400000000000000000000014371474652102600141400ustar00rootroot00000000000000github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= mybase-1.1.0/option.go000066400000000000000000000211371474652102600146430ustar00rootroot00000000000000package mybase import ( "fmt" "os" "runtime" "sort" "strings" "unicode" "github.com/mitchellh/go-wordwrap" terminal "golang.org/x/term" ) // OptionType is an enum for representing the type of an option. type OptionType int // Constants representing different OptionType enumerated values. // Note that there intentionally aren't separate types for int, comma-separated // list, regex, etc. From the perspective of the CLI or an option file, these // are all strings; callers may *process* a string value as a different Golang // type at runtime using Config.GetInt, Config.GetSlice, etc. const ( OptionTypeString OptionType = iota // String-valued option OptionTypeBool // Boolean-valued option ) // Option represents a flag/setting for a Command. Any Option present for a // parent Command will automatically be available to all of its descendent // subcommands, although subcommands may choose to override the exact semantics // by providing another conflicting Option of same Name. type Option struct { Name string Shorthand rune Type OptionType Default string Description string RequireValue bool HiddenOnCLI bool Group string // Used in help information } // StringOption creates a string-type Option. By default, string options require // a value, though this can be overridden via ValueOptional(). func StringOption(long string, short rune, defaultValue string, description string) *Option { return &Option{ Name: strings.Replace(long, "_", "-", -1), Shorthand: short, Type: OptionTypeString, Default: defaultValue, Description: description, RequireValue: true, } } // BoolOption creates a boolean-type Option. By default, boolean options do not // require a value, though this can be overridden via ValueRequired(). func BoolOption(long string, short rune, defaultValue bool, description string) *Option { var defaultAsStr string if defaultValue { defaultAsStr = "1" } else { defaultAsStr = "" } return &Option{ Name: strings.Replace(long, "_", "-", -1), Shorthand: short, Type: OptionTypeBool, Default: defaultAsStr, Description: description, RequireValue: false, } } // Hidden prevents an Option from being displayed in a Command's help/usage // text. func (opt *Option) Hidden() *Option { opt.HiddenOnCLI = true return opt } // ValueRequired marks an Option as needing a value, so it will be an error if // the option is supplied alone without any corresponding value. func (opt *Option) ValueRequired() *Option { if opt.Type == OptionTypeBool { panic(fmt.Errorf("Option %s: boolean options cannot have required value", opt.Name)) } opt.RequireValue = true return opt } // ValueOptional marks an Option as not needing a value, allowing the Option to // appear without any value associated. func (opt *Option) ValueOptional() *Option { opt.RequireValue = false return opt } // Usage displays one-line help information on the Option. func (opt *Option) Usage(maxNameLength int) string { if opt.HiddenOnCLI { return "" } lineLen := 10000 stdinFd := int(os.Stderr.Fd()) if terminal.IsTerminal(stdinFd) { lineLen, _, _ = terminal.GetSize(stdinFd) if lineLen < 80 { lineLen = 80 } // Avoid extra blank lines on Windows when output matches full line length if runtime.GOOS == "windows" { lineLen-- } } var shorthand string if opt.Shorthand > 0 { shorthand = fmt.Sprintf("-%c,", opt.Shorthand) } head := fmt.Sprintf(" %3s --%*s ", shorthand, -1*maxNameLength, opt.usageName()) desc := fmt.Sprintf("%s%s", opt.Description, opt.DefaultUsage()) if len(desc)+len(head) > lineLen { desc = wordwrap.WrapString(desc, uint(lineLen-len(head))) spacer := fmt.Sprintf("\n%s", strings.Repeat(" ", len(head))) desc = strings.Replace(desc, "\n", spacer, -1) } return fmt.Sprintf("%s%s\n", head, desc) } // DefaultUsage returns usage information relating to the Option's default // value. func (opt *Option) DefaultUsage() string { if opt.HiddenOnCLI || !opt.HasNonzeroDefault() { return "" } else if opt.Type == OptionTypeBool { return fmt.Sprintf(" (enabled by default; disable with --skip-%s)", opt.Name) } return fmt.Sprintf(" (default %s)", opt.PrintableDefault()) } // usageName returns the option's name, potentially modified/annotated for // display on help screen. func (opt *Option) usageName() string { if opt.HiddenOnCLI { return "" } else if opt.Type == OptionTypeBool { if opt.HasNonzeroDefault() { return fmt.Sprintf("[skip-]%s", opt.Name) } return opt.Name } else if opt.RequireValue { return fmt.Sprintf("%s value", opt.Name) } return fmt.Sprintf("%s[=value]", opt.Name) } // HasNonzeroDefault returns true if the Option's default value differs from // its type's zero/empty value. func (opt *Option) HasNonzeroDefault() bool { switch opt.Type { case OptionTypeString: return opt.Default != "" case OptionTypeBool: return BoolValue(opt.Default) default: return false } } // PrintableDefault returns a human-friendly version of the Option's default // value. func (opt *Option) PrintableDefault() string { switch opt.Type { case OptionTypeBool: if BoolValue(opt.Default) { return "true" } return "false" default: return fmt.Sprintf(`"%s"`, opt.Default) } } // OptionGroup is a group of related Options, used in generation of usage // instructions for a Command. type OptionGroup struct { Name string Options []*Option } func newOptionGroup(group string, options []*Option) *OptionGroup { grp := &OptionGroup{Name: group} lookup := make(map[string]*Option, len(options)) names := make([]string, 0, len(options)) for _, opt := range options { lookup[opt.Name] = opt names = append(names, opt.Name) } sort.Strings(names) for _, name := range names { grp.Options = append(grp.Options, lookup[name]) } return grp } // NormalizeOptionToken takes a string of form "foo=bar" or just "foo", and // parses it into separate key and value. It also returns whether the arg // included a value (to tell "" vs no-value) and whether it had a "loose-" // prefix, meaning that the calling parser shouldn't return an error if the key // does not correspond to any existing option. func NormalizeOptionToken(arg string) (key, value string, hasValue, loose bool) { tokens := strings.SplitN(arg, "=", 2) key = strings.TrimFunc(tokens[0], unicode.IsSpace) if key == "" { return } key = strings.ToLower(key) key = strings.Replace(key, "_", "-", -1) if strings.HasPrefix(key, "loose-") { key = key[6:] loose = true } var negated bool if strings.HasPrefix(key, "skip-") { key = key[5:] negated = true } else if strings.HasPrefix(key, "disable-") { key = key[8:] negated = true } else if strings.HasPrefix(key, "enable-") { key = key[7:] } if len(tokens) > 1 { hasValue = true value = strings.TrimFunc(tokens[1], unicode.IsSpace) // negated and value supplied: set to falsey value of "" UNLESS the value is // also falsey, in which case we have a double-negative, meaning enable if negated { if BoolValue(value) { value = "" } else { value = "1" } } } else if negated { // No value supplied and negated: set to falsey value of "" value = "" // But negation still satisfies "having a value" for RequireValue options hasValue = true } return } // BoolValue converts the supplied option value string to a boolean. // The case-insensitive values "", "off", "false", and "0" are considered false; // all other values are considered true. func BoolValue(input string) bool { switch strings.ToLower(input) { case "", "off", "false", "0": return false default: return true } } // NormalizeOptionName is a convenience function that only returns the "key" // portion of NormalizeOptionToken. func NormalizeOptionName(name string) string { ret, _, _, _ := NormalizeOptionToken(name) return ret } // OptionNotDefinedError is an error returned when an unknown Option is used. type OptionNotDefinedError struct { Name string Source string } // Error satisfies golang's error interface. func (ond OptionNotDefinedError) Error() string { var source string if ond.Source != "" { source = fmt.Sprintf("%s: ", ond.Source) } return fmt.Sprintf("%sUnknown option \"%s\"", source, ond.Name) } // OptionMissingValueError is an error returned when an Option requires a value, // but no value was supplied. type OptionMissingValueError struct { Name string Source string } // Error satisfies golang's error interface. func (omv OptionMissingValueError) Error() string { var source string if omv.Source != "" { source = fmt.Sprintf("%s: ", omv.Source) } return fmt.Sprintf("%sMissing required value for option %s", source, omv.Name) } mybase-1.1.0/testing.go000066400000000000000000000063271474652102600150140ustar00rootroot00000000000000package mybase import ( "bytes" "testing" "unicode" ) // This file contains exported methods and types that may be useful in testing // applications using MyBase, as well as testing MyBase itself. // AssertFileSetsOptions verifies that the file sets all of the supplied option // names in at least one of its currently-selected sections. The test fails if // not. func AssertFileSetsOptions(t *testing.T, file *File, options ...string) { t.Helper() for _, option := range options { if _, setsOption := file.OptionValue(option); !setsOption { t.Errorf("Expected %s to set option %s, but it does not", file, option) } } } // AssertFileMissingOptions verifies that the file does NOT set any of the // supplied option names in any of its currently-selected sections. The test // fails otherwise. func AssertFileMissingOptions(t *testing.T, file *File, options ...string) { t.Helper() for _, option := range options { if _, setsOption := file.OptionValue(option); setsOption { t.Errorf("Expected %s to NOT contain %s, but it does", file, option) } } } // SimpleSource is the most trivial possible implementation of the OptionValuer // interface: it just maps option name strings to option value strings. type SimpleSource = StringMapValues // SimpleConfig returns a stub config based on a single map of key->value string // pairs. All keys in the map will automatically be considered valid options. func SimpleConfig(values map[string]string) *Config { cmd := NewCommand("test", "1.0", "this is for testing", nil) for key := range values { cmd.AddOption(StringOption(key, 0, "", key)) } cli := &CommandLine{ Command: cmd, } return NewConfig(cli, SimpleSource(values)) } // ParseFakeCLI splits a single command-line string into a slice of arg // token strings, and then calls ParseCLI using those args. It understands // simple quoting and escaping rules, but does not attempt to replicate more // advanced bash tokenization, wildcards, etc. func ParseFakeCLI(t *testing.T, cmd *Command, commandLine string, sources ...OptionValuer) *Config { t.Helper() args := tokenizeCommandLine(t, commandLine) cfg, err := ParseCLI(cmd, args) if err != nil { t.Fatalf("ParseCLI returned unexpected error: %s", err) } for _, src := range sources { cfg.AddSource(src) } cfg.IsTest = true return cfg } func tokenizeCommandLine(t *testing.T, commandLine string) []string { t.Helper() var b bytes.Buffer var inQuote, escapeNext bool var curQuote rune var args []string for _, c := range commandLine { if escapeNext { b.WriteRune(c) escapeNext = false continue } switch { case c == '\\': escapeNext = true case c == '\'' || c == '"': if !inQuote { inQuote = true curQuote = c } else if curQuote == c { inQuote = false } else { // in a quote, but a different type b.WriteRune(c) } case unicode.IsSpace(c): if inQuote { b.WriteRune(c) } else if b.Len() > 0 { args = append(args, b.String()) b.Reset() } default: b.WriteRune(c) } } if inQuote || escapeNext { t.Fatalf("Invalid command-line passed to tokenizeCommandLine(\"%s\"): final inQuote=%t, escapeNext=%t", commandLine, inQuote, escapeNext) } if b.Len() > 0 { args = append(args, b.String()) } return args }