• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1"""Defines HTMLPrinter, a BasePrinter subclass for a single-page HTML results file."""
2
3# Copyright (c) 2018-2019 Collabora, Ltd.
4#
5# SPDX-License-Identifier: Apache-2.0
6#
7# Author(s):    Ryan Pavlik <ryan.pavlik@collabora.com>
8
9import html
10import re
11from collections import namedtuple
12
13from .base_printer import BasePrinter, getColumn
14from .shared import (MessageContext, MessageType, generateInclude,
15                     getHighlightedRange)
16
17# Bootstrap styles (for constructing CSS class names) associated with MessageType values.
18MESSAGE_TYPE_STYLES = {
19    MessageType.ERROR: 'danger',
20    MessageType.WARNING: 'warning',
21    MessageType.NOTE: 'secondary'
22}
23
24
25# HTML Entity for a little emoji-icon associated with MessageType values.
26MESSAGE_TYPE_ICONS = {
27    MessageType.ERROR: '&#x2297;',  # makeIcon('times-circle'),
28    MessageType.WARNING: '&#9888;',  # makeIcon('exclamation-triangle'),
29    MessageType.NOTE: '&#x2139;'  # makeIcon('info-circle')
30}
31
32LINK_ICON = '&#128279;'  # link icon
33
34
35class HTMLPrinter(BasePrinter):
36    """Implementation of BasePrinter for generating diagnostic reports in HTML format.
37
38    Generates a single file containing neatly-formatted messages.
39
40    The HTML file loads Bootstrap 4 as well as 'prism' syntax highlighting from CDN.
41    """
42
43    def __init__(self, filename):
44        """Construct by opening the file."""
45        self.f = open(filename, 'w', encoding='utf-8')
46        self.f.write("""<!doctype html>
47        <html lang="en"><head>
48        <meta charset="utf-8">
49        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
50        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/themes/prism.min.css" integrity="sha256-N1K43s+8twRa+tzzoF3V8EgssdDiZ6kd9r8Rfgg8kZU=" crossorigin="anonymous" />
51        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/plugins/line-numbers/prism-line-numbers.min.css" integrity="sha256-Afz2ZJtXw+OuaPX10lZHY7fN1+FuTE/KdCs+j7WZTGc=" crossorigin="anonymous" />
52        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/plugins/line-highlight/prism-line-highlight.min.css" integrity="sha256-FFGTaA49ZxFi2oUiWjxtTBqoda+t1Uw8GffYkdt9aco=" crossorigin="anonymous" />
53        <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
54        <style>
55        pre {
56            overflow-x: scroll;
57            white-space: nowrap;
58        }
59        </style>
60        <title>check_spec_links results</title>
61        </head>
62        <body>
63        <div class="container">
64        <h1><code>check_spec_links.py</code> Scan Results</h1>
65        """)
66        #
67        self.filenameTransformer = re.compile(r'[^\w]+')
68        self.fileRange = {}
69        self.fileLines = {}
70        self.backLink = namedtuple(
71            'BackLink', ['lineNum', 'col', 'end_col', 'target', 'tooltip', 'message_type'])
72        self.fileBackLinks = {}
73
74        self.nextAnchor = 0
75        super().__init__()
76
77    def close(self):
78        """Write the tail end of the file and close it."""
79        self.f.write("""
80        </div>
81        <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/prism.min.js" integrity="sha256-jc6y1s/Y+F+78EgCT/lI2lyU7ys+PFYrRSJ6q8/R8+o=" crossorigin="anonymous"></script>
82        <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/plugins/keep-markup/prism-keep-markup.min.js" integrity="sha256-mP5i3m+wTxxOYkH+zXnKIG5oJhXLIPQYoiicCV1LpkM=" crossorigin="anonymous"></script>
83        <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/components/prism-asciidoc.min.js" integrity="sha256-NHPE1p3VBIdXkmfbkf/S0hMA6b4Ar4TAAUlR+Rlogoc=" crossorigin="anonymous"></script>
84        <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/plugins/line-numbers/prism-line-numbers.min.js" integrity="sha256-JfF9MVfGdRUxzT4pecjOZq6B+F5EylLQLwcQNg+6+Qk=" crossorigin="anonymous"></script>
85        <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/plugins/line-highlight/prism-line-highlight.min.js" integrity="sha256-DEl9ZQE+lseY13oqm2+mlUr+sVI18LG813P+kzzIm8o=" crossorigin="anonymous"></script>
86        <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.slim.min.js" integrity="sha256-3edrmyuQ0w65f8gfBsqowzjJe2iM6n0nKciPUp8y+7E=" crossorigin="anonymous"></script>
87        <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.6/esm/popper.min.js" integrity="sha256-T0gPN+ySsI9ixTd/1ciLl2gjdLJLfECKvkQjJn98lOs=" crossorigin="anonymous"></script>
88        <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script>
89        <script>
90        $(function () {
91            $('[data-toggle="tooltip"]').tooltip();
92            function autoExpand() {
93                var hash = window.location.hash;
94                if (!hash) {
95                    return;
96                }
97                $(hash).parents().filter('.collapse').collapse('show');
98            }
99            window.addEventListener('hashchange', autoExpand);
100            $(document).ready(autoExpand);
101            $('.accordion').on('shown.bs.collapse', function(e) {
102                e.target.parentNode.scrollIntoView();
103            })
104        })
105        </script>
106        </body></html>
107        """)
108        self.f.close()
109
110    ###
111    # Output methods: these all write to the HTML file.
112    def outputResults(self, checker, broken_links=True,
113                      missing_includes=False):
114        """Output the full results of a checker run.
115
116        Includes the diagnostics, broken links (if desired),
117        missing includes (if desired), and excerpts of all files with diagnostics.
118        """
119        self.output(checker)
120        self.outputBrokenAndMissing(
121            checker, broken_links=broken_links, missing_includes=missing_includes)
122
123        self.f.write("""
124        <div class="container">
125        <h2>Excerpts of referenced files</h2>""")
126        for fn in self.fileRange:
127            self.outputFileExcerpt(fn)
128        self.f.write('</div><!-- .container -->\n')
129
130    def outputChecker(self, checker):
131        """Output the contents of a MacroChecker object.
132
133        Starts and ends the accordion populated by outputCheckerFile().
134        """
135        self.f.write(
136            '<div class="container"><h2>Per-File Warnings and Errors</h2>\n')
137        self.f.write('<div class="accordion" id="fileAccordion">\n')
138        super(HTMLPrinter, self).outputChecker(checker)
139        self.f.write("""</div><!-- #fileAccordion -->
140        </div><!-- .container -->\n""")
141
142    def outputCheckerFile(self, fileChecker):
143        """Output the contents of a MacroCheckerFile object.
144
145        Stashes the lines of the file for later excerpts,
146        and outputs any diagnostics in an accordion card.
147        """
148        # Save lines for later
149        self.fileLines[fileChecker.filename] = fileChecker.lines
150
151        if not fileChecker.numDiagnostics():
152            return
153
154        self.f.write("""
155        <div class="card">
156        <div class="card-header" id="{id}-file-heading">
157        <div class="row">
158        <div class="col">
159        <button data-target="#collapse-{id}" class="btn btn-link btn-primary mb-0 collapsed" type="button" data-toggle="collapse" aria-expanded="false" aria-controls="collapse-{id}">
160        {relativefn}
161        </button>
162        </div>
163        """.format(id=self.makeIdentifierFromFilename(fileChecker.filename), relativefn=html.escape(self.getRelativeFilename(fileChecker.filename))))
164        self.f.write('<div class="col-1">')
165        warnings = fileChecker.numMessagesOfType(MessageType.WARNING)
166        if warnings > 0:
167            self.f.write("""<span class="badge badge-warning" data-toggle="tooltip" title="{num} warnings in this file">
168                            {icon}
169                            {num}<span class="sr-only"> warnings</span></span>""".format(num=warnings, icon=MESSAGE_TYPE_ICONS[MessageType.WARNING]))
170        self.f.write('</div>\n<div class="col-1">')
171        errors = fileChecker.numMessagesOfType(MessageType.ERROR)
172        if errors > 0:
173            self.f.write("""<span class="badge badge-danger" data-toggle="tooltip" title="{num} errors in this file">
174                            {icon}
175                            {num}<span class="sr-only"> errors</span></span>""".format(num=errors, icon=MESSAGE_TYPE_ICONS[MessageType.ERROR]))
176        self.f.write("""
177        </div><!-- .col-1 -->
178        </div><!-- .row -->
179        </div><!-- .card-header -->
180        <div id="collapse-{id}" class="collapse" aria-labelledby="{id}-file-heading" data-parent="#fileAccordion">
181        <div class="card-body">
182        """.format(id=self.makeIdentifierFromFilename(fileChecker.filename)))
183        super(HTMLPrinter, self).outputCheckerFile(fileChecker)
184
185        self.f.write("""
186        </div><!-- .card-body -->
187        </div><!-- .collapse -->
188        </div><!-- .card -->
189        <!-- ..................................... -->
190        """)
191
192    def outputMessage(self, msg):
193        """Output a Message."""
194        anchor = self.getUniqueAnchor()
195
196        self.recordUsage(msg.context,
197                         linkBackTarget=anchor,
198                         linkBackTooltip='{}: {} [...]'.format(
199                             msg.message_type, msg.message[0]),
200                         linkBackType=msg.message_type)
201
202        self.f.write("""
203        <div class="card">
204        <div class="card-body">
205        <h5 class="card-header bg bg-{style}" id="{anchor}">{icon} {t} Line {lineNum}, Column {col} (-{arg})</h5>
206        <p class="card-text">
207        """.format(
208            anchor=anchor,
209            icon=MESSAGE_TYPE_ICONS[msg.message_type],
210            style=MESSAGE_TYPE_STYLES[msg.message_type],
211            t=self.formatBrief(msg.message_type),
212            lineNum=msg.context.lineNum,
213            col=getColumn(msg.context),
214            arg=msg.message_id.enable_arg()))
215        self.f.write(self.formatContext(msg.context))
216        self.f.write('<br/>')
217        for line in msg.message:
218            self.f.write(html.escape(line))
219            self.f.write('<br />\n')
220        self.f.write('</p>\n')
221        if msg.see_also:
222            self.f.write('<p>See also:</p><ul>\n')
223            for see in msg.see_also:
224                if isinstance(see, MessageContext):
225                    self.f.write(
226                        '<li>{}</li>\n'.format(self.formatContext(see)))
227                    self.recordUsage(see,
228                                     linkBackTarget=anchor,
229                                     linkBackType=MessageType.NOTE,
230                                     linkBackTooltip='see-also associated with {} at {}'.format(msg.message_type, self.formatContextBrief(see)))
231                else:
232                    self.f.write('<li>{}</li>\n'.format(self.formatBrief(see)))
233            self.f.write('</ul>')
234        if msg.replacement is not None:
235            self.f.write(
236                '<div class="alert alert-primary">Hover the highlight text to view suggested replacement.</div>')
237        if msg.fix is not None:
238            self.f.write(
239                '<div class="alert alert-info">Note: Auto-fix available.</div>')
240        if msg.script_location:
241            self.f.write(
242                '<p>Message originated at <code>{}</code></p>'.format(msg.script_location))
243        self.f.write('<pre class="line-numbers language-asciidoc" data-start="{}"><code>'.format(
244            msg.context.lineNum))
245        highlightStart, highlightEnd = getHighlightedRange(msg.context)
246        self.f.write(html.escape(msg.context.line[:highlightStart]))
247        self.f.write(
248            '<span class="border border-{}"'.format(MESSAGE_TYPE_STYLES[msg.message_type]))
249        if msg.replacement is not None:
250            self.f.write(
251                ' data-toggle="tooltip" title="{}"'.format(msg.replacement))
252        self.f.write('>')
253        self.f.write(html.escape(
254            msg.context.line[highlightStart:highlightEnd]))
255        self.f.write('</span>')
256        self.f.write(html.escape(msg.context.line[highlightEnd:]))
257        self.f.write('</code></pre></div></div>')
258
259    def outputBrokenLinks(self, checker, broken):
260        """Output a table of broken links.
261
262        Called by self.outputBrokenAndMissing() if requested.
263        """
264        self.f.write("""
265        <div class="container">
266        <h2>Missing Referenced API Includes</h2>
267        <p>Items here have been referenced by a linking macro, so these are all broken links in the spec!</p>
268        <table class="table table-striped">
269        <thead>
270        <th scope="col">Add line to include this file</th>
271        <th scope="col">or add this macro instead</th>
272        <th scope="col">Links to this entity</th></thead>
273        """)
274
275        for entity_name, uses in sorted(broken.items()):
276            category = checker.findEntity(entity_name).category
277            anchor = self.getUniqueAnchor()
278            asciidocAnchor = '[[{}]]'.format(entity_name)
279            include = generateInclude(dir_traverse='../../generated/',
280                                      generated_type='api',
281                                      category=category,
282                                      entity=entity_name)
283            self.f.write("""
284            <tr id={}>
285            <td><code class="text-dark language-asciidoc">{}</code></td>
286            <td><code class="text-dark">{}</code></td>
287            <td><ul class="list-inline">
288            """.format(anchor, include, asciidocAnchor))
289            for context in uses:
290                self.f.write(
291                    '<li class="list-inline-item">{}</li>'.format(self.formatContext(context, MessageType.NOTE)))
292                self.recordUsage(
293                    context,
294                    linkBackTooltip='Link broken in spec: {} not seen'.format(
295                        include),
296                    linkBackTarget=anchor,
297                    linkBackType=MessageType.NOTE)
298            self.f.write("""</ul></td></tr>""")
299        self.f.write("""</table></div>""")
300
301    def outputMissingIncludes(self, checker, missing):
302        """Output a table of missing includes.
303
304        Called by self.outputBrokenAndMissing() if requested.
305        """
306        self.f.write("""
307        <div class="container">
308        <h2>Missing Unreferenced API Includes</h2>
309        <p>These items are expected to be generated in the spec build process, but aren't included.
310        However, as they also are not referenced by any linking macros, they aren't broken links - at worst they are undocumented entities,
311        at best they are errors in <code>check_spec_links.py</code> logic computing which entities get generated files.</p>
312        <table class="table table-striped">
313        <thead>
314        <th scope="col">Add line to include this file</th>
315        <th scope="col">or add this macro instead</th>
316        """)
317
318        for entity in sorted(missing):
319            fn = checker.findEntity(entity).filename
320            anchor = '[[{}]]'.format(entity)
321            self.f.write("""
322            <tr>
323            <td><code class="text-dark">{filename}</code></td>
324            <td><code class="text-dark">{anchor}</code></td>
325            """.format(filename=fn, anchor=anchor))
326        self.f.write("""</table></div>""")
327
328    def outputFileExcerpt(self, filename):
329        """Output a card containing an excerpt of a file, sufficient to show locations of all diagnostics plus some context.
330
331        Called by self.outputResults().
332        """
333        self.f.write("""<div class="card">
334            <div class="card-header" id="heading-{id}"><h5 class="mb-0">
335            <button class="btn btn-link" type="button">
336            {fn}
337            </button></h5></div><!-- #heading-{id} -->
338            <div class="card-body">
339            """.format(id=self.makeIdentifierFromFilename(filename), fn=self.getRelativeFilename(filename)))
340        lines = self.fileLines[filename]
341        r = self.fileRange[filename]
342        self.f.write("""<pre class="line-numbers language-asciidoc line-highlight" id="excerpt-{id}" data-start="{start}"><code>""".format(
343            id=self.makeIdentifierFromFilename(filename),
344            start=r.start))
345        for lineNum, line in enumerate(
346                lines[(r.start - 1):(r.stop - 1)], r.start):
347            # self.f.write(line)
348            lineLinks = [x for x in self.fileBackLinks[filename]
349                         if x.lineNum == lineNum]
350            for col, char in enumerate(line):
351                colLinks = (x for x in lineLinks if x.col == col)
352                for link in colLinks:
353                    # TODO right now the syntax highlighting is interfering with the link! so the link-generation is commented out,
354                    # only generating the emoji icon.
355
356                    # self.f.write('<a href="#{target}" title="{title}" data-toggle="tooltip" data-container="body">{icon}'.format(
357                    # target=link.target, title=html.escape(link.tooltip),
358                    # icon=MESSAGE_TYPE_ICONS[link.message_type]))
359                    self.f.write(MESSAGE_TYPE_ICONS[link.message_type])
360                    self.f.write('<span class="sr-only">Cross reference: {t} {title}</span>'.format(
361                        title=html.escape(link.tooltip, False), t=link.message_type))
362
363                    # self.f.write('</a>')
364
365                # Write the actual character
366                self.f.write(html.escape(char))
367            self.f.write('\n')
368
369        self.f.write('</code></pre>')
370        self.f.write('</div><!-- .card-body -->\n')
371        self.f.write('</div><!-- .card -->\n')
372
373    def outputFallback(self, obj):
374        """Output some text in a general way."""
375        self.f.write(obj)
376
377    ###
378    # Format method: return a string.
379    def formatContext(self, context, message_type=None):
380        """Format a message context in a verbose way."""
381        if message_type is None:
382            icon = LINK_ICON
383        else:
384            icon = MESSAGE_TYPE_ICONS[message_type]
385        return 'In context: <a href="{href}">{icon}{relative}:{lineNum}:{col}</a>'.format(
386            href=self.getAnchorLinkForContext(context),
387            icon=icon,
388            # id=self.makeIdentifierFromFilename(context.filename),
389            relative=self.getRelativeFilename(context.filename),
390            lineNum=context.lineNum,
391            col=getColumn(context))
392
393    ###
394    # Internal methods: not mandated by parent class.
395    def recordUsage(self, context, linkBackTooltip=None,
396                    linkBackTarget=None, linkBackType=MessageType.NOTE):
397        """Internally record a 'usage' of something.
398
399        Increases the range of lines that are included in the excerpts,
400        and records back-links if appropriate.
401        """
402        BEFORE_CONTEXT = 6
403        AFTER_CONTEXT = 3
404        # Clamp because we need accurate start line number to make line number
405        # display right
406        start = max(1, context.lineNum - BEFORE_CONTEXT)
407        stop = context.lineNum + AFTER_CONTEXT + 1
408        if context.filename not in self.fileRange:
409            self.fileRange[context.filename] = range(start, stop)
410            self.fileBackLinks[context.filename] = []
411        else:
412            oldRange = self.fileRange[context.filename]
413            self.fileRange[context.filename] = range(
414                min(start, oldRange.start), max(stop, oldRange.stop))
415
416        if linkBackTarget is not None:
417            start_col, end_col = getHighlightedRange(context)
418            self.fileBackLinks[context.filename].append(self.backLink(
419                lineNum=context.lineNum, col=start_col, end_col=end_col,
420                target=linkBackTarget, tooltip=linkBackTooltip,
421                message_type=linkBackType))
422
423    def makeIdentifierFromFilename(self, fn):
424        """Compute an acceptable HTML anchor name from a filename."""
425        return self.filenameTransformer.sub('_', self.getRelativeFilename(fn))
426
427    def getAnchorLinkForContext(self, context):
428        """Compute the anchor link to the excerpt for a MessageContext."""
429        return '#excerpt-{}.{}'.format(
430            self.makeIdentifierFromFilename(context.filename), context.lineNum)
431
432    def getUniqueAnchor(self):
433        """Create and return a new unique string usable as a link anchor."""
434        anchor = 'anchor-{}'.format(self.nextAnchor)
435        self.nextAnchor += 1
436        return anchor
437