Skip to content
Open
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
165 changes: 165 additions & 0 deletions shortcuts/common/flag_suggest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package common

import (
"strings"

"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)

// unknownFlagPrefixes lists the pflag error message prefixes that indicate an
// unknown flag. The list is ordered longest-first so we match the most specific
// prefix first and extract the correct flag name.
var unknownFlagPrefixes = []string{
"unknown flag: --",
"flag provided but not defined: --",
}

// extractUnknownFlagName returns the bare flag name (without "--") from a pflag
// error message, or "" if the message does not match a known unknown-flag
// pattern (e.g. "bad flag syntax: -", "unknown shorthand flag: 'x' in -xfoo").
func extractUnknownFlagName(msg string) string {
for _, prefix := range unknownFlagPrefixes {
if strings.HasPrefix(msg, prefix) {
return strings.TrimPrefix(msg, prefix)
}
}
return ""
}

// editDistance computes the Levenshtein edit distance between two rune slices.
// It is rune-aware so non-ASCII flag names (theoretically possible) are handled
// correctly.
func editDistance(a, b string) int {
ra, rb := []rune(a), []rune(b)
la, lb := len(ra), len(rb)
if la == 0 {
return lb
}
if lb == 0 {
return la
}

// Single-row DP: prev[j] = edit distance between ra[:i] and rb[:j].
prev := make([]int, lb+1)
for j := range prev {
prev[j] = j
}
curr := make([]int, lb+1)

for i := 1; i <= la; i++ {
curr[0] = i
for j := 1; j <= lb; j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
curr[j] = min3(
curr[j-1]+1, // insert
prev[j]+1, // delete
prev[j-1]+cost, // replace
)
}
prev, curr = curr, prev
}
return prev[lb]
}

func min3(a, b, c int) int {
if a < b {
if a < c {
return a
}
return c
}
if b < c {
return b
}
return c
}

// flagSuggestThreshold returns the maximum edit distance we accept when
// suggesting a flag candidate. Tighter for short names (higher false-positive
// risk), looser for long names.
//
// rune len ≤ 3 → 1
// rune len 4–7 → 2
// rune len ≥ 8 → 3
func flagSuggestThreshold(name string) int {
n := len([]rune(name))
switch {
case n <= 3:
return 1
case n <= 7:
return 2
default:
return 3
}
}

// collectKnownFlagNamesFromCmd collects names via the standard pflag
// VisitAll interface used by cobra.
func collectKnownFlagNamesFromCmd(cmd *cobra.Command) []string {
var names []string
cmd.Flags().VisitAll(func(f *pflag.Flag) {
names = append(names, f.Name)
})
return names
}

// didYouMeanFlag searches knownFlags for the closest match to unknown using
// Levenshtein edit distance. Returns (bestCandidate, distance); returns
// ("", -1) when no candidate falls within the dynamic threshold.
func didYouMeanFlag(unknown string, knownFlags []string) (string, int) {
threshold := flagSuggestThreshold(unknown)
best, bestDist := "", threshold+1
for _, known := range knownFlags {
d := editDistance(unknown, known)
if d < bestDist {
best, bestDist = known, d
}
}
if bestDist > threshold {
return "", -1
}
return best, bestDist
}

// wrapFlagError is the cobra FlagErrorFunc injected by mountDeclarative.
// It implements the four-priority logic:
//
// 1. FlagHints exact match → unknown_flag + "did you mean: --<value>?"
// 2. edit-distance match → unknown_flag + "did you mean: --<best>?"
// 3. unknown flag, no match → validation_error, no hint
// 4. non-unknown-flag error → validation_error, no hint
func wrapFlagError(s *Shortcut, cmd *cobra.Command, err error) error {
msg := err.Error()
name := extractUnknownFlagName(msg)

if name == "" {
// Priority 4: not an unknown-flag error (e.g. bad syntax, shorthand).
return output.Errorf(output.ExitValidation, "validation_error", msg)
}

// Priority 1: per-shortcut FlagHints exact match.
if s.FlagHints != nil {
if correct, ok := s.FlagHints[name]; ok {
hint := "did you mean: --" + correct + "?"
return output.ErrWithHint(output.ExitValidation, "unknown_flag", msg, hint)
}
}

// Priority 2: edit-distance fallback over registered flags.
knownFlags := collectKnownFlagNamesFromCmd(cmd)
if candidate, _ := didYouMeanFlag(name, knownFlags); candidate != "" {
hint := "did you mean: --" + candidate + "?"
return output.ErrWithHint(output.ExitValidation, "unknown_flag", msg, hint)
}

// Priority 3: no suggestion available.
return output.Errorf(output.ExitValidation, "validation_error", msg)
}
Loading
Loading