@@ -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:]))