Skip to content

Commit

Permalink
Convert help function to a function
Browse files Browse the repository at this point in the history
  • Loading branch information
mfridman committed Dec 28, 2024
1 parent acf6f75 commit 718d987
Show file tree
Hide file tree
Showing 3 changed files with 102 additions and 117 deletions.
210 changes: 97 additions & 113 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"context"
"flag"
"fmt"
"os"
"slices"
"sort"
"strings"
Expand Down Expand Up @@ -53,9 +52,6 @@ type Command struct {
// Exec defines the command's execution logic. It receives the current application [State] and
// returns an error if execution fails. This function is called when [Run] is invoked on the
// command.
//
// May return a [HelpError] to indicate that the command should display its help text when [Run]
// is called.
Exec func(ctx context.Context, s *State) error

state *State
Expand Down Expand Up @@ -105,29 +101,74 @@ func (c *Command) findSubCommand(name string) *Command {
return nil
}

func (c *Command) showHelp() error {
w := c.Flags.Output()
if w == nil {
w = os.Stdout // Fallback to stdout if no output is set
func (c *Command) getSuggestions(unknownCmd string) []string {
var availableCommands []string
for _, subcmd := range c.SubCommands {
availableCommands = append(availableCommands, subcmd.Name)
}

suggestions := make([]struct {
name string
score float64
}, 0, len(availableCommands))

// Calculate similarity scores
for _, name := range availableCommands {
score := calculateSimilarity(unknownCmd, name)
if score > 0.5 { // Only include reasonably similar commands
suggestions = append(suggestions, struct {
name string
score float64
}{name, score})
}
}
// Sort suggestions by score (highest first)
sort.Slice(suggestions, func(i, j int) bool {
return suggestions[i].score > suggestions[j].score
})
// Get top 3 suggestions
maxSuggestions := 3
result := make([]string, 0, maxSuggestions)
for i := 0; i < len(suggestions) && i < maxSuggestions; i++ {
result = append(result, suggestions[i].name)
}

return result
}

func (c *Command) formatUnknownCommandError(unknownCmd string) error {
suggestions := c.getSuggestions(unknownCmd)
if len(suggestions) > 0 {
return fmt.Errorf("unknown command %q. Did you mean one of these?\n\t%s",
unknownCmd,
strings.Join(suggestions, "\n\t"))
}
return fmt.Errorf("unknown command %q", unknownCmd)
}

func defaultUsage(c *Command) string {
var b strings.Builder

// Handle custom usage function
if c.UsageFunc != nil {
fmt.Fprintf(w, "%s\n", c.UsageFunc(c))
return flag.ErrHelp
return c.UsageFunc(c)
}

// Short help section
if c.ShortHelp != "" {
for _, line := range wrapText(c.ShortHelp, 80) {
fmt.Fprintf(w, "%s\n", line)
b.WriteString(line)
b.WriteRune('\n')
}
fmt.Fprintln(w)
b.WriteRune('\n')
}

fmt.Fprintf(w, "Usage:\n ")
// Usage section
b.WriteString("Usage:\n ")
if c.Usage != "" {
fmt.Fprintf(w, "%s\n", c.Usage)
b.WriteString(c.Usage)
b.WriteRune('\n')
} else {
// Add nil check for state
usage := c.Name
if c.state != nil && len(c.state.commandPath) > 0 {
usage = getCommandPath(c.state.commandPath)
Expand All @@ -138,11 +179,13 @@ func (c *Command) showHelp() error {
if len(c.SubCommands) > 0 {
usage += " <command>"
}
fmt.Fprintf(w, "%s\n", usage)
b.WriteString(usage)
b.WriteRune('\n')
}

// Available Commands section
if len(c.SubCommands) > 0 {
fmt.Fprintf(w, "Available Commands:\n")
b.WriteString("Available Commands:\n")

sortedCommands := slices.Clone(c.SubCommands)
slices.SortFunc(sortedCommands, func(a, b *Command) int {
Expand All @@ -158,7 +201,7 @@ func (c *Command) showHelp() error {

for _, sub := range sortedCommands {
if sub.ShortHelp == "" {
fmt.Fprintf(w, " %s\n", sub.Name)
fmt.Fprintf(&b, " %s\n", sub.Name)
continue
}

Expand All @@ -167,31 +210,24 @@ func (c *Command) showHelp() error {

lines := wrapText(sub.ShortHelp, wrapWidth)
padding := strings.Repeat(" ", maxLen-len(sub.Name)+4)
fmt.Fprintf(w, " %s%s%s\n", sub.Name, padding, lines[0])
fmt.Fprintf(&b, " %s%s%s\n", sub.Name, padding, lines[0])

indentPadding := strings.Repeat(" ", nameWidth+2)
for _, line := range lines[1:] {
fmt.Fprintf(w, "%s%s\n", indentPadding, line)
fmt.Fprintf(&b, "%s%s\n", indentPadding, line)
}
}
fmt.Fprintln(w)
b.WriteRune('\n')
}

type flagInfo struct {
name string
usage string
defval string
global bool
}
var flags []flagInfo

// Use command path to collect all flags
if c.state != nil && len(c.state.commandPath) > 0 {
for i, cmd := range c.state.commandPath {
if cmd.Flags == nil {
continue
}
isGlobal := i < len(c.state.commandPath)-1 // If not the current command, it's global
isGlobal := i < len(c.state.commandPath)-1
cmd.Flags.VisitAll(func(f *flag.Flag) {
flags = append(flags, flagInfo{
name: "-" + f.Name,
Expand Down Expand Up @@ -226,108 +262,56 @@ func (c *Command) showHelp() error {
}

if hasLocal {
fmt.Fprintf(w, "Flags:\n")
for _, f := range flags {
if !f.global {
nameWidth := maxLen + 4
wrapWidth := 80 - nameWidth

usageText := f.usage
if f.defval != "" && f.defval != "false" {
usageText += fmt.Sprintf(" (default %s)", f.defval)
}

lines := wrapText(usageText, wrapWidth)
padding := strings.Repeat(" ", maxLen-len(f.name)+4)
fmt.Fprintf(w, " %s%s%s\n", f.name, padding, lines[0])

indentPadding := strings.Repeat(" ", nameWidth+2)
for _, line := range lines[1:] {
fmt.Fprintf(w, "%s%s\n", indentPadding, line)
}
}
}
fmt.Fprintln(w)
b.WriteString("Flags:\n")
writeFlagSection(&b, flags, maxLen, false)
b.WriteRune('\n')
}

if hasGlobal {
fmt.Fprintf(w, "Global Flags:\n")
for _, f := range flags {
if f.global {
nameWidth := maxLen + 4
wrapWidth := 80 - nameWidth

usageText := f.usage
if f.defval != "" && f.defval != "false" {
usageText += fmt.Sprintf(" (default %s)", f.defval)
}

lines := wrapText(usageText, wrapWidth)
padding := strings.Repeat(" ", maxLen-len(f.name)+4)
fmt.Fprintf(w, " %s%s%s\n", f.name, padding, lines[0])

indentPadding := strings.Repeat(" ", nameWidth+2)
for _, line := range lines[1:] {
fmt.Fprintf(w, "%s%s\n", indentPadding, line)
}
}
}
fmt.Fprintln(w)
b.WriteString("Global Flags:\n")
writeFlagSection(&b, flags, maxLen, true)
b.WriteRune('\n')
}
}

// Help suggestion for subcommands
if len(c.SubCommands) > 0 {
// Use the full command path for the help suggestion
fmt.Fprintf(w, "Use \"%s [command] --help\" for more information about a command.\n",
fmt.Fprintf(&b, "Use \"%s [command] --help\" for more information about a command.\n",
getCommandPath(c.state.commandPath))
}

return flag.ErrHelp
return strings.TrimRight(b.String(), "\n")
}

func (c *Command) getSuggestions(unknownCmd string) []string {
var availableCommands []string
for _, subcmd := range c.SubCommands {
availableCommands = append(availableCommands, subcmd.Name)
}
// writeFlagSection writes either the local or global flags section
func writeFlagSection(b *strings.Builder, flags []flagInfo, maxLen int, global bool) {
for _, f := range flags {
if f.global == global {
nameWidth := maxLen + 4
wrapWidth := 80 - nameWidth

suggestions := make([]struct {
name string
score float64
}, 0, len(availableCommands))
usageText := f.usage
if f.defval != "" && f.defval != "false" {
usageText += fmt.Sprintf(" (default %s)", f.defval)
}

// Calculate similarity scores
for _, name := range availableCommands {
score := calculateSimilarity(unknownCmd, name)
if score > 0.5 { // Only include reasonably similar commands
suggestions = append(suggestions, struct {
name string
score float64
}{name, score})
lines := wrapText(usageText, wrapWidth)
padding := strings.Repeat(" ", maxLen-len(f.name)+4)
fmt.Fprintf(b, " %s%s%s\n", f.name, padding, lines[0])

indentPadding := strings.Repeat(" ", nameWidth+2)
for _, line := range lines[1:] {
fmt.Fprintf(b, "%s%s\n", indentPadding, line)
}
}
}
// Sort suggestions by score (highest first)
sort.Slice(suggestions, func(i, j int) bool {
return suggestions[i].score > suggestions[j].score
})
// Get top 3 suggestions
maxSuggestions := 3
result := make([]string, 0, maxSuggestions)
for i := 0; i < len(suggestions) && i < maxSuggestions; i++ {
result = append(result, suggestions[i].name)
}

return result
}

func (c *Command) formatUnknownCommandError(unknownCmd string) error {
suggestions := c.getSuggestions(unknownCmd)
if len(suggestions) > 0 {
return fmt.Errorf("unknown command %q. Did you mean one of these?\n\t%s",
unknownCmd,
strings.Join(suggestions, "\n\t"))
}
return fmt.Errorf("unknown command %q", unknownCmd)
type flagInfo struct {
name string
usage string
defval string
global bool
}

func calculateSimilarity(a, b string) float64 {
Expand Down
3 changes: 1 addition & 2 deletions examples/cmd/echo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ func main() {
},
Exec: func(ctx context.Context, s *cli.State) error {
if len(s.Args) == 0 {
// Return a new error with the error code ErrShowHelp
return fmt.Errorf("no text provided")
return errors.New("must provide text to echo, see --help")
}
output := strings.Join(s.Args, " ")
// If -c flag is set, capitalize the output
Expand Down
6 changes: 4 additions & 2 deletions parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,10 @@ func Parse(root *Command, args []string) error {
// Add the help check here, after we've found the correct command
for _, arg := range argsToParse {
if arg == "-h" || arg == "--h" || arg == "-help" || arg == "--help" {
combinedFlags.Usage = func() { _ = current.showHelp() }
_ = current.showHelp()
current.Flags.Usage = func() {
fmt.Fprintln(current.Flags.Output(), defaultUsage(current))
}
current.Flags.Usage()
return flag.ErrHelp
}
}
Expand Down

0 comments on commit 718d987

Please sign in to comment.