]> git.lizzy.rs Git - rust.git/blob - src/etc/htmldocck.py
Rollup merge of #91479 - scottmcm:slice-as-simd, r=workingjubilee
[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):
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     actual_str = ET.tostring(tree).decode('utf-8')
417
418     if expected_str != actual_str:
419         if bless:
420             with open(snapshot_path, 'w') as snapshot_file:
421                 snapshot_file.write(actual_str)
422         else:
423             print('--- expected ---\n')
424             print(expected_str)
425             print('\n\n--- actual ---\n')
426             print(actual_str)
427             print()
428             raise FailedCheck('Actual snapshot value is different than expected')
429
430 def stderr(*args):
431     if sys.version_info.major < 3:
432         file = codecs.getwriter('utf-8')(sys.stderr)
433     else:
434         file = sys.stderr
435
436     print(*args, file=file)
437
438
439 def print_err(lineno, context, err, message=None):
440     global ERR_COUNT
441     ERR_COUNT += 1
442     stderr("{}: {}".format(lineno, message or err))
443     if message and err:
444         stderr("\t{}".format(err))
445
446     if context:
447         stderr("\t{}".format(context))
448
449
450 ERR_COUNT = 0
451
452
453 def check_command(c, cache):
454     try:
455         cerr = ""
456         if c.cmd == 'has' or c.cmd == 'matches':  # string test
457             regexp = (c.cmd == 'matches')
458             if len(c.args) == 1 and not regexp:  # @has <path> = file existence
459                 try:
460                     cache.get_file(c.args[0])
461                     ret = True
462                 except FailedCheck as err:
463                     cerr = str(err)
464                     ret = False
465             elif len(c.args) == 2:  # @has/matches <path> <pat> = string test
466                 cerr = "`PATTERN` did not match"
467                 ret = check_string(cache.get_file(c.args[0]), c.args[1], regexp)
468             elif len(c.args) == 3:  # @has/matches <path> <pat> <match> = XML tree test
469                 cerr = "`XPATH PATTERN` did not match"
470                 tree = cache.get_tree(c.args[0])
471                 pat, sep, attr = c.args[1].partition('/@')
472                 if sep:  # attribute
473                     tree = cache.get_tree(c.args[0])
474                     ret = check_tree_attr(tree, pat, attr, c.args[2], regexp)
475                 else:  # normalized text
476                     pat = c.args[1]
477                     if pat.endswith('/text()'):
478                         pat = pat[:-7]
479                     ret = check_tree_text(cache.get_tree(c.args[0]), pat, c.args[2], regexp)
480             else:
481                 raise InvalidCheck('Invalid number of @{} arguments'.format(c.cmd))
482
483         elif c.cmd == 'count':  # count test
484             if len(c.args) == 3:  # @count <path> <pat> <count> = count test
485                 expected = int(c.args[2])
486                 found = get_tree_count(cache.get_tree(c.args[0]), c.args[1])
487                 cerr = "Expected {} occurrences but found {}".format(expected, found)
488                 ret = expected == found
489             else:
490                 raise InvalidCheck('Invalid number of @{} arguments'.format(c.cmd))
491
492         elif c.cmd == 'snapshot':  # snapshot test
493             if len(c.args) == 3:  # @snapshot <snapshot-name> <html-path> <xpath>
494                 [snapshot_name, html_path, pattern] = c.args
495                 tree = cache.get_tree(html_path)
496                 xpath = normalize_xpath(pattern)
497                 subtrees = tree.findall(xpath)
498                 if len(subtrees) == 1:
499                     [subtree] = subtrees
500                     try:
501                         check_snapshot(snapshot_name, subtree)
502                         ret = True
503                     except FailedCheck as err:
504                         cerr = str(err)
505                         ret = False
506                 elif len(subtrees) == 0:
507                     raise FailedCheck('XPATH did not match')
508                 else:
509                     raise FailedCheck('Expected 1 match, but found {}'.format(len(subtrees)))
510             else:
511                 raise InvalidCheck('Invalid number of @{} arguments'.format(c.cmd))
512
513         elif c.cmd == 'has-dir':  # has-dir test
514             if len(c.args) == 1:  # @has-dir <path> = has-dir test
515                 try:
516                     cache.get_dir(c.args[0])
517                     ret = True
518                 except FailedCheck as err:
519                     cerr = str(err)
520                     ret = False
521             else:
522                 raise InvalidCheck('Invalid number of @{} arguments'.format(c.cmd))
523
524         elif c.cmd == 'valid-html':
525             raise InvalidCheck('Unimplemented @valid-html')
526
527         elif c.cmd == 'valid-links':
528             raise InvalidCheck('Unimplemented @valid-links')
529
530         else:
531             raise InvalidCheck('Unrecognized @{}'.format(c.cmd))
532
533         if ret == c.negated:
534             raise FailedCheck(cerr)
535
536     except FailedCheck as err:
537         message = '@{}{} check failed'.format('!' if c.negated else '', c.cmd)
538         print_err(c.lineno, c.context, str(err), message)
539     except InvalidCheck as err:
540         print_err(c.lineno, c.context, str(err))
541
542
543 def check(target, commands):
544     cache = CachedFiles(target)
545     for c in commands:
546         check_command(c, cache)
547
548
549 if __name__ == '__main__':
550     if len(sys.argv) not in [3, 4]:
551         stderr('Usage: {} <doc dir> <template> [--bless]'.format(sys.argv[0]))
552         raise SystemExit(1)
553
554     rust_test_path = sys.argv[2]
555     if len(sys.argv) > 3 and sys.argv[3] == '--bless':
556         bless = True
557     else:
558         # We only support `--bless` at the end of the arguments.
559         # This assert is to prevent silent failures.
560         assert '--bless' not in sys.argv
561         bless = False
562     check(sys.argv[1], get_commands(rust_test_path))
563     if ERR_COUNT:
564         stderr("\nEncountered {} errors".format(ERR_COUNT))
565         raise SystemExit(1)