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
57 changes: 57 additions & 0 deletions cmd/flag_suggest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"testing"

"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/flagalias"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -102,3 +103,59 @@ func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
}
}

func TestFlagDidYouMean_InvalidAliasValueUsesCallerSpelling(t *testing.T) {
tests := []struct {
name string
args []string
wantParam string
wantMap bool
}{
{name: "alias equals value", args: []string{"--page-size=bad"}, wantParam: "--page-size", wantMap: true},
{name: "canonical equals value", args: []string{"--limit=bad"}, wantParam: "--limit"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
c := &cobra.Command{Use: "demo"}
c.Flags().Int("limit", 10, "")
if err := flagalias.Bind(c, []flagalias.Spec{{Canonical: "limit", Aliases: []string{"page-size"}}}); err != nil {
t.Fatal(err)
}
parseErr := c.ParseFlags(test.args)
if parseErr == nil {
t.Fatal("ParseFlags() succeeded, want invalid integer error")
}

err := flagDidYouMean(c, parseErr)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T, want *errs.ValidationError", err)
}
if validationErr.Param != test.wantParam {
t.Fatalf("param = %q, want %q", validationErr.Param, test.wantParam)
}
hasMapping := strings.Contains(validationErr.Hint, "maps to canonical flag --limit")
if hasMapping != test.wantMap {
t.Fatalf("hint = %q, mapping guidance = %v, want %v", validationErr.Hint, hasMapping, test.wantMap)
}
})
}
}

func TestFlagDidYouMean_InvalidNonAliasValueStaysGeneric(t *testing.T) {
c := &cobra.Command{Use: "demo"}
c.Flags().Int("limit", 10, "")
parseErr := c.ParseFlags([]string{"--limit=bad"})
if parseErr == nil {
t.Fatal("ParseFlags() succeeded, want invalid integer error")
}

err := flagDidYouMean(c, parseErr)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T, want *errs.ValidationError", err)
}
if validationErr.Param != "" || len(validationErr.Params) != 0 {
t.Fatalf("Param=%q Params=%v, want ordinary pflag behavior unchanged", validationErr.Param, validationErr.Params)
}
}
15 changes: 12 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/flagalias"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/skillscheck"
Expand Down Expand Up @@ -596,13 +597,21 @@ func isLarkDomain(c *cobra.Command) bool {
// converts cobra's flag-parse errors into a typed validation envelope: an
// unknown flag gets a focused "did you mean" hint (so agents recover even when
// the typo is semantic, e.g. --query vs --find, where edit distance alone finds
// nothing) and the offending flag in `params`. Other flag errors stay typed
// but generic.
// nothing) and the offending flag in `params`. Invalid values on alias-backed
// flags retain the caller's spelling; all other flag errors stay typed but
// generic.
func flagDidYouMean(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagName(ferr)
if !isUnknown {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
WithHint("run `%s --help` for valid flags", c.CommandPath())
if attribution, ok := flagalias.InvalidValueAttributionOf(ferr); ok {
validationErr.WithParam("--" + attribution.Source)
if attribution.Source != attribution.Canonical {
validationErr.WithHint("--%s maps to canonical flag --%s; run `%s --help` for valid values", attribution.Source, attribution.Canonical, c.CommandPath())
}
}
return validationErr
Comment thread
liangshuo-1 marked this conversation as resolved.
}
valid := visibleFlagNames(c)
suggestions := suggest.Closest(name, valid, 3)
Expand Down
44 changes: 44 additions & 0 deletions internal/flagalias/error_attribution.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package flagalias

import (
"errors"

"github.com/spf13/pflag"
)

// InvalidValueAttribution identifies the canonical flag and the long-form
// spelling that supplied a value which pflag could not convert. Names do not
// include leading dashes.
type InvalidValueAttribution struct {
Canonical string
Source string
}

// InvalidValueAttributionOf resolves a typed pflag conversion error for a flag
// managed by Bind. Ordinary pflags return ok=false so installing aliases does
// not broaden the root error contract for unrelated commands.
//
// pflag's InvalidValueError does not report whether a canonical flag with a
// shorthand was supplied as -x or --long. That ambiguous case also returns
// ok=false; an alias spelling remains unambiguous because Bind records it
// before value conversion.
func InvalidValueAttributionOf(err error) (InvalidValueAttribution, bool) {
var invalidValue *pflag.InvalidValueError
if !errors.As(err, &invalidValue) || invalidValue == nil {
return InvalidValueAttribution{}, false
}
flag := invalidValue.GetFlag()
if flag == nil || len(Aliases(flag)) == 0 {
return InvalidValueAttribution{}, false
}

canonical := flag.Name
source := Source(flag)
if source == "" || (source == canonical && flag.Shorthand != "") {
return InvalidValueAttribution{}, false
}
return InvalidValueAttribution{Canonical: canonical, Source: source}, true
}
129 changes: 129 additions & 0 deletions internal/flagalias/error_attribution_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package flagalias

import (
"errors"
"fmt"
"testing"

"github.com/spf13/cobra"
)

func TestInvalidValueAttributionOf(t *testing.T) {
tests := []struct {
name string
args []string
managed bool
shorthand bool
wrap bool
want InvalidValueAttribution
wantOK bool
}{
{
name: "alias separate value",
args: []string{"--page-size", "bad"},
managed: true,
want: InvalidValueAttribution{Canonical: "limit", Source: "page-size"},
wantOK: true,
},
{
name: "alias equals value",
args: []string{"--page-size=bad"},
managed: true,
want: InvalidValueAttribution{Canonical: "limit", Source: "page-size"},
wantOK: true,
},
{
name: "canonical separate value",
args: []string{"--limit", "bad"},
managed: true,
want: InvalidValueAttribution{Canonical: "limit", Source: "limit"},
wantOK: true,
},
{
name: "canonical equals value",
args: []string{"--limit=bad"},
managed: true,
want: InvalidValueAttribution{Canonical: "limit", Source: "limit"},
wantOK: true,
},
{
name: "alias fails after canonical",
args: []string{"--limit=10", "--page-size=bad"},
managed: true,
want: InvalidValueAttribution{Canonical: "limit", Source: "page-size"},
wantOK: true,
},
{
name: "canonical fails after alias",
args: []string{"--page-size=10", "--limit=bad"},
managed: true,
want: InvalidValueAttribution{Canonical: "limit", Source: "limit"},
wantOK: true,
},
{
name: "ordinary flag",
args: []string{"--limit=bad"},
wantOK: false,
},
{
name: "shorthand source is ambiguous",
args: []string{"-l", "bad"},
managed: true,
shorthand: true,
wantOK: false,
},
{
name: "alias remains exact when canonical has shorthand",
args: []string{"--page-size=bad"},
managed: true,
shorthand: true,
want: InvalidValueAttribution{Canonical: "limit", Source: "page-size"},
wantOK: true,
},
{
name: "wrapped pflag error",
args: []string{"--page-size=bad"},
managed: true,
wrap: true,
want: InvalidValueAttribution{Canonical: "limit", Source: "page-size"},
wantOK: true,
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "demo"}
if test.shorthand {
cmd.Flags().IntP("limit", "l", 10, "")
} else {
cmd.Flags().Int("limit", 10, "")
}
if test.managed {
if err := Bind(cmd, []Spec{{Canonical: "limit", Aliases: []string{"page-size"}}}); err != nil {
t.Fatal(err)
}
}
parseErr := cmd.ParseFlags(test.args)
if parseErr == nil {
t.Fatal("ParseFlags() succeeded, want invalid integer error")
}
if test.wrap {
parseErr = fmt.Errorf("parse flags: %w", parseErr)
}

got, ok := InvalidValueAttributionOf(parseErr)
if ok != test.wantOK || got != test.want {
t.Fatalf("InvalidValueAttributionOf() = (%+v, %v), want (%+v, %v)", got, ok, test.want, test.wantOK)
}
})
}
}

func TestInvalidValueAttributionOfRejectsOtherErrors(t *testing.T) {
if got, ok := InvalidValueAttributionOf(errors.New("flag needs an argument: --limit")); ok {
t.Fatalf("InvalidValueAttributionOf() = (%+v, true), want no attribution", got)
}
}
Loading
Loading