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
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
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')
41OPENER = re.compile(r'^( {0,3})(`{3,}|~{3,})(.*)$')
45 def __init__(self, path, number, line, info, text):
47 self.number = number # among every fenced block in the file
48 self.line = line # of the opening fence, from 1
50 self.text = text # the lines between the fences
54 return self.info.split()[0] if self.info.split() else ''
58 """The text as a case holds it, with one trailing newline."""
59 return self.text + '\n'
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')
75 m = OPENER.match(lines[i])
76 if not m or (m.group(2)[0] == '`' and '`' in m.group(3)):
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),
83 while j < len(lines) and not closer.match(lines[j]):
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(),
93def export_files(paths):
96 if os.path.isdir(path):
97 names = sorted(n for n in os.listdir(path) if n.endswith('.md'))
99 raise ue.ToolError('no .md files in %s'
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))
105 raise ue.ToolError('no such file: %s' % path)
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))
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):
118 if globs and not any(fnmatch.fnmatchcase(name, g) for g in globs):
120 with open(path, encoding='utf-8') as f:
121 cases[name] = f.read()
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()
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=''))
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]
152 'no code block is labelled %s in %s; export the doc again'
153 % (LANGUAGE, ', '.join(files)))
154 cases = doc_cases(root, globs)
157 width = max(len(b.where()) for b in blocks)
159 names = [n for n, s in cases.items() if s == b.source]
162 print('ok %-*s %s' % (width, b.where(), ', '.join(names)))
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}
169 print('NEW %s: no doc-* case holds it' % b.where())
170 name = closest(b.source, stale)
172 print(' the closest case no block holds is %s:' % name)
173 for line in diff(root, name, stale[name], b):
176 first = b.text.split('\n')[0]
177 print(' it starts: %s' % first)
181 print('STALE %s: no block labelled %s holds its source'
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
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; '
208 opts = ap.parse_args(argv)
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)
214 except (OSError, UnicodeDecodeError) as e:
215 print('doc-blocks: %s' % e, file=sys.stderr)
219if __name__ == '__main__':
220 sys.exit(main(sys.argv[1:]))
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list