on master help sign in
jibb/tools/doc-blocks
220 lines 8.0 kB history raw
1#!/usr/bin/env python3
2"""Check the design doc's Jibb code blocks against the golden cases.
4Every code block the doc labels `jibb` is a golden test: some case
5tests/golden/doc-*/ holds it as source.jibb, the block's text plus one
6trailing newline. Export each tab of the doc as Markdown and pass the
7files, or a folder of them:
9 tools/doc-blocks Grammar.md Overview.md Core-language.md
10 tools/doc-blocks exports/
12Each labelled block is matched to the cases with the same source. The
13check prints every block with its case, then each block that no case
14holds (a new or changed example), with a diff against the closest
15unmatched case, and each doc-* case that no block holds (an example
16changed or gone from the doc).
18A block's number counts every fenced block in its file, labelled or
19not, as the case names do: doc-grammar-06-cart-store is the sixth block
20of the Grammar tab.
22Exit status: 0 when every block has a case and every case a block, 1
23when one doesn't, 2 when the check can't run, as when no file has a
24block labelled jibb.
25"""
27import argparse
28import difflib
29import fnmatch
30import os
31import re
32import sys
34sys.dont_write_bytecode = True
35sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
37import urbit_eval as ue # noqa: E402
39DEFAULT_DIR = os.path.join(ue.ROOT, 'tests', 'golden')
40LANGUAGE = 'jibb'
41OPENER = re.compile(r'^( {0,3})(`{3,}|~{3,})(.*)$')
44class 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
52 @property
53 def language(self):
54 return self.info.split()[0] if self.info.split() else ''
56 @property
57 def source(self):
58 """The text as a case holds it, with one trailing newline."""
59 return self.text + '\n'
61 def where(self):
62 return '%s block %d, line %d' % (self.path, self.number, self.line)
65def 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
93def 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
109def 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
125def 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
135def 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=''))
143def count(n, noun):
144 return '%d %s%s' % (n, noun, '' if n == 1 else 's')
147def 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
191def 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
219if __name__ == '__main__':
220 sys.exit(main(sys.argv[1:]))