main.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. // Command generate reads a checkout of VIA's public definition collection and
  2. // writes internal/spotted/spotted.json: for every effect ID a board's
  3. // definitions have been seen to use, which spellings occur and how often.
  4. //
  5. // It exists because the alternative is a name the tool made up. No definition
  6. // file, firmware image, protocol query or version number yields the spelling of
  7. // an effect for a board whose vendor never wrote one down, and README says so
  8. // rather than filling the gap. What can be measured is what the boards that did
  9. // write theirs call the same number, which is evidence and not an answer: the
  10. // measurement this tool takes itself finds that 95 of the 123 definitions
  11. // carrying an rgb_matrix effect list are one manufacturer's, so a bare majority
  12. // would report one house style as a community consensus. The counts and the
  13. // vendor concentration travel with every name for that reason.
  14. //
  15. // The output is generated, never hand-edited, and it carries the commit it was
  16. // measured from. It is a snapshot of a collection that moves, so a name in it
  17. // can be out of date; the SHA is what says how out of date.
  18. //
  19. // Usage:
  20. //
  21. // generate <path-to-the-via/keyboards> > internal/spotted/spotted.json
  22. package main
  23. import (
  24. "encoding/json"
  25. "fmt"
  26. "io/fs"
  27. "os"
  28. "os/exec"
  29. "path/filepath"
  30. "sort"
  31. "strings"
  32. )
  33. // effectValueKeySuffix is what a VIA value key for an effect ends in. All effect
  34. // dropdowns in the collection use a QMK value key, so keying on the suffix keeps
  35. // this from needing a list of the keys themselves.
  36. const effectValueKeySuffix = "_effect"
  37. // maxCandidates is how many spellings per ID are kept. Three is what makes the
  38. // disagreement visible: where two boards call one number different effects, the
  39. // runner-up is the whole point, and a longer tail would only bury it.
  40. const maxCandidates = 3
  41. // Document is one board's effect list, keyed by the VIA value key it was under.
  42. type Document struct {
  43. Vendor string
  44. Effects map[string]map[int]string
  45. }
  46. // Candidate is one spelling seen for an effect ID, and how many boards wrote it.
  47. type Candidate struct {
  48. Name string `json:"name"`
  49. Count int `json:"count"`
  50. }
  51. // Observation is what was seen for one effect ID under one value key.
  52. type Observation struct {
  53. // Boards is how many definitions listed this ID at all. It is the sample
  54. // size, and a low one is why a name here is worth less than a name a
  55. // definition file carries.
  56. Boards int `json:"boards"`
  57. // Candidates are the spellings, most frequent first.
  58. Candidates []Candidate `json:"candidates"`
  59. // TopVendor is the manufacturer behind the most of them, and TopVendorCount
  60. // how many. A name whose share is nearly all one vendor is that vendor's
  61. // spelling, not a community one, and this is what says so.
  62. TopVendor string `json:"topVendor,omitempty"`
  63. TopVendorCount int `json:"topVendorCount,omitempty"`
  64. }
  65. // Report is the whole measurement, and what it was measured from.
  66. type Report struct {
  67. // Source and Commit pin the collection. A name in here is a claim about the
  68. // collection at that commit, and a collection that moves means the claim
  69. // ages.
  70. Source string `json:"source"`
  71. Commit string `json:"commit,omitempty"`
  72. // Definitions is how many files were read, and EffectBoards how many of them
  73. // carried an effect list at all. The two differ by an order of magnitude,
  74. // which is the size of the problem this file cannot solve.
  75. Definitions int `json:"definitions"`
  76. EffectBoards int `json:"effectBoards"`
  77. // Effects is keyed by the VIA value key, then by effect ID as a string,
  78. // because JSON object keys are strings and the IDs are bytes.
  79. Effects map[string]map[string]Observation `json:"effects"`
  80. }
  81. func main() {
  82. if len(os.Args) != 2 {
  83. fmt.Fprintln(os.Stderr, "usage: generate <path-to-the-via/keyboards>")
  84. os.Exit(2)
  85. }
  86. root := os.Args[1]
  87. report, err := measure(root)
  88. if err != nil {
  89. fmt.Fprintln(os.Stderr, err)
  90. os.Exit(1)
  91. }
  92. enc := json.NewEncoder(os.Stdout)
  93. enc.SetIndent("", " ")
  94. if err := enc.Encode(report); err != nil {
  95. fmt.Fprintln(os.Stderr, err)
  96. os.Exit(1)
  97. }
  98. }
  99. func measure(root string) (*Report, error) {
  100. v3 := filepath.Join(root, "v3")
  101. // The collection nests to a varying depth, so it is walked rather than
  102. // globbed: a glob of a fixed depth reads a fraction of it, and a measurement
  103. // over a fraction is a measurement of something else.
  104. var paths []string
  105. err := filepath.WalkDir(v3, func(path string, entry fs.DirEntry, err error) error {
  106. if err != nil {
  107. return err
  108. }
  109. if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") {
  110. paths = append(paths, path)
  111. }
  112. return nil
  113. })
  114. if err != nil {
  115. return nil, err
  116. }
  117. sort.Strings(paths)
  118. report := &Report{
  119. Source: "https://github.com/the-via/keyboards",
  120. Commit: commitOf(root),
  121. Effects: map[string]map[string]Observation{},
  122. Definitions: len(paths),
  123. }
  124. // spellings[valueKey][id][spelling] and vendors[valueKey][id][vendor], so one
  125. // pass over the collection answers every ID at once.
  126. spellings := map[string]map[int]map[string]int{}
  127. vendors := map[string]map[int]map[string]int{}
  128. for _, path := range paths {
  129. doc := readDocument(path)
  130. if len(doc.Effects) == 0 {
  131. continue
  132. }
  133. report.EffectBoards++
  134. for key, effects := range doc.Effects {
  135. if spellings[key] == nil {
  136. spellings[key] = map[int]map[string]int{}
  137. vendors[key] = map[int]map[string]int{}
  138. }
  139. for id, name := range effects {
  140. if spellings[key][id] == nil {
  141. spellings[key][id] = map[string]int{}
  142. vendors[key][id] = map[string]int{}
  143. }
  144. spellings[key][id][normalise(name)]++
  145. vendors[key][id][doc.Vendor]++
  146. }
  147. }
  148. }
  149. for key, byID := range spellings {
  150. ids := make([]int, 0, len(byID))
  151. for id := range byID {
  152. ids = append(ids, id)
  153. }
  154. sort.Ints(ids)
  155. report.Effects[key] = map[string]Observation{}
  156. for _, id := range ids {
  157. report.Effects[key][fmt.Sprint(id)] = observe(byID[id], vendors[key][id])
  158. }
  159. }
  160. return report, nil
  161. }
  162. // observe turns the counts for one ID into what the report carries for it.
  163. func observe(byName map[string]int, byVendor map[string]int) Observation {
  164. obs := Observation{Boards: 0}
  165. for _, n := range byName {
  166. obs.Boards += n
  167. }
  168. type pair struct {
  169. name string
  170. count int
  171. }
  172. pairs := make([]pair, 0, len(byName))
  173. for name, count := range byName {
  174. pairs = append(pairs, pair{name, count})
  175. }
  176. // Ties are broken by name so the output is the same for the same input.
  177. sort.Slice(pairs, func(i, j int) bool {
  178. if pairs[i].count != pairs[j].count {
  179. return pairs[i].count > pairs[j].count
  180. }
  181. return pairs[i].name < pairs[j].name
  182. })
  183. for i, p := range pairs {
  184. if i >= maxCandidates {
  185. break
  186. }
  187. obs.Candidates = append(obs.Candidates, Candidate{Name: p.name, Count: p.count})
  188. }
  189. for vendor, count := range byVendor {
  190. if count > obs.TopVendorCount {
  191. obs.TopVendor, obs.TopVendorCount = vendor, count
  192. }
  193. }
  194. return obs
  195. }
  196. // readDocument reads one definition file and returns the effect dropdowns it
  197. // carries. A file that does not parse, or that carries none, is not an error:
  198. // the collection holds files for boards that do not light up, and a measurement
  199. // that stopped at the first of them would describe nothing.
  200. func readDocument(path string) Document {
  201. doc := Document{Effects: map[string]map[int]string{}}
  202. // The vendor is the directory above the board's, which is how the collection
  203. // is laid out: v3/<vendor>/<board>/.../<board>.json. For a two-segment path it
  204. // is the second segment.
  205. parts := strings.Split(filepath.ToSlash(path), "/")
  206. for i, p := range parts {
  207. if p == "v3" && i+1 < len(parts) {
  208. doc.Vendor = parts[i+1]
  209. break
  210. }
  211. }
  212. raw, err := os.ReadFile(path)
  213. if err != nil {
  214. return doc
  215. }
  216. var file any
  217. if err := json.Unmarshal(raw, &file); err != nil {
  218. return doc
  219. }
  220. walk(file, "", doc.Effects)
  221. return doc
  222. }
  223. // walk finds every dropdown whose value key names an effect. The key is read
  224. // from the dropdown's own content rather than from a label, because the label is
  225. // a display string and the key is the address.
  226. func walk(node any, key string, out map[string]map[int]string) {
  227. switch n := node.(type) {
  228. case []any:
  229. for _, child := range n {
  230. walk(child, key, out)
  231. }
  232. case map[string]any:
  233. content, _ := n["content"].([]any)
  234. if n["type"] == "dropdown" && len(content) > 0 {
  235. if valueKey, ok := content[0].(string); ok && strings.HasSuffix(valueKey, effectValueKeySuffix) {
  236. if options, ok := n["options"].([]any); ok {
  237. if parsed, ok := parseOptions(options); ok {
  238. if out[valueKey] == nil {
  239. out[valueKey] = map[int]string{}
  240. }
  241. for id, name := range parsed {
  242. out[valueKey][id] = name
  243. }
  244. }
  245. }
  246. }
  247. }
  248. for _, child := range n {
  249. walk(child, key, out)
  250. }
  251. }
  252. }
  253. // parseOptions reads a dropdown's options as number-name pairs. An option's
  254. // number is not its position, which is why this reads the number rather than
  255. // counting: a board may name its first effect 1 and have no 0 at all.
  256. func parseOptions(options []any) (map[int]string, bool) {
  257. out := map[int]string{}
  258. for _, option := range options {
  259. pair, ok := option.([]any)
  260. if !ok || len(pair) != 2 {
  261. return nil, false
  262. }
  263. name, ok := pair[0].(string)
  264. if !ok {
  265. return nil, false
  266. }
  267. var number int
  268. switch v := pair[1].(type) {
  269. case float64:
  270. number = int(v)
  271. case string:
  272. if _, err := fmt.Sscanf(v, "%d", &number); err != nil {
  273. return nil, false
  274. }
  275. default:
  276. return nil, false
  277. }
  278. out[number] = name
  279. }
  280. return out, len(out) > 0
  281. }
  282. // normalise reduces a display spelling to something a lookup can compare: case
  283. // and separators are the whole difference between a manufacturer's spelling and
  284. // the tool's, and `fixed wave` and `fixed_wave` are one effect.
  285. //
  286. // A leading number is decoration and is dropped. One manufacturer's definitions
  287. // write `07. RAINBOW_MOVING_CHEVRON` on every effect, and without this the most
  288. // frequent spelling of every ID would carry that vendor's numbering rather than
  289. // the name, which is the opposite of what a candidate is for. Two spellings that
  290. // differ only in such a prefix are the same name and merge.
  291. func normalise(name string) string {
  292. var b strings.Builder
  293. for _, r := range strings.ToLower(strings.TrimSpace(name)) {
  294. switch {
  295. case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
  296. b.WriteRune(r)
  297. default:
  298. b.WriteRune('_')
  299. }
  300. }
  301. return strings.TrimLeft(stripLeadingNumber(b.String()), "_")
  302. }
  303. // stripLeadingNumber removes a leading run of digits and the underscore after it,
  304. // so `07__rainbow_moving_chevron` becomes `rainbow_moving_chevron` and
  305. // `00__none` becomes `none`. A name that is only a number is left alone: that is
  306. // not decoration, and there is nothing else in it.
  307. func stripLeadingNumber(s string) string {
  308. i := 0
  309. for i < len(s) && s[i] >= '0' && s[i] <= '9' {
  310. i++
  311. }
  312. if i == 0 || i == len(s) {
  313. return s
  314. }
  315. for i < len(s) && s[i] == '_' {
  316. i++
  317. }
  318. if i == len(s) {
  319. return s
  320. }
  321. return s[i:]
  322. }
  323. // commitOf reads the commit a checkout is at, so the report says which one it
  324. // measured. A checkout without git, or a detached one, is not a reason to fail:
  325. // the report is still written, without the SHA, and says so in the field.
  326. func commitOf(root string) string {
  327. out, err := exec.Command("git", "-C", root, "rev-parse", "HEAD").Output()
  328. if err != nil {
  329. return ""
  330. }
  331. return strings.TrimSpace(string(out))
  332. }