187 lines
5.2 KiB
Go
187 lines
5.2 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0
|
|
|
|
package cmdutil
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// MaxCommandSuggestions keeps typo recovery concise even for products with a
|
|
// large command surface.
|
|
const MaxCommandSuggestions = 3
|
|
|
|
type rankedCommandSuggestion struct {
|
|
name string
|
|
explicit bool
|
|
prefix bool
|
|
distance int
|
|
lengthDelta int
|
|
}
|
|
|
|
// SuggestSubcommands returns at most MaxCommandSuggestions visible canonical
|
|
// child names, ranked by reviewed SuggestFor matches, prefix relevance, edit
|
|
// distance, length delta, and finally canonical name for deterministic ties.
|
|
// Aliases participate in scoring, but recovery always teaches the canonical
|
|
// command name.
|
|
func SuggestSubcommands(parent *cobra.Command, candidate string) []string {
|
|
if parent == nil {
|
|
return nil
|
|
}
|
|
candidate = strings.ToLower(strings.TrimSpace(candidate))
|
|
if candidate == "" {
|
|
return []string{}
|
|
}
|
|
minimumDistance := parent.SuggestionsMinimumDistance
|
|
if minimumDistance <= 0 {
|
|
minimumDistance = 2
|
|
}
|
|
shortcutOnly := strings.HasPrefix(candidate, "+")
|
|
ranked := make([]rankedCommandSuggestion, 0)
|
|
for _, child := range parent.Commands() {
|
|
if !child.IsAvailableCommand() || (shortcutOnly && !strings.HasPrefix(child.Name(), "+")) {
|
|
continue
|
|
}
|
|
|
|
explicit := false
|
|
for _, suggestion := range child.SuggestFor {
|
|
if strings.EqualFold(candidate, suggestion) {
|
|
explicit = true
|
|
break
|
|
}
|
|
}
|
|
|
|
identities := append([]string{child.Name()}, child.Aliases...)
|
|
prefix := false
|
|
bestDistance := -1
|
|
bestLengthDelta := 0
|
|
for _, identity := range identities {
|
|
identity = strings.ToLower(strings.TrimSpace(identity))
|
|
if identity == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(identity, candidate) {
|
|
prefix = true
|
|
}
|
|
distance := LevenshteinDist(candidate, identity)
|
|
lengthDelta := len(identity) - len(candidate)
|
|
if lengthDelta < 0 {
|
|
lengthDelta = -lengthDelta
|
|
}
|
|
if bestDistance < 0 || distance < bestDistance || (distance == bestDistance && lengthDelta < bestLengthDelta) {
|
|
bestDistance = distance
|
|
bestLengthDelta = lengthDelta
|
|
}
|
|
}
|
|
if !explicit && !prefix && (bestDistance < 0 || bestDistance > minimumDistance) {
|
|
continue
|
|
}
|
|
ranked = append(ranked, rankedCommandSuggestion{
|
|
name: child.Name(),
|
|
explicit: explicit,
|
|
prefix: prefix,
|
|
distance: bestDistance,
|
|
lengthDelta: bestLengthDelta,
|
|
})
|
|
}
|
|
|
|
sort.Slice(ranked, func(i, j int) bool {
|
|
left, right := ranked[i], ranked[j]
|
|
if left.explicit != right.explicit {
|
|
return left.explicit
|
|
}
|
|
if left.prefix != right.prefix {
|
|
return left.prefix
|
|
}
|
|
if left.distance != right.distance {
|
|
return left.distance < right.distance
|
|
}
|
|
if left.lengthDelta != right.lengthDelta {
|
|
return left.lengthDelta < right.lengthDelta
|
|
}
|
|
return left.name < right.name
|
|
})
|
|
if len(ranked) > MaxCommandSuggestions {
|
|
ranked = ranked[:MaxCommandSuggestions]
|
|
}
|
|
|
|
suggestions := make([]string, 0, len(ranked))
|
|
for _, suggestion := range ranked {
|
|
suggestions = append(suggestions, suggestion.name)
|
|
}
|
|
return suggestions
|
|
}
|
|
|
|
// SuggestDescendantSubcommands returns bounded relative paths for available
|
|
// descendants whose canonical name or alias exactly matches candidate. It is
|
|
// the reviewed deep-recovery mode used by hierarchical products such as
|
|
// Sheet, where a model may omit an intermediate group (`sheet read` instead
|
|
// of `sheet range read`). Fuzzy ranking remains a sibling concern so a large
|
|
// subtree cannot drown the user in speculative paths.
|
|
func SuggestDescendantSubcommands(parent *cobra.Command, candidate string) []string {
|
|
if parent == nil {
|
|
return nil
|
|
}
|
|
candidate = strings.ToLower(strings.TrimSpace(candidate))
|
|
if candidate == "" {
|
|
return []string{}
|
|
}
|
|
|
|
paths := make([]string, 0)
|
|
var walk func(*cobra.Command, []string)
|
|
walk = func(command *cobra.Command, prefix []string) {
|
|
for _, child := range command.Commands() {
|
|
if !child.IsAvailableCommand() {
|
|
continue
|
|
}
|
|
path := append(append([]string(nil), prefix...), child.Name())
|
|
matched := strings.EqualFold(strings.TrimSpace(child.Name()), candidate)
|
|
if !matched {
|
|
for _, alias := range child.Aliases {
|
|
if strings.EqualFold(strings.TrimSpace(alias), candidate) {
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if matched {
|
|
paths = append(paths, strings.Join(path, " "))
|
|
}
|
|
walk(child, path)
|
|
}
|
|
}
|
|
walk(parent, nil)
|
|
sort.Strings(paths)
|
|
return normalizeCommandSuggestions(paths)
|
|
}
|
|
|
|
// FormatSubcommandSuggestionHint renders bounded suggestions and always keeps
|
|
// the full-list recovery action visible in the same hint.
|
|
func FormatSubcommandSuggestionHint(parent *cobra.Command, suggestions []string, fallback string) string {
|
|
if len(suggestions) > MaxCommandSuggestions {
|
|
suggestions = suggestions[:MaxCommandSuggestions]
|
|
}
|
|
paths := make([]string, 0, len(suggestions))
|
|
parentPath := commandResolutionParentPath(parent)
|
|
for _, suggestion := range suggestions {
|
|
paths = append(paths, fmt.Sprintf("%q", parentPath+" "+suggestion))
|
|
}
|
|
var hint string
|
|
switch len(paths) {
|
|
case 0:
|
|
return fallback
|
|
case 1:
|
|
hint = "Did you mean " + paths[0] + "?"
|
|
default:
|
|
hint = "Did you mean one of: " + strings.Join(paths, ", ") + "?"
|
|
}
|
|
if fallback != "" {
|
|
hint += " (" + fallback + ")"
|
|
}
|
|
return hint
|
|
}
|