]> git.lizzy.rs Git - rust.git/blob - src/etc/htmldocck.py
Rollup merge of #99030 - rust-lang:notriddle/field-recovery, r=petrochenkov
[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 * `@count PATH XPATH TEXT COUNT` checks for the occurrence of the given XPath
98   with the given text in the specified file. The number of occurrences must
99   match the given count.
100
101 * `@snapshot NAME PATH XPATH` creates a snapshot test named NAME.
102   A snapshot test captures a subtree of the DOM, at the location
103   determined by the XPath, and compares it to a pre-recorded value
104   in a file. The file's name is the test's name with the `.rs` extension
105   replaced with `.NAME.html`, where NAME is the snapshot's name.
106
107   htmldocck supports the `--bless` option to accept the current subtree
108   as expected, saving it to the file determined by the snapshot's name.
109   compiletest's `--bless` flag is forwarded to htmldocck.
110
111 * `@has-dir PATH` checks for the existence of the given directory.
112
113 All conditions can be negated with `!`. `@!has foo/type.NoSuch.html`
114 checks if the given file does not exist, for example.
115
116 """
117
118 from __future__ import absolute_import, print_function, unicode_literals
119
120 import codecs
121 import io
122 import sys
123 import os.path
124 import re
125 import shlex
126 from collections import namedtuple
127 try:
128     from html.parser import HTMLParser
129 except ImportError:
130     from HTMLParser import HTMLParser
131 try:
132     from xml.etree import cElementTree as ET
133 except ImportError:
134     from xml.etree import ElementTree as ET
135
136 try:
137     from html.entities import name2codepoint
138 except ImportError:
139     from htmlentitydefs import name2codepoint
140
141 # "void elements" (no closing tag) from the HTML Standard section 12.1.2
142 VOID_ELEMENTS = {'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen',
143                      'link', 'menuitem', 'meta', 'param', 'source', 'track', 'wbr'}
144
145 # Python 2 -> 3 compatibility
146 try:
147     unichr
148 except NameError:
149     unichr = chr
150
151
152 channel = os.environ["DOC_RUST_LANG_ORG_CHANNEL"]
153
154 # Initialized in main
155 rust_test_path = None
156 bless = None
157
158 class CustomHTMLParser(HTMLParser):
159     """simplified HTML parser.
160
161     this is possible because we are dealing with very regular HTML from
162     rustdoc; we only have to deal with i) void elements and ii) empty
163     attributes."""
164     def __init__(self, target=None):
165         HTMLParser.__init__(self)
166         self.__builder = target or ET.TreeBuilder()
167
168     def handle_starttag(self, tag, attrs):
169         attrs = {k: v or '' for k, v in attrs}
170         self.__builder.start(tag, attrs)
171         if tag in VOID_ELEMENTS:
172             self.__builder.end(tag)
173
174     def handle_endtag(self, tag):
175         self.__builder.end(tag)
176
177     def handle_startendtag(self, tag, attrs):
178         attrs = {k: v or '' for k, v in attrs}
179         self.__builder.start(tag, attrs)
180         self.__builder.end(tag)
181
182     def handle_data(self, data):
183         self.__builder.data(data)
184
185     def handle_entityref(self, name):
186         self.__builder.data(unichr(name2codepoint[name]))
187
188     def handle_charref(self, name):
189         code = int(name[1:], 16) if name.startswith(('x', 'X')) else int(name, 10)
190         self.__builder.data(unichr(code))
191
192     def close(self):
193         HTMLParser.close(self)
194         return self.__builder.close()
195
196
197 Command = namedtuple('Command', 'negated cmd args lineno context')
198
199
200 class FailedCheck(Exception):
201     pass
202
203
204 class InvalidCheck(Exception):
205     pass
206
207
208 def concat_multi_lines(f):
209     """returns a generator out of the file object, which
210     - removes `\\` then `\n` then a shared prefix with the previous line then
211       optional whitespace;
212     - keeps a line number (starting from 0) of the first line being
213       concatenated."""
214     lastline = None  # set to the last line when the last line has a backslash
215     firstlineno = None
216     catenated = ''
217     for lineno, line in enumerate(f):
218         line = line.rstrip('\r\n')
219
220         # strip the common prefix from the current line if needed
221         if lastline is not None:
222             common_prefix = os.path.commonprefix([line, lastline])
223             line = line[len(common_prefix):].lstrip()
224
225         firstlineno = firstlineno or lineno
226         if line.endswith('\\'):
227             if lastline is None:
228                 lastline = line[:-1]
229             catenated += line[:-1]
230         else:
231             yield firstlineno, catenated + line
232             lastline = None
233             firstlineno = None
234             catenated = ''
235
236     if lastline is not None:
237         print_err(lineno, line, 'Trailing backslash at the end of the file')
238
239
240 LINE_PATTERN = re.compile(r'''
241     (?<=(?<!\S))(?P<invalid>!?)@(?P<negated>!?)
242     (?P<cmd>[A-Za-z]+(?:-[A-Za-z]+)*)
243     (?P<args>.*)$
244 ''', re.X | re.UNICODE)
245
246
247 def get_commands(template):
248     with io.open(template, encoding='utf-8') as f:
249         for lineno, line in concat_multi_lines(f):
250             m = LINE_PATTERN.search(line)
251             if not m:
252                 continue
253
254             negated = (m.group('negated') == '!')
255             cmd = m.group('cmd')
256             if m.group('invalid') == '!':
257                 print_err(
258                     lineno,
259                     line,
260                     'Invalid command: `!@{0}{1}`, (help: try with `@!{1}`)'.format(
261                         '!' if negated else '',
262                         cmd,
263                     ),
264                 )
265                 continue
266             args = m.group('args')
267             if args and not args[:1].isspace():
268                 print_err(lineno, line, 'Invalid template syntax')
269                 continue
270             try:
271                 args = shlex.split(args)
272             except UnicodeEncodeError:
273                 args = [arg.decode('utf-8') for arg in shlex.split(args.encode('utf-8'))]
274             yield Command(negated=negated, cmd=cmd, args=args, lineno=lineno+1, context=line)
275
276
277 def _flatten(node, acc):
278     if node.text:
279         acc.append(node.text)
280     for e in node:
281         _flatten(e, acc)
282         if e.tail:
283             acc.append(e.tail)
284
285
286 def flatten(node):
287     acc = []
288     _flatten(node, acc)
289     return ''.join(acc)
290
291
292 def make_xml(text):
293     xml = ET.XML('<xml>%s</xml>' % text)
294     return xml
295
296
297 def normalize_xpath(path):
298     path = path.replace("{{channel}}", channel)
299     if path.startswith('//'):
300         return '.' + path  # avoid warnings
301     elif path.startswith('.//'):
302         return path
303     else:
304         raise InvalidCheck('Non-absolute XPath is not supported due to implementation issues')
305
306
307 class CachedFiles(object):
308     def __init__(self, root):
309         self.root = root
310         self.files = {}
311         self.trees = {}
312         self.last_path = None
313
314     def resolve_path(self, path):
315         if path != '-':
316             path = os.path.normpath(path)
317             self.last_path = path
318             return path
319         elif self.last_path is None:
320             raise InvalidCheck('Tried to use the previous path in the first command')
321         else:
322             return self.last_path
323
324     def get_file(self, path):
325         path = self.resolve_path(path)
326         if path in self.files:
327             return self.files[path]
328
329         abspath = os.path.join(self.root, path)
330         if not(os.path.exists(abspath) and os.path.isfile(abspath)):
331             raise FailedCheck('File does not exist {!r}'.format(path))
332
333         with io.open(abspath, encoding='utf-8') as f:
334             data = f.read()
335             self.files[path] = data
336             return data
337
338     def get_tree(self, path):
339         path = self.resolve_path(path)
340         if path in self.trees:
341             return self.trees[path]
342
343         abspath = os.path.join(self.root, path)
344         if not(os.path.exists(abspath) and os.path.isfile(abspath)):
345             raise FailedCheck('File does not exist {!r}'.format(path))
346
347         with io.open(abspath, encoding='utf-8') as f:
348             try:
349                 tree = ET.fromstringlist(f.readlines(), CustomHTMLParser())
350             except Exception as e:
351                 raise RuntimeError('Cannot parse an HTML file {!r}: {}'.format(path, e))
352             self.trees[path] = tree
353             return self.trees[path]
354
355     def get_dir(self, path):
356         path = self.resolve_path(path)
357         abspath = os.path.join(self.root, path)
358         if not(os.path.exists(abspath) and os.path.isdir(abspath)):
359             raise FailedCheck('Directory does not exist {!r}'.format(path))
360
361
362 def check_string(data, pat, regexp):
363     pat = pat.replace("{{channel}}", channel)
364     if not pat:
365         return True  # special case a presence testing
366     elif regexp:
367         return re.search(pat, data, flags=re.UNICODE) is not None
368     else:
369         data = ' '.join(data.split())
370         pat = ' '.join(pat.split())
371         return pat in data
372
373
374 def check_tree_attr(tree, path, attr, pat, regexp):
375     path = normalize_xpath(path)
376     ret = False
377     for e in tree.findall(path):
378         if attr in e.attrib:
379             value = e.attrib[attr]
380         else:
381             continue
382
383         ret = check_string(value, pat, regexp)
384         if ret:
385             break
386     return ret
387
388
389 # Returns the number of occurences matching the regex (`regexp`) and the text (`pat`).
390 def check_tree_text(tree, path, pat, regexp, stop_at_first):
391     path = normalize_xpath(path)
392     match_count = 0
393     try:
394         for e in tree.findall(path):
395             try:
396                 value = flatten(e)
397             except KeyError:
398                 continue
399             else:
400                 if check_string(value, pat, regexp):
401                     match_count += 1
402                     if stop_at_first:
403                         break
404     except Exception:
405         print('Failed to get path "{}"'.format(path))
406         raise
407     return match_count
408
409
410 def get_tree_count(tree, path):
411     path = normalize_xpath(path)
412     return len(tree.findall(path))
413
414
415 def check_snapshot(snapshot_name, actual_tree, normalize_to_text):
416     assert rust_test_path.endswith('.rs')
417     snapshot_path = '{}.{}.{}'.format(rust_test_path[:-3], snapshot_name, 'html')
418     try:
419         with open(snapshot_path, 'r') as snapshot_file:
420             expected_str = snapshot_file.read().replace("{{channel}}", channel)
421     except FileNotFoundError:
422         if bless:
423             expected_str = None
424         else:
425             raise FailedCheck('No saved snapshot value')
426
427     if not normalize_to_text:
428         actual_str = ET.tostring(actual_tree).decode('utf-8')
429     else:
430         actual_str = flatten(actual_tree)
431
432     # Conditions:
433     #  1. Is --bless
434     #  2. Are actual and expected tree different
435     #  3. Are actual and expected text different
436     if not expected_str \
437         or (not normalize_to_text and \
438             not compare_tree(make_xml(actual_str), make_xml(expected_str), stderr)) \
439         or (normalize_to_text and actual_str != expected_str):
440
441         if bless:
442             with open(snapshot_path, 'w') as snapshot_file:
443                 snapshot_file.write(actual_str)
444         else:
445             print('--- expected ---\n')
446             print(expected_str)
447             print('\n\n--- actual ---\n')
448             print(actual_str)
449             print()
450             raise FailedCheck('Actual snapshot value is different than expected')
451
452
453 # Adapted from https://github.com/formencode/formencode/blob/3a1ba9de2fdd494dd945510a4568a3afeddb0b2e/formencode/doctest_xml_compare.py#L72-L120
454 def compare_tree(x1, x2, reporter=None):
455     if x1.tag != x2.tag:
456         if reporter:
457             reporter('Tags do not match: %s and %s' % (x1.tag, x2.tag))
458         return False
459     for name, value in x1.attrib.items():
460         if x2.attrib.get(name) != value:
461             if reporter:
462                 reporter('Attributes do not match: %s=%r, %s=%r'
463                          % (name, value, name, x2.attrib.get(name)))
464             return False
465     for name in x2.attrib:
466         if name not in x1.attrib:
467             if reporter:
468                 reporter('x2 has an attribute x1 is missing: %s'
469                          % name)
470             return False
471     if not text_compare(x1.text, x2.text):
472         if reporter:
473             reporter('text: %r != %r' % (x1.text, x2.text))
474         return False
475     if not text_compare(x1.tail, x2.tail):
476         if reporter:
477             reporter('tail: %r != %r' % (x1.tail, x2.tail))
478         return False
479     cl1 = list(x1)
480     cl2 = list(x2)
481     if len(cl1) != len(cl2):
482         if reporter:
483             reporter('children length differs, %i != %i'
484                      % (len(cl1), len(cl2)))
485         return False
486     i = 0
487     for c1, c2 in zip(cl1, cl2):
488         i += 1
489         if not compare_tree(c1, c2, reporter=reporter):
490             if reporter:
491                 reporter('children %i do not match: %s'
492                          % (i, c1.tag))
493             return False
494     return True
495
496
497 def text_compare(t1, t2):
498     if not t1 and not t2:
499         return True
500     if t1 == '*' or t2 == '*':
501         return True
502     return (t1 or '').strip() == (t2 or '').strip()
503
504
505 def stderr(*args):
506     if sys.version_info.major < 3:
507         file = codecs.getwriter('utf-8')(sys.stderr)
508     else:
509         file = sys.stderr
510
511     print(*args, file=file)
512
513
514 def print_err(lineno, context, err, message=None):
515     global ERR_COUNT
516     ERR_COUNT += 1
517     stderr("{}: {}".format(lineno, message or err))
518     if message and err:
519         stderr("\t{}".format(err))
520
521     if context:
522         stderr("\t{}".format(context))
523
524
525 def get_nb_matching_elements(cache, c, regexp, stop_at_first):
526     tree = cache.get_tree(c.args[0])
527     pat, sep, attr = c.args[1].partition('/@')
528     if sep:  # attribute
529         tree = cache.get_tree(c.args[0])
530         return check_tree_attr(tree, pat, attr, c.args[2], False)
531     else:  # normalized text
532         pat = c.args[1]
533         if pat.endswith('/text()'):
534             pat = pat[:-7]
535         return check_tree_text(cache.get_tree(c.args[0]), pat, c.args[2], regexp, stop_at_first)
536
537
538 ERR_COUNT = 0
539
540
541 def check_command(c, cache):
542     try:
543         cerr = ""
544         if c.cmd == 'has' or c.cmd == 'matches':  # string test
545             regexp = (c.cmd == 'matches')
546             if len(c.args) == 1 and not regexp:  # @has <path> = file existence
547                 try:
548                     cache.get_file(c.args[0])
549                     ret = True
550                 except FailedCheck as err:
551                     cerr = str(err)
552                     ret = False
553             elif len(c.args) == 2:  # @has/matches <path> <pat> = string test
554                 cerr = "`PATTERN` did not match"
555                 ret = check_string(cache.get_file(c.args[0]), c.args[1], regexp)
556             elif len(c.args) == 3:  # @has/matches <path> <pat> <match> = XML tree test
557                 cerr = "`XPATH PATTERN` did not match"
558                 ret = get_nb_matching_elements(cache, c, regexp, True) != 0
559             else:
560                 raise InvalidCheck('Invalid number of @{} arguments'.format(c.cmd))
561
562         elif c.cmd == 'count':  # count test
563             if len(c.args) == 3:  # @count <path> <pat> <count> = count test
564                 expected = int(c.args[2])
565                 found = get_tree_count(cache.get_tree(c.args[0]), c.args[1])
566                 cerr = "Expected {} occurrences but found {}".format(expected, found)
567                 ret = expected == found
568             elif len(c.args) == 4:  # @count <path> <pat> <text> <count> = count test
569                 expected = int(c.args[3])
570                 found = get_nb_matching_elements(cache, c, False, False)
571                 cerr = "Expected {} occurrences but found {}".format(expected, found)
572                 ret = found == expected
573             else:
574                 raise InvalidCheck('Invalid number of @{} arguments'.format(c.cmd))
575
576         elif c.cmd == 'snapshot':  # snapshot test
577             if len(c.args) == 3:  # @snapshot <snapshot-name> <html-path> <xpath>
578                 [snapshot_name, html_path, pattern] = c.args
579                 tree = cache.get_tree(html_path)
580                 xpath = normalize_xpath(pattern)
581                 normalize_to_text = False
582                 if xpath.endswith('/text()'):
583                     xpath = xpath[:-7]
584                     normalize_to_text = True
585
586                 subtrees = tree.findall(xpath)
587                 if len(subtrees) == 1:
588                     [subtree] = subtrees
589                     try:
590                         check_snapshot(snapshot_name, subtree, normalize_to_text)
591                         ret = True
592                     except FailedCheck as err:
593                         cerr = str(err)
594                         ret = False
595                 elif len(subtrees) == 0:
596                     raise FailedCheck('XPATH did not match')
597                 else:
598                     raise FailedCheck('Expected 1 match, but found {}'.format(len(subtrees)))
599             else:
600                 raise InvalidCheck('Invalid number of @{} arguments'.format(c.cmd))
601
602         elif c.cmd == 'has-dir':  # has-dir test
603             if len(c.args) == 1:  # @has-dir <path> = has-dir test
604                 try:
605                     cache.get_dir(c.args[0])
606                     ret = True
607                 except FailedCheck as err:
608                     cerr = str(err)
609                     ret = False
610             else:
611                 raise InvalidCheck('Invalid number of @{} arguments'.format(c.cmd))
612
613         elif c.cmd == 'valid-html':
614             raise InvalidCheck('Unimplemented @valid-html')
615
616         elif c.cmd == 'valid-links':
617             raise InvalidCheck('Unimplemented @valid-links')
618
619         else:
620             raise InvalidCheck('Unrecognized @{}'.format(c.cmd))
621
622         if ret == c.negated:
623             raise FailedCheck(cerr)
624
625     except FailedCheck as err:
626         message = '@{}{} check failed'.format('!' if c.negated else '', c.cmd)
627         print_err(c.lineno, c.context, str(err), message)
628     except InvalidCheck as err:
629         print_err(c.lineno, c.context, str(err))
630
631
632 def check(target, commands):
633     cache = CachedFiles(target)
634     for c in commands:
635         check_command(c, cache)
636
637
638 if __name__ == '__main__':
639     if len(sys.argv) not in [3, 4]:
640         stderr('Usage: {} <doc dir> <template> [--bless]'.format(sys.argv[0]))
641         raise SystemExit(1)
642
643     rust_test_path = sys.argv[2]
644     if len(sys.argv) > 3 and sys.argv[3] == '--bless':
645         bless = True
646     else:
647         # We only support `--bless` at the end of the arguments.
648         # This assert is to prevent silent failures.
649         assert '--bless' not in sys.argv
650         bless = False
651     check(sys.argv[1], get_commands(rust_test_path))
652     if ERR_COUNT:
653         stderr("\nEncountered {} errors".format(ERR_COUNT))
654         raise SystemExit(1)