]> git.lizzy.rs Git - rust.git/blob - src/etc/htmldocck.py
Suggest collecting into `Vec<_>` when collecting into `[_]`
[rust.git] / src / etc / htmldocck.py
1 #!/usr/bin/env python
2 # -*- coding: utf-8 -*-
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
11 commands like `@has` or `@matches`. Each command is used to check if
12 some pattern is present or not present in the particular file or in
13 a particular node of the HTML tree. In many cases, the template script
14 happens to be the source code given to rustdoc.
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
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
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
25 from Python's standard library. Hopefully, we won't render
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
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.
36
37 There are a number of supported commands:
38
39 * `@has PATH` checks for the existence of the given file.
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
45   the occurrence of the given pattern `PATTERN` in the specified file.
46   Only one occurrence of the pattern is enough.
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.
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)`
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
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.
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
70   `XPATH` is an XPath expression to match. The XPath is fairly limited:
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
82     a class of `fqn`. Note that there cannot be any additional elements
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
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`.
92
93 * `@count PATH XPATH COUNT` checks for the occurrence of the given XPath
94   in the specified file. The number of occurrences must match the given
95   count.
96
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
107 * `@has-dir PATH` checks for the existence of the given directory.
108
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
114 from __future__ import absolute_import, print_function, unicode_literals
115
116 import codecs
117 import io
118 import sys
119 import os.path
120 import re
121 import shlex
122 from collections import namedtuple
123 try:
124     from html.parser import HTMLParser
125 except ImportError:
126     from HTMLParser import HTMLParser
127 try:
128     from xml.etree import cElementTree as ET
129 except ImportError:
130     from xml.etree import ElementTree as ET
131
132 try:
133     from html.entities import name2codepoint
134 except ImportError:
135     from htmlentitydefs import name2codepoint
136
137 # "void elements" (no closing tag) from the HTML Standard section 12.1.2
138 VOID_ELEMENTS = {'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen',
139                      'link', 'menuitem', 'meta', 'param', 'source', 'track', 'wbr'}
140
141 # Python 2 -> 3 compatibility
142 try:
143     unichr
144 except NameError:
145     unichr = chr
146
147
148 channel = os.environ["DOC_RUST_LANG_ORG_CHANNEL"]
149
150 # Initialized in main
151 rust_test_path = None
152 bless = None
153
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):
165         attrs = {k: v or '' for k, v in attrs}
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):
174         attrs = {k: v or '' for k, v in attrs}
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):
182         self.__builder.data(unichr(name2codepoint[name]))
183
184     def handle_charref(self, name):
185         code = int(name[1:], 16) if name.startswith(('x', 'X')) else int(name, 10)
186         self.__builder.data(unichr(code))
187
188     def close(self):
189         HTMLParser.close(self)
190         return self.__builder.close()
191
192
193 Command = namedtuple('Command', 'negated cmd args lineno context')
194
195
196 class FailedCheck(Exception):
197     pass
198
199
200 class InvalidCheck(Exception):
201     pass
202
203
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."""
210     lastline = None  # set to the last line when the last line has a backslash
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:
218             common_prefix = os.path.commonprefix([line, lastline])
219             line = line[len(common_prefix):].lstrip()
220
221         firstlineno = firstlineno or lineno
222         if line.endswith('\\'):
223             if lastline is None:
224                 lastline = line[:-1]
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:
233         print_err(lineno, line, 'Trailing backslash at the end of the file')
234
235
236 LINE_PATTERN = re.compile(r'''
237     (?<=(?<!\S))(?P<invalid>!?)@(?P<negated>!?)
238     (?P<cmd>[A-Za-z]+(?:-[A-Za-z]+)*)
239     (?P<args>.*)$
240 ''', re.X | re.UNICODE)
241
242
243 def get_commands(template):
244     with io.open(template, encoding='utf-8') as f:
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')
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
262             args = m.group('args')
263             if args and not args[:1].isspace():
264                 print_err(lineno, line, 'Invalid template syntax')
265                 continue
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'))]
270             yield Command(negated=negated, cmd=cmd, args=args, lineno=lineno+1, context=line)
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
288 def normalize_xpath(path):
289     path = path.replace("{{channel}}", channel)
290     if path.startswith('//'):
291         return '.' + path  # avoid warnings
292     elif path.startswith('.//'):
293         return path
294     else:
295         raise InvalidCheck('Non-absolute XPath is not supported due to implementation issues')
296
297
298 class CachedFiles(object):
299     def __init__(self, root):
300         self.root = root
301         self.files = {}
302         self.trees = {}
303         self.last_path = None
304
305     def resolve_path(self, path):
306         if path != '-':
307             path = os.path.normpath(path)
308             self.last_path = path
309             return path
310         elif self.last_path is None:
311             raise InvalidCheck('Tried to use the previous path in the first command')
312         else:
313             return self.last_path
314
315     def get_file(self, path):
316         path = self.resolve_path(path)
317         if path in self.files:
318             return self.files[path]
319
320         abspath = os.path.join(self.root, path)
321         if not(os.path.exists(abspath) and os.path.isfile(abspath)):
322             raise FailedCheck('File does not exist {!r}'.format(path))
323
324         with io.open(abspath, encoding='utf-8') as f:
325             data = f.read()
326             self.files[path] = data
327             return data
328
329     def get_tree(self, path):
330         path = self.resolve_path(path)
331         if path in self.trees:
332             return self.trees[path]
333
334         abspath = os.path.join(self.root, path)
335         if not(os.path.exists(abspath) and os.path.isfile(abspath)):
336             raise FailedCheck('File does not exist {!r}'.format(path))
337
338         with io.open(abspath, encoding='utf-8') as f:
339             try:
340                 tree = ET.fromstringlist(f.readlines(), CustomHTMLParser())
341             except Exception as e:
342                 raise RuntimeError('Cannot parse an HTML file {!r}: {}'.format(path, e))
343             self.trees[path] = tree
344             return self.trees[path]
345
346     def get_dir(self, path):
347         path = self.resolve_path(path)
348         abspath = os.path.join(self.root, path)
349         if not(os.path.exists(abspath) and os.path.isdir(abspath)):
350             raise FailedCheck('Directory does not exist {!r}'.format(path))
351
352
353 def check_string(data, pat, regexp):
354     pat = pat.replace("{{channel}}", channel)
355     if not pat:
356         return True  # special case a presence testing
357     elif regexp:
358         return re.search(pat, data, flags=re.UNICODE) is not None
359     else:
360         data = ' '.join(data.split())
361         pat = ' '.join(pat.split())
362         return pat in data
363
364
365 def check_tree_attr(tree, path, attr, pat, regexp):
366     path = normalize_xpath(path)
367     ret = False
368     for e in tree.findall(path):
369         if attr in e.attrib:
370             value = e.attrib[attr]
371         else:
372             continue
373
374         ret = check_string(value, pat, regexp)
375         if ret:
376             break
377     return ret
378
379
380 def check_tree_text(tree, path, pat, regexp):
381     path = normalize_xpath(path)
382     ret = False
383     try:
384         for e in tree.findall(path):
385             try:
386                 value = flatten(e)
387             except KeyError:
388                 continue
389             else:
390                 ret = check_string(value, pat, regexp)
391                 if ret:
392                     break
393     except Exception:
394         print('Failed to get path "{}"'.format(path))
395         raise
396     return ret
397
398
399 def get_tree_count(tree, path):
400     path = normalize_xpath(path)
401     return len(tree.findall(path))
402
403
404 def check_snapshot(snapshot_name, tree, normalize_to_text):
405     assert rust_test_path.endswith('.rs')
406     snapshot_path = '{}.{}.{}'.format(rust_test_path[:-3], snapshot_name, 'html')
407     try:
408         with open(snapshot_path, 'r') as snapshot_file:
409             expected_str = snapshot_file.read()
410     except FileNotFoundError:
411         if bless:
412             expected_str = None
413         else:
414             raise FailedCheck('No saved snapshot value')
415
416     if not normalize_to_text:
417         actual_str = ET.tostring(tree).decode('utf-8')
418     else:
419         actual_str = flatten(tree)
420
421     if expected_str != actual_str:
422         if bless:
423             with open(snapshot_path, 'w') as snapshot_file:
424                 snapshot_file.write(actual_str)
425         else:
426             print('--- expected ---\n')
427             print(expected_str)
428             print('\n\n--- actual ---\n')
429             print(actual_str)
430             print()
431             raise FailedCheck('Actual snapshot value is different than expected')
432
433 def stderr(*args):
434     if sys.version_info.major < 3:
435         file = codecs.getwriter('utf-8')(sys.stderr)
436     else:
437         file = sys.stderr
438
439     print(*args, file=file)
440
441
442 def print_err(lineno, context, err, message=None):
443     global ERR_COUNT
444     ERR_COUNT += 1
445     stderr("{}: {}".format(lineno, message or err))
446     if message and err:
447         stderr("\t{}".format(err))
448
449     if context:
450         stderr("\t{}".format(context))
451
452
453 ERR_COUNT = 0
454
455
456 def check_command(c, cache):
457     try:
458         cerr = ""
459         if c.cmd == 'has' or c.cmd == 'matches':  # string test
460             regexp = (c.cmd == 'matches')
461             if len(c.args) == 1 and not regexp:  # @has <path> = file existence
462                 try:
463                     cache.get_file(c.args[0])
464                     ret = True
465                 except FailedCheck as err:
466                     cerr = str(err)
467                     ret = False
468             elif len(c.args) == 2:  # @has/matches <path> <pat> = string test
469                 cerr = "`PATTERN` did not match"
470                 ret = check_string(cache.get_file(c.args[0]), c.args[1], regexp)
471             elif len(c.args) == 3:  # @has/matches <path> <pat> <match> = XML tree test
472                 cerr = "`XPATH PATTERN` did not match"
473                 tree = cache.get_tree(c.args[0])
474                 pat, sep, attr = c.args[1].partition('/@')
475                 if sep:  # attribute
476                     tree = cache.get_tree(c.args[0])
477                     ret = check_tree_attr(tree, pat, attr, c.args[2], regexp)
478                 else:  # normalized text
479                     pat = c.args[1]
480                     if pat.endswith('/text()'):
481                         pat = pat[:-7]
482                     ret = check_tree_text(cache.get_tree(c.args[0]), pat, c.args[2], regexp)
483             else:
484                 raise InvalidCheck('Invalid number of @{} arguments'.format(c.cmd))
485
486         elif c.cmd == 'count':  # count test
487             if len(c.args) == 3:  # @count <path> <pat> <count> = count test
488                 expected = int(c.args[2])
489                 found = get_tree_count(cache.get_tree(c.args[0]), c.args[1])
490                 cerr = "Expected {} occurrences but found {}".format(expected, found)
491                 ret = expected == found
492             else:
493                 raise InvalidCheck('Invalid number of @{} arguments'.format(c.cmd))
494
495         elif c.cmd == 'snapshot':  # snapshot test
496             if len(c.args) == 3:  # @snapshot <snapshot-name> <html-path> <xpath>
497                 [snapshot_name, html_path, pattern] = c.args
498                 tree = cache.get_tree(html_path)
499                 xpath = normalize_xpath(pattern)
500                 normalize_to_text = False
501                 if xpath.endswith('/text()'):
502                     xpath = xpath[:-7]
503                     normalize_to_text = True
504
505                 subtrees = tree.findall(xpath)
506                 if len(subtrees) == 1:
507                     [subtree] = subtrees
508                     try:
509                         check_snapshot(snapshot_name, subtree, normalize_to_text)
510                         ret = True
511                     except FailedCheck as err:
512                         cerr = str(err)
513                         ret = False
514                 elif len(subtrees) == 0:
515                     raise FailedCheck('XPATH did not match')
516                 else:
517                     raise FailedCheck('Expected 1 match, but found {}'.format(len(subtrees)))
518             else:
519                 raise InvalidCheck('Invalid number of @{} arguments'.format(c.cmd))
520
521         elif c.cmd == 'has-dir':  # has-dir test
522             if len(c.args) == 1:  # @has-dir <path> = has-dir test
523                 try:
524                     cache.get_dir(c.args[0])
525                     ret = True
526                 except FailedCheck as err:
527                     cerr = str(err)
528                     ret = False
529             else:
530                 raise InvalidCheck('Invalid number of @{} arguments'.format(c.cmd))
531
532         elif c.cmd == 'valid-html':
533             raise InvalidCheck('Unimplemented @valid-html')
534
535         elif c.cmd == 'valid-links':
536             raise InvalidCheck('Unimplemented @valid-links')
537
538         else:
539             raise InvalidCheck('Unrecognized @{}'.format(c.cmd))
540
541         if ret == c.negated:
542             raise FailedCheck(cerr)
543
544     except FailedCheck as err:
545         message = '@{}{} check failed'.format('!' if c.negated else '', c.cmd)
546         print_err(c.lineno, c.context, str(err), message)
547     except InvalidCheck as err:
548         print_err(c.lineno, c.context, str(err))
549
550
551 def check(target, commands):
552     cache = CachedFiles(target)
553     for c in commands:
554         check_command(c, cache)
555
556
557 if __name__ == '__main__':
558     if len(sys.argv) not in [3, 4]:
559         stderr('Usage: {} <doc dir> <template> [--bless]'.format(sys.argv[0]))
560         raise SystemExit(1)
561
562     rust_test_path = sys.argv[2]
563     if len(sys.argv) > 3 and sys.argv[3] == '--bless':
564         bless = True
565     else:
566         # We only support `--bless` at the end of the arguments.
567         # This assert is to prevent silent failures.
568         assert '--bless' not in sys.argv
569         bless = False
570     check(sys.argv[1], get_commands(rust_test_path))
571     if ERR_COUNT:
572         stderr("\nEncountered {} errors".format(ERR_COUNT))
573         raise SystemExit(1)