From 3f9778920c90cf2e9c4410dd658c44d907ba04e3 Mon Sep 17 00:00:00 2001 From: Florent Heyworth Date: Wed, 1 Apr 2026 16:23:00 +0200 Subject: [PATCH 1/2] feat(parser): enhance argument parsing and add support for greedy commands - Refactor argument handling to support "greedy" commands that capture unbound positionals. - Improve positional and flag parsing logic to ensure robustness. - Deprecate `HelpStyleGroupedClean` in favor of `HelpStyleGrouped` with clean formatting as default. - Update internationalization (i18n) messages for improved validation feedback and consistency across locales. - Improve description alignment and formatting in help outputs. - Add utility functions for building command/subcommand names with positionals. - Update tests to reflect new command and help output behavior. --- v2/command_config_funcs.go | 9 + v2/completion/data.go | 8 +- v2/definitions.go | 5 +- v2/examples/i18n-load-system-locales/main.go | 2 +- v2/goopt.go | 19 +- v2/goopt_internal.go | 179 ++++++++++++------- v2/goopt_internal_positionals.go | 14 +- v2/goopt_test.go | 10 +- v2/help_parser.go | 38 ++-- v2/i18n/all_locales/ar.json | 2 +- v2/i18n/all_locales/de.json | 2 +- v2/i18n/all_locales/en.json | 2 +- v2/i18n/all_locales/es.json | 2 +- v2/i18n/all_locales/fr.json | 2 +- v2/i18n/all_locales/he.json | 2 +- v2/i18n/all_locales/hi.json | 2 +- v2/i18n/all_locales/ja.json | 2 +- v2/i18n/all_locales/pt.json | 2 +- v2/i18n/all_locales/zh.json | 2 +- v2/i18n/locales/ar/ar_gen.go | 2 +- v2/i18n/locales/de/de_gen.go | 2 +- v2/i18n/locales/en/en_gen.go | 2 +- v2/i18n/locales/es/es_gen.go | 2 +- v2/i18n/locales/fr/fr_gen.go | 2 +- v2/i18n/locales/he/he_gen.go | 2 +- v2/i18n/locales/hi/hi_gen.go | 2 +- v2/i18n/locales/ja/ja_gen.go | 2 +- v2/i18n/locales/pt/pt_gen.go | 2 +- v2/i18n/locales/zh/zh_gen.go | 2 +- v2/internal/parse/tag.go | 6 + v2/parser_config_funcs_test.go | 2 +- v2/renderer.go | 8 + v2/types/common.go | 1 + v2/validation/parser.go | 27 +++ 34 files changed, 251 insertions(+), 117 deletions(-) diff --git a/v2/command_config_funcs.go b/v2/command_config_funcs.go index 0d87473..cd858fe 100644 --- a/v2/command_config_funcs.go +++ b/v2/command_config_funcs.go @@ -99,3 +99,12 @@ func WithExecuteOnParse(cmdExecOnParse bool) ConfigureCommandFunc { command.ExecOnParse = cmdExecOnParse } } + +// WithGreedy sets the Greedy property of the command. If true, any further ags will not be evaluated but are added as +// unbound positionals - this is useful for passthrough commands that are only used to invoke other commands, +// e.g. `git branch` or `git checkout`. +func WithGreedy(beGreedy bool) ConfigureCommandFunc { + return func(command *Command) { + command.Greedy = beGreedy + } +} diff --git a/v2/completion/data.go b/v2/completion/data.go index ccbba7a..81408c4 100644 --- a/v2/completion/data.go +++ b/v2/completion/data.go @@ -12,13 +12,13 @@ type FlagType int const ( // Single denotes a flag accepting a string value - FlagTypeSingle FlagType = 0 + FlagTypeSingle FlagType = 1 // Chained denotes a flag accepting a string value which should be evaluated as a list - FlagTypeChained FlagType = 1 + FlagTypeChained FlagType = 2 // Standalone denotes a boolean flag (does not accept a value) - FlagTypeStandalone FlagType = 2 + FlagTypeStandalone FlagType = 3 // File denotes a flag which is evaluated as a path - FlagTypeFile FlagType = 3 + FlagTypeFile FlagType = 4 ) // FlagPair represents a short and long version of the same flag diff --git a/v2/definitions.go b/v2/definitions.go index ec13ba3..bac35f0 100644 --- a/v2/definitions.go +++ b/v2/definitions.go @@ -103,9 +103,11 @@ type Command struct { ExecOnParse bool Description string DescriptionKey string + Greedy bool // Greedy if true any further commands and flags will be consumed as unbound positionals topLevel bool path string callbackLocation reflect.Value // stores reference to a field which may contain a CommandFunc in the future + } // FlagInfo is used to store information about a flag @@ -129,7 +131,7 @@ type HelpStyle int const ( HelpStyleFlat HelpStyle = iota // PrintUsage HelpStyleGrouped // PrintUsageWithGroups - HelpStyleGroupedClean // PrintUsageWithGroups, clean (no ** markers) + HelpStyleGroupedClean // Deprecated: alias for HelpStyleGrouped (now uses clean formatting by default) HelpStyleCompact // Deduplicated, minimal HelpStyleHierarchical // Command-focused, drill-down HelpStyleSmart // Auto-detect based on CLI size @@ -231,6 +233,7 @@ type Parser struct { treatUnknownAsPositionals bool // If true, treat unknown flags and their values as positionals mu sync.Mutex envVarPrefix string // Prefix for environment variables + greedyAfterPos int // Position of the first arg after which all remaining args are greedily consumed as positionals } // CompletionData is used to store information for command line completion diff --git a/v2/examples/i18n-load-system-locales/main.go b/v2/examples/i18n-load-system-locales/main.go index ee049db..4470a1e 100644 --- a/v2/examples/i18n-load-system-locales/main.go +++ b/v2/examples/i18n-load-system-locales/main.go @@ -121,7 +121,7 @@ func demoLocales(parser *goopt.Parser, cfg *Config) { printSection("🧭 RTL Language Features", i18n.IsRTL(currentLang), func() { fmt.Println(" ➤ Direction: Right-to-Left") - fmt.Println(" ➤ Note: Terminal rendering for RTL may vary across platforms\n") + fmt.Println(" ➤ Note: Greedy rendering for RTL may vary across platforms\n") }) printSection("🗣️ Sample Translations", true, func() { diff --git a/v2/goopt.go b/v2/goopt.go index 8350af9..5644dbf 100644 --- a/v2/goopt.go +++ b/v2/goopt.go @@ -160,7 +160,7 @@ func NewParserFromInterface(i interface{}, config ...ConfigureCmdLineFunc) (*Par // SetEnvVarPrefix sets the prefix for environment variables. func (p *Parser) SetEnvVarPrefix(prefix string) { if !strings.HasSuffix(prefix, "_") { - prefix = prefix + "_" + prefix += "_" } p.envVarPrefix = prefix } @@ -697,7 +697,7 @@ func (p *Parser) Parse(args []string, defaults ...string) bool { } else { // Parse the next command - terminating := p.parseCommand(state, cmdQueue, &commandPathSlice) + terminating, cmd := p.parseCommand(state, cmdQueue, &commandPathSlice) currentCommandPath = strings.Join(commandPathSlice, " ") // Inject relevant environment variables for the current command context if instanceCount, exists := envInserted[currentCommandPath]; !exists || instanceCount < cmdQueue.Len() { @@ -722,6 +722,11 @@ func (p *Parser) Parse(args []string, defaults ...string) bool { lastCommandPath = currentCommandPath commandPathSlice = commandPathSlice[:0] } + + if cmd != nil && cmd.Greedy { + p.greedyAfterPos = state.Pos() + 1 + break + } } } @@ -1717,8 +1722,8 @@ func (p *Parser) GetCompletionData() completion.CompletionData { cmd := "" flagName := flag if len(flagParts) > 1 { - cmd = flagParts[0] - flagName = flagParts[1] + flagName = flagParts[0] + cmd = flagParts[1] } addFlagToCompletionData(&data, cmd, flagName, flagInfo, p.renderer) @@ -2046,10 +2051,8 @@ func (p *Parser) PrintHelp(writer io.Writer) { switch style { case HelpStyleFlat: p.printFlatHelp(writer) - case HelpStyleGrouped: + case HelpStyleGrouped, HelpStyleGroupedClean: p.printGroupedHelp(writer) - case HelpStyleGroupedClean: - p.printGroupedCleanHelp(writer) case HelpStyleCompact: p.printCompactHelp(writer) case HelpStyleHierarchical: @@ -2072,7 +2075,7 @@ func (p *Parser) DefaultPrettyPrintConfig() *PrettyPrintConfig { NewCommandPrefix: " + ", DefaultPrefix: " ├─ ", TerminalPrefix: " └─ ", - InnerLevelBindPrefix: " ** ", + InnerLevelBindPrefix: " ", OuterLevelBindPrefix: " │ ", } } diff --git a/v2/goopt_internal.go b/v2/goopt_internal.go index 50d297b..ee4bcf7 100644 --- a/v2/goopt_internal.go +++ b/v2/goopt_internal.go @@ -573,7 +573,7 @@ func (p *Parser) queueSecureArgument(name string, argument *Argument) { p.secureArguments.Set(name, &argument.Secure) } -func (p *Parser) parseCommand(state parse.State, cmdQueue *queue.Q[*Command], commandPathSlice *[]string) bool { +func (p *Parser) parseCommand(state parse.State, cmdQueue *queue.Q[*Command], commandPathSlice *[]string) (bool, *Command) { terminating := false currentArg := state.CurrentArg() @@ -585,7 +585,7 @@ func (p *Parser) parseCommand(state parse.State, cmdQueue *queue.Q[*Command], co if cmdQueue.Len() > 0 { ok, curSub = p.checkSubCommands(cmdQueue, currentArg) if !ok { - return false + return false, nil } } @@ -607,7 +607,7 @@ func (p *Parser) parseCommand(state parse.State, cmdQueue *queue.Q[*Command], co if cmd != nil { // Use the canonical command name for the path *commandPathSlice = append(*commandPathSlice, cmd.Name) - if len(cmd.Subcommands) == 0 { + if len(cmd.Subcommands) == 0 || cmd.Greedy { cmdQueue.Clear() terminating = true } else { @@ -676,14 +676,14 @@ func (p *Parser) parseCommand(state parse.State, cmdQueue *queue.Q[*Command], co } } p.addError(fmt.Errorf("%s", formatted)) - return false + return false, nil } } } } } - return terminating + return terminating, cmd } func (p *Parser) queueCommandCallback(cmd *Command) { @@ -1772,12 +1772,15 @@ func (p *Parser) groupEnvVarsByCommand() map[string][]string { return commandEnvVars } for _, env := range p.envResolver.Environ() { + var v string kv := strings.SplitN(env, "=", 2) - v := strings.Replace(kv[0], p.envVarPrefix, "", 1) + if p.envVarPrefix != "" && !strings.HasPrefix(kv[0], p.envVarPrefix) { + continue + } + v = strings.Replace(kv[0], p.envVarPrefix, "", 1) if v == "" { continue } - v = p.envNameConverter(v) for f := p.acceptedFlags.Front(); f != nil; f = f.Next() { paths := splitPathFlag(*f.Key) @@ -1974,6 +1977,23 @@ func toArgument(c *types.TagConfig) (*Argument, error) { if len(validators) > 0 { allValidators = append(allValidators, validators...) } + + // If no accepted tag was provided, derive AcceptedValues from isoneof + // so that completion data can see the allowed values. + if len(c.AcceptedValues) == 0 { + if values := validation.ExtractIsOneOfValues(c.Validators); len(values) > 0 { + pvs := make([]types.PatternValue, len(values)) + for i, v := range values { + escaped := regexp.QuoteMeta(v) + pvs[i] = types.PatternValue{ + Pattern: "^" + escaped + "$", + Description: v, + Compiled: regexp.MustCompile("^" + escaped + "$"), + } + } + configs = append(configs, WithAcceptedValues(pvs)) + } + } } // Add all validators to the argument @@ -1996,6 +2016,7 @@ type CommandConfig struct { DescriptionKey string NameKey string Parent *Command + Greedy bool } func (p *Parser) buildCommand(commandPath, description, descriptionKey string, parent *Command) (*Command, error) { @@ -2040,7 +2061,8 @@ func (p *Parser) buildCommandFromConfig(config *CommandConfig) (*Command, error) } else { // Create a new top-level command newCommand := &Command{ - Name: cmdName, + Name: cmdName, + Greedy: config.Greedy, } // Only apply properties if this is the actual command being configured @@ -2079,6 +2101,7 @@ func (p *Parser) buildCommandFromConfig(config *CommandConfig) (*Command, error) Name: cmdName, Subcommands: []Command{}, path: config.Path, + Greedy: config.Greedy, } // For single command paths, always apply properties // For multi-command paths, only apply to the last command @@ -2381,6 +2404,7 @@ func (p *Parser) processStructCommands(val reflect.Value, currentPath string, cu DescriptionKey: cmd.DescriptionKey, NameKey: cmd.NameKey, Parent: nil, + Greedy: cmd.Greedy, }) if err != nil { return errs.WrapOnce(err, errs.ErrProcessingCommand, cmd.path) @@ -2460,6 +2484,7 @@ func (p *Parser) processStructCommands(val reflect.Value, currentPath string, cu DescriptionKey: cmd.DescriptionKey, NameKey: cmd.NameKey, Parent: parent, + Greedy: cmd.Greedy, }) if err != nil { return errs.ErrProcessingCommand.WithArgs(cmdPath).Wrap(err) @@ -2507,6 +2532,7 @@ func (p *Parser) processStructCommands(val reflect.Value, currentPath string, cu DescriptionKey: config.DescriptionKey, NameKey: config.NameKey, Parent: parent, + Greedy: config.Greedy, }) if err != nil { return errs.WrapOnce(err, errs.ErrProcessingCommand, cmdPath) @@ -3058,35 +3084,33 @@ func (p *Parser) printCompactHelp(writer io.Writer) { // Commands with flag counts if p.registeredCommands.Count() > 0 { + // Pre-pass: compute max command name width for alignment + maxCmdWidth := 0 + for cmd := p.registeredCommands.Front(); cmd != nil; cmd = cmd.Next() { + if cmd.Value.topLevel { + cmdName := p.buildCommandNameWithPositionals(cmd.Value) + if len(cmdName) > maxCmdWidth { + maxCmdWidth = len(cmdName) + } + } + } + maxCmdWidth += 2 // add padding after longest name + fmt.Fprintf(writer, "\n%s:\n", p.layeredProvider.GetMessage(messages.MsgCommandsKey)) for cmd := p.registeredCommands.Front(); cmd != nil; cmd = cmd.Next() { if cmd.Value.topLevel { flagCount := p.countCommandFlags(cmd.Value.Name) - // Use CommandUsage to include positionals - cmdName := cmd.Value.Name + cmdName := p.buildCommandNameWithPositionals(cmd.Value) desc := p.renderer.CommandDescription(cmd.Value) - // Get positionals to build the full command name with args - positionals := p.getPositionalsForCommand(cmd.Value.path) - for _, pos := range positionals { - flagName := pos.Value - if idx := strings.LastIndex(flagName, "@"); idx >= 0 { - flagName = flagName[:idx] - } - if pos.Argument.Required { - cmdName += " <" + flagName + ">" - } else { - cmdName += " [" + flagName + "]" - } - } - if desc != "" { - fmt.Fprintf(writer, " %-15s \"%-40s\"", - cmdName, - util.Truncate(desc, 40)) + quotedDesc := fmt.Sprintf("\"%s\"", util.Truncate(desc, 40)) + fmt.Fprintf(writer, " %-*s %-42s", + maxCmdWidth, cmdName, + quotedDesc) } else { - fmt.Fprintf(writer, " %-15s %-40s", - cmdName, "") + fmt.Fprintf(writer, " %-*s %-42s", + maxCmdWidth, cmdName, "") } if flagCount > 0 { fmt.Fprintf(writer, " [%d %s]", flagCount, p.layeredProvider.GetMessage(messages.MsgFlagsKey)) @@ -3260,7 +3284,7 @@ func (p *Parser) printCompactFlag(writer io.Writer, arg *Argument) { } if p.helpConfig.ShowDefaults && arg.DefaultValue != "" { - flagStr += fmt.Sprintf(" %s (%s)", p.layeredProvider.GetMessage(messages.MsgDefaultsToKey), + flagStr += fmt.Sprintf(" (%s: %s)", p.layeredProvider.GetMessage(messages.MsgDefaultsToKey), arg.DefaultValue) } @@ -3279,27 +3303,36 @@ func (p *Parser) printCompactFlag(writer io.Writer, arg *Argument) { // printCommandTree prints the command hierarchy as a tree func (p *Parser) printCommandTree(writer io.Writer) { + // Pre-pass: compute max widths for alignment + maxTopWidth := 0 + maxSubWidth := 0 for cmd := p.registeredCommands.Front(); cmd != nil; cmd = cmd.Next() { - ppConfig := p.DefaultPrettyPrintConfig() if cmd.Value.topLevel { - // Build command name with positionals - cmdName := cmd.Value.Name - positionals := p.getPositionalsForCommand(cmd.Value.path) - for _, pos := range positionals { - flagName := pos.Value - if idx := strings.LastIndex(flagName, "@"); idx >= 0 { - flagName = flagName[:idx] - } - if pos.Argument.Required { - cmdName += " <" + flagName + ">" - } else { - cmdName += " [" + flagName + "]" + cmdName := p.buildCommandNameWithPositionals(cmd.Value) + if len(cmdName) > maxTopWidth { + maxTopWidth = len(cmdName) + } + for i := range cmd.Value.Subcommands { + sub := &cmd.Value.Subcommands[i] + subPath := cmd.Value.Name + " " + sub.Name + subName := p.buildSubcommandNameWithPositionals(sub, subPath) + if len(subName) > maxSubWidth { + maxSubWidth = len(subName) } } + } + } + maxTopWidth += 2 // add padding after longest name + maxSubWidth += 2 + + for cmd := p.registeredCommands.Front(); cmd != nil; cmd = cmd.Next() { + ppConfig := p.DefaultPrettyPrintConfig() + if cmd.Value.topLevel { + cmdName := p.buildCommandNameWithPositionals(cmd.Value) desc := p.renderer.CommandDescription(cmd.Value) if desc != "" { - fmt.Fprintf(writer, "\n%-20s \"%s\"\n", cmdName, desc) + fmt.Fprintf(writer, "\n%-*s \"%s\"\n", maxTopWidth, cmdName, desc) } else { fmt.Fprintf(writer, "\n%s\n", cmdName) } @@ -3309,30 +3342,15 @@ func (p *Parser) printCommandTree(writer io.Writer) { prefix = ppConfig.TerminalPrefix } sub := &cmd.Value.Subcommands[i] - // Look up the actual registered command to get the correct description subPath := cmd.Value.Name + " " + sub.Name - - // Build subcommand name with positionals - subName := sub.Name - subPositionals := p.getPositionalsForCommand(subPath) - for _, pos := range subPositionals { - flagName := pos.Value - if idx := strings.LastIndex(flagName, "@"); idx >= 0 { - flagName = flagName[:idx] - } - if pos.Argument.Required { - subName += " <" + flagName + ">" - } else { - subName += " [" + flagName + "]" - } - } + subName := p.buildSubcommandNameWithPositionals(sub, subPath) if registeredSub, found := p.registeredCommands.Get(subPath); found { desc := util.Truncate(p.renderer.CommandDescription(registeredSub), 50) - fmt.Fprintf(writer, " %s %-20s \"%s\"\n", prefix, subName, desc) + fmt.Fprintf(writer, " %s %-*s \"%s\"\n", prefix, maxSubWidth, subName, desc) } else { desc := util.Truncate(p.renderer.CommandDescription(sub), 50) - fmt.Fprintf(writer, " %s %-20s \"%s\"\n", prefix, subName, desc) + fmt.Fprintf(writer, " %s %-*s \"%s\"\n", prefix, maxSubWidth, subName, desc) } } } @@ -3350,6 +3368,43 @@ func (p *Parser) countCommandFlags(cmdPath string) int { return count } +// buildCommandNameWithPositionals builds a command name string including its positional args +// (e.g., "cp " or "ls [subfolder]") +func (p *Parser) buildCommandNameWithPositionals(cmd *Command) string { + cmdName := cmd.Name + positionals := p.getPositionalsForCommand(cmd.path) + for _, pos := range positionals { + flagName := pos.Value + if idx := strings.LastIndex(flagName, "@"); idx >= 0 { + flagName = flagName[:idx] + } + if pos.Argument.Required { + cmdName += " <" + flagName + ">" + } else { + cmdName += " [" + flagName + "]" + } + } + return cmdName +} + +// buildSubcommandNameWithPositionals builds a subcommand name string including its positional args +func (p *Parser) buildSubcommandNameWithPositionals(sub *Command, subPath string) string { + subName := sub.Name + subPositionals := p.getPositionalsForCommand(subPath) + for _, pos := range subPositionals { + flagName := pos.Value + if idx := strings.LastIndex(flagName, "@"); idx >= 0 { + flagName = flagName[:idx] + } + if pos.Argument.Required { + subName += " <" + flagName + ">" + } else { + subName += " [" + flagName + "]" + } + } + return subName +} + // extractFlagPrefix extracts the prefix from a flag name (e.g., "core.ldap" from "core.ldap.host") func extractFlagPrefix(flagName string) string { parts := strings.Split(flagName, ".") diff --git a/v2/goopt_internal_positionals.go b/v2/goopt_internal_positionals.go index 641707e..85b8d1b 100644 --- a/v2/goopt_internal_positionals.go +++ b/v2/goopt_internal_positionals.go @@ -171,9 +171,7 @@ func (p *Parser) shouldSkipBooleanAfterStandalone(args []string, i int, currentC } return false -} - -// updateCommandPath updates the current command path based on the argument +} // updateCommandPath updates the current command path based on the argument // Returns true if the argument was a command func (p *Parser) updateCommandPath(arg string, currentCmdPath *[]string, argPos *int, executedCommands map[string]bool) bool { isCmd := false @@ -341,6 +339,16 @@ func (p *Parser) setPositionalArguments(state parse.State) { executedCommands[""] = true // Always check global positionals for i, arg := range args { + if p.greedyAfterPos > 0 && i >= p.greedyAfterPos { + positional = append(positional, PositionalArgument{ + Position: i, + Value: arg, + ArgPos: argPos, + }) + argPos++ + continue + } + if skipNext { skipNext = false continue diff --git a/v2/goopt_test.go b/v2/goopt_test.go index df96ed4..629e51b 100644 --- a/v2/goopt_test.go +++ b/v2/goopt_test.go @@ -1395,10 +1395,10 @@ Global Flags: Commands: + create "Create resources" ├─ + create user "Manage users" - │ │ ** --email or -e "Email for user creation" (optional) + │ │ --email or -e "Email for user creation" (optional) └─ + create user type "Specify user type" - │ │ │ ** --username "Username for user creation" (required) - │ │ │ ** --firstName "User first name" (optional) + │ │ │ --username "Username for user creation" (required) + │ │ │ --firstName "User first name" (optional) └─ + create group "Manage groups" ` output := strings.Join(*writer.data, "") @@ -13941,7 +13941,7 @@ func TestParser_CommandHierarchyDescriptions(t *testing.T) { // This is intentional - it helps catch regressions where descriptionKeys might be // incorrectly propagated from child to parent commands. // Descriptions now have quotes - if !strings.Contains(output, "middle \"middle.desc\"") { + if !strings.Contains(output, "\"middle.desc\"") || !strings.Contains(output, "middle") { t.Errorf("Hierarchical help doesn't show correct description key for middle command.\nOutput:\n%s", output) } } @@ -14031,7 +14031,7 @@ func TestParser_HierarchicalHelpRegression(t *testing.T) { // The bug was that "copy" showed "copy blob configuration" instead of "nexus copy commands" // Descriptions now have quotes - if !strings.Contains(output, "copy \"nexus copy commands\"") { + if !strings.Contains(output, "\"nexus copy commands\"") || !strings.Contains(output, "copy") { t.Errorf("Hierarchical help shows wrong description for copy command.\nOutput:\n%s", output) } } diff --git a/v2/help_parser.go b/v2/help_parser.go index 1b1fe44..31a2433 100644 --- a/v2/help_parser.go +++ b/v2/help_parser.go @@ -212,26 +212,42 @@ func (h *HelpParser) normalize(args []string) ([]string, int) { return args, 0 } - // Collect all non-flag strings before the first flag + // Collect all non-flag, non-keyword strings from anywhere in the args. + // These are command path components (e.g., "keyfile" "rekey"). + // We need to collect them regardless of position because pos:0 on a []string + // only captures the first positional arg — multiple separate args get lost. commands := []string{} - if firstFlagIndex > 0 { - for i := 0; i < firstFlagIndex; i++ { - commands = append(commands, args[i]) + flags := []string{} + for i, arg := range args { + if h.hp.isFlag(arg) { + if !h.isHelpArg(arg) { + flags = append(flags, arg) + // If this flag needs a value, also capture the next arg + if i+1 < len(args) && !h.hp.isFlag(args[i+1]) { + flags = append(flags, args[i+1]) + } + } + continue + } + // Skip values already consumed by flags above + if i > 0 && h.hp.isFlag(args[i-1]) && !h.isHelpArg(args[i-1]) { + continue + } + if !isHelpKeyword(arg) { + commands = append(commands, arg) } } // Build new args newArgs := []string{} - // If we have commands, add them as a comma-separated positional argument + // Add commands as a comma-separated positional argument so pos:0 []string works if len(commands) > 0 { newArgs = append(newArgs, strings.Join(commands, ",")) } - // Add everything after the help flag (except the help flag itself) - for i := helpIndex + 1; i < len(args); i++ { - newArgs = append(newArgs, args[i]) - } + // Add the non-help flags + newArgs = append(newArgs, flags...) return newArgs, len(commands) } @@ -790,10 +806,8 @@ func (h *HelpParser) showDefault(writer io.Writer) error { switch style { case HelpStyleFlat: return h.showFlatStyle(writer) - case HelpStyleGrouped: + case HelpStyleGrouped, HelpStyleGroupedClean: return h.showGroupedStyle(writer) - case HelpStyleGroupedClean: - return h.showGroupedCleanStyle(writer) case HelpStyleCompact: return h.showCompactStyle(writer) case HelpStyleHierarchical: diff --git a/v2/i18n/all_locales/ar.json b/v2/i18n/all_locales/ar.json index 78d7d0c..a057487 100644 --- a/v2/i18n/all_locales/ar.json +++ b/v2/i18n/all_locales/ar.json @@ -136,7 +136,7 @@ "goopt.error.validation.value_at_most": "يجب أن تكون القيمة '%[2]s' على الأكثر %[1]v", "goopt.error.validation.value_between": "يجب أن تكون القيمة '%[3]s' بين %[1]v و %[2]v", "goopt.error.validation.value_cannot_be": "لا يمكن أن تكون القيمة '%[2]s': %[1]s", - "goopt.error.validation.value_must_be_one_of": "يجب أن تكون القيمة '%[2]s' واحدة من: %[1]s", + "goopt.error.validation.value_must_be_one_of": "يجب أن تكون القيمة '%[1]s' واحدة من: %[2]s", "goopt.error.validation_failed": "فشل التحقق للعلامة %[1]s", "goopt.error.wrapped": "%[1]s: %[2]v", "goopt.msg.all_flags": "كل العلامات:", diff --git a/v2/i18n/all_locales/de.json b/v2/i18n/all_locales/de.json index ad1480e..235fd9e 100644 --- a/v2/i18n/all_locales/de.json +++ b/v2/i18n/all_locales/de.json @@ -136,7 +136,7 @@ "goopt.error.validation.value_at_most": "Wert darf höchstens %[1]v sein", "goopt.error.validation.value_between": "Wert muss zwischen %[1]v und %[2]v liegen", "goopt.error.validation.value_cannot_be": "Wert darf nicht sein: %[1]s", - "goopt.error.validation.value_must_be_one_of": "Wert muss einer der folgenden sein: %[1]s", + "goopt.error.validation.value_must_be_one_of": "Wert '%[1]s' muss einer der folgenden sein: %[2]s", "goopt.error.validation_failed": "Validierung fehlgeschlagen für Flag %[1]s", "goopt.error.wrapped": "%[1]s: %[2]v", "goopt.msg.all_flags": "Alle Flags:", diff --git a/v2/i18n/all_locales/en.json b/v2/i18n/all_locales/en.json index f3047bc..0a9154d 100644 --- a/v2/i18n/all_locales/en.json +++ b/v2/i18n/all_locales/en.json @@ -147,7 +147,7 @@ "goopt.error.validation.value_at_least": "value '%[2]s' must be at least %[1]v", "goopt.error.validation.value_at_most": "value '%[2]s' must be at most %[1]v", "goopt.error.validation.pattern_match": "value '%[2]s' must match pattern: %[1]s", - "goopt.error.validation.value_must_be_one_of": "value '%[2]s' must be one of: %[1]s", + "goopt.error.validation.value_must_be_one_of": "value '%[1]s' must be one of: %[2]s", "goopt.error.validation.value_cannot_be": "value '%[2]s' cannot be: %[1]s", "goopt.error.validation.must_be_integer": "value '%[1]s' must be an integer", "goopt.error.validation.must_be_boolean": "value '%[1]s' must be true or false", diff --git a/v2/i18n/all_locales/es.json b/v2/i18n/all_locales/es.json index 8febd6e..2b7d6b3 100644 --- a/v2/i18n/all_locales/es.json +++ b/v2/i18n/all_locales/es.json @@ -137,7 +137,7 @@ "goopt.error.validation.value_at_most": "el valor '%[2]s' debe ser como máximo %[1]v", "goopt.error.validation.value_between": "el valor '%[3]s' debe estar entre %[1]v y %[2]v", "goopt.error.validation.value_cannot_be": "el valor '%[2]s' no puede ser: %[1]s", - "goopt.error.validation.value_must_be_one_of": "el valor '%[2]s' debe ser uno de: %[1]s", + "goopt.error.validation.value_must_be_one_of": "el valor '%[1]s' debe ser uno de: %[2]s", "goopt.error.wrapped": "%[1]s: %[2]v", "goopt.msg.all_flags": "Todas las banderas:", "goopt.msg.all_parent_flags": "todas las banderas heredadas", diff --git a/v2/i18n/all_locales/fr.json b/v2/i18n/all_locales/fr.json index 3e9b1f8..84e85c4 100644 --- a/v2/i18n/all_locales/fr.json +++ b/v2/i18n/all_locales/fr.json @@ -136,7 +136,7 @@ "goopt.error.validation.value_at_most": "la valeur ne doit pas dépasser %[1]v", "goopt.error.validation.value_between": "la valeur doit être comprise entre %[1]v et %[2]v", "goopt.error.validation.value_cannot_be": "la valeur ne peut pas être : %[1]s", - "goopt.error.validation.value_must_be_one_of": "la valeur doit être l'une des suivantes : %[1]s", + "goopt.error.validation.value_must_be_one_of": "la valeur '%[1]s' doit être l'une des suivantes : %[2]s", "goopt.error.validation_failed": "échec de la validation pour l'option %[1]s", "goopt.error.wrapped": "%[1]s : %[2]v", "goopt.msg.all_flags": "Toutes les options :", diff --git a/v2/i18n/all_locales/he.json b/v2/i18n/all_locales/he.json index e3a3d76..a669096 100644 --- a/v2/i18n/all_locales/he.json +++ b/v2/i18n/all_locales/he.json @@ -136,7 +136,7 @@ "goopt.error.validation.value_at_most": "הערך '%[2]s' חייב להיות לכל היותר %[1]v", "goopt.error.validation.value_between": "הערך '%[3]s' חייב להיות בין %[1]v ל %[2]v", "goopt.error.validation.value_cannot_be": "הערך '%[2]s' אינו יכול להיות: %[1]s", - "goopt.error.validation.value_must_be_one_of": "הערך '%[2]s' חייב להיות אחד מ: %[1]s", + "goopt.error.validation.value_must_be_one_of": "הערך '%[1]s' חייב להיות אחד מ: %[2]s", "goopt.error.validation_failed": "האימות נכשל עבור דגל %[1]s", "goopt.error.wrapped": "%[1]s: %[2]v", "goopt.msg.all_flags": "כל הדגלים:", diff --git a/v2/i18n/all_locales/hi.json b/v2/i18n/all_locales/hi.json index 6824116..ac0b504 100644 --- a/v2/i18n/all_locales/hi.json +++ b/v2/i18n/all_locales/hi.json @@ -136,7 +136,7 @@ "goopt.error.validation.value_at_most": "मान '%[2]s' अधिकतम %[1]v होना चाहिए", "goopt.error.validation.value_between": "मान '%[3]s' %[1]v और %[2]v के बीच होना चाहिए", "goopt.error.validation.value_cannot_be": "मान '%[2]s' यह नहीं हो सकता: %[1]s", - "goopt.error.validation.value_must_be_one_of": "मान '%[2]s' इनमें से एक होना चाहिए: %[1]s", + "goopt.error.validation.value_must_be_one_of": "मान '%[1]s' इनमें से एक होना चाहिए: %[2]s", "goopt.error.validation_failed": "फ्लैग %[1]s के लिए सत्यापन विफल", "goopt.error.wrapped": "%[1]s: %[2]v", "goopt.msg.all_flags": "सभी फ़्लैग:", diff --git a/v2/i18n/all_locales/ja.json b/v2/i18n/all_locales/ja.json index c82501a..37670ea 100644 --- a/v2/i18n/all_locales/ja.json +++ b/v2/i18n/all_locales/ja.json @@ -136,7 +136,7 @@ "goopt.error.validation.value_at_most": "値 '%[2]s' は最大 %[1]v でなければなりません", "goopt.error.validation.value_between": "値 '%[3]s' は %[1]v 〜 %[2]v の間でなければなりません", "goopt.error.validation.value_cannot_be": "値 '%[2]s' は次のいずれでもあってはなりません: %[1]s", - "goopt.error.validation.value_must_be_one_of": "値 '%[2]s' は次のいずれかである必要があります: %[1]s", + "goopt.error.validation.value_must_be_one_of": "値 '%[1]s' は次のいずれかである必要があります: %[2]s", "goopt.error.validation_failed": "フラグ %[1]s の検証に失敗しました", "goopt.error.wrapped": "%[1]s: %[2]v", "goopt.msg.all_flags": "すべてのフラグ:", diff --git a/v2/i18n/all_locales/pt.json b/v2/i18n/all_locales/pt.json index 5de1eed..7a13c98 100644 --- a/v2/i18n/all_locales/pt.json +++ b/v2/i18n/all_locales/pt.json @@ -136,7 +136,7 @@ "goopt.error.validation.value_at_most": "[TODO] value '%[2]s' must be at most %[1]v", "goopt.error.validation.value_between": "[TODO] value '%[3]s' must be between %[1]v and %[2]v", "goopt.error.validation.value_cannot_be": "[TODO] value '%[2]s' cannot be: %[1]s", - "goopt.error.validation.value_must_be_one_of": "[TODO] value '%[2]s' must be one of: %[1]s", + "goopt.error.validation.value_must_be_one_of": "o valor '%[1]s' deve ser um de: %[2]s", "goopt.error.validation_failed": "validação falhou para a flag %[1]s", "goopt.error.wrapped": "%[1]s: %[2]v", "goopt.flag.help": "ajuda", diff --git a/v2/i18n/all_locales/zh.json b/v2/i18n/all_locales/zh.json index 09fd5c7..2f58e00 100644 --- a/v2/i18n/all_locales/zh.json +++ b/v2/i18n/all_locales/zh.json @@ -136,7 +136,7 @@ "goopt.error.validation.value_at_most": "值 '%[2]s' 必须最多为 %[1]v", "goopt.error.validation.value_between": "值 '%[3]s' 必须在 %[1]v 和 %[2]v 之间", "goopt.error.validation.value_cannot_be": "值 '%[2]s' 不能是: %[1]s", - "goopt.error.validation.value_must_be_one_of": "值 '%[2]s' 必须是以下之一: %[1]s", + "goopt.error.validation.value_must_be_one_of": "值 '%[1]s' 必须是以下之一: %[2]s", "goopt.error.validation_failed": "标志 %[1]s 的验证失败", "goopt.error.wrapped": "%[1]s: %[2]v", "goopt.msg.all_flags": "所有标志:", diff --git a/v2/i18n/locales/ar/ar_gen.go b/v2/i18n/locales/ar/ar_gen.go index 1a11c9f..1323f10 100644 --- a/v2/i18n/locales/ar/ar_gen.go +++ b/v2/i18n/locales/ar/ar_gen.go @@ -146,7 +146,7 @@ const SystemTranslations = `{ "goopt.error.validation.value_at_most": "يجب أن تكون القيمة '%[2]s' على الأكثر %[1]v", "goopt.error.validation.value_between": "يجب أن تكون القيمة '%[3]s' بين %[1]v و %[2]v", "goopt.error.validation.value_cannot_be": "لا يمكن أن تكون القيمة '%[2]s': %[1]s", - "goopt.error.validation.value_must_be_one_of": "يجب أن تكون القيمة '%[2]s' واحدة من: %[1]s", + "goopt.error.validation.value_must_be_one_of": "يجب أن تكون القيمة '%[1]s' واحدة من: %[2]s", "goopt.error.validation_failed": "فشل التحقق للعلامة %[1]s", "goopt.error.wrapped": "%[1]s: %[2]v", "goopt.flag.help": "مساعدة", diff --git a/v2/i18n/locales/de/de_gen.go b/v2/i18n/locales/de/de_gen.go index a3f1824..470233d 100644 --- a/v2/i18n/locales/de/de_gen.go +++ b/v2/i18n/locales/de/de_gen.go @@ -146,7 +146,7 @@ const SystemTranslations = `{ "goopt.error.validation.value_at_most": "Wert darf höchstens %[1]v sein", "goopt.error.validation.value_between": "Wert muss zwischen %[1]v und %[2]v liegen", "goopt.error.validation.value_cannot_be": "Wert darf nicht sein: %[1]s", - "goopt.error.validation.value_must_be_one_of": "Wert muss einer der folgenden sein: %[1]s", + "goopt.error.validation.value_must_be_one_of": "Wert '%[1]s' muss einer der folgenden sein: %[2]s", "goopt.error.validation_failed": "Validierung fehlgeschlagen für Flag %[1]s", "goopt.error.wrapped": "%[1]s: %[2]v", "goopt.flag.help": "Hilfe", diff --git a/v2/i18n/locales/en/en_gen.go b/v2/i18n/locales/en/en_gen.go index 4830c66..5374671 100644 --- a/v2/i18n/locales/en/en_gen.go +++ b/v2/i18n/locales/en/en_gen.go @@ -146,7 +146,7 @@ const SystemTranslations = `{ "goopt.error.validation.value_at_most": "value '%[2]s' must be at most %[1]v", "goopt.error.validation.value_between": "value '%[3]s' must be between %[1]v and %[2]v", "goopt.error.validation.value_cannot_be": "value '%[2]s' cannot be: %[1]s", - "goopt.error.validation.value_must_be_one_of": "value '%[2]s' must be one of: %[1]s", + "goopt.error.validation.value_must_be_one_of": "value '%[1]s' must be one of: %[2]s", "goopt.error.validation_failed": "validation failed for flag %[1]s", "goopt.error.wrapped": "%[1]s: %[2]v", "goopt.flag.help": "help", diff --git a/v2/i18n/locales/es/es_gen.go b/v2/i18n/locales/es/es_gen.go index b8b65af..26abd9c 100644 --- a/v2/i18n/locales/es/es_gen.go +++ b/v2/i18n/locales/es/es_gen.go @@ -146,7 +146,7 @@ const SystemTranslations = `{ "goopt.error.validation.value_at_most": "el valor '%[2]s' debe ser como máximo %[1]v", "goopt.error.validation.value_between": "el valor '%[3]s' debe estar entre %[1]v y %[2]v", "goopt.error.validation.value_cannot_be": "el valor '%[2]s' no puede ser: %[1]s", - "goopt.error.validation.value_must_be_one_of": "el valor '%[2]s' debe ser uno de: %[1]s", + "goopt.error.validation.value_must_be_one_of": "el valor '%[1]s' debe ser uno de: %[2]s", "goopt.error.validation_failed": "validación fallida para la bandera %[1]s", "goopt.error.wrapped": "%[1]s: %[2]v", "goopt.flag.help": "ayuda", diff --git a/v2/i18n/locales/fr/fr_gen.go b/v2/i18n/locales/fr/fr_gen.go index 0e50bf9..186b60d 100644 --- a/v2/i18n/locales/fr/fr_gen.go +++ b/v2/i18n/locales/fr/fr_gen.go @@ -146,7 +146,7 @@ const SystemTranslations = `{ "goopt.error.validation.value_at_most": "la valeur ne doit pas dépasser %[1]v", "goopt.error.validation.value_between": "la valeur doit être comprise entre %[1]v et %[2]v", "goopt.error.validation.value_cannot_be": "la valeur ne peut pas être : %[1]s", - "goopt.error.validation.value_must_be_one_of": "la valeur doit être l'une des suivantes : %[1]s", + "goopt.error.validation.value_must_be_one_of": "la valeur '%[1]s' doit être l'une des suivantes : %[2]s", "goopt.error.validation_failed": "échec de la validation pour l'option %[1]s", "goopt.error.wrapped": "%[1]s : %[2]v", "goopt.flag.help": "aide", diff --git a/v2/i18n/locales/he/he_gen.go b/v2/i18n/locales/he/he_gen.go index 4168074..88ce6bc 100644 --- a/v2/i18n/locales/he/he_gen.go +++ b/v2/i18n/locales/he/he_gen.go @@ -146,7 +146,7 @@ const SystemTranslations = `{ "goopt.error.validation.value_at_most": "הערך '%[2]s' חייב להיות לכל היותר %[1]v", "goopt.error.validation.value_between": "הערך '%[3]s' חייב להיות בין %[1]v ל %[2]v", "goopt.error.validation.value_cannot_be": "הערך '%[2]s' אינו יכול להיות: %[1]s", - "goopt.error.validation.value_must_be_one_of": "הערך '%[2]s' חייב להיות אחד מ: %[1]s", + "goopt.error.validation.value_must_be_one_of": "הערך '%[1]s' חייב להיות אחד מ: %[2]s", "goopt.error.validation_failed": "האימות נכשל עבור דגל %[1]s", "goopt.error.wrapped": "%[1]s: %[2]v", "goopt.flag.help": "עזרה", diff --git a/v2/i18n/locales/hi/hi_gen.go b/v2/i18n/locales/hi/hi_gen.go index d783160..9b03a4d 100644 --- a/v2/i18n/locales/hi/hi_gen.go +++ b/v2/i18n/locales/hi/hi_gen.go @@ -146,7 +146,7 @@ const SystemTranslations = `{ "goopt.error.validation.value_at_most": "मान '%[2]s' अधिकतम %[1]v होना चाहिए", "goopt.error.validation.value_between": "मान '%[3]s' %[1]v और %[2]v के बीच होना चाहिए", "goopt.error.validation.value_cannot_be": "मान '%[2]s' यह नहीं हो सकता: %[1]s", - "goopt.error.validation.value_must_be_one_of": "मान '%[2]s' इनमें से एक होना चाहिए: %[1]s", + "goopt.error.validation.value_must_be_one_of": "मान '%[1]s' इनमें से एक होना चाहिए: %[2]s", "goopt.error.validation_failed": "फ्लैग %[1]s के लिए सत्यापन विफल", "goopt.error.wrapped": "%[1]s: %[2]v", "goopt.flag.help": "सहायता", diff --git a/v2/i18n/locales/ja/ja_gen.go b/v2/i18n/locales/ja/ja_gen.go index d378880..3d1b424 100644 --- a/v2/i18n/locales/ja/ja_gen.go +++ b/v2/i18n/locales/ja/ja_gen.go @@ -146,7 +146,7 @@ const SystemTranslations = `{ "goopt.error.validation.value_at_most": "値 '%[2]s' は最大 %[1]v でなければなりません", "goopt.error.validation.value_between": "値 '%[3]s' は %[1]v 〜 %[2]v の間でなければなりません", "goopt.error.validation.value_cannot_be": "値 '%[2]s' は次のいずれでもあってはなりません: %[1]s", - "goopt.error.validation.value_must_be_one_of": "値 '%[2]s' は次のいずれかである必要があります: %[1]s", + "goopt.error.validation.value_must_be_one_of": "値 '%[1]s' は次のいずれかである必要があります: %[2]s", "goopt.error.validation_failed": "フラグ %[1]s の検証に失敗しました", "goopt.error.wrapped": "%[1]s: %[2]v", "goopt.flag.help": "ヘルプ", diff --git a/v2/i18n/locales/pt/pt_gen.go b/v2/i18n/locales/pt/pt_gen.go index acdccfb..3e00211 100644 --- a/v2/i18n/locales/pt/pt_gen.go +++ b/v2/i18n/locales/pt/pt_gen.go @@ -146,7 +146,7 @@ const SystemTranslations = `{ "goopt.error.validation.value_at_most": "[TODO] value '%[2]s' must be at most %[1]v", "goopt.error.validation.value_between": "[TODO] value '%[3]s' must be between %[1]v and %[2]v", "goopt.error.validation.value_cannot_be": "[TODO] value '%[2]s' cannot be: %[1]s", - "goopt.error.validation.value_must_be_one_of": "[TODO] value '%[2]s' must be one of: %[1]s", + "goopt.error.validation.value_must_be_one_of": "o valor '%[1]s' deve ser um de: %[2]s", "goopt.error.validation_failed": "validação falhou para a flag %[1]s", "goopt.error.wrapped": "%[1]s: %[2]v", "goopt.flag.help": "ajuda", diff --git a/v2/i18n/locales/zh/zh_gen.go b/v2/i18n/locales/zh/zh_gen.go index f2d10f0..78bba82 100644 --- a/v2/i18n/locales/zh/zh_gen.go +++ b/v2/i18n/locales/zh/zh_gen.go @@ -146,7 +146,7 @@ const SystemTranslations = `{ "goopt.error.validation.value_at_most": "值 '%[2]s' 必须最多为 %[1]v", "goopt.error.validation.value_between": "值 '%[3]s' 必须在 %[1]v 和 %[2]v 之间", "goopt.error.validation.value_cannot_be": "值 '%[2]s' 不能是: %[1]s", - "goopt.error.validation.value_must_be_one_of": "值 '%[2]s' 必须是以下之一: %[1]s", + "goopt.error.validation.value_must_be_one_of": "值 '%[1]s' 必须是以下之一: %[2]s", "goopt.error.validation_failed": "标志 %[1]s 的验证失败", "goopt.error.wrapped": "%[1]s: %[2]v", "goopt.flag.help": "帮助", diff --git a/v2/internal/parse/tag.go b/v2/internal/parse/tag.go index 94679bc..791374a 100644 --- a/v2/internal/parse/tag.go +++ b/v2/internal/parse/tag.go @@ -106,6 +106,12 @@ func UnmarshalTagFormat(tag string, field reflect.StructField) (*types.TagConfig config.Description = value case "default": config.Default = value + case "greedy": + boolVal, err := strconv.ParseBool(value) + if err != nil { + return nil, errs.ErrInvalidAttributeForType.WithArgs("'greedy'", field.Name, value) + } + config.Greedy = boolVal case "required": boolVal, err := strconv.ParseBool(value) if err != nil { diff --git a/v2/parser_config_funcs_test.go b/v2/parser_config_funcs_test.go index 22765e3..db1ac16 100644 --- a/v2/parser_config_funcs_test.go +++ b/v2/parser_config_funcs_test.go @@ -697,7 +697,7 @@ func TestWithPrettyPrintConfig(t *testing.T) { assert.Equal(t, " + ", defaultConfig.NewCommandPrefix) assert.Equal(t, " ├─ ", defaultConfig.DefaultPrefix) assert.Equal(t, " └─ ", defaultConfig.TerminalPrefix) - assert.Equal(t, " ** ", defaultConfig.InnerLevelBindPrefix) + assert.Equal(t, " ", defaultConfig.InnerLevelBindPrefix) assert.Equal(t, " │ ", defaultConfig.OuterLevelBindPrefix) }) diff --git a/v2/renderer.go b/v2/renderer.go index 998a67d..20d6fb0 100644 --- a/v2/renderer.go +++ b/v2/renderer.go @@ -206,6 +206,14 @@ func (r *DefaultRenderer) PositionalUsage(f *Argument, position int) string { // Get the flag name (positionals use flag storage internally) flagName := r.FlagName(f) + // Wrap positional name in brackets to distinguish from flags: + // for required, [name] for optional + if f.Required { + flagName = "<" + flagName + ">" + } else { + flagName = "[" + flagName + "]" + } + // Build the description part var parts []string diff --git a/v2/types/common.go b/v2/types/common.go index a9dee21..a661bae 100644 --- a/v2/types/common.go +++ b/v2/types/common.go @@ -81,6 +81,7 @@ type TagConfig struct { Capacity int Position *int Validators []string // List of validator specifications + Greedy bool // Indicates that this command is the last one in the command chain } // Describe a PatternValue (regular expression with a human-readable explanation of the pattern) diff --git a/v2/validation/parser.go b/v2/validation/parser.go index d39b8b1..14f24b5 100644 --- a/v2/validation/parser.go +++ b/v2/validation/parser.go @@ -105,6 +105,33 @@ func ParseValidators(specs []string) ([]ValidatorFunc, error) { return validators, nil } +// ExtractIsOneOfValues scans validator specs for isoneof(...) and returns the +// literal allowed values. This lets callers (e.g. toArgument) populate +// AcceptedValues for shell completion without duplicating the accepted tag. +func ExtractIsOneOfValues(specs []string) []string { + for _, spec := range specs { + spec = strings.TrimSpace(spec) + parenIndex := strings.Index(spec, "(") + if parenIndex == -1 || !strings.HasSuffix(spec, ")") { + continue + } + name := spec[:parenIndex] + if !strings.EqualFold(name, ValidatorIsOneOf) { + continue + } + argsStr := spec[parenIndex+1 : len(spec)-1] + if argsStr == "" { + continue + } + args := strings.Split(argsStr, ",") + for i := range args { + args[i] = strings.TrimSpace(args[i]) + } + return args + } + return nil +} + // parseCompositeArgs parses arguments for composite validators like oneof and all // It handles nested validators by tracking parentheses/braces depth func parseCompositeArgs(input string) []string { From 21591c88fb760901fe1e6ed061b24fdc135b4813 Mon Sep 17 00:00:00 2001 From: Florent Heyworth Date: Wed, 1 Apr 2026 16:25:40 +0200 Subject: [PATCH 2/2] (doc) improve completion example --- docs/_v2/guides/05-built-in-features/03-shell-completion.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/_v2/guides/05-built-in-features/03-shell-completion.md b/docs/_v2/guides/05-built-in-features/03-shell-completion.md index 1835a09..d508ecc 100644 --- a/docs/_v2/guides/05-built-in-features/03-shell-completion.md +++ b/docs/_v2/guides/05-built-in-features/03-shell-completion.md @@ -25,7 +25,7 @@ import ( "log" "fmt" "github.com/napalu/goopt/v2" - c "github.com/napalu/goopt/v2/completion" + comp "github.com/napalu/goopt/v2/completion" ) func main() { @@ -36,7 +36,7 @@ func main() { parser.AddCommand(goopt.NewCommand( goopt.WithName("completion"), goopt.WithCommandDescription("Generate shell completion script"), - goopt.WithCallback(func(p *goopt.Parser, c *goopt.Command) error { + goopt.WithCallback(func(p *goopt.Parser, _ *goopt.Command) error { // In a real app, you'd let the user specify the shell // as an argument to this command (e.g., 'completion bash'). shell := "bash" @@ -46,7 +46,7 @@ func main() { return err } - manager, err := c.NewManager(shell, exec) + manager, err := comp.NewManager(shell, exec) if err != nil { return err }