on master help sign in
Add tools/doc-blocks, which checks the doc's examples
5a4aa73Claude
README.md+12−2
@@ -9,7 +9,7 @@ Jibb is a UI language for Urbit: a Hoon dialect with an indentation layout. Its
9- `my-app/`: an example app desk built from the doc's worked examples.
9- `my-app/`: an example app desk built from the doc's worked examples.
10- `vendor/`: pinned copies of the base-dev and landscape files the desks need.
10- `vendor/`: pinned copies of the base-dev and landscape files the desks need.
11- `tests/`: golden cases in `golden/<case>/`, and on-ship tests for the `-test` thread in `lib/jibb.hoon`.
11- `tests/`: golden cases in `golden/<case>/`, and on-ship tests for the `-test` thread in `lib/jibb.hoon`.
12−- `tools/`: dev tooling: `golden` and `hoon`, with their self-test in `testdata/`.
12+- `tools/`: dev tooling: `golden`, `hoon` and `doc-blocks`, with their self-test in `testdata/`, and `ship`.
13
13
14## Golden tests
14## Golden tests
15
15
@@ -39,7 +39,17 @@ tools/hoon '(golden /a/b/jibb (crip "++ main view"))'
39tools/hoon --raw --cord src=tests/golden/foo/source.jibb 'tree:(golden /a/b/jibb src)'
39tools/hoon --raw --cord src=tests/golden/foo/source.jibb 'tree:(golden /a/b/jibb src)'
40```
40```
41
41
42−`tools/testdata/selftest` tests both tools against a stub library.
42+`tools/testdata/selftest` tests both tools against a stub library, and `tools/doc-blocks` against Markdown it writes.
43+
44+### The doc's examples
45+
46+Every code block the design doc labels `jibb` is a golden case, `doc-<tab>-<n>-<what>`, where `<n>` counts the tab's code blocks, labelled or not. To check that none is missing or stale, export the doc's tabs as Markdown and pass the files, or a folder of them:
47+
48+```
49+tools/doc-blocks Grammar.md Overview.md Core-language.md
50+```
51+
52+It matches each labelled block to the case whose `source.jibb` is the block's text plus a newline, and lists the blocks no case holds, each with a diff against the closest unmatched case, and the `doc-*` cases no block holds. Pass every tab with Jibb blocks, or the other tabs' cases count as stale. The exit status is 0 when every block has a case and every case a block, 1 when one doesn't, and 2 when the check can't run, for example because no block is labelled `jibb`.
43
53
44## Fake ship
54## Fake ship
45
55
tools/doc-blocksnew file+220
@@ -0,0 +1,220 @@
1+#!/usr/bin/env python3
2+"""Check the design doc's Jibb code blocks against the golden cases.
3+
4+Every code block the doc labels `jibb` is a golden test: some case
5+tests/golden/doc-*/ holds it as source.jibb, the block's text plus one
6+trailing newline. Export each tab of the doc as Markdown and pass the
7+files, or a folder of them:
8+
9+ tools/doc-blocks Grammar.md Overview.md Core-language.md
10+ tools/doc-blocks exports/
11+
12+Each labelled block is matched to the cases with the same source. The
13+check prints every block with its case, then each block that no case
14+holds (a new or changed example), with a diff against the closest
15+unmatched case, and each doc-* case that no block holds (an example
16+changed or gone from the doc).
17+
18+A block's number counts every fenced block in its file, labelled or
19+not, as the case names do: doc-grammar-06-cart-store is the sixth block
20+of the Grammar tab.
21+
22+Exit status: 0 when every block has a case and every case a block, 1
23+when one doesn't, 2 when the check can't run, as when no file has a
24+block labelled jibb.
25+"""
26+
27+import argparse
28+import difflib
29+import fnmatch
30+import os
31+import re
32+import sys
33+
34+sys.dont_write_bytecode = True
35+sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
36+
37+import urbit_eval as ue # noqa: E402
38+
39+DEFAULT_DIR = os.path.join(ue.ROOT, 'tests', 'golden')
40+LANGUAGE = 'jibb'
41+OPENER = re.compile(r'^( {0,3})(`{3,}|~{3,})(.*)$')
42+
43+
44+class Block:
45+ def __init__(self, path, number, line, info, text):
46+ self.path = path
47+ self.number = number # among every fenced block in the file
48+ self.line = line # of the opening fence, from 1
49+ self.info = info
50+ self.text = text # the lines between the fences
51+
52+ @property
53+ def language(self):
54+ return self.info.split()[0] if self.info.split() else ''
55+
56+ @property
57+ def source(self):
58+ """The text as a case holds it, with one trailing newline."""
59+ return self.text + '\n'
60+
61+ def where(self):
62+ return '%s block %d, line %d' % (self.path, self.number, self.line)
63+
64+
65+def fenced_blocks(path):
66+ """Every fenced code block in a Markdown file, as CommonMark reads
67+ them: a fence of three or more backticks or tildes, indented up to
68+ three spaces, closed by a fence of the same kind at least as long,
69+ or by the end of the file."""
70+ with open(path, encoding='utf-8') as f:
71+ lines = f.read().split('\n')
72+ blocks = []
73+ i = 0
74+ while i < len(lines):
75+ m = OPENER.match(lines[i])
76+ if not m or (m.group(2)[0] == '`' and '`' in m.group(3)):
77+ i += 1
78+ continue
79+ indent, fence, info = len(m.group(1)), m.group(2), m.group(3)
80+ closer = re.compile(r'^ {0,3}%s%s* *$' % (re.escape(fence),
81+ re.escape(fence[0])))
82+ j = i + 1
83+ while j < len(lines) and not closer.match(lines[j]):
84+ j += 1
85+ body = [re.sub(r'^ {0,%d}' % indent, '', l) if indent else l
86+ for l in lines[i + 1:j]]
87+ blocks.append(Block(path, len(blocks) + 1, i + 1, info.strip(),
88+ '\n'.join(body)))
89+ i = j + 1
90+ return blocks
91+
92+
93+def export_files(paths):
94+ files = []
95+ for path in paths:
96+ if os.path.isdir(path):
97+ names = sorted(n for n in os.listdir(path) if n.endswith('.md'))
98+ if not names:
99+ raise ue.ToolError('no .md files in %s'
100+ % path)
101+ files += [os.path.normpath(os.path.join(path, n)) for n in names]
102+ elif os.path.isfile(path):
103+ files.append(os.path.normpath(path))
104+ else:
105+ raise ue.ToolError('no such file: %s' % path)
106+ return files
107+
108+
109+def doc_cases(root, globs):
110+ """The doc-* cases' sources, by name."""
111+ if not os.path.isdir(root):
112+ raise ue.ToolError('no such folder: %s' % ue.display_path(root))
113+ cases = {}
114+ for name in sorted(os.listdir(root)):
115+ path = os.path.join(root, name, 'source.jibb')
116+ if not name.startswith('doc-') or not os.path.isfile(path):
117+ continue
118+ if globs and not any(fnmatch.fnmatchcase(name, g) for g in globs):
119+ continue
120+ with open(path, encoding='utf-8') as f:
121+ cases[name] = f.read()
122+ return cases
123+
124+
125+def closest(text, cases):
126+ """The case whose source is most like text, or None."""
127+ best, score = None, 0.0
128+ for name, source in cases.items():
129+ s = difflib.SequenceMatcher(None, source, text).ratio()
130+ if s > score:
131+ best, score = name, s
132+ return best if score >= 0.5 else None
133+
134+
135+def diff(root, name, source, block):
136+ old = source[:-1] if source.endswith('\n') else source
137+ return list(difflib.unified_diff(
138+ old.split('\n'), block.text.split('\n'),
139+ fromfile=ue.display_path(os.path.join(root, name, 'source.jibb')),
140+ tofile=block.where(), lineterm=''))
141+
142+
143+def count(n, noun):
144+ return '%d %s%s' % (n, noun, '' if n == 1 else 's')
145+
146+
147+def check(files, root, globs):
148+ blocks = [b for f in files for b in fenced_blocks(f)
149+ if b.language == LANGUAGE]
150+ if not blocks:
151+ raise ue.ToolError(
152+ 'no code block is labelled %s in %s; export the doc again'
153+ % (LANGUAGE, ', '.join(files)))
154+ cases = doc_cases(root, globs)
155+ held = set()
156+ new = []
157+ width = max(len(b.where()) for b in blocks)
158+ for b in blocks:
159+ names = [n for n, s in cases.items() if s == b.source]
160+ held.update(names)
161+ if names:
162+ print('ok %-*s %s' % (width, b.where(), ', '.join(names)))
163+ else:
164+ new.append(b)
165+ print('NEW %-*s no case holds it' % (width, b.where()))
166+ stale = {n: s for n, s in cases.items() if n not in held}
167+ for b in new:
168+ print()
169+ print('NEW %s: no doc-* case holds it' % b.where())
170+ name = closest(b.source, stale)
171+ if name:
172+ print(' the closest case no block holds is %s:' % name)
173+ for line in diff(root, name, stale[name], b):
174+ print(' ' + line)
175+ else:
176+ first = b.text.split('\n')[0]
177+ print(' it starts: %s' % first)
178+ if stale:
179+ print()
180+ for name in stale:
181+ print('STALE %s: no block labelled %s holds its source'
182+ % (name, LANGUAGE))
183+ print()
184+ print('%s labelled %s, %s: %s without a case, %s without a block'
185+ % (count(len(blocks), 'block'), LANGUAGE,
186+ count(len(cases), 'doc-* case'), count(len(new), 'block'),
187+ count(len(stale), 'case')))
188+ return 1 if new or stale else 0
189+
190+
191+def main(argv):
192+ ap = argparse.ArgumentParser(
193+ prog='tools/doc-blocks',
194+ formatter_class=argparse.RawDescriptionHelpFormatter,
195+ description='Check that every code block the design doc labels '
196+ 'jibb is the source of a golden case '
197+ 'tests/golden/doc-*/, and every such case is a block.',
198+ epilog='Exit status: 0 when every block has a case and every case '
199+ 'a block, 1 when one\ndoesn\'t, 2 when the check can\'t run.')
200+ ap.add_argument('exports', nargs='+', metavar='FILE',
201+ help='a Markdown export of a tab of the doc, or a '
202+ 'folder of them; pass every tab with Jibb blocks')
203+ ap.add_argument('--dir', default=DEFAULT_DIR,
204+ help='the folder of cases (default: tests/golden)')
205+ ap.add_argument('--case', action='append', default=[], metavar='GLOB',
206+ help='only doc-* cases whose name matches GLOB; '
207+ 'repeatable')
208+ opts = ap.parse_args(argv)
209+ try:
210+ return check(export_files(opts.exports), opts.dir, opts.case)
211+ except ue.ToolError as e:
212+ print('doc-blocks: %s' % e, file=sys.stderr)
213+ return 2
214+ except (OSError, UnicodeDecodeError) as e:
215+ print('doc-blocks: %s' % e, file=sys.stderr)
216+ return 2
217+
218+
219+if __name__ == '__main__':
220+ sys.exit(main(sys.argv[1:]))
tools/testdata/selftest+44−3
@@ -1,5 +1,6 @@
1#!/usr/bin/env python3
1#!/usr/bin/env python3
2−"""Test tools/golden and tools/hoon against tools/testdata/stub.hoon.
2+"""Test tools/golden and tools/hoon against tools/testdata/stub.hoon,
3+and tools/doc-blocks against Markdown it writes.
3
4
4 tools/testdata/selftest all checks, about half a minute
5 tools/testdata/selftest all checks, about half a minute
5 tools/testdata/selftest -k crash only checks whose name has "crash"
6 tools/testdata/selftest -k crash only checks whose name has "crash"
@@ -23,7 +24,8 @@ TOOLS = os.path.dirname(HERE)
23ROOT = os.path.dirname(TOOLS)
24ROOT = os.path.dirname(TOOLS)
24GOLDEN = os.path.join(TOOLS, 'golden')
25GOLDEN = os.path.join(TOOLS, 'golden')
25HOON = os.path.join(TOOLS, 'hoon')
26HOON = os.path.join(TOOLS, 'hoon')
27+DOC_BLOCKS = os.path.join(TOOLS, 'doc-blocks')
26−STUB = os.path.join(HERE, 'stub.hoon')
28+STUB =os.path.join(HERE, 'stub.hoon')
27CASES = os.path.join(HERE, 'golden')
29CASES = os.path.join(HERE, 'golden')
28
30
29sys.dont_write_bytecode = True
31sys.dont_write_bytecode = True
@@ -394,7 +396,46 @@ def check_hoon(c, tmp):
394 c.expect((status, out) == (0, '42\n'), 'stdin: %r' % out)
396 c.expect((status, out) == (0, '42\n'), 'stdin: %r' % out)
395
397
396
398
399+def check_doc_blocks(c, tmp):
400+ cases = os.path.join(tmp, 'golden')
401+ add_case(cases, 'doc-tab-01-one', b'++ one 1\n')
402+ add_case(cases, 'doc-tab-03-three', b'++ three 3\n')
403+ add_case(cases, 'inline-other', b'++ other 0\n')
404+ export = os.path.join(tmp, 'Tab.md')
405+
406+ def doc_blocks(*blocks):
407+ with open(export, 'w') as f:
408+ f.write('# Tab\n\n' + '\n\nText.\n\n'.join(blocks) + '\n')
409+ return run(DOC_BLOCKS, export, '--dir', cases)
410+
411+ one = '```jibb\n++ one 1\n```'
412+ hoon = '```hoon\n++ one 1\n```'
413+ status, out = doc_blocks(one, hoon, '```jibb\n++ three 3\n```')
414+ c.expect(status == 0 and
415+ re.search(r'^ok +\S*Tab\.md block 3, line 15 +doc-tab-03-three$',
416+ out, re.M) and
417+ '2 blocks labelled jibb, 2 doc-* cases: 0 blocks without a '
418+ 'case, 0 cases without a block' in out,
419+ 'every block has a case: %d\n%s' % (status, out))
420+ status, out = doc_blocks(one, hoon, '```jibb\n++ three 4\n```')
421+ c.expect(status == 1 and
422+ re.search(r'^NEW +\S*Tab\.md block 3, line 15: no doc-\* case '
423+ r'holds it$', out, re.M) and
424+ '-++ three 3\n' in out and '+++ three 4\n' in out and
425+ re.search(r'^STALE doc-tab-03-three: ', out, re.M),
426+ 'a changed block: %d\n%s' % (status, out))
427+ status, out = doc_blocks(one, hoon, '```\n++ three 3\n```')
428+ c.expect(status == 1 and 'NEW' not in out and
429+ re.search(r'^STALE doc-tab-03-three: ', out, re.M),
430+ 'a block without its label: %d\n%s' % (status, out))
431+ status, out = doc_blocks(one, ' ~~~~ jibb\n ++ three 3\n ~~~~')
432+ c.expect(status == 0, 'an indented tilde fence: %d\n%s' % (status, out))
433+ status, out = doc_blocks('```\n++ one 1\n```')
434+ c.expect(status == 2 and 'no code block is labelled jibb' in out,
435+ 'no labelled block: %d\n%s' % (status, out))
436+
437+
397−CHECKS = [(name[len('check_'):], fn) for name, fn in globals().items()
438+CHECKS =[(name[len('check_'):], fn) for name, fn in globals().items()
398 if name.startswith('check_')]
439 if name.startswith('check_')]
399
440
400
441