| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259 |
- 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 <hex>`, `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 <hex>` 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 `<TAB>`, 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, " ")), "<tab")
- }
- // namesTheZone reports whether an argument is written as a zone. A usage form may
- // spell it `<zone>`, 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"
- }
|