Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 120 additions & 1 deletion dispatcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,24 @@ func (d *Dispatcher) Execute(args []string) error {
if hasHelp {
return d.showHelp()
}
return fmt.Errorf("unknown command: %s", strings.Join(args, " "))

// Nothing but flags was typed, so there is no word to call an unknown
// command. Name the flag instead. No command matched, so there is no
// flag set to draw suggestions from.
if len(nonFlagArgs) == 0 {
for _, arg := range args {
if arg == "--" {
break
}
if strings.HasPrefix(arg, "-") && arg != "-" {
// Report "--bogus", not "--bogus=value".
name, _, _ := strings.Cut(arg, "=")
return &UnknownFlagError{Flag: name}
}
}
}

return d.unknownCommandError(nonFlagArgs)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// Check if remaining non-flag args form a namespace under the matched command.
Expand Down Expand Up @@ -344,9 +361,35 @@ func (d *Dispatcher) Execute(args []string) error {
fs.disableAutoHelp = true
}
if err := fs.Parse(allArgs); err != nil {
// Leftover positional arguments are not a flag-parsing problem, and
// saying so only misdirects the reader.
var unexpected *UnexpectedArgsError
if errors.As(err, &unexpected) {
if name, ok := d.mistypedSubCommand(entry, unexpected.Args); ok {
return d.unknownSubCommandError(entry.Path, name)
}
return err
}

// "unknown flag: --naem" already says which part of the command line
// went wrong; prefixing it adds nothing and pushes the suggestions
// further from the summary.
var unknownFlag *UnknownFlagError
if errors.As(err, &unknownFlag) {
return err
}

return fmt.Errorf("error parsing flags: %w", err)
}

// A command that tolerates unknown flags skips Parse's extra-argument check
// entirely, so a mistyped sub-command under one would otherwise run as if
// nothing were wrong. Catch it here, once Parse has had its chance to claim
// those words as flag values.
if name, ok := d.mistypedSubCommand(entry, fs.Args()); ok {
return d.unknownSubCommandError(entry.Path, name)
}

// Execute the command with the parsed flagset and remaining args
err := entry.Command.Run(fs, fs.Args())

Expand All @@ -363,6 +406,82 @@ func (d *Dispatcher) Run(args []string) error {
return d.Execute(args)
}

// unknownCommandError builds the error for command words that matched nothing.
// It first walks the words the user typed to find the deepest namespace among
// them, so that "miren debug wibble" blames "wibble" under "debug" rather than
// blaming "debug", which is perfectly valid.
func (d *Dispatcher) unknownCommandError(words []string) error {
parentPath := ""
unknownIdx := 0

for i := 1; i < len(words); i++ {
candidate := normalizeCommandPath(strings.Join(words[:i], " "))
if d.isNamespace(candidate) {
parentPath = candidate
unknownIdx = i
}
}

name := ""
if unknownIdx < len(words) {
name = words[unknownIdx]
}

return d.unknownSubCommandError(parentPath, name)
}

// mistypedSubCommand reports whether leftover arguments under entry can only be
// a misspelled sub-command: the command has children of its own and declares
// nowhere to put arguments. It returns the offending word.
func (d *Dispatcher) mistypedSubCommand(entry *CommandEntry, leftover []string) (string, bool) {
if len(leftover) == 0 {
return "", false
}

fs := entry.Command.FlagSet()
if fs == nil || fs.PositionalCount() > 0 || fs.restField != nil {
return "", false
}

children := d.GetDirectChildren(entry.Path)
if len(children) == 0 {
return "", false
}

known := make(map[string]bool, len(children))
for _, c := range children {
known[c.Name] = true
}

for _, word := range leftover {
// "help" is a keyword the dispatcher handles for itself, and a word
// that really names a sub-command was already matched as one.
if word == "help" || known[word] {
continue
}
return word, true
}

return "", false
}

// unknownSubCommandError builds the error for a word that should have named a
// sub-command of parentPath but named nothing at all.
func (d *Dispatcher) unknownSubCommandError(parentPath, name string) error {
children := d.GetDirectChildren(parentPath)
candidates := make([]string, 0, len(children))
for _, c := range children {
candidates = append(candidates, c.Name)
}

return &UnknownCommandError{
Program: d.name,
ParentPath: parentPath,
Name: name,
Suggestions: suggestNames(name, candidates),
}
}

// findCommand finds the best matching command for the given arguments
func (d *Dispatcher) findCommand(args []string) (*CommandEntry, []string) {
// Try progressively shorter command paths
Expand Down
6 changes: 3 additions & 3 deletions dispatcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1523,7 +1523,7 @@ func TestDispatcherRejectsExtraArgs(t *testing.T) {

err := d.Execute([]string{"app", "services"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "unexpected arguments: [services]")
assert.Contains(t, err.Error(), `unexpected argument "services"`)
})

t.Run("nested command with no positional args rejects extra", func(t *testing.T) {
Expand All @@ -1538,7 +1538,7 @@ func TestDispatcherRejectsExtraArgs(t *testing.T) {

err := d.Execute([]string{"app", "services", "extra"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "unexpected arguments: [extra]")
assert.Contains(t, err.Error(), `unexpected argument "extra"`)
})

t.Run("command with positional args accepts correct count", func(t *testing.T) {
Expand Down Expand Up @@ -1572,7 +1572,7 @@ func TestDispatcherRejectsExtraArgs(t *testing.T) {

err := d.Execute([]string{"deploy", "production", "extra"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "unexpected arguments: [extra]")
assert.Contains(t, err.Error(), `unexpected argument "extra"`)
})

t.Run("command with rest field accepts all args", func(t *testing.T) {
Expand Down
94 changes: 94 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package mflags

import (
"fmt"
"strings"
)

// UnknownCommandError reports a command word that matches nothing registered.
// It carries the surrounding context — which program, which parent command, and
// what the user might have meant — so callers can render it themselves rather
// than parsing the message back out of a string.
type UnknownCommandError struct {
// Program is the binary name, e.g. "miren".
Program string
// ParentPath is the command the unknown word appeared under, e.g. "app".
// Empty when the word was typed at the top level.
ParentPath string
// Name is the single word that matched nothing.
Name string
// Suggestions holds close matches, nearest first. May be empty.
Suggestions []string
}

func (e *UnknownCommandError) Error() string {
helpTarget := strings.TrimSpace(e.Program + " " + e.ParentPath)

var sections []string

if e.ParentPath != "" {
sections = append(sections, fmt.Sprintf("unknown command %q for %q", e.Name, helpTarget))
} else {
sections = append(sections, fmt.Sprintf("unknown command %q", e.Name))
}

if len(e.Suggestions) > 0 {
var b strings.Builder
b.WriteString("Did you mean?")
for _, s := range e.Suggestions {
fmt.Fprintf(&b, "\n %s", s)
}
sections = append(sections, b.String())
}

if helpTarget != "" {
sections = append(sections, fmt.Sprintf("Run '%s --help' to see available commands.", helpTarget))
}

return strings.Join(sections, "\n\n")
}

// UnknownFlagError reports a flag the command does not define. It unwraps to
// ErrUnknownFlag, so existing errors.Is checks keep working.
type UnknownFlagError struct {
// Flag is the flag as the user typed it, dashes included.
Flag string
// Suggestions holds close long-flag names, dashes included, nearest first.
// Short flags carry no suggestions: a single letter is too little to guess
// from.
Suggestions []string
}

func (e *UnknownFlagError) Error() string {
sections := []string{"unknown flag: " + e.Flag}

if len(e.Suggestions) > 0 {
var b strings.Builder
b.WriteString("Did you mean?")
for _, s := range e.Suggestions {
fmt.Fprintf(&b, "\n %s", s)
}
sections = append(sections, b.String())
}

return strings.Join(sections, "\n\n")
}

func (e *UnknownFlagError) Unwrap() error { return ErrUnknownFlag }

// UnexpectedArgsError reports positional arguments a command has nowhere to put.
type UnexpectedArgsError struct {
Args []string
}

func (e *UnexpectedArgsError) Error() string {
if len(e.Args) == 1 {
return fmt.Sprintf("unexpected argument %q", e.Args[0])
}

quoted := make([]string, 0, len(e.Args))
for _, a := range e.Args {
quoted = append(quoted, fmt.Sprintf("%q", a))
}
return "unexpected arguments: " + strings.Join(quoted, " ")
}
28 changes: 24 additions & 4 deletions mflags.go
Original file line number Diff line number Diff line change
Expand Up @@ -655,8 +655,7 @@ func (f *FlagSet) Parse(arguments []string) error {
// Check for unexpected extra arguments when no rest field is defined
// Skip validation if allowUnknownFlags is enabled (pass-through mode)
if f.restField == nil && !f.allowUnknownFlags && len(f.args) > f.PositionalCount() {
extra := f.args[f.PositionalCount():]
return fmt.Errorf("unexpected arguments: %v", extra)
return &UnexpectedArgsError{Args: f.args[f.PositionalCount():]}
}

// If we have a rest field, populate it with remaining args after positional ones
Expand Down Expand Up @@ -709,6 +708,27 @@ func (f *FlagSet) validateRequired() error {
return nil
}

// unknownLongFlagError reports an undefined long flag, offering the defined
// flags whose names are closest to what was typed.
func (f *FlagSet) unknownLongFlagError(name string) error {
candidates := make([]string, 0, len(f.flags))
for defined, flag := range f.flags {
// Hidden flags stay out of help output, so advertising one here on a
// near-miss typo would undo that.
if flag.Hidden {
continue
}
candidates = append(candidates, defined)
}

suggestions := suggestNames(name, candidates)
for i, s := range suggestions {
suggestions[i] = "--" + s
}

return &UnknownFlagError{Flag: "--" + name, Suggestions: suggestions}
}

func (f *FlagSet) parseLongFlag(name string, args []string, index *int) (bool, error) {
var value string
hasValue := false
Expand All @@ -728,7 +748,7 @@ func (f *FlagSet) parseLongFlag(name string, args []string, index *int) (bool, e
*index = len(args) - 1 // Skip to end
return true, nil
}
return false, fmt.Errorf("%w: --%s", ErrUnknownFlag, name)
return false, f.unknownLongFlagError(name)
}

if flag.Value.IsBool() {
Expand Down Expand Up @@ -768,7 +788,7 @@ func (f *FlagSet) parseShortFlags(shortFlags string, args []string, index *int)
*index = len(args) - 1 // Skip to end
return nil
}
return fmt.Errorf("%w: -%c", ErrUnknownFlag, r)
return &UnknownFlagError{Flag: fmt.Sprintf("-%c", r)}
}

if flag.Value.IsBool() {
Expand Down
6 changes: 3 additions & 3 deletions mflags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1092,7 +1092,7 @@ func TestInvalidRestFieldType(t *testing.T) {
// Since the rest field is invalid and ignored, extra args should be rejected
err = fs.Parse([]string{"arg1", "arg2"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "unexpected arguments")
assert.Contains(t, err.Error(), `unexpected arguments: "arg1" "arg2"`)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// The rest field should be ignored since it's not []string
assert.Equal(t, "", config.RestField)
Expand Down Expand Up @@ -1287,7 +1287,7 @@ func TestInvalidPositionTag(t *testing.T) {
// so any args should be rejected
err = fs.Parse([]string{"value"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "unexpected arguments")
assert.Contains(t, err.Error(), `unexpected argument "value"`)

assert.Equal(t, "", config.Item) // Field is ignored due to invalid position
}
Expand All @@ -1307,7 +1307,7 @@ func TestNegativePositionTag(t *testing.T) {
// so any args should be rejected
err = fs.Parse([]string{"value"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "unexpected arguments")
assert.Contains(t, err.Error(), `unexpected argument "value"`)

assert.Equal(t, "", config.Item) // Field is ignored due to negative position
}
Expand Down
8 changes: 4 additions & 4 deletions positional_api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ func TestExtraArgumentsRejected(t *testing.T) {

err := fs.Parse([]string{"extra"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "unexpected arguments: [extra]")
assert.Contains(t, err.Error(), `unexpected argument "extra"`)
})

t.Run("multiple extra args rejected", func(t *testing.T) {
Expand All @@ -436,7 +436,7 @@ func TestExtraArgumentsRejected(t *testing.T) {

err := fs.Parse([]string{"extra1", "extra2", "extra3"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "unexpected arguments: [extra1 extra2 extra3]")
assert.Contains(t, err.Error(), `unexpected arguments: "extra1" "extra2" "extra3"`)
})

t.Run("positional args rejects extra args", func(t *testing.T) {
Expand All @@ -446,7 +446,7 @@ func TestExtraArgumentsRejected(t *testing.T) {

err := fs.Parse([]string{"a", "b", "extra"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "unexpected arguments: [extra]")
assert.Contains(t, err.Error(), `unexpected argument "extra"`)
})

t.Run("positional with gaps rejects extra args", func(t *testing.T) {
Expand All @@ -459,7 +459,7 @@ func TestExtraArgumentsRejected(t *testing.T) {
// 5 args fill all positions, 6th is extra
err := fs.Parse([]string{"a", "b", "c", "d", "e", "extra"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "unexpected arguments: [extra]")
assert.Contains(t, err.Error(), `unexpected argument "extra"`)
})

t.Run("rest field accepts all args", func(t *testing.T) {
Expand Down
Loading