mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Validate the example commands embedded in shortcut definitions (the "Example: lark-cli ..." lines in each shortcut's Tips, shown in --help) against the real command tree built by cmd.Build. Implemented entirely as test-only code in cmd/ (package cmd_test), so it ships in no binary and is not importable by product code; the truth source is cmd.Build, the same tree the binary uses, so the check cannot drift. It runs in the standard unit-test CI job (go test ./cmd/...); a renamed command or unaccepted flag in an example fails that job.
161 lines
4.4 KiB
Go
161 lines
4.4 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package cmd_test
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// universalFlags are accepted by every command (cobra auto-injects help; the
|
|
// root injects version). They are never reported as unknown.
|
|
var universalFlags = map[string]bool{"--help": true, "-h": true, "--version": true}
|
|
|
|
// catalog is the source-of-truth command catalog: command path -> accepted flag
|
|
// tokens. A path is the command words WITHOUT the "lark-cli" root prefix, e.g.
|
|
// "contact +search-user". The root command is the empty path "".
|
|
type catalog struct {
|
|
flagsByPath map[string]map[string]bool
|
|
group map[string]bool // paths that are parent groups (have subcommands)
|
|
sorted []string // cached sorted paths for suggestCommand; invalidated on addCommand
|
|
}
|
|
|
|
func newCatalog() *catalog {
|
|
return &catalog{
|
|
flagsByPath: map[string]map[string]bool{},
|
|
group: map[string]bool{},
|
|
}
|
|
}
|
|
|
|
// setGroup records whether path is a parent group (has subcommands). Leftover
|
|
// words after a group node are unknown subcommands; after a leaf they are
|
|
// positionals (e.g. "api GET /path").
|
|
func (c *catalog) setGroup(path string, isGroup bool) {
|
|
if isGroup {
|
|
c.group[path] = true
|
|
}
|
|
}
|
|
|
|
func (c *catalog) isGroup(path string) bool { return c.group[path] }
|
|
|
|
// addCommand registers a command path and the flags it accepts. Repeated calls
|
|
// for the same path union the flag sets. flags are full tokens ("--query", "-q").
|
|
func (c *catalog) addCommand(path string, flags []string) {
|
|
set := c.flagsByPath[path]
|
|
if set == nil {
|
|
set = map[string]bool{}
|
|
c.flagsByPath[path] = set
|
|
}
|
|
for _, f := range flags {
|
|
set[f] = true
|
|
}
|
|
c.sorted = nil // invalidate cached suggestion list
|
|
}
|
|
|
|
func (c *catalog) hasCommand(path string) bool {
|
|
_, ok := c.flagsByPath[path]
|
|
return ok
|
|
}
|
|
|
|
// hasFlag reports whether flag is accepted by command path (universal flags
|
|
// always pass).
|
|
func (c *catalog) hasFlag(path, flag string) bool {
|
|
if universalFlags[flag] {
|
|
return true
|
|
}
|
|
set := c.flagsByPath[path]
|
|
return set[flag]
|
|
}
|
|
|
|
// longestPrefix returns the longest known command path that is a prefix of
|
|
// words, plus how many words it consumed. This separates real subcommands from
|
|
// trailing positionals (e.g. "api GET /path" resolves to "api"). When words is
|
|
// empty it falls back to the root command. ok=false means not even the first
|
|
// word names a command.
|
|
func (c *catalog) longestPrefix(words []string) (path string, n int, ok bool) {
|
|
if len(words) == 0 {
|
|
if c.hasCommand("") {
|
|
return "", 0, true
|
|
}
|
|
return "", 0, false
|
|
}
|
|
for i := len(words); i >= 1; i-- {
|
|
cand := strings.Join(words[:i], " ")
|
|
if c.hasCommand(cand) {
|
|
return cand, i, true
|
|
}
|
|
}
|
|
return "", 0, false
|
|
}
|
|
|
|
// paths returns all known command paths, sorted.
|
|
func (c *catalog) paths() []string {
|
|
out := make([]string, 0, len(c.flagsByPath))
|
|
for p := range c.flagsByPath {
|
|
out = append(out, p)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// suggestCommand returns the known command path closest to want (small edit
|
|
// distance), for error hints. Returns "" when nothing is reasonably close.
|
|
func (c *catalog) suggestCommand(want string) string {
|
|
if c.sorted == nil {
|
|
c.sorted = c.paths() // built once after the catalog is fully populated
|
|
}
|
|
return closest(want, c.sorted)
|
|
}
|
|
|
|
// suggestFlag returns the flag of path closest to flag, for error hints.
|
|
func (c *catalog) suggestFlag(path, flag string) string {
|
|
set := c.flagsByPath[path]
|
|
cands := make([]string, 0, len(set))
|
|
for f := range set {
|
|
cands = append(cands, f)
|
|
}
|
|
sort.Strings(cands)
|
|
return closest(flag, cands)
|
|
}
|
|
|
|
// closest returns the candidate with the smallest Levenshtein distance to want,
|
|
// but only if that distance is within a tolerance scaled to want's length
|
|
// (avoids absurd suggestions).
|
|
func closest(want string, cands []string) string {
|
|
best := ""
|
|
bestD := 1 << 30
|
|
for _, cand := range cands {
|
|
d := levenshtein(want, cand)
|
|
if d < bestD {
|
|
bestD, best = d, cand
|
|
}
|
|
}
|
|
tol := len(want)/2 + 1
|
|
if bestD > tol {
|
|
return ""
|
|
}
|
|
return best
|
|
}
|
|
|
|
func levenshtein(a, b string) int {
|
|
ra, rb := []rune(a), []rune(b)
|
|
prev := make([]int, len(rb)+1)
|
|
for j := range prev {
|
|
prev[j] = j
|
|
}
|
|
for i := 1; i <= len(ra); i++ {
|
|
cur := make([]int, len(rb)+1)
|
|
cur[0] = i
|
|
for j := 1; j <= len(rb); j++ {
|
|
cost := 1
|
|
if ra[i-1] == rb[j-1] {
|
|
cost = 0
|
|
}
|
|
cur[j] = min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost)
|
|
}
|
|
prev = cur
|
|
}
|
|
return prev[len(rb)]
|
|
}
|