|
|
@@ -0,0 +1,259 @@
|
|
|
+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"
|
|
|
+}
|