package main import ( "os" "strconv" "strings" "testing" "github.com/spf13/cobra" "github.com/spf13/pflag" ) // Every example in the docs is a command a reader may type. Six of them were not: // `brightness 160`, `color `, `speed 0`, `effect 46`, `effect none` and // `effect rainbow` all predate the zone becoming the first argument, and each // either fails at the argument count or is read as a channel named after the // value. A broken example in a reference is worse than no example, so the // documented command lines are checked against the arity the commands enforce. // // Both shapes a reader may copy are read: the lines of a fenced bash block, and // the inline code spans in prose. Five of the six were in prose, so reading only // the blocks would have missed them. func TestDocumentedInvocationsMatchTheCommandArity(t *testing.T) { // The argument counts each command accepts, as withZoneArgs and zoneArgs // declare them. A command missing here is one that names no channel, so its // argument count is not this test's business. arity := map[string][2]int{ "enable": {1, 1}, "disable": {1, 1}, "effect": {1, 2}, "brightness": {2, 2}, "speed": {2, 2}, "color": {2, 2}, "info": {0, 1}, } takesValue := flagsThatTakeAValue(newRootCommand()) seen := 0 for _, path := range []string{ "../../README.md", "../../AGENTS.md", "../../.claude/skills/qmk-rgb/SKILL.md", } { data, err := os.ReadFile(path) if err != nil { t.Errorf("read %s: %v", path, err) continue } for _, line := range documentedCommandLines(string(data)) { command, args := splitInvocation(line.text, takesValue) want, known := arity[command] // A span naming the command alone is prose about it, and a quoted error // message carries the command without being one. if !known || len(args) == 0 || isQuotedMessage(args) { continue } seen++ if got := len(args); got < want[0] || got > want[1] { t.Errorf("%s:%d passes %s to `%s`, which takes %s: %q", path, line.no, plural(got), command, describeArity(want), line.text) continue } // Every one of these commands takes its zone first. Whether the first // argument is a placeholder or a real value, it has to be a channel: // `color ` and `effect 46` both write the value where the zone goes, // and the command reads it as a channel named after it. A completion // example is the third thing entirely: what follows the command is a // keypress, not an argument. if isKeypress(args) { continue } if !namesTheZone(args[0]) { t.Errorf("%s:%d writes %q where `%s` takes its zone: %q", path, line.no, args[0], command, line.text) } } } // A checker that reads nothing proves nothing, and the fence handling is the // part most able to match everything by accident. if seen < 10 { t.Errorf("checked %d documented command lines, want enough to be worth having", seen) } } // commandLine is one documented command line, with a prompt, a path prefix and a // trailing comment already stripped. type commandLine struct { text string no int } // documentedCommandLines returns every line the docs offer as something to run: // the command lines of a fenced bash block, and the inline code spans of prose. // Neither a `$` prompt nor a leading `./` is part of what the command is given, so // both go. func documentedCommandLines(body string) []commandLine { var out []commandLine inFence := false for i, raw := range strings.Split(body, "\n") { line := strings.TrimSpace(raw) if strings.HasPrefix(line, "```") { inFence = strings.Contains(line, "bash") continue } if inFence { if idx := strings.Index(line, "#"); idx >= 0 { line = strings.TrimSpace(line[:idx]) } if stripped := stripPrompt(line); stripped != "" { out = append(out, commandLine{text: stripped, no: i + 1}) } continue } for _, span := range inlineCodeSpans(raw) { if stripped := stripPrompt(span); stripped != "" { out = append(out, commandLine{text: stripped, no: i + 1}) } } } return out } // stripPrompt removes a `$ ` prompt and a leading `./`, and leaves the rest alone. // The binary's own name is left in place, because whether a line carries it says // something: a block usually does and a prose span usually does not, and matching // the command is what the caller does either way. func stripPrompt(line string) string { line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "$ ")) line = strings.TrimSpace(strings.TrimPrefix(line, "./")) return strings.TrimSpace(line) } // inlineCodeSpans returns the backtick-quoted spans of a prose line, which is // where a documented command lives when it is not in a block. A double backtick // span is skipped, because that is how a span containing a backtick is written // and it is not an invocation. func inlineCodeSpans(line string) []string { var out []string parts := strings.Split(line, "``") if len(parts) > 1 { parts = parts[:1] } parts = strings.Split(parts[0], "`") for i := 1; i < len(parts); i += 2 { if span := strings.TrimSpace(parts[i]); span != "" { out = append(out, span) } } return out } // flagsThatTakeAValue reads the persistent flags off the real command tree, so // whether a flag swallows the word after it is answered by the flag's own // declaration rather than by a list written here. Guessing is what made an earlier // version of this file read `--json effect all` as the command `all`. func flagsThatTakeAValue(root *cobra.Command) map[string]bool { out := map[string]bool{} root.PersistentFlags().VisitAll(func(f *pflag.Flag) { if f.NoOptDefVal == "" { out["--"+f.Name] = true } }) return out } // splitInvocation returns the command a line invokes and the positional arguments // it is given. The binary's own name goes first, because a line may carry a flag // before the command: `--device 2 brightness logo 160`. A flag that carries a // value consumes that word, and it is the flag that has to be the first word after // the command, so `info --json` leaves `info` with no arguments. A redirection // ends the arguments, so `completion bash > file` gives `completion` one. func splitInvocation(line string, takesValue map[string]bool) (string, []string) { args := strings.Fields(line) if len(args) > 0 && args[0] == "qmk-rgb-tool" { args = args[1:] } for len(args) > 0 && isFlag(args[0]) { name, _, inline := strings.Cut(args[0], "=") args = args[1:] if !inline && takesValue[name] && len(args) > 0 { args = args[1:] } } if len(args) == 0 { return "", nil } command := args[0] args = args[1:] // A flag may also follow the command, and a flag is not an argument. for len(args) > 0 && isFlag(args[len(args)-1]) { args = args[:len(args)-1] } for i, a := range args { if strings.HasPrefix(a, ">") { args = args[:i] break } } return command, args } func isFlag(arg string) bool { return len(arg) > 1 && strings.HasPrefix(arg, "-") } // isKeypress reports whether a documented line ends in ``, which is how the // docs write a shell completion example. The command is named, and what follows it // is a key the reader presses rather than an argument they pass, so there is no // arity to check. func isKeypress(args []string) bool { return strings.Contains(strings.ToLower(strings.Join(args, " ")), "`, or `[zone]` where the command reads without one. Otherwise it // is a channel name: a QMK subsystem name, the word `all`, a comma separated list // of them, or a board's own name from its definition file — of which the docs // name only the Impact 80's, since that is the board the file in the repository // describes. // // This is a list of names and not a resolver, and it is the reason a zone the docs // do not know cannot be checked here: the check is here to catch a value written // where a name belongs, not to enumerate every name a board may carry. func namesTheZone(arg string) bool { // The brackets that mark a placeholder say nothing about what it is. stripped := strings.NewReplacer("<", "", ">", "", "[", "", "]", "").Replace(arg) lowered := strings.ToLower(stripped) for _, zone := range []string{"zone", "all", "backlight", "rgblight", "rgb_matrix", "audio", "led_matrix", "logo", "side"} { if strings.Contains(lowered, zone) { return true } } return false } // isQuotedMessage reports whether a span is an error message the docs quote rather // than a command someone would run. The messages this tool emits are English, so // they carry words a command line cannot: `effect off is not supported on // backlight` names the command without being an invocation of it. func isQuotedMessage(args []string) bool { joined := strings.Join(args, " ") return strings.Contains(joined, " is ") || strings.Contains(joined, " not ") } func describeArity(want [2]int) string { if want[0] == want[1] { return "exactly " + strconv.Itoa(want[0]) } return strconv.Itoa(want[0]) + " to " + strconv.Itoa(want[1]) } func plural(n int) string { if n == 1 { return "1 argument" } return strconv.Itoa(n) + " arguments" }