From 04d5f2aed00b79beb860fb2f67fb659555e8deee Mon Sep 17 00:00:00 2001 From: bircni Date: Sun, 11 Oct 2026 22:54:27 +0200 Subject: [PATCH] feat: suggest similar flags for an unknown flag tea issue list --lable bug now fails with "unknown flag: --lable; did you mean --label?". Suggestions are the command's visible flags that start with the typed name or are at most two edits away from it. Signed-off-by: bircni --- internal/app/app.go | 42 +++++++++++++++++++++++++++++++++++++++- internal/app/app_test.go | 16 +++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/internal/app/app.go b/internal/app/app.go index f6008f59..66e5e679 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -103,7 +103,7 @@ func NewRoot(deps Dependencies) *cobra.Command { root.SetIn(deps.Streams.In) root.SetOut(deps.Streams.Out) root.SetErr(deps.Streams.ErrOut) - root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { return clierr.Wrap(clierr.Usage, err) }) + root.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { return clierr.Wrap(clierr.Usage, suggestFlags(cmd, err)) }) flags := root.PersistentFlags() flags.StringVar(&a.options.host, "host", "", "Gitea instance `URL`, including any base path") flags.StringVar(&a.options.user, "user", "", "Account `name` on the selected instance") @@ -215,6 +215,46 @@ func flagAliases(cmd *cobra.Command, aliases map[string]string) { }) } +// suggestFlags adds the flags of cmd with a similar name to an unknown flag error, as in +// "unknown flag: --lable; did you mean --label?". +func suggestFlags(cmd *cobra.Command, err error) error { + name, unknown := strings.CutPrefix(err.Error(), "unknown flag: --") + if !unknown || len(name) < 3 { + return err + } + var suggestions []string + cmd.Flags().VisitAll(func(flag *pflag.Flag) { + if !flag.Hidden && (strings.HasPrefix(flag.Name, name) || editDistance(name, flag.Name) <= 2) { + suggestions = append(suggestions, "--"+flag.Name) + } + }) + if len(suggestions) == 0 { + return err + } + return fmt.Errorf("%w; did you mean %s?", err, strings.Join(suggestions, " or ")) +} + +// editDistance counts the single-character insertions, deletions and substitutions that turn a into b. +func editDistance(a, b string) int { + row := make([]int, len(b)+1) + for j := range row { + row[j] = j + } + for i := 1; i <= len(a); i++ { + diagonal := row[0] + row[0] = i + for j := 1; j <= len(b); j++ { + substitution := diagonal + if a[i-1] != b[j-1] { + substitution++ + } + diagonal = row[j] + row[j] = min(row[j]+1, row[j-1]+1, substitution) + } + } + return row[len(b)] +} + // checkOutputFlags validates --json and --jq before a command runs. --jq alone selects every field, // except on commands that filter their raw JSON themselves (annotation tea.rawjson). func (a *application) checkOutputFlags(cmd *cobra.Command) error { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 00d8b814..d34cbab6 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -196,6 +196,22 @@ func TestRawJSONCommandsKeepJQToThemselves(t *testing.T) { require.ErrorContains(t, root.Execute(), "raw prints text; --json is not supported") } +func TestUnknownFlagsSuggestSimilarFlags(t *testing.T) { + requireUsageBeforeRequests(t, "unknown flag: --lable; did you mean --label?", "issue", "list", "--lable", "bug") + requireUsageBeforeRequests(t, "unknown flag: --stat; did you mean --state?", "issue", "list", "--stat", "open") + requireUsageBeforeRequests(t, "unknown flag: --add-label; did you mean --add-labels?", "issue", "edit", "1", "--add-label", "bug") + deps, _, _ := testDeps(t) + err := Execute(context.Background(), deps, []string{"issue", "list", "--zzzzzz"}) + require.Equal(t, clierr.Usage, clierr.ExitCode(err)) + require.EqualError(t, err, "unknown flag: --zzzzzz") + for _, tc := range []struct { + a, b string + distance int + }{{"kitten", "sitting", 3}, {"", "abc", 3}, {"label", "label", 0}, {"lable", "label", 2}} { + require.Equal(t, tc.distance, editDistance(tc.a, tc.b), tc) + } +} + func TestRootHelpGroupsCommandsAndTopics(t *testing.T) { deps, out, _ := testDeps(t) require.NoError(t, Execute(context.Background(), deps, []string{"alias", "set", "co", "pr checkout"}))