doc_examples_test.go 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. package main
  2. import (
  3. "os"
  4. "strconv"
  5. "strings"
  6. "testing"
  7. "github.com/spf13/cobra"
  8. "github.com/spf13/pflag"
  9. )
  10. // Every example in the docs is a command a reader may type. Six of them were not:
  11. // `brightness 160`, `color <hex>`, `speed 0`, `effect 46`, `effect none` and
  12. // `effect rainbow` all predate the zone becoming the first argument, and each
  13. // either fails at the argument count or is read as a channel named after the
  14. // value. A broken example in a reference is worse than no example, so the
  15. // documented command lines are checked against the arity the commands enforce.
  16. //
  17. // Both shapes a reader may copy are read: the lines of a fenced bash block, and
  18. // the inline code spans in prose. Five of the six were in prose, so reading only
  19. // the blocks would have missed them.
  20. func TestDocumentedInvocationsMatchTheCommandArity(t *testing.T) {
  21. // The argument counts each command accepts, as withZoneArgs and zoneArgs
  22. // declare them. A command missing here is one that names no channel, so its
  23. // argument count is not this test's business.
  24. arity := map[string][2]int{
  25. "enable": {1, 1},
  26. "disable": {1, 1},
  27. "effect": {1, 2},
  28. "brightness": {2, 2},
  29. "speed": {2, 2},
  30. "color": {2, 2},
  31. "info": {0, 1},
  32. }
  33. takesValue := flagsThatTakeAValue(newRootCommand())
  34. seen := 0
  35. for _, path := range []string{
  36. "../../README.md",
  37. "../../AGENTS.md",
  38. "../../.claude/skills/qmk-rgb/SKILL.md",
  39. } {
  40. data, err := os.ReadFile(path)
  41. if err != nil {
  42. t.Errorf("read %s: %v", path, err)
  43. continue
  44. }
  45. for _, line := range documentedCommandLines(string(data)) {
  46. command, args := splitInvocation(line.text, takesValue)
  47. want, known := arity[command]
  48. // A span naming the command alone is prose about it, and a quoted error
  49. // message carries the command without being one.
  50. if !known || len(args) == 0 || isQuotedMessage(args) {
  51. continue
  52. }
  53. seen++
  54. if got := len(args); got < want[0] || got > want[1] {
  55. t.Errorf("%s:%d passes %s to `%s`, which takes %s: %q",
  56. path, line.no, plural(got), command, describeArity(want), line.text)
  57. continue
  58. }
  59. // Every one of these commands takes its zone first. Whether the first
  60. // argument is a placeholder or a real value, it has to be a channel:
  61. // `color <hex>` and `effect 46` both write the value where the zone goes,
  62. // and the command reads it as a channel named after it. A completion
  63. // example is the third thing entirely: what follows the command is a
  64. // keypress, not an argument.
  65. if isKeypress(args) {
  66. continue
  67. }
  68. if !namesTheZone(args[0]) {
  69. t.Errorf("%s:%d writes %q where `%s` takes its zone: %q",
  70. path, line.no, args[0], command, line.text)
  71. }
  72. }
  73. }
  74. // A checker that reads nothing proves nothing, and the fence handling is the
  75. // part most able to match everything by accident.
  76. if seen < 10 {
  77. t.Errorf("checked %d documented command lines, want enough to be worth having", seen)
  78. }
  79. }
  80. // commandLine is one documented command line, with a prompt, a path prefix and a
  81. // trailing comment already stripped.
  82. type commandLine struct {
  83. text string
  84. no int
  85. }
  86. // documentedCommandLines returns every line the docs offer as something to run:
  87. // the command lines of a fenced bash block, and the inline code spans of prose.
  88. // Neither a `$` prompt nor a leading `./` is part of what the command is given, so
  89. // both go.
  90. func documentedCommandLines(body string) []commandLine {
  91. var out []commandLine
  92. inFence := false
  93. for i, raw := range strings.Split(body, "\n") {
  94. line := strings.TrimSpace(raw)
  95. if strings.HasPrefix(line, "```") {
  96. inFence = strings.Contains(line, "bash")
  97. continue
  98. }
  99. if inFence {
  100. if idx := strings.Index(line, "#"); idx >= 0 {
  101. line = strings.TrimSpace(line[:idx])
  102. }
  103. if stripped := stripPrompt(line); stripped != "" {
  104. out = append(out, commandLine{text: stripped, no: i + 1})
  105. }
  106. continue
  107. }
  108. for _, span := range inlineCodeSpans(raw) {
  109. if stripped := stripPrompt(span); stripped != "" {
  110. out = append(out, commandLine{text: stripped, no: i + 1})
  111. }
  112. }
  113. }
  114. return out
  115. }
  116. // stripPrompt removes a `$ ` prompt and a leading `./`, and leaves the rest alone.
  117. // The binary's own name is left in place, because whether a line carries it says
  118. // something: a block usually does and a prose span usually does not, and matching
  119. // the command is what the caller does either way.
  120. func stripPrompt(line string) string {
  121. line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "$ "))
  122. line = strings.TrimSpace(strings.TrimPrefix(line, "./"))
  123. return strings.TrimSpace(line)
  124. }
  125. // inlineCodeSpans returns the backtick-quoted spans of a prose line, which is
  126. // where a documented command lives when it is not in a block. A double backtick
  127. // span is skipped, because that is how a span containing a backtick is written
  128. // and it is not an invocation.
  129. func inlineCodeSpans(line string) []string {
  130. var out []string
  131. parts := strings.Split(line, "``")
  132. if len(parts) > 1 {
  133. parts = parts[:1]
  134. }
  135. parts = strings.Split(parts[0], "`")
  136. for i := 1; i < len(parts); i += 2 {
  137. if span := strings.TrimSpace(parts[i]); span != "" {
  138. out = append(out, span)
  139. }
  140. }
  141. return out
  142. }
  143. // flagsThatTakeAValue reads the persistent flags off the real command tree, so
  144. // whether a flag swallows the word after it is answered by the flag's own
  145. // declaration rather than by a list written here. Guessing is what made an earlier
  146. // version of this file read `--json effect all` as the command `all`.
  147. func flagsThatTakeAValue(root *cobra.Command) map[string]bool {
  148. out := map[string]bool{}
  149. root.PersistentFlags().VisitAll(func(f *pflag.Flag) {
  150. if f.NoOptDefVal == "" {
  151. out["--"+f.Name] = true
  152. }
  153. })
  154. return out
  155. }
  156. // splitInvocation returns the command a line invokes and the positional arguments
  157. // it is given. The binary's own name goes first, because a line may carry a flag
  158. // before the command: `--device 2 brightness logo 160`. A flag that carries a
  159. // value consumes that word, and it is the flag that has to be the first word after
  160. // the command, so `info --json` leaves `info` with no arguments. A redirection
  161. // ends the arguments, so `completion bash > file` gives `completion` one.
  162. func splitInvocation(line string, takesValue map[string]bool) (string, []string) {
  163. args := strings.Fields(line)
  164. if len(args) > 0 && args[0] == "qmk-rgb-tool" {
  165. args = args[1:]
  166. }
  167. for len(args) > 0 && isFlag(args[0]) {
  168. name, _, inline := strings.Cut(args[0], "=")
  169. args = args[1:]
  170. if !inline && takesValue[name] && len(args) > 0 {
  171. args = args[1:]
  172. }
  173. }
  174. if len(args) == 0 {
  175. return "", nil
  176. }
  177. command := args[0]
  178. args = args[1:]
  179. // A flag may also follow the command, and a flag is not an argument.
  180. for len(args) > 0 && isFlag(args[len(args)-1]) {
  181. args = args[:len(args)-1]
  182. }
  183. for i, a := range args {
  184. if strings.HasPrefix(a, ">") {
  185. args = args[:i]
  186. break
  187. }
  188. }
  189. return command, args
  190. }
  191. func isFlag(arg string) bool { return len(arg) > 1 && strings.HasPrefix(arg, "-") }
  192. // isKeypress reports whether a documented line ends in `<TAB>`, which is how the
  193. // docs write a shell completion example. The command is named, and what follows it
  194. // is a key the reader presses rather than an argument they pass, so there is no
  195. // arity to check.
  196. func isKeypress(args []string) bool {
  197. return strings.Contains(strings.ToLower(strings.Join(args, " ")), "<tab")
  198. }
  199. // namesTheZone reports whether an argument is written as a zone. A usage form may
  200. // spell it `<zone>`, or `[zone]` where the command reads without one. Otherwise it
  201. // is a channel name: a QMK subsystem name, the word `all`, a comma separated list
  202. // of them, or a board's own name from its definition file — of which the docs
  203. // name only the Impact 80's, since that is the board the file in the repository
  204. // describes.
  205. //
  206. // This is a list of names and not a resolver, and it is the reason a zone the docs
  207. // do not know cannot be checked here: the check is here to catch a value written
  208. // where a name belongs, not to enumerate every name a board may carry.
  209. func namesTheZone(arg string) bool {
  210. // The brackets that mark a placeholder say nothing about what it is.
  211. stripped := strings.NewReplacer("<", "", ">", "", "[", "", "]", "").Replace(arg)
  212. lowered := strings.ToLower(stripped)
  213. for _, zone := range []string{"zone", "all", "backlight", "rgblight", "rgb_matrix", "audio", "led_matrix", "logo", "side"} {
  214. if strings.Contains(lowered, zone) {
  215. return true
  216. }
  217. }
  218. return false
  219. }
  220. // isQuotedMessage reports whether a span is an error message the docs quote rather
  221. // than a command someone would run. The messages this tool emits are English, so
  222. // they carry words a command line cannot: `effect off is not supported on
  223. // backlight` names the command without being an invocation of it.
  224. func isQuotedMessage(args []string) bool {
  225. joined := strings.Join(args, " ")
  226. return strings.Contains(joined, " is ") || strings.Contains(joined, " not ")
  227. }
  228. func describeArity(want [2]int) string {
  229. if want[0] == want[1] {
  230. return "exactly " + strconv.Itoa(want[0])
  231. }
  232. return strconv.Itoa(want[0]) + " to " + strconv.Itoa(want[1])
  233. }
  234. func plural(n int) string {
  235. if n == 1 {
  236. return "1 argument"
  237. }
  238. return strconv.Itoa(n) + " arguments"
  239. }