scripts/doccheck, run by make lint: relative links and anchors resolve, docs/README.md links every doc, every file in docs/media is shown by a doc and made by a tape (GIFs at most 1 MB), and every tape records something a doc shows. Tests next to the registries: menu paths the docs name lead to menu items, command ids named in keys.md and macros.md are registered, keys.md lists every key bound to a command and every vim key, and settings and O12_ variables the docs name are options.
- commit
- b94d6e306aa35a5d4604f613d1e8b5ed5239f82f
- parent
- 5e5646f483c7ed22c9f3d590caa03982abc93ce2
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 86 | ++++++++++ |
| A | internal/ | 203 | ++++++++++++++++++++++ |
| A | scripts/ | 271 | ++++++++++++++++++++++++++++++ |
| M | scripts/ | 7 | + |
@@ -0,0 +1,86 @@
1+package config2+3+import (4+ "os"5+ "path/filepath"6+ "regexp"7+ "slices"8+ "strings"9+ "testing"10+)11+12+var (13+ // inlineSetting is a setting quoted in prose: `theme = Dracula`.14+ inlineSetting = regexp.MustCompile("`([a-z][a-z0-9-]*) = [^`]*`")15+ // fileSetting is a line of a config example.16+ fileSetting = regexp.MustCompile(`^([a-z][a-z0-9-]*)\s*=`)17+ // envVar is one of 012's own variables, or the JEV service's.18+ envVar = regexp.MustCompile(`\b(?:O12|TYPESAFE)_[A-Z_]+\b`)19+)20+21+// notOptions are variables the docs name that aren't options: the JEV key22+// never goes through the config (see docs/jev.md).23+var notOptions = []string{"TYPESAFE_API_KEY"}24+25+// TestDocsNameOptions checks the settings and variables the docs name are26+// options. A config example is a fenced block whose first line is a27+// comment naming the file, `# ~/.config/012/config`; other blocks (theme28+// files, shell) aren't read.29+func TestDocsNameOptions(t *testing.T) {30+ paths, err := filepath.Glob("../../docs/*.md")31+ if err != nil {32+ t.Fatal(err)33+ }34+ var env []string35+ for _, o := range Options {36+ env = append(env, o.Env...)37+ }38+ for _, p := range append(paths, "../../README.md") {39+ data, err := os.ReadFile(p)40+ if err != nil {41+ t.Fatal(err)42+ }43+ name := strings.TrimPrefix(p, "../../")44+ check := func(key string) {45+ if _, ok := Lookup(key); !ok && key != "key" {46+ t.Errorf("%s names setting %q, which isn't an option", name, key)47+ }48+ }49+ for _, m := range inlineSetting.FindAllStringSubmatch(string(data), -1) {50+ check(m[1])51+ }52+ for _, block := range configBlocks(string(data)) {53+ for _, l := range block {54+ if m := fileSetting.FindStringSubmatch(strings.TrimSpace(l)); m != nil {55+ check(m[1])56+ }57+ }58+ }59+ for _, v := range envVar.FindAllString(string(data), -1) {60+ if !slices.Contains(env, v) && !slices.Contains(notOptions, v) {61+ t.Errorf("%s names %s, which no option reads", name, v)62+ }63+ }64+ }65+}66+67+// configBlocks returns the lines of the fenced blocks that are config68+// examples.69+func configBlocks(doc string) [][]string {70+ var out [][]string71+ var cur []string72+ in := false73+ for l := range strings.SplitSeq(doc, "\n") {74+ if strings.HasPrefix(strings.TrimSpace(l), "```") {75+ if in && len(cur) > 0 && strings.HasPrefix(cur[0], "#") && strings.Contains(cur[0], "012/config") {76+ out = append(out, cur)77+ }78+ in, cur = !in, nil79+ continue80+ }81+ if in {82+ cur = append(cur, strings.TrimSpace(l))83+ }84+ }85+ return out86+}@@ -0,0 +1,203 @@
1+package ui2+3+import (4+ "os"5+ "path/filepath"6+ "regexp"7+ "strings"8+ "testing"9+ "unicode"10+11+ "github.com/FelineStateMachine/012/internal/ui/theme"12+)13+14+// The docs name menus, commands and keys; these tests keep what they name15+// true of the registry. scripts/doccheck checks links, the index and media.16+17+// userDocs reads README.md and docs/*.md with fenced code blocks and18+// inline code removed and each paragraph on one line, so a menu path19+// wrapped across lines reads whole. The generated function reference is20+// left out: it names no menus.21+func userDocs(t *testing.T) map[string]string {22+ t.Helper()23+ paths, err := filepath.Glob("../../docs/*.md")24+ if err != nil {25+ t.Fatal(err)26+ }27+ paths = append(paths, "../../README.md")28+ out := map[string]string{}29+ for _, p := range paths {30+ if filepath.Base(p) == "functions.md" {31+ continue32+ }33+ data, err := os.ReadFile(p)34+ if err != nil {35+ t.Fatal(err)36+ }37+ var b strings.Builder38+ fenced := false39+ for l := range strings.SplitSeq(string(data), "\n") {40+ if strings.HasPrefix(strings.TrimSpace(l), "```") {41+ fenced = !fenced42+ continue43+ }44+ if !fenced {45+ b.WriteString(l)46+ b.WriteByte(' ')47+ }48+ }49+ out[strings.TrimPrefix(p, "../../")] = strings.Join(strings.Fields(b.String()), " ")50+ }51+ return out52+}53+54+var menuPath = regexp.MustCompile(`\b(File|Edit|View|Insert|Format|Data|Help) > `)55+56+// TestDocsMenuPaths checks every menu path the docs name ("Data > Macros >57+// Record macro") leads to an item of 012's menu bar. A path after a58+// possessive ("Sheets' Insert > Pivot table") is another program's.59+func TestDocsMenuPaths(t *testing.T) {60+ for name, text := range userDocs(t) {61+ for _, loc := range menuPath.FindAllStringSubmatchIndex(text, -1) {62+ before := strings.TrimRight(text[:loc[0]], " *")63+ if strings.HasSuffix(before, "'") || strings.HasSuffix(before, "'s") || strings.HasSuffix(before, "’") {64+ continue65+ }66+ title := text[loc[2]:loc[3]]67+ if err := resolveMenuPath(title, text[loc[1]:]); err != "" {68+ end := min(len(text), loc[1]+40)69+ t.Errorf("%s: %q: %s", name, text[loc[0]:end], err)70+ }71+ }72+ }73+}74+75+func TestResolveMenuPath(t *testing.T) {76+ for _, c := range []struct{ title, rest, err string }{77+ {"Data", "Macros > Record macro, then", ""},78+ {"File", "Settings > Vim keys turns", ""},79+ {"Data", "Frequency table (column stats), or", ""},80+ {"Data", "Frequency table, or", ""},81+ {"Data", "Pivots > Edit", "no such item in the Data menu"},82+ {"Data", "Macros > Play macro", "no such item in the Macros menu"},83+ {"Insert", "Chart > Pie", "Chart has no submenu"},84+ } {85+ if got := resolveMenuPath(c.title, c.rest); got != c.err {86+ t.Errorf("%s > %s: %q, want %q", c.title, c.rest, got, c.err)87+ }88+ }89+}90+91+// resolveMenuPath follows rest, the text after "Title > ", through the92+// menu's items, and says what it couldn't find.93+func resolveMenuPath(title, rest string) string {94+ var items []menuItem95+ for _, d := range menuBar {96+ if d.title == title {97+ items = visibleItems(d.items)98+ }99+ }100+ for {101+ var found *menuItem102+ for i, it := range items {103+ if it.sep {104+ continue105+ }106+ label := it.label()107+ short, _, _ := strings.Cut(label, " (")108+ for _, l := range []string{label, short} {109+ if labelPrefix(rest, l) && (found == nil || len(l) > len(found.label())) {110+ found = &items[i]111+ }112+ }113+ }114+ if found == nil {115+ return "no such item in the " + title + " menu"116+ }117+ label := found.label()118+ if !labelPrefix(rest, label) {119+ label, _, _ = strings.Cut(label, " (")120+ }121+ rest = rest[len(label):]122+ if !strings.HasPrefix(rest, " > ") {123+ return ""124+ }125+ if found.items == nil {126+ return label + " has no submenu"127+ }128+ title, items, rest = label, found.items, rest[len(" > "):]129+ }130+}131+132+// labelPrefix reports whether text starts with label as whole words.133+func labelPrefix(text, label string) bool {134+ if !strings.HasPrefix(strings.ToLower(text), strings.ToLower(label)) {135+ return false136+ }137+ rest := text[len(label):]138+ return rest == "" || !unicode.IsLetter(rune(rest[0])) && !unicode.IsDigit(rune(rest[0]))139+}140+141+var (142+ colonCommand = regexp.MustCompile("`:([a-z_]+\\.[a-z_.0-9]+)`")143+ runCommandID = regexp.MustCompile(`run\("([a-z_.0-9]+)"`)144+)145+146+// TestDocsCommandIDs checks the command ids the docs name, on the vim147+// command line (`:settings.vim`) or in scripts (run("format.bold")),148+// are registered.149+func TestDocsCommandIDs(t *testing.T) {150+ for _, p := range []string{"../../docs/keys.md", "../../docs/macros.md"} {151+ data, err := os.ReadFile(p)152+ if err != nil {153+ t.Fatal(err)154+ }155+ for _, re := range []*regexp.Regexp{colonCommand, runCommandID} {156+ for _, m := range re.FindAllStringSubmatch(string(data), -1) {157+ if commands[m[1]] == nil {158+ t.Errorf("%s names command %q, which isn't registered", p, m[1])159+ }160+ }161+ }162+ }163+}164+165+// TestKeysDocCoversKeymap checks docs/keys.md lists every key bound to a166+// command, written as the menus and help show it.167+func TestKeysDocCoversKeymap(t *testing.T) {168+ data, err := os.ReadFile("../../docs/keys.md")169+ if err != nil {170+ t.Fatal(err)171+ }172+ doc := string(data)173+ for k, id := range keymap {174+ if commands[id] == nil {175+ continue176+ }177+ label := keyLabel(k)178+ re := regexp.MustCompile(`(^|[\s,|(/])` + regexp.QuoteMeta(label) + `($|[\s,|)/.;:])`)179+ if !re.MatchString(doc) {180+ t.Errorf("docs/keys.md doesn't list %s (%s, %s)", label, id, commands[id].title)181+ }182+ }183+ var vimKeys []string184+ for _, table := range []map[string]vimBinding{vimNormal, vimVisual} {185+ for k := range table {186+ vimKeys = append(vimKeys, k)187+ }188+ }189+ for k := range vimMotions {190+ if !isMoveKey(k) { // Sheets' own keys, listed above191+ vimKeys = append(vimKeys, k)192+ }193+ }194+ for _, k := range vimKeys {195+ if strings.Contains(k, "+") || len(k) > 2 {196+ if !strings.Contains(doc, keyLabel(k)) && !strings.Contains(doc, theme.KeyLabel(k)) {197+ t.Errorf("docs/keys.md doesn't list the vim key %s", k)198+ }199+ } else if !strings.Contains(doc, "`"+k+"`") {200+ t.Errorf("docs/keys.md doesn't list the vim key `%s`", k)201+ }202+ }203+}@@ -0,0 +1,271 @@
1+// Command doccheck keeps the documentation's structure sound, where a2+// machine can tell:3+//4+// - every relative link and image in Markdown points at a file that5+// exists, and every #anchor at a heading of that file;6+// - docs/README.md, the index, links every doc in docs/;7+// - every file in docs/media is shown by some doc, comes from a VHS tape8+// in demos/ (its Output or a Screenshot), and no GIF is over maxGIF;9+// - every tape in demos/ records something a doc shows.10+//11+// What the docs say about the program (keys, menus, commands, options) is12+// checked by tests next to the code they name: internal/ui's docs_test.go13+// and internal/config's.14+//15+// Usage: go run ./scripts/doccheck (from the repository root)16+package main17+18+import (19+ "bufio"20+ "fmt"21+ "io/fs"22+ "os"23+ "path"24+ "path/filepath"25+ "regexp"26+ "slices"27+ "strings"28+)29+30+// maxGIF keeps the README's recordings quick to load.31+const maxGIF = 1 << 2032+33+func main() {34+ docs, err := readDocs(".")35+ if err != nil {36+ fmt.Println("doccheck:", err)37+ os.Exit(1)38+ }39+ var problems []string40+ problems = append(problems, checkLinks(docs)...)41+ problems = append(problems, checkIndex(docs)...)42+ problems = append(problems, checkMedia(docs)...)43+ for _, p := range problems {44+ fmt.Println(p)45+ }46+ if len(problems) > 0 {47+ fmt.Printf("doccheck: %d problems\n", len(problems))48+ os.Exit(1)49+ }50+}51+52+// doc is a Markdown file: its links and the anchors of its headings.53+type doc struct {54+ path string // slash-separated, relative to the root55+ links []link56+ anchors map[string]bool57+}58+59+type link struct {60+ line int61+ target string62+}63+64+// readDocs reads every Markdown file under root, skipping hidden65+// directories, test data and generated output.66+func readDocs(root string) (map[string]*doc, error) {67+ docs := map[string]*doc{}68+ err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {69+ if err != nil {70+ return err71+ }72+ name := d.Name()73+ if d.IsDir() {74+ if p != root && (strings.HasPrefix(name, ".") || name == "testdata" || name == "node_modules" || name == "out") {75+ return filepath.SkipDir76+ }77+ return nil78+ }79+ if filepath.Ext(name) != ".md" {80+ return nil81+ }82+ dc, err := parseDoc(p)83+ if err != nil {84+ return err85+ }86+ docs[dc.path] = dc87+ return nil88+ })89+ return docs, err90+}91+92+var (93+ linkRE = regexp.MustCompile(`!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)`)94+ inlineCode = regexp.MustCompile("`[^`]*`")95+ headingRE = regexp.MustCompile(`^#{1,6}\s+(.*?)\s*#*\s*$`)96+ anchorTag = regexp.MustCompile(`<a\s+(?:name|id)="([^"]+)"`)97+)98+99+func parseDoc(p string) (*doc, error) {100+ f, err := os.Open(p)101+ if err != nil {102+ return nil, err103+ }104+ defer f.Close()105+ dc := &doc{path: filepath.ToSlash(filepath.Clean(p)), anchors: map[string]bool{}}106+ seen := map[string]int{}107+ fenced := false108+ sc := bufio.NewScanner(f)109+ sc.Buffer(make([]byte, 1<<20), 1<<20)110+ for n := 1; sc.Scan(); n++ {111+ l := sc.Text()112+ if strings.HasPrefix(strings.TrimSpace(l), "```") {113+ fenced = !fenced114+ continue115+ }116+ if fenced {117+ continue118+ }119+ if m := headingRE.FindStringSubmatch(l); m != nil {120+ s := slug(m[1])121+ if k := seen[s]; k > 0 {122+ dc.anchors[fmt.Sprintf("%s-%d", s, k)] = true123+ } else {124+ dc.anchors[s] = true125+ }126+ seen[s]++127+ }128+ for _, m := range anchorTag.FindAllStringSubmatch(l, -1) {129+ dc.anchors[m[1]] = true130+ }131+ for _, m := range linkRE.FindAllStringSubmatch(inlineCode.ReplaceAllString(l, "``"), -1) {132+ dc.links = append(dc.links, link{n, m[1]})133+ }134+ }135+ return dc, sc.Err()136+}137+138+// slug is the anchor GitHub gives a heading: lower case, spaces as139+// hyphens, punctuation other than hyphens and underscores dropped.140+func slug(heading string) string {141+ heading = strings.ToLower(inlineCode.ReplaceAllStringFunc(heading, func(s string) string { return strings.Trim(s, "`") }))142+ var b strings.Builder143+ for _, r := range heading {144+ switch {145+ case r == ' ':146+ b.WriteByte('-')147+ case r == '-' || r == '_' || r >= '0' && r <= '9' || r >= 'a' && r <= 'z' || r > 127 && isLetter(r):148+ b.WriteRune(r)149+ }150+ }151+ return b.String()152+}153+154+func isLetter(r rune) bool { return strings.ToLower(string(r)) != strings.ToUpper(string(r)) }155+156+// checkLinks reports relative links to missing files or anchors.157+func checkLinks(docs map[string]*doc) []string {158+ var out []string159+ for _, p := range sortedKeys(docs) {160+ dc := docs[p]161+ for _, l := range dc.links {162+ t := l.target163+ if strings.Contains(t, "://") || strings.HasPrefix(t, "mailto:") {164+ continue165+ }166+ file, anchor, _ := strings.Cut(t, "#")167+ target := dc.path168+ if file != "" {169+ target = path.Clean(path.Join(path.Dir(dc.path), file))170+ }171+ where := fmt.Sprintf("%s:%d: %s", dc.path, l.line, t)172+ if _, err := os.Stat(filepath.FromSlash(target)); err != nil {173+ out = append(out, where+": no such file")174+ continue175+ }176+ if anchor == "" {177+ continue178+ }179+ td, ok := docs[target]180+ if !ok {181+ out = append(out, where+": an anchor into a file that isn't Markdown")182+ } else if !td.anchors[anchor] {183+ out = append(out, where+": no heading with that anchor in "+target)184+ }185+ }186+ }187+ return out188+}189+190+// checkIndex reports docs the index doesn't link.191+func checkIndex(docs map[string]*doc) []string {192+ index, ok := docs["docs/README.md"]193+ if !ok {194+ return []string{"docs/README.md: missing"}195+ }196+ linked := map[string]bool{}197+ for _, l := range index.links {198+ file, _, _ := strings.Cut(l.target, "#")199+ linked[path.Clean(path.Join("docs", file))] = true200+ }201+ var out []string202+ for _, p := range sortedKeys(docs) {203+ if path.Dir(p) == "docs" && p != "docs/README.md" && !linked[p] {204+ out = append(out, "docs/README.md: doesn't link "+p)205+ }206+ }207+ return out208+}209+210+var (211+ tapeOutput = regexp.MustCompile(`(?m)^Output\s+"?out/([^"\s]+)"?`)212+ tapeScreenshot = regexp.MustCompile(`(?m)^Screenshot\s+"?out/stills/([^"\s]+)"?`)213+)214+215+// checkMedia reports media no doc shows, media no tape makes, GIFs over216+// maxGIF and tapes whose recordings no doc shows.217+func checkMedia(docs map[string]*doc) []string {218+ shown := map[string]bool{}219+ for _, dc := range docs {220+ for _, l := range dc.links {221+ file, _, _ := strings.Cut(l.target, "#")222+ if file != "" && !strings.Contains(file, "://") {223+ shown[path.Clean(path.Join(path.Dir(dc.path), file))] = true224+ }225+ }226+ }227+ tapes, _ := filepath.Glob("demos/*.tape")228+ madeBy := map[string]string{} // media file name -> tape229+ var out []string230+ for _, t := range tapes {231+ src, err := os.ReadFile(t)232+ if err != nil {233+ out = append(out, t+": "+err.Error())234+ continue235+ }236+ var made []string237+ for _, re := range []*regexp.Regexp{tapeOutput, tapeScreenshot} {238+ for _, m := range re.FindAllStringSubmatch(string(src), -1) {239+ made = append(made, m[1])240+ madeBy[m[1]] = t241+ }242+ }243+ if !slices.ContainsFunc(made, func(name string) bool { return shown["docs/media/"+name] }) {244+ out = append(out, t+": no doc shows its recording or stills (docs/media/"+strings.Join(made, ", docs/media/")+")")245+ }246+ }247+ media, _ := filepath.Glob("docs/media/*")248+ for _, m := range media {249+ m = filepath.ToSlash(m)250+ name := path.Base(m)251+ if !shown[m] {252+ out = append(out, m+": no doc shows it")253+ }254+ if madeBy[name] == "" {255+ out = append(out, m+": no tape in demos/ makes it (Output or Screenshot)")256+ }257+ if fi, err := os.Stat(m); err == nil && path.Ext(m) == ".gif" && fi.Size() > maxGIF {258+ out = append(out, fmt.Sprintf("%s: %d KB, over %d KB", m, fi.Size()>>10, maxGIF>>10))259+ }260+ }261+ return out262+}263+264+func sortedKeys[V any](m map[string]V) []string {265+ keys := make([]string, 0, len(m))266+ for k := range m {267+ keys = append(keys, k)268+ }269+ slices.Sort(keys)270+ return keys271+}@@ -4,6 +4,7 @@
44 # - no function with cognitive complexity over 25 (gocognit)55 # - no Go file over 500 lines66 # - no docs or comments narrating history (scripts/doclint)7+# - links, anchors, the docs index, media and tapes in step (scripts/doccheck)78 # The tools run with go run at pinned versions, so they never enter go.mod.89 set -eu910 cd "$(dirname "$0")/.."@@ -47,4 +48,10 @@ if ! go run ./scripts/doclint; then
4748 fail=14849 fi4950 51+# Links and anchors resolve, the index lists every doc, and every tape52+# records something a doc shows.53+if ! go run ./scripts/doccheck; then54+ fail=155+fi56+5057 exit $fail