]>
Commit | Line | Data |
---|---|---|
0731742a XL |
1 | #!/usr/bin/env python |
2 | # -*- coding: utf-8 -*- | |
85aaf69f SL |
3 | |
4 | r""" | |
5 | htmldocck.py is a custom checker script for Rustdoc HTML outputs. | |
6 | ||
7 | # How and why? | |
8 | ||
9 | The principle is simple: This script receives a path to generated HTML | |
10 | documentation and a "template" script, which has a series of check | |
0bf4aa26 | 11 | commands like `@has` or `@matches`. Each command is used to check if |
85aaf69f | 12 | some pattern is present or not present in the particular file or in |
0bf4aa26 XL |
13 | a particular node of the HTML tree. In many cases, the template script |
14 | happens to be the source code given to rustdoc. | |
85aaf69f SL |
15 | |
16 | While it indeed is possible to test in smaller portions, it has been | |
17 | hard to construct tests in this fashion and major rendering errors were | |
0bf4aa26 XL |
18 | discovered much later. This script is designed to make black-box and |
19 | regression testing of Rustdoc easy. This does not preclude the needs for | |
20 | unit testing, but can be used to complement related tests by quickly | |
85aaf69f SL |
21 | showing the expected renderings. |
22 | ||
23 | In order to avoid one-off dependencies for this task, this script uses | |
24 | a reasonably working HTML parser and the existing XPath implementation | |
0bf4aa26 | 25 | from Python's standard library. Hopefully, we won't render |
85aaf69f SL |
26 | non-well-formed HTML. |
27 | ||
28 | # Commands | |
29 | ||
30 | Commands start with an `@` followed by a command name (letters and | |
31 | hyphens), and zero or more arguments separated by one or more whitespace | |
0bf4aa26 XL |
32 | characters and optionally delimited with single or double quotes. The `@` |
33 | mark cannot be preceded by a non-whitespace character. Other lines | |
34 | (including every text up to the first `@`) are ignored, but it is | |
35 | recommended to avoid the use of `@` in the template file. | |
85aaf69f SL |
36 | |
37 | There are a number of supported commands: | |
38 | ||
0bf4aa26 | 39 | * `@has PATH` checks for the existence of the given file. |
85aaf69f SL |
40 | |
41 | `PATH` is relative to the output directory. It can be given as `-` | |
42 | which repeats the most recently used `PATH`. | |
43 | ||
44 | * `@has PATH PATTERN` and `@matches PATH PATTERN` checks for | |
0bf4aa26 XL |
45 | the occurrence of the given pattern `PATTERN` in the specified file. |
46 | Only one occurrence of the pattern is enough. | |
85aaf69f SL |
47 | |
48 | For `@has`, `PATTERN` is a whitespace-normalized (every consecutive | |
49 | whitespace being replaced by one single space character) string. | |
50 | The entire file is also whitespace-normalized including newlines. | |
51 | ||
52 | For `@matches`, `PATTERN` is a Python-supported regular expression. | |
0bf4aa26 XL |
53 | The file remains intact but the regexp is matched without the `MULTILINE` |
54 | and `IGNORECASE` options. You can still use a prefix `(?m)` or `(?i)` | |
85aaf69f SL |
55 | to override them, and `\A` and `\Z` for definitely matching |
56 | the beginning and end of the file. | |
57 | ||
58 | (The same distinction goes to other variants of these commands.) | |
59 | ||
60 | * `@has PATH XPATH PATTERN` and `@matches PATH XPATH PATTERN` checks for | |
0bf4aa26 XL |
61 | the presence of the given XPath `XPATH` in the specified HTML file, |
62 | and also the occurrence of the given pattern `PATTERN` in the matching | |
63 | node or attribute. Only one occurrence of the pattern in the match | |
64 | is enough. | |
85aaf69f SL |
65 | |
66 | `PATH` should be a valid and well-formed HTML file. It does *not* | |
67 | accept arbitrary HTML5; it should have matching open and close tags | |
68 | and correct entity references at least. | |
69 | ||
0bf4aa26 | 70 | `XPATH` is an XPath expression to match. The XPath is fairly limited: |
85aaf69f SL |
71 | `tag`, `*`, `.`, `//`, `..`, `[@attr]`, `[@attr='value']`, `[tag]`, |
72 | `[POS]` (element located in given `POS`), `[last()-POS]`, `text()` | |
73 | and `@attr` (both as the last segment) are supported. Some examples: | |
74 | ||
75 | - `//pre` or `.//pre` matches any element with a name `pre`. | |
76 | - `//a[@href]` matches any element with an `href` attribute. | |
77 | - `//*[@class="impl"]//code` matches any element with a name `code`, | |
78 | which is an ancestor of some element which `class` attr is `impl`. | |
79 | - `//h1[@class="fqn"]/span[1]/a[last()]/@class` matches a value of | |
80 | `class` attribute in the last `a` element (can be followed by more | |
81 | elements that are not `a`) inside the first `span` in the `h1` with | |
0bf4aa26 | 82 | a class of `fqn`. Note that there cannot be any additional elements |
85aaf69f SL |
83 | between them due to the use of `/` instead of `//`. |
84 | ||
85 | Do not try to use non-absolute paths, it won't work due to the flawed | |
86 | ElementTree implementation. The script rejects them. | |
87 | ||
88 | For the text matches (i.e. paths not ending with `@attr`), any | |
89 | subelements are flattened into one string; this is handy for ignoring | |
0bf4aa26 XL |
90 | highlights for example. If you want to simply check for the presence of |
91 | a given node or attribute, use an empty string (`""`) as a `PATTERN`. | |
85aaf69f | 92 | |
a2a8927a | 93 | * `@count PATH XPATH COUNT` checks for the occurrence of the given XPath |
0bf4aa26 XL |
94 | in the specified file. The number of occurrences must match the given |
95 | count. | |
c34b1796 | 96 | |
a2a8927a XL |
97 | * `@snapshot NAME PATH XPATH` creates a snapshot test named NAME. |
98 | A snapshot test captures a subtree of the DOM, at the location | |
99 | determined by the XPath, and compares it to a pre-recorded value | |
100 | in a file. The file's name is the test's name with the `.rs` extension | |
101 | replaced with `.NAME.html`, where NAME is the snapshot's name. | |
102 | ||
103 | htmldocck supports the `--bless` option to accept the current subtree | |
104 | as expected, saving it to the file determined by the snapshot's name. | |
105 | compiletest's `--bless` flag is forwarded to htmldocck. | |
106 | ||
ea8adc8c XL |
107 | * `@has-dir PATH` checks for the existence of the given directory. |
108 | ||
85aaf69f SL |
109 | All conditions can be negated with `!`. `@!has foo/type.NoSuch.html` |
110 | checks if the given file does not exist, for example. | |
111 | ||
112 | """ | |
113 | ||
0731742a XL |
114 | from __future__ import absolute_import, print_function, unicode_literals |
115 | ||
116 | import codecs | |
117 | import io | |
85aaf69f SL |
118 | import sys |
119 | import os.path | |
120 | import re | |
121 | import shlex | |
122 | from collections import namedtuple | |
3b2f2976 XL |
123 | try: |
124 | from html.parser import HTMLParser | |
125 | except ImportError: | |
126 | from HTMLParser import HTMLParser | |
f035d41b XL |
127 | try: |
128 | from xml.etree import cElementTree as ET | |
129 | except ImportError: | |
130 | from xml.etree import ElementTree as ET | |
85aaf69f | 131 | |
3b2f2976 | 132 | try: |
0731742a | 133 | from html.entities import name2codepoint |
3b2f2976 | 134 | except ImportError: |
0731742a | 135 | from htmlentitydefs import name2codepoint |
85aaf69f SL |
136 | |
137 | # "void elements" (no closing tag) from the HTML Standard section 12.1.2 | |
3dfed10e XL |
138 | VOID_ELEMENTS = {'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen', |
139 | 'link', 'menuitem', 'meta', 'param', 'source', 'track', 'wbr'} | |
85aaf69f | 140 | |
3b2f2976 XL |
141 | # Python 2 -> 3 compatibility |
142 | try: | |
143 | unichr | |
144 | except NameError: | |
145 | unichr = chr | |
85aaf69f | 146 | |
74b04a01 | 147 | |
17df50a5 XL |
148 | channel = os.environ["DOC_RUST_LANG_ORG_CHANNEL"] |
149 | ||
a2a8927a XL |
150 | # Initialized in main |
151 | rust_test_path = None | |
152 | bless = None | |
153 | ||
85aaf69f SL |
154 | class CustomHTMLParser(HTMLParser): |
155 | """simplified HTML parser. | |
156 | ||
157 | this is possible because we are dealing with very regular HTML from | |
158 | rustdoc; we only have to deal with i) void elements and ii) empty | |
159 | attributes.""" | |
160 | def __init__(self, target=None): | |
161 | HTMLParser.__init__(self) | |
162 | self.__builder = target or ET.TreeBuilder() | |
163 | ||
164 | def handle_starttag(self, tag, attrs): | |
3dfed10e | 165 | attrs = {k: v or '' for k, v in attrs} |
85aaf69f SL |
166 | self.__builder.start(tag, attrs) |
167 | if tag in VOID_ELEMENTS: | |
168 | self.__builder.end(tag) | |
169 | ||
170 | def handle_endtag(self, tag): | |
171 | self.__builder.end(tag) | |
172 | ||
173 | def handle_startendtag(self, tag, attrs): | |
3dfed10e | 174 | attrs = {k: v or '' for k, v in attrs} |
85aaf69f SL |
175 | self.__builder.start(tag, attrs) |
176 | self.__builder.end(tag) | |
177 | ||
178 | def handle_data(self, data): | |
179 | self.__builder.data(data) | |
180 | ||
181 | def handle_entityref(self, name): | |
0731742a | 182 | self.__builder.data(unichr(name2codepoint[name])) |
85aaf69f SL |
183 | |
184 | def handle_charref(self, name): | |
185 | code = int(name[1:], 16) if name.startswith(('x', 'X')) else int(name, 10) | |
0731742a | 186 | self.__builder.data(unichr(code)) |
85aaf69f SL |
187 | |
188 | def close(self): | |
189 | HTMLParser.close(self) | |
190 | return self.__builder.close() | |
191 | ||
74b04a01 | 192 | |
9cc50fc6 | 193 | Command = namedtuple('Command', 'negated cmd args lineno context') |
85aaf69f | 194 | |
74b04a01 | 195 | |
9cc50fc6 SL |
196 | class FailedCheck(Exception): |
197 | pass | |
198 | ||
74b04a01 | 199 | |
9cc50fc6 SL |
200 | class InvalidCheck(Exception): |
201 | pass | |
85aaf69f | 202 | |
74b04a01 | 203 | |
85aaf69f SL |
204 | def concat_multi_lines(f): |
205 | """returns a generator out of the file object, which | |
206 | - removes `\\` then `\n` then a shared prefix with the previous line then | |
207 | optional whitespace; | |
208 | - keeps a line number (starting from 0) of the first line being | |
209 | concatenated.""" | |
74b04a01 | 210 | lastline = None # set to the last line when the last line has a backslash |
85aaf69f SL |
211 | firstlineno = None |
212 | catenated = '' | |
213 | for lineno, line in enumerate(f): | |
214 | line = line.rstrip('\r\n') | |
215 | ||
216 | # strip the common prefix from the current line if needed | |
217 | if lastline is not None: | |
3b2f2976 XL |
218 | common_prefix = os.path.commonprefix([line, lastline]) |
219 | line = line[len(common_prefix):].lstrip() | |
85aaf69f SL |
220 | |
221 | firstlineno = firstlineno or lineno | |
222 | if line.endswith('\\'): | |
9346a6ac AL |
223 | if lastline is None: |
224 | lastline = line[:-1] | |
85aaf69f SL |
225 | catenated += line[:-1] |
226 | else: | |
227 | yield firstlineno, catenated + line | |
228 | lastline = None | |
229 | firstlineno = None | |
230 | catenated = '' | |
231 | ||
232 | if lastline is not None: | |
9cc50fc6 | 233 | print_err(lineno, line, 'Trailing backslash at the end of the file') |
85aaf69f | 234 | |
74b04a01 | 235 | |
85aaf69f | 236 | LINE_PATTERN = re.compile(r''' |
5869c6ff | 237 | (?<=(?<!\S))(?P<invalid>!?)@(?P<negated>!?) |
85aaf69f SL |
238 | (?P<cmd>[A-Za-z]+(?:-[A-Za-z]+)*) |
239 | (?P<args>.*)$ | |
0731742a | 240 | ''', re.X | re.UNICODE) |
85aaf69f SL |
241 | |
242 | ||
243 | def get_commands(template): | |
0731742a | 244 | with io.open(template, encoding='utf-8') as f: |
85aaf69f SL |
245 | for lineno, line in concat_multi_lines(f): |
246 | m = LINE_PATTERN.search(line) | |
247 | if not m: | |
248 | continue | |
249 | ||
250 | negated = (m.group('negated') == '!') | |
251 | cmd = m.group('cmd') | |
5869c6ff XL |
252 | if m.group('invalid') == '!': |
253 | print_err( | |
254 | lineno, | |
255 | line, | |
256 | 'Invalid command: `!@{0}{1}`, (help: try with `@!{1}`)'.format( | |
257 | '!' if negated else '', | |
258 | cmd, | |
259 | ), | |
260 | ) | |
261 | continue | |
85aaf69f SL |
262 | args = m.group('args') |
263 | if args and not args[:1].isspace(): | |
9cc50fc6 SL |
264 | print_err(lineno, line, 'Invalid template syntax') |
265 | continue | |
0731742a XL |
266 | try: |
267 | args = shlex.split(args) | |
268 | except UnicodeEncodeError: | |
269 | args = [arg.decode('utf-8') for arg in shlex.split(args.encode('utf-8'))] | |
9cc50fc6 | 270 | yield Command(negated=negated, cmd=cmd, args=args, lineno=lineno+1, context=line) |
85aaf69f SL |
271 | |
272 | ||
273 | def _flatten(node, acc): | |
274 | if node.text: | |
275 | acc.append(node.text) | |
276 | for e in node: | |
277 | _flatten(e, acc) | |
278 | if e.tail: | |
279 | acc.append(e.tail) | |
280 | ||
281 | ||
282 | def flatten(node): | |
283 | acc = [] | |
284 | _flatten(node, acc) | |
285 | return ''.join(acc) | |
286 | ||
287 | ||
04454e1e FG |
288 | def make_xml(text): |
289 | xml = ET.XML('<xml>%s</xml>' % text) | |
290 | return xml | |
291 | ||
292 | ||
85aaf69f | 293 | def normalize_xpath(path): |
17df50a5 | 294 | path = path.replace("{{channel}}", channel) |
85aaf69f | 295 | if path.startswith('//'): |
74b04a01 | 296 | return '.' + path # avoid warnings |
85aaf69f SL |
297 | elif path.startswith('.//'): |
298 | return path | |
299 | else: | |
9cc50fc6 | 300 | raise InvalidCheck('Non-absolute XPath is not supported due to implementation issues') |
85aaf69f SL |
301 | |
302 | ||
303 | class CachedFiles(object): | |
304 | def __init__(self, root): | |
305 | self.root = root | |
306 | self.files = {} | |
307 | self.trees = {} | |
308 | self.last_path = None | |
309 | ||
310 | def resolve_path(self, path): | |
311 | if path != '-': | |
312 | path = os.path.normpath(path) | |
313 | self.last_path = path | |
314 | return path | |
315 | elif self.last_path is None: | |
9cc50fc6 | 316 | raise InvalidCheck('Tried to use the previous path in the first command') |
85aaf69f SL |
317 | else: |
318 | return self.last_path | |
319 | ||
320 | def get_file(self, path): | |
321 | path = self.resolve_path(path) | |
9cc50fc6 | 322 | if path in self.files: |
85aaf69f | 323 | return self.files[path] |
9cc50fc6 SL |
324 | |
325 | abspath = os.path.join(self.root, path) | |
326 | if not(os.path.exists(abspath) and os.path.isfile(abspath)): | |
327 | raise FailedCheck('File does not exist {!r}'.format(path)) | |
328 | ||
0731742a | 329 | with io.open(abspath, encoding='utf-8') as f: |
9cc50fc6 SL |
330 | data = f.read() |
331 | self.files[path] = data | |
332 | return data | |
85aaf69f SL |
333 | |
334 | def get_tree(self, path): | |
335 | path = self.resolve_path(path) | |
9cc50fc6 | 336 | if path in self.trees: |
85aaf69f | 337 | return self.trees[path] |
9cc50fc6 SL |
338 | |
339 | abspath = os.path.join(self.root, path) | |
340 | if not(os.path.exists(abspath) and os.path.isfile(abspath)): | |
341 | raise FailedCheck('File does not exist {!r}'.format(path)) | |
342 | ||
0731742a | 343 | with io.open(abspath, encoding='utf-8') as f: |
85aaf69f | 344 | try: |
0731742a | 345 | tree = ET.fromstringlist(f.readlines(), CustomHTMLParser()) |
85aaf69f SL |
346 | except Exception as e: |
347 | raise RuntimeError('Cannot parse an HTML file {!r}: {}'.format(path, e)) | |
9cc50fc6 SL |
348 | self.trees[path] = tree |
349 | return self.trees[path] | |
85aaf69f | 350 | |
ea8adc8c XL |
351 | def get_dir(self, path): |
352 | path = self.resolve_path(path) | |
353 | abspath = os.path.join(self.root, path) | |
354 | if not(os.path.exists(abspath) and os.path.isdir(abspath)): | |
355 | raise FailedCheck('Directory does not exist {!r}'.format(path)) | |
356 | ||
85aaf69f SL |
357 | |
358 | def check_string(data, pat, regexp): | |
17df50a5 | 359 | pat = pat.replace("{{channel}}", channel) |
85aaf69f | 360 | if not pat: |
74b04a01 | 361 | return True # special case a presence testing |
85aaf69f | 362 | elif regexp: |
0731742a | 363 | return re.search(pat, data, flags=re.UNICODE) is not None |
85aaf69f SL |
364 | else: |
365 | data = ' '.join(data.split()) | |
366 | pat = ' '.join(pat.split()) | |
367 | return pat in data | |
368 | ||
369 | ||
370 | def check_tree_attr(tree, path, attr, pat, regexp): | |
371 | path = normalize_xpath(path) | |
372 | ret = False | |
373 | for e in tree.findall(path): | |
9cc50fc6 | 374 | if attr in e.attrib: |
85aaf69f | 375 | value = e.attrib[attr] |
85aaf69f | 376 | else: |
9cc50fc6 SL |
377 | continue |
378 | ||
379 | ret = check_string(value, pat, regexp) | |
380 | if ret: | |
381 | break | |
85aaf69f SL |
382 | return ret |
383 | ||
384 | ||
385 | def check_tree_text(tree, path, pat, regexp): | |
386 | path = normalize_xpath(path) | |
387 | ret = False | |
94b46f34 XL |
388 | try: |
389 | for e in tree.findall(path): | |
390 | try: | |
391 | value = flatten(e) | |
392 | except KeyError: | |
393 | continue | |
394 | else: | |
395 | ret = check_string(value, pat, regexp) | |
396 | if ret: | |
397 | break | |
74b04a01 | 398 | except Exception: |
94b46f34 | 399 | print('Failed to get path "{}"'.format(path)) |
0731742a | 400 | raise |
85aaf69f SL |
401 | return ret |
402 | ||
403 | ||
a7813a04 | 404 | def get_tree_count(tree, path): |
c34b1796 | 405 | path = normalize_xpath(path) |
a7813a04 | 406 | return len(tree.findall(path)) |
c34b1796 | 407 | |
74b04a01 | 408 | |
04454e1e | 409 | def check_snapshot(snapshot_name, actual_tree, normalize_to_text): |
a2a8927a XL |
410 | assert rust_test_path.endswith('.rs') |
411 | snapshot_path = '{}.{}.{}'.format(rust_test_path[:-3], snapshot_name, 'html') | |
412 | try: | |
413 | with open(snapshot_path, 'r') as snapshot_file: | |
414 | expected_str = snapshot_file.read() | |
415 | except FileNotFoundError: | |
416 | if bless: | |
417 | expected_str = None | |
418 | else: | |
419 | raise FailedCheck('No saved snapshot value') | |
420 | ||
5099ac24 | 421 | if not normalize_to_text: |
04454e1e | 422 | actual_str = ET.tostring(actual_tree).decode('utf-8') |
5099ac24 | 423 | else: |
04454e1e FG |
424 | actual_str = flatten(actual_tree) |
425 | ||
426 | # Conditions: | |
427 | # 1. Is --bless | |
428 | # 2. Are actual and expected tree different | |
429 | # 3. Are actual and expected text different | |
430 | if not expected_str \ | |
431 | or (not normalize_to_text and \ | |
432 | not compare_tree(make_xml(actual_str), make_xml(expected_str), stderr)) \ | |
433 | or (normalize_to_text and actual_str != expected_str): | |
a2a8927a | 434 | |
a2a8927a XL |
435 | if bless: |
436 | with open(snapshot_path, 'w') as snapshot_file: | |
437 | snapshot_file.write(actual_str) | |
438 | else: | |
439 | print('--- expected ---\n') | |
440 | print(expected_str) | |
441 | print('\n\n--- actual ---\n') | |
442 | print(actual_str) | |
443 | print() | |
444 | raise FailedCheck('Actual snapshot value is different than expected') | |
445 | ||
04454e1e FG |
446 | |
447 | # Adapted from https://github.com/formencode/formencode/blob/3a1ba9de2fdd494dd945510a4568a3afeddb0b2e/formencode/doctest_xml_compare.py#L72-L120 | |
448 | def compare_tree(x1, x2, reporter=None): | |
449 | if x1.tag != x2.tag: | |
450 | if reporter: | |
451 | reporter('Tags do not match: %s and %s' % (x1.tag, x2.tag)) | |
452 | return False | |
453 | for name, value in x1.attrib.items(): | |
454 | if x2.attrib.get(name) != value: | |
455 | if reporter: | |
456 | reporter('Attributes do not match: %s=%r, %s=%r' | |
457 | % (name, value, name, x2.attrib.get(name))) | |
458 | return False | |
459 | for name in x2.attrib: | |
460 | if name not in x1.attrib: | |
461 | if reporter: | |
462 | reporter('x2 has an attribute x1 is missing: %s' | |
463 | % name) | |
464 | return False | |
465 | if not text_compare(x1.text, x2.text): | |
466 | if reporter: | |
467 | reporter('text: %r != %r' % (x1.text, x2.text)) | |
468 | return False | |
469 | if not text_compare(x1.tail, x2.tail): | |
470 | if reporter: | |
471 | reporter('tail: %r != %r' % (x1.tail, x2.tail)) | |
472 | return False | |
473 | cl1 = list(x1) | |
474 | cl2 = list(x2) | |
475 | if len(cl1) != len(cl2): | |
476 | if reporter: | |
477 | reporter('children length differs, %i != %i' | |
478 | % (len(cl1), len(cl2))) | |
479 | return False | |
480 | i = 0 | |
481 | for c1, c2 in zip(cl1, cl2): | |
482 | i += 1 | |
483 | if not compare_tree(c1, c2, reporter=reporter): | |
484 | if reporter: | |
485 | reporter('children %i do not match: %s' | |
486 | % (i, c1.tag)) | |
487 | return False | |
488 | return True | |
489 | ||
490 | ||
491 | def text_compare(t1, t2): | |
492 | if not t1 and not t2: | |
493 | return True | |
494 | if t1 == '*' or t2 == '*': | |
495 | return True | |
496 | return (t1 or '').strip() == (t2 or '').strip() | |
497 | ||
498 | ||
9cc50fc6 | 499 | def stderr(*args): |
0731742a XL |
500 | if sys.version_info.major < 3: |
501 | file = codecs.getwriter('utf-8')(sys.stderr) | |
502 | else: | |
503 | file = sys.stderr | |
504 | ||
505 | print(*args, file=file) | |
c34b1796 | 506 | |
74b04a01 | 507 | |
9cc50fc6 SL |
508 | def print_err(lineno, context, err, message=None): |
509 | global ERR_COUNT | |
510 | ERR_COUNT += 1 | |
511 | stderr("{}: {}".format(lineno, message or err)) | |
512 | if message and err: | |
513 | stderr("\t{}".format(err)) | |
514 | ||
515 | if context: | |
516 | stderr("\t{}".format(context)) | |
517 | ||
74b04a01 | 518 | |
9cc50fc6 SL |
519 | ERR_COUNT = 0 |
520 | ||
74b04a01 | 521 | |
9cc50fc6 SL |
522 | def check_command(c, cache): |
523 | try: | |
524 | cerr = "" | |
74b04a01 | 525 | if c.cmd == 'has' or c.cmd == 'matches': # string test |
85aaf69f | 526 | regexp = (c.cmd == 'matches') |
74b04a01 | 527 | if len(c.args) == 1 and not regexp: # @has <path> = file existence |
85aaf69f SL |
528 | try: |
529 | cache.get_file(c.args[0]) | |
530 | ret = True | |
9cc50fc6 | 531 | except FailedCheck as err: |
3b2f2976 | 532 | cerr = str(err) |
85aaf69f | 533 | ret = False |
74b04a01 | 534 | elif len(c.args) == 2: # @has/matches <path> <pat> = string test |
9cc50fc6 | 535 | cerr = "`PATTERN` did not match" |
85aaf69f | 536 | ret = check_string(cache.get_file(c.args[0]), c.args[1], regexp) |
74b04a01 | 537 | elif len(c.args) == 3: # @has/matches <path> <pat> <match> = XML tree test |
9cc50fc6 | 538 | cerr = "`XPATH PATTERN` did not match" |
85aaf69f SL |
539 | tree = cache.get_tree(c.args[0]) |
540 | pat, sep, attr = c.args[1].partition('/@') | |
74b04a01 | 541 | if sep: # attribute |
9cc50fc6 SL |
542 | tree = cache.get_tree(c.args[0]) |
543 | ret = check_tree_attr(tree, pat, attr, c.args[2], regexp) | |
74b04a01 | 544 | else: # normalized text |
85aaf69f SL |
545 | pat = c.args[1] |
546 | if pat.endswith('/text()'): | |
547 | pat = pat[:-7] | |
548 | ret = check_tree_text(cache.get_tree(c.args[0]), pat, c.args[2], regexp) | |
549 | else: | |
9cc50fc6 | 550 | raise InvalidCheck('Invalid number of @{} arguments'.format(c.cmd)) |
85aaf69f | 551 | |
74b04a01 XL |
552 | elif c.cmd == 'count': # count test |
553 | if len(c.args) == 3: # @count <path> <pat> <count> = count test | |
a7813a04 XL |
554 | expected = int(c.args[2]) |
555 | found = get_tree_count(cache.get_tree(c.args[0]), c.args[1]) | |
556 | cerr = "Expected {} occurrences but found {}".format(expected, found) | |
557 | ret = expected == found | |
c34b1796 | 558 | else: |
9cc50fc6 | 559 | raise InvalidCheck('Invalid number of @{} arguments'.format(c.cmd)) |
a2a8927a XL |
560 | |
561 | elif c.cmd == 'snapshot': # snapshot test | |
562 | if len(c.args) == 3: # @snapshot <snapshot-name> <html-path> <xpath> | |
563 | [snapshot_name, html_path, pattern] = c.args | |
564 | tree = cache.get_tree(html_path) | |
565 | xpath = normalize_xpath(pattern) | |
5099ac24 FG |
566 | normalize_to_text = False |
567 | if xpath.endswith('/text()'): | |
568 | xpath = xpath[:-7] | |
569 | normalize_to_text = True | |
570 | ||
a2a8927a XL |
571 | subtrees = tree.findall(xpath) |
572 | if len(subtrees) == 1: | |
573 | [subtree] = subtrees | |
574 | try: | |
5099ac24 | 575 | check_snapshot(snapshot_name, subtree, normalize_to_text) |
a2a8927a XL |
576 | ret = True |
577 | except FailedCheck as err: | |
578 | cerr = str(err) | |
579 | ret = False | |
580 | elif len(subtrees) == 0: | |
581 | raise FailedCheck('XPATH did not match') | |
582 | else: | |
583 | raise FailedCheck('Expected 1 match, but found {}'.format(len(subtrees))) | |
584 | else: | |
585 | raise InvalidCheck('Invalid number of @{} arguments'.format(c.cmd)) | |
586 | ||
74b04a01 XL |
587 | elif c.cmd == 'has-dir': # has-dir test |
588 | if len(c.args) == 1: # @has-dir <path> = has-dir test | |
ea8adc8c XL |
589 | try: |
590 | cache.get_dir(c.args[0]) | |
591 | ret = True | |
592 | except FailedCheck as err: | |
593 | cerr = str(err) | |
594 | ret = False | |
595 | else: | |
596 | raise InvalidCheck('Invalid number of @{} arguments'.format(c.cmd)) | |
a2a8927a | 597 | |
85aaf69f | 598 | elif c.cmd == 'valid-html': |
9cc50fc6 | 599 | raise InvalidCheck('Unimplemented @valid-html') |
85aaf69f SL |
600 | |
601 | elif c.cmd == 'valid-links': | |
9cc50fc6 | 602 | raise InvalidCheck('Unimplemented @valid-links') |
a2a8927a | 603 | |
85aaf69f | 604 | else: |
9cc50fc6 | 605 | raise InvalidCheck('Unrecognized @{}'.format(c.cmd)) |
85aaf69f SL |
606 | |
607 | if ret == c.negated: | |
9cc50fc6 SL |
608 | raise FailedCheck(cerr) |
609 | ||
610 | except FailedCheck as err: | |
611 | message = '@{}{} check failed'.format('!' if c.negated else '', c.cmd) | |
3b2f2976 | 612 | print_err(c.lineno, c.context, str(err), message) |
9cc50fc6 | 613 | except InvalidCheck as err: |
3b2f2976 | 614 | print_err(c.lineno, c.context, str(err)) |
9cc50fc6 | 615 | |
74b04a01 | 616 | |
9cc50fc6 SL |
617 | def check(target, commands): |
618 | cache = CachedFiles(target) | |
619 | for c in commands: | |
620 | check_command(c, cache) | |
85aaf69f | 621 | |
74b04a01 | 622 | |
85aaf69f | 623 | if __name__ == '__main__': |
a2a8927a XL |
624 | if len(sys.argv) not in [3, 4]: |
625 | stderr('Usage: {} <doc dir> <template> [--bless]'.format(sys.argv[0])) | |
9cc50fc6 SL |
626 | raise SystemExit(1) |
627 | ||
a2a8927a XL |
628 | rust_test_path = sys.argv[2] |
629 | if len(sys.argv) > 3 and sys.argv[3] == '--bless': | |
630 | bless = True | |
631 | else: | |
632 | # We only support `--bless` at the end of the arguments. | |
633 | # This assert is to prevent silent failures. | |
634 | assert '--bless' not in sys.argv | |
635 | bless = False | |
636 | check(sys.argv[1], get_commands(rust_test_path)) | |
9cc50fc6 SL |
637 | if ERR_COUNT: |
638 | stderr("\nEncountered {} errors".format(ERR_COUNT)) | |
85aaf69f | 639 | raise SystemExit(1) |