From 98aab48194c6249400bf66245e284c84c1aaeda0 Mon Sep 17 00:00:00 2001 From: Florent Heyworth Date: Mon, 10 Nov 2025 12:08:32 +0100 Subject: [PATCH 1/2] improve and fix styles - add new clean grouped help output --- v2/definitions.go | 3 + v2/goopt.go | 67 +++++++++++++------ v2/goopt_internal.go | 36 ++++++++-- v2/goopt_test.go | 2 + v2/help_parser.go | 55 +++++++-------- v2/parser_config_funcs.go | 7 ++ v2/parser_config_funcs_test.go | 118 +++++++++++++++++++++++++++++++++ v2/renderer.go | 49 ++++++++++++++ 8 files changed, 283 insertions(+), 54 deletions(-) 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..1373362 100644 --- a/v2/goopt_test.go +++ b/v2/goopt_test.go @@ -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 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