diff --git a/v2/definitions.go b/v2/definitions.go index 155994d..ec13ba3 100644 --- a/v2/definitions.go +++ b/v2/definitions.go @@ -129,6 +129,7 @@ type HelpStyle int const ( HelpStyleFlat HelpStyle = iota // PrintUsage HelpStyleGrouped // PrintUsageWithGroups + HelpStyleGroupedClean // PrintUsageWithGroups, clean (no ** markers) HelpStyleCompact // Deduplicated, minimal HelpStyleHierarchical // Command-focused, drill-down HelpStyleSmart // Auto-detect based on CLI size @@ -196,6 +197,7 @@ type Parser struct { structCtx any suggestionsFormatter SuggestionsFormatter helpConfig HelpConfig + prettyPrintConfig *PrettyPrintConfig helpBehavior HelpBehavior autoHelp bool helpFlags []string @@ -246,6 +248,7 @@ type Renderer interface { FlagName(f *Argument) string FlagDescription(f *Argument) string FlagUsage(f *Argument) string + PositionalUsage(f *Argument, position int) string CommandName(c *Command) string CommandDescription(c *Command) string CommandUsage(c *Command) string diff --git a/v2/goopt.go b/v2/goopt.go index 7d025d2..8350af9 100644 --- a/v2/goopt.go +++ b/v2/goopt.go @@ -1779,13 +1779,7 @@ func (p *Parser) PrintUsageWithGroups(writer io.Writer, config ...*PrettyPrintCo if len(config) > 0 { prettyPrintConfig = config[0] } else { - prettyPrintConfig = &PrettyPrintConfig{ - NewCommandPrefix: " + ", - DefaultPrefix: " ├─ ", - TerminalPrefix: " └─ ", - InnerLevelBindPrefix: " ** ", - OuterLevelBindPrefix: " │ ", - } + prettyPrintConfig = p.DefaultPrettyPrintConfig() } p.PrintPositionalArgs(writer) @@ -1929,21 +1923,22 @@ func (p *Parser) PrintCommandsWithFlags(writer io.Writer, config *PrettyPrintCon for kv := p.registeredCommands.Front(); kv != nil; kv = kv.Next() { if kv.Value.topLevel { kv.Value.Visit(func(cmd *Command, level int) bool { - // Determine the correct prefix based on command level and position - var prefix string + // Determine the tree prefix based on command level and position + var treePrefix string switch { case level == 0: - prefix = config.NewCommandPrefix + treePrefix = "" case len(cmd.Subcommands) == 0: - prefix = config.TerminalPrefix + treePrefix = config.TerminalPrefix default: - prefix = config.DefaultPrefix + treePrefix = config.DefaultPrefix } // Print the command itself with proper indentation // Use CommandUsage to get the properly formatted command with positionals commandStr := p.renderer.CommandUsage(cmd) - command := fmt.Sprintf("%s%s%s\n", prefix, strings.Repeat(config.InnerLevelBindPrefix, level), commandStr) + // All commands get the + marker (NewCommandPrefix) + command := fmt.Sprintf("%s%s%s\n", treePrefix, config.NewCommandPrefix, commandStr) if _, err := writer.Write([]byte(command)); err != nil { return false } @@ -1959,14 +1954,28 @@ func (p *Parser) PrintCommandsWithFlags(writer io.Writer, config *PrettyPrintCon // PrintCommandSpecificFlags print flags for a specific command with the appropriate indentation func (p *Parser) PrintCommandSpecificFlags(writer io.Writer, commandPath string, level int, config *PrettyPrintConfig) { + // Build indentation: outer line(s) + inner line + // For level 0: " │ │ " (one outer + inner separator) + // For level 1: " │ │ │ " (two outer + inner separator) + indent := strings.Repeat(config.OuterLevelBindPrefix, level+1) + config.InnerLevelBindPrefix + + // First, collect and display positional arguments + positionals := p.getPositionalsForCommand(commandPath) + for _, pos := range positionals { + positionalUsage := p.renderer.PositionalUsage(pos.Argument, pos.Position) + output := fmt.Sprintf("%s%s\n", indent, positionalUsage) + _, _ = writer.Write([]byte(output)) + } + + // Then display regular flags for f := p.acceptedFlags.Front(); f != nil; f = f.Next() { if f.Value.CommandPath == commandPath { - // Skip positional arguments as they're shown inline with the command + // Skip positional arguments - already displayed above if f.Value.Argument.isPositional() { continue } - flag := fmt.Sprintf("%s%s\n", strings.Repeat(config.OuterLevelBindPrefix, level+1), p.renderer.FlagUsage(f.Value.Argument)) + flag := fmt.Sprintf("%s%s\n", indent, p.renderer.FlagUsage(f.Value.Argument)) _, _ = writer.Write([]byte(flag)) } @@ -1979,6 +1988,11 @@ func (p *Parser) PrintFlags(writer io.Writer) { printedFlags := make(map[string]bool) for f := p.acceptedFlags.Front(); f != nil; f = f.Next() { + // Skip positional arguments - they are shown inline with commands + if f.Value.Argument.isPositional() { + continue + } + // Extract the base flag name (without command path) flagKey := *f.Key flagParts := splitPathFlag(flagKey) @@ -1989,11 +2003,6 @@ func (p *Parser) PrintFlags(writer io.Writer) { continue } - // Skip positional arguments - if f.Value.Argument.isPositional() { - continue - } - // Mark as printed and output printedFlags[baseFlagName] = true _, _ = writer.Write([]byte(fmt.Sprintf(" %s\n", p.renderer.FlagUsage(f.Value.Argument)))) @@ -2015,6 +2024,16 @@ func (p *Parser) GetHelpConfig() HelpConfig { return p.helpConfig } +// SetPrettyPrintConfig sets the configuration for pretty-printing command trees and help output +func (p *Parser) SetPrettyPrintConfig(config *PrettyPrintConfig) { + p.prettyPrintConfig = config +} + +// GetPrettyPrintConfig returns the current pretty-print configuration, or nil if not set +func (p *Parser) GetPrettyPrintConfig() *PrettyPrintConfig { + return p.prettyPrintConfig +} + // PrintHelp prints help according to the configured style func (p *Parser) PrintHelp(writer io.Writer) { style := p.helpConfig.Style @@ -2029,6 +2048,8 @@ func (p *Parser) PrintHelp(writer io.Writer) { p.printFlatHelp(writer) case HelpStyleGrouped: p.printGroupedHelp(writer) + case HelpStyleGroupedClean: + p.printGroupedCleanHelp(writer) case HelpStyleCompact: p.printCompactHelp(writer) case HelpStyleHierarchical: @@ -2041,6 +2062,12 @@ func (p *Parser) PrintHelp(writer io.Writer) { } func (p *Parser) DefaultPrettyPrintConfig() *PrettyPrintConfig { + // Return custom config if set + if p.prettyPrintConfig != nil { + return p.prettyPrintConfig + } + + // Otherwise return default config return &PrettyPrintConfig{ NewCommandPrefix: " + ", DefaultPrefix: " ├─ ", diff --git a/v2/goopt_internal.go b/v2/goopt_internal.go index 988be0b..50d297b 100644 --- a/v2/goopt_internal.go +++ b/v2/goopt_internal.go @@ -2974,7 +2974,12 @@ func (p *Parser) detectBestStyle() HelpStyle { return HelpStyleGrouped } - // Simple CLI + // CLI with any commands uses grouped style for clarity + if cmdCount > 0 { + return HelpStyleGrouped + } + + // Simple CLI with no commands - flat style return HelpStyleFlat } @@ -2988,6 +2993,18 @@ func (p *Parser) printGroupedHelp(writer io.Writer) { p.PrintUsageWithGroups(writer) } +// printGroupedCleanHelp prints grouped help with clean, compact formatting (no ** markers, tighter spacing) +func (p *Parser) printGroupedCleanHelp(writer io.Writer) { + cleanConfig := &PrettyPrintConfig{ + NewCommandPrefix: " + ", + DefaultPrefix: " ├─ ", + TerminalPrefix: " └─ ", + InnerLevelBindPrefix: " ", // 2 spaces - clean and compact + OuterLevelBindPrefix: " │ ", + } + p.PrintUsageWithGroups(writer, cleanConfig) +} + // printCompactHelp prints deduplicated, compact help func (p *Parser) printCompactHelp(writer io.Writer) { fmt.Fprintln(writer, p.layeredProvider.GetFormattedMessage(messages.MsgUsageKey, os.Args[0])) @@ -3063,9 +3080,14 @@ func (p *Parser) printCompactHelp(writer io.Writer) { } } - fmt.Fprintf(writer, " %-15s %-40s", - cmdName, - util.Truncate(desc, 40)) + if desc != "" { + fmt.Fprintf(writer, " %-15s \"%-40s\"", + cmdName, + util.Truncate(desc, 40)) + } else { + fmt.Fprintf(writer, " %-15s %-40s", + cmdName, "") + } if flagCount > 0 { fmt.Fprintf(writer, " [%d %s]", flagCount, p.layeredProvider.GetMessage(messages.MsgFlagsKey)) } @@ -3277,7 +3299,7 @@ func (p *Parser) printCommandTree(writer io.Writer) { desc := p.renderer.CommandDescription(cmd.Value) if desc != "" { - fmt.Fprintf(writer, "\n%-20s %s\n", cmdName, desc) + fmt.Fprintf(writer, "\n%-20s \"%s\"\n", cmdName, desc) } else { fmt.Fprintf(writer, "\n%s\n", cmdName) } @@ -3307,10 +3329,10 @@ func (p *Parser) printCommandTree(writer io.Writer) { 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 %-20s \"%s\"\n", prefix, 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 %-20s \"%s\"\n", prefix, subName, desc) } } } diff --git a/v2/goopt_test.go b/v2/goopt_test.go index e83e74d..df96ed4 100644 --- a/v2/goopt_test.go +++ b/v2/goopt_test.go @@ -1394,12 +1394,12 @@ Global Flags: Commands: + create "Create resources" - ├─ ** create user "Manage users" - │ │ --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) - └─ ** create group "Manage groups" + ├─ + create user "Manage users" + │ │ ** --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) + └─ + create group "Manage groups" ` output := strings.Join(*writer.data, "") assert.Equal(t, expectedOutput, output, "usage output should be grouped and formatted correctly") @@ -1467,11 +1467,11 @@ func TestParser_PrintUsageWithCustomGroups(t *testing.T) { opts.PrintCommandsWithFlags(writer, printConfig) expectedOutput := ` + create "Create resources" - │ * create user "Manage users" - └ └ --email "Email for user creation" (optional) - └ * * create user type "Specify user type" - └ └ └ --username "Username for user creation" (required) - └ * create group "Manage groups" + │ + create user "Manage users" + └ └ * --email "Email for user creation" (optional) + └ + create user type "Specify user type" + └ └ └ * --username "Username for user creation" (required) + └ + create group "Manage groups" ` // Check that the printed output matches the expected structure @@ -10244,6 +10244,8 @@ func TestParser_SetRenderer(t *testing.T) { }) assert.NoError(t, err) + // Explicitly set flat style for this test since we're testing custom renderer format + p.SetHelpStyle(HelpStyleFlat) p.PrintHelp(output) // ensure table-like output from CustomRenderer assert.Contains(t, output.String(), ` -f, --flag1 Flag 1 @@ -13938,7 +13940,8 @@ func TestParser_CommandHierarchyDescriptions(t *testing.T) { // Note: Without translations set up, descriptionKeys are shown instead of descriptions. // This is intentional - it helps catch regressions where descriptionKeys might be // incorrectly propagated from child to parent commands. - if !strings.Contains(output, "middle middle.desc") { + // Descriptions now have quotes + if !strings.Contains(output, "middle \"middle.desc\"") { t.Errorf("Hierarchical help doesn't show correct description key for middle command.\nOutput:\n%s", output) } } @@ -14027,7 +14030,8 @@ func TestParser_HierarchicalHelpRegression(t *testing.T) { output := buf.String() // The bug was that "copy" showed "copy blob configuration" instead of "nexus copy commands" - if !strings.Contains(output, "copy nexus copy commands") { + // Descriptions now have quotes + if !strings.Contains(output, "copy \"nexus copy commands\"") { 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 d333be7..1b1fe44 100644 --- a/v2/help_parser.go +++ b/v2/help_parser.go @@ -40,7 +40,7 @@ type HelpOptions struct { Filter string `goopt:"short:f;desc:Filter subcommands"` Search string `goopt:"short:q;desc:Search subcommands"` Command []string `goopt:"pos:0;desc:Command path"` - Style string `goopt:"desc:Help style;validators:isoneof(flat,grouped,compact,hierarchical,smart)"` + Style string `goopt:"desc:Help style;validators:isoneof(flat,grouped,grouped-clean,compact,hierarchical,smart)"` // Negative flags for disabling features NoDescriptions bool `goopt:"name:no-desc;desc:Hide descriptions;default:false"` @@ -148,6 +148,8 @@ func (h *HelpParser) Parse(args []string) error { h.config.Style = HelpStyleFlat case "grouped": h.config.Style = HelpStyleGrouped + case "grouped-clean": + h.config.Style = HelpStyleGroupedClean case "compact": h.config.Style = HelpStyleCompact case "hierarchical": @@ -560,30 +562,6 @@ func (h *HelpParser) findSimilarSubcommands(subcommands []Command, input string) return suggestions } -// detectBestStyle automatically selects the best help style based on CLI complexity -func (h *HelpParser) detectBestStyle() HelpStyle { - flagCount := h.mainParser.acceptedFlags.Count() - cmdCount := h.mainParser.registeredCommands.Len() - - // Large CLI with many flags and commands - if float64(flagCount) > float64(h.config.CompactThreshold)*1.4 && cmdCount > 5 { - return HelpStyleHierarchical - } - - // Medium CLI with moderate complexity - if flagCount > h.config.CompactThreshold { - return HelpStyleCompact - } - - // Small CLI with commands - if cmdCount > 3 { - return HelpStyleGrouped - } - - // Simple CLI - return HelpStyleFlat -} - // findSimilarCommandsInSliceWithContext recursively searches for similar commands considering both canonical and translated names func (h *HelpParser) findSimilarCommandsInSliceWithContext(prefix string, commands []Command, input string, suggestions *[]suggestion, currentLang language.Tag) { for i := range commands { @@ -805,7 +783,7 @@ func (h *HelpParser) showDefault(writer io.Writer) error { // Auto-detect style if set to Smart if style == HelpStyleSmart { - style = h.detectBestStyle() + style = h.mainParser.detectBestStyle() } // Apply the selected style @@ -814,6 +792,8 @@ func (h *HelpParser) showDefault(writer io.Writer) error { return h.showFlatStyle(writer) case HelpStyleGrouped: return h.showGroupedStyle(writer) + case HelpStyleGroupedClean: + return h.showGroupedCleanStyle(writer) case HelpStyleCompact: return h.showCompactStyle(writer) case HelpStyleHierarchical: @@ -862,6 +842,22 @@ func (h *HelpParser) showGroupedStyle(writer io.Writer) error { return nil } +// showGroupedCleanStyle shows grouped help with clean, compact formatting (no ** markers, tighter spacing) +func (h *HelpParser) showGroupedCleanStyle(writer io.Writer) error { + h.showVersionHeader(writer) + + // Use PrintUsageWithGroups with clean pretty print config + cleanConfig := &PrettyPrintConfig{ + NewCommandPrefix: " + ", + DefaultPrefix: " ├─ ", + TerminalPrefix: " └─ ", + InnerLevelBindPrefix: " ", // 2 spaces - clean and compact + OuterLevelBindPrefix: " │ ", + } + h.mainParser.PrintUsageWithGroups(writer, cleanConfig) + return nil +} + // showCompactStyle shows deduplicated, compact help func (h *HelpParser) showCompactStyle(writer io.Writer) error { h.showVersionHeader(writer) @@ -1025,6 +1021,11 @@ func (h *HelpParser) collectFlags(commandPath string) []*Argument { var flags []*Argument for f := h.mainParser.acceptedFlags.Front(); f != nil; f = f.Next() { + // Skip positional arguments - they are shown inline with commands + if f.Value.Argument.isPositional() { + continue + } + if commandPath == "" || f.Value.CommandPath == commandPath { flags = append(flags, f.Value.Argument) } @@ -1346,7 +1347,7 @@ func (h *HelpParser) showHelpForHelp(writer io.Writer) error { // Style option fmt.Fprintf(writer, " --style