From b9a9f9486869f01b513ffe58dc7f5f847a1a2326 Mon Sep 17 00:00:00 2001 From: shanglei Date: Thu, 4 Jun 2026 11:13:02 +0800 Subject: [PATCH] feat: check shortcut example commands against the live CLI tree 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. --- cmd/cmdexample_catalog_test.go | 160 ++++++++++++++++++++++ cmd/cmdexample_check_test.go | 60 +++++++++ cmd/cmdexample_parse_test.go | 222 +++++++++++++++++++++++++++++++ cmd/cmdexample_test.go | 113 ++++++++++++++++ cmd/cmdexample_units_test.go | 233 +++++++++++++++++++++++++++++++++ 5 files changed, 788 insertions(+) create mode 100644 cmd/cmdexample_catalog_test.go create mode 100644 cmd/cmdexample_check_test.go create mode 100644 cmd/cmdexample_parse_test.go create mode 100644 cmd/cmdexample_test.go create mode 100644 cmd/cmdexample_units_test.go diff --git a/cmd/cmdexample_catalog_test.go b/cmd/cmdexample_catalog_test.go new file mode 100644 index 000000000..fa3029a3d --- /dev/null +++ b/cmd/cmdexample_catalog_test.go @@ -0,0 +1,160 @@ +// 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)] +} diff --git a/cmd/cmdexample_check_test.go b/cmd/cmdexample_check_test.go new file mode 100644 index 000000000..ae9775026 --- /dev/null +++ b/cmd/cmdexample_check_test.go @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd_test + +import "strings" + +// Finding kinds. +const ( + unknownCommand = "unknown_command" + unknownFlag = "unknown_flag" +) + +// finding is a single mismatch between an example command reference and the +// catalog. +type finding struct { + line int + raw string + kind string // unknownCommand | unknownFlag + path string // resolved command path (unknownFlag) or attempted path (unknownCommand) + flag string // offending flag (unknownFlag only) + suggest string // nearest known command/flag, "" if none close +} + +// checkRefs validates refs against cat and returns all mismatches in order. +func checkRefs(cat *catalog, refs []ref) []finding { + var out []finding + for _, r := range refs { + path, n, ok := cat.longestPrefix(r.words) + if !ok { + attempted := strings.Join(r.words, " ") + out = append(out, finding{ + line: r.line, raw: r.raw, kind: unknownCommand, + path: attempted, suggest: cat.suggestCommand(attempted), + }) + continue + } + // Leftover words after a group node are an unknown subcommand (e.g. a + // mistyped method like "batch_modify_message"). After a leaf they are + // positionals (e.g. "api GET /path"), so only groups trigger this. + if n < len(r.words) && cat.isGroup(path) { + attempted := strings.Join(r.words, " ") + out = append(out, finding{ + line: r.line, raw: r.raw, kind: unknownCommand, + path: attempted, suggest: cat.suggestCommand(attempted), + }) + continue + } + for _, f := range r.flags { + if cat.hasFlag(path, f) { + continue + } + out = append(out, finding{ + line: r.line, raw: r.raw, kind: unknownFlag, + path: path, flag: f, suggest: cat.suggestFlag(path, f), + }) + } + } + return out +} diff --git a/cmd/cmdexample_parse_test.go b/cmd/cmdexample_parse_test.go new file mode 100644 index 000000000..360310a2a --- /dev/null +++ b/cmd/cmdexample_parse_test.go @@ -0,0 +1,222 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd_test + +import ( + "regexp" + "strings" +) + +// ref is one lark-cli command reference extracted from a shortcut example. +type ref struct { + line int // 1-based line number (the line where the command starts) + raw string // reconstructed command text, for error display + words []string // command words before the first flag (subcommand candidates) + flags []string // flag tokens used, e.g. "--query", "-q" +} + +const cliToken = "lark-cli" + +// subcommandStart guards against false positives from prose: a real command's +// first word is ASCII (a service name or a +shortcut). A token starting with +// CJK / punctuation is treated as narration, not a command. +var subcommandStart = regexp.MustCompile(`^[A-Za-z+]`) + +// shellStops are standalone tokens that terminate a command (pipes, redirects, +// separators). Separators glued to a token (`get;`, `foo|`) are handled inline. +var shellStops = map[string]bool{ + "|": true, "||": true, "&&": true, "&": true, ";": true, + ">": true, ">>": true, "<": true, "2>": true, "2>&1": true, +} + +// wordTrailPunct is sentence / CJK punctuation that can cling to a command word +// in prose ("auth login." / "auth login,"); stripped so the word still resolves +// instead of being dropped as an unknown command or non-ASCII narration. +const wordTrailPunct = `.,;:!?"')]},。、;:!?)】」』` + +// parseRefs extracts every lark-cli command reference from text (a shortcut's +// Tips line, which may embed an "Example: lark-cli ..." command). It is +// deliberately format-agnostic: it keys on the "lark-cli" token whether it sits +// in a ```bash fence, an inline `code` span, or bare prose. Backslash +// line-continuations are joined first so a multi-line invocation is parsed as +// one command; inline-code backticks and trailing # comments terminate it. +func parseRefs(content string) []ref { + var refs []ref + lines := strings.Split(content, "\n") + for i := 0; i < len(lines); i++ { + lineNo := i + 1 + logical := lines[i] + // Shell line continuation: a trailing backslash joins the next physical + // line. Without this, flags on the continuation lines of a multi-line + // `lark-cli ... \` example are never seen by the checker. + for endsWithBackslash(logical) && i+1 < len(lines) { + logical = strings.TrimRight(logical, " \t") + logical = logical[:len(logical)-1] // drop the trailing backslash + i++ + logical += " " + lines[i] + } + refs = append(refs, parseLine(logical, lineNo)...) + } + return refs +} + +func endsWithBackslash(s string) bool { + return strings.HasSuffix(strings.TrimRight(s, " \t"), `\`) +} + +func parseLine(line string, lineNo int) []ref { + var refs []ref + rest := line + for { + idx := strings.Index(rest, cliToken) + if idx < 0 { + break + } + after := rest[idx+len(cliToken):] + beforeOK := idx == 0 || isBoundary(rest[idx-1]) + afterOK := after == "" || isBoundary(after[0]) + if beforeOK && afterOK { + if words, flags, raw, ok := parseCmd(after); ok { + refs = append(refs, ref{line: lineNo, raw: cliToken + raw, words: words, flags: flags}) + } + } + rest = after + } + return refs +} + +// parseCmd tokenizes the text following "lark-cli" into leading command words +// (the subcommand path, up to the first flag) and flag tokens. It stops at a +// shell separator (standalone or glued), an inline-code backtick, a comment, or +// a placeholder/prose word. ok=false filters out non-commands. +func parseCmd(after string) (words, flags []string, raw string, ok bool) { + // An inline code span ends at the next backtick; a command never spans one. + if i := strings.IndexByte(after, '`'); i >= 0 { + after = after[:i] + } + // Drop $(...) command substitutions so flags belonging to the inner command + // (e.g. `--data "$(jq -n --arg x ...)"`) are not mistaken for lark-cli flags. + after = stripCmdSubst(after) + + var kept []string + inFlags := false + for _, orig := range strings.Fields(after) { + tok := orig + if shellStops[tok] || strings.HasPrefix(tok, "#") { + break + } + // A shell separator glued to a token ends the command mid-token + // ("get;", "foo|next"): keep the part before it, handle it, then stop. + stop := false + if i := strings.IndexAny(tok, ";|"); i >= 0 { + tok, stop = tok[:i], true + } + switch { + case tok == "" || tok == "-": + // empty (after a glued separator) or a bare stdin marker — skip + case strings.HasPrefix(tok, "-"): + if f := normalizeFlag(tok); f != "" { + inFlags = true + flags = append(flags, f) + kept = append(kept, tok) + } + case inFlags: + // positional / flag value after the first flag — not a command word + kept = append(kept, tok) + default: + // Command-path word. ASCII placeholder markers (, [x], {x|y}, + // +, ...) end the command — checked on the RAW token so the + // trailing-punct stripping below cannot erase a "..." ellipsis + // ("base +..." must stay a placeholder, not become "+"). + if strings.ContainsAny(tok, "<>[]{}|") || strings.Contains(tok, "...") { + stop = true + break + } + // Strip trailing sentence/CJK punctuation so "login." / "login," + // resolve to "login"; non-ASCII narration ends the command. + w := strings.TrimRight(tok, wordTrailPunct) + if w == "" || hasNonASCII(w) { + stop = true + break + } + words = append(words, w) + kept = append(kept, tok) + } + if stop { + break + } + } + if len(kept) > 0 { + raw = " " + strings.Join(kept, " ") + } + // Keep root-only refs ("lark-cli --help") and refs whose first word looks + // like a subcommand; drop prose ("lark-cli 就能搞定 ..."). + if len(words) == 0 { + return words, flags, raw, len(flags) > 0 + } + if !subcommandStart.MatchString(words[0]) { + return nil, nil, "", false + } + return words, flags, raw, true +} + +// stripCmdSubst removes $(...) command substitutions (including nested ones) +// from s, leaving the surrounding text intact. Backtick substitutions are +// already handled upstream (a command never spans a backtick). +func stripCmdSubst(s string) string { + var b strings.Builder + depth := 0 + for i := 0; i < len(s); i++ { + if depth == 0 && i+1 < len(s) && s[i] == '$' && s[i+1] == '(' { + depth = 1 + i++ // skip '(' + continue + } + if depth > 0 { + switch s[i] { + case '(': + depth++ + case ')': + depth-- + } + continue + } + b.WriteByte(s[i]) + } + return b.String() +} + +// isPlaceholderOrProse reports whether a command word is a doc placeholder +// (, [flags], {a|b}, +, ...) or narration (CJK / other +// non-ASCII), rather than a literal command token. +func isPlaceholderOrProse(w string) bool { + if hasNonASCII(w) { + return true + } + return strings.ContainsAny(w, "<>[]{}|") || strings.Contains(w, "...") +} + +func hasNonASCII(s string) bool { + return strings.IndexFunc(s, func(r rune) bool { return r > 127 }) >= 0 +} + +// flagShape matches the leading flag token, stripping any trailing junk such as +// a "=value" suffix or punctuation that bled in from the surrounding markdown +// ("--help\"", "--help;", "--params={}"). The underscore is allowed because +// real flags use it ("--input_format", "--output_as"). Returns "" for non-flags. +var flagShape = regexp.MustCompile(`^--?[A-Za-z][A-Za-z0-9_-]*`) + +// normalizeFlag extracts the canonical flag token from tok, or "" if tok is not +// a real flag (e.g. a shell-string fragment like "-草稿'"). +func normalizeFlag(tok string) string { + return flagShape.FindString(tok) +} + +func isBoundary(b byte) bool { + switch b { + case ' ', '\t', '`', '(', ')', '\'', '"', '*': + return true + } + return false +} diff --git a/cmd/cmdexample_test.go b/cmd/cmdexample_test.go new file mode 100644 index 000000000..659ca2609 --- /dev/null +++ b/cmd/cmdexample_test.go @@ -0,0 +1,113 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// This file and its cmdexample_*_test.go siblings implement a test-only check: +// the example commands embedded in shortcut definitions (the "Example: lark-cli +// ..." lines in each shortcut's Tips, shown in --help) must match the real +// command tree. It lives entirely in _test.go files (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 mismatch — an +// example using a renamed command or an unaccepted flag — fails that job. + +package cmd_test + +import ( + "context" + "sort" + "strings" + "testing" + + "github.com/larksuite/cli/cmd" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/shortcuts" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// TestShortcutExampleCommands checks the example commands embedded in every +// shortcut's Tips against the live command tree. A shortcut that defines no +// example is simply skipped. +// +// Because the examples and the command definitions live in the same Go code, +// this is a self-consistency check: any mismatch (an example using a renamed +// command or a flag the command doesn't accept) is a bug to fix at the source. +// It runs over all shortcuts — no baseline, no diff — since a wrong example is +// always a defect, never acceptable "pre-existing drift". +func TestShortcutExampleCommands(t *testing.T) { + // Reproducibility: use the embedded API metadata (not a developer's stale + // ~/.lark-cli remote cache, which can miss commands) and an empty config + // dir so local strict mode / plugins / policy cannot reshape the tree. + // t.Setenv auto-restores after the test, so other cmd tests are unaffected. + t.Setenv("LARKSUITE_CLI_REMOTE_META", "off") + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + cat := buildCmdExampleCatalog() + + type located struct { + shortcut string + f finding + } + var findings []located + for _, sc := range shortcuts.AllShortcuts() { + var refs []ref + for _, tip := range sc.Tips { + refs = append(refs, parseRefs(tip)...) + } + label := strings.TrimSpace(sc.Service + " " + sc.Command) + for _, f := range checkRefs(cat, refs) { + findings = append(findings, located{shortcut: label, f: f}) + } + } + + if len(findings) == 0 { + return + } + sort.Slice(findings, func(i, j int) bool { return findings[i].shortcut < findings[j].shortcut }) + for _, lf := range findings { + hint := "" + if lf.f.suggest != "" { + hint = " (did you mean " + lf.f.suggest + "?)" + } + if lf.f.kind == unknownFlag { + t.Errorf("shortcut %q example uses unknown flag %s on %q%s\n %s", + lf.shortcut, lf.f.flag, lf.f.path, hint, strings.TrimSpace(lf.f.raw)) + } else { + t.Errorf("shortcut %q example uses unknown command %q%s\n %s", + lf.shortcut, lf.f.path, hint, strings.TrimSpace(lf.f.raw)) + } + } + t.Fatalf("%d shortcut example command(s) don't match the real CLI — "+ + "fix the Example in the shortcut definition.", len(findings)) +} + +// buildCmdExampleCatalog walks the live cobra command tree and records every +// command path (minus the "lark-cli" root prefix) with its accepted flags and +// whether it is a parent group. This is the same Build() the binary uses, so +// the catalog can never drift from the real commands. +func buildCmdExampleCatalog() *catalog { + root := cmd.Build(context.Background(), cmdutil.InvocationContext{}) + cat := newCatalog() + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + path := strings.TrimSpace(strings.TrimPrefix(c.CommandPath(), "lark-cli")) + var flags []string + add := func(fl *pflag.Flag) { + flags = append(flags, "--"+fl.Name) + if fl.Shorthand != "" { + flags = append(flags, "-"+fl.Shorthand) + } + } + c.Flags().VisitAll(add) + c.InheritedFlags().VisitAll(add) + c.PersistentFlags().VisitAll(add) // root's own persistent flags (e.g. --profile) + cat.addCommand(path, flags) + cat.setGroup(path, c.HasSubCommands()) + for _, sub := range c.Commands() { + walk(sub) + } + } + walk(root) + return cat +} diff --git a/cmd/cmdexample_units_test.go b/cmd/cmdexample_units_test.go new file mode 100644 index 000000000..235cd023e --- /dev/null +++ b/cmd/cmdexample_units_test.go @@ -0,0 +1,233 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd_test + +import ( + "strings" + "testing" +) + +func testCatalog() *catalog { + c := newCatalog() + c.addCommand("", []string{"--profile"}) // root + c.setGroup("", true) + c.addCommand("contact", []string{"--profile"}) + c.setGroup("contact", true) + c.addCommand("contact +search-user", []string{"--query", "--as", "--format", "-q"}) + c.addCommand("api", []string{"--params", "--data", "--as"}) // leaf (no subcommands) + c.addCommand("mail", nil) + c.setGroup("mail", true) + c.addCommand("mail user_mailbox.messages", []string{"--profile"}) + c.setGroup("mail user_mailbox.messages", true) + c.addCommand("mail user_mailbox.messages batch_modify", []string{"--params", "--data"}) + return c +} + +func TestCmdExampleCatalogHasCommandAndFlag(t *testing.T) { + c := testCatalog() + if !c.hasCommand("contact +search-user") { + t.Fatal("expected contact +search-user to exist") + } + if c.hasCommand("contact +nope") { + t.Fatal("did not expect contact +nope") + } + if !c.hasFlag("contact +search-user", "--query") { + t.Fatal("--query should be valid") + } + if c.hasFlag("contact +search-user", "--nope") { + t.Fatal("--nope should be invalid") + } + // universal flags pass on any command + for _, f := range []string{"--help", "-h", "--version"} { + if !c.hasFlag("contact +search-user", f) { + t.Fatalf("universal flag %s should pass", f) + } + } +} + +func TestCmdExampleLongestPrefix(t *testing.T) { + c := testCatalog() + tests := []struct { + words []string + want string + wantN int + wantOK bool + }{ + {[]string{"contact", "+search-user"}, "contact +search-user", 2, true}, + {[]string{"api", "GET", "/open-apis/x"}, "api", 1, true}, // trailing positionals + {[]string{"nope"}, "", 0, false}, + {nil, "", 0, true}, // empty -> root + } + for _, tt := range tests { + got, n, ok := c.longestPrefix(tt.words) + if got != tt.want || n != tt.wantN || ok != tt.wantOK { + t.Errorf("longestPrefix(%v) = (%q,%d,%v), want (%q,%d,%v)", + tt.words, got, n, ok, tt.want, tt.wantN, tt.wantOK) + } + } +} + +func refWordsOf(refs []ref) [][]string { + var out [][]string + for _, r := range refs { + out = append(out, r.words) + } + return out +} + +func TestCmdExampleParseRefsExtractsCommands(t *testing.T) { + content := strings.Join([]string{ + "运行 `lark-cli contact +search-user --query 张三` 搜索", // inline code + "```bash", + "lark-cli api GET /open-apis/x --params '{}'", // bash block + "```", + "用 lark-cli mail user_mailbox.messages batch_modify 即可", // bare prose command + "npx foo | lark-cli api GET /y", // after a pipe + }, "\n") + refs := parseRefs(content) + if len(refs) != 4 { + t.Fatalf("expected 4 refs, got %d: %v", len(refs), refWordsOf(refs)) + } + if got := refs[0]; strings.Join(got.words, " ") != "contact +search-user" || + len(got.flags) != 1 || got.flags[0] != "--query" { + t.Errorf("ref0 = %+v", got) + } + if got := refs[1]; strings.Join(got.words, " ") != "api GET /open-apis/x" { + t.Errorf("ref1 words = %v", got.words) + } +} + +func TestCmdExampleParseRefsFiltersPlaceholdersAndProse(t *testing.T) { + // A line whose first word is prose yields no command at all. + if refs := parseRefs("lark-cli 就能搞定这件事"); len(refs) != 0 { + t.Errorf("prose-first line should yield 0 refs, got %v", refWordsOf(refs)) + } + // Syntax templates / trailing prose may leave a real leading word ("mail"), + // but no placeholder or CJK token may leak into the command words — that is + // what prevents false positives like an "" unknown-command report. + for _, line := range []string{ + "lark-cli mail [flags]", + "lark-cli apps + [flags]", + "lark-cli base +...", + "lark-cli mail 写信场景下的格式说明", + } { + for _, r := range parseRefs(line) { + for _, w := range r.words { + if isPlaceholderOrProse(w) { + t.Errorf("%q: placeholder/prose token %q leaked into words %v", line, w, r.words) + } + } + } + } +} + +func TestCmdExampleParseRefsStripsTrailingJunk(t *testing.T) { + // frontmatter-style quoted value: the trailing quote must not bleed into the flag + refs := parseRefs(`cliHelp: "lark-cli contact --help"`) + if len(refs) != 1 { + t.Fatalf("expected 1 ref, got %d", len(refs)) + } + if len(refs[0].flags) != 1 || refs[0].flags[0] != "--help" { + t.Errorf("expected flag --help, got %v", refs[0].flags) + } + // bare "-" (stdin marker) and "=value" suffix + refs = parseRefs("lark-cli api GET /x --params={} --data -") + if len(refs) != 1 { + t.Fatalf("expected 1 ref, got %d", len(refs)) + } + flags := strings.Join(refs[0].flags, " ") + if flags != "--params --data" { + t.Errorf("expected '--params --data', got %q", flags) + } +} + +func TestCmdExampleCheck(t *testing.T) { + c := testCatalog() + tests := []struct { + name string + r ref + wantKind string // "" = no finding + wantPath string + }{ + {"valid shortcut", ref{words: []string{"contact", "+search-user"}, flags: []string{"--query"}}, "", ""}, + {"valid leaf positional", ref{words: []string{"api", "GET", "/x"}}, "", ""}, + {"unknown top command", ref{words: []string{"nope"}}, unknownCommand, "nope"}, + {"group leftover = unknown subcommand", + ref{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}}, + unknownCommand, "mail user_mailbox.messages batch_modify_message"}, + {"unknown flag", ref{words: []string{"contact", "+search-user"}, flags: []string{"--nope"}}, unknownFlag, "contact +search-user"}, + {"universal flag ok", ref{words: []string{"contact", "+search-user"}, flags: []string{"--help"}}, "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := checkRefs(c, []ref{tt.r}) + if tt.wantKind == "" { + if len(fs) != 0 { + t.Fatalf("expected no finding, got %+v", fs) + } + return + } + if len(fs) != 1 { + t.Fatalf("expected 1 finding, got %d: %+v", len(fs), fs) + } + if fs[0].kind != tt.wantKind || fs[0].path != tt.wantPath { + t.Errorf("got kind=%s path=%q, want kind=%s path=%q", fs[0].kind, fs[0].path, tt.wantKind, tt.wantPath) + } + }) + } +} + +func TestCmdExampleCheckSuggestsNearest(t *testing.T) { + c := testCatalog() + fs := checkRefs(c, []ref{{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}}}) + if len(fs) != 1 || fs[0].suggest != "mail user_mailbox.messages batch_modify" { + t.Fatalf("expected suggestion 'mail user_mailbox.messages batch_modify', got %+v", fs) + } +} + +// TestCmdExampleParseRefsRobustness covers the parser edge cases hardened after +// review: backslash continuation, underscore flags, $(...) substitution, glued +// separators, trailing punctuation, and the "..." placeholder. +func TestCmdExampleParseRefsRobustness(t *testing.T) { + cases := []struct { + name, content, wantWords, wantFlags string + wantRefs int + }{ + {"backslash continuation joins flags", + "lark-cli contact +search-user \\\n --query foo \\\n --as user", + "contact +search-user", "--query --as", 1}, + {"underscore flag not truncated", + "lark-cli whiteboard +update --input_format mermaid", + "whiteboard +update", "--input_format", 1}, + {"command-substitution flags ignored", + `lark-cli slides x create --data "$(jq -n --arg c '{}')" --as user`, + "slides x create", "--data --as", 1}, + {"glued separator truncates", + "lark-cli auth login; echo done", + "auth login", "", 1}, + {"trailing CJK punctuation stripped", + "用 lark-cli auth login。", + "auth login", "", 1}, + {"ellipsis placeholder stays placeholder", + "lark-cli base +...", + "base", "", 1}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + refs := parseRefs(tt.content) + if len(refs) != tt.wantRefs { + t.Fatalf("refs=%d want %d: %v", len(refs), tt.wantRefs, refWordsOf(refs)) + } + if tt.wantRefs == 0 { + return + } + if got := strings.Join(refs[0].words, " "); got != tt.wantWords { + t.Errorf("words=%q want %q", got, tt.wantWords) + } + if got := strings.Join(refs[0].flags, " "); got != tt.wantFlags { + t.Errorf("flags=%q want %q", got, tt.wantFlags) + } + }) + } +}