| """Defines HTMLPrinter, a BasePrinter subclass for a single-page HTML results file.""" |
| |
| # Copyright (c) 2018-2019 Collabora, Ltd. |
| # |
| # SPDX-License-Identifier: Apache-2.0 |
| # |
| # Author(s): Ryan Pavlik <ryan.pavlik@collabora.com> |
| |
| import html |
| import re |
| from collections import namedtuple |
| |
| from .base_printer import BasePrinter, getColumn |
| from .shared import (MessageContext, MessageType, generateInclude, |
| getHighlightedRange) |
| |
| # Bootstrap styles (for constructing CSS class names) associated with MessageType values. |
| MESSAGE_TYPE_STYLES = { |
| MessageType.ERROR: 'danger', |
| MessageType.WARNING: 'warning', |
| MessageType.NOTE: 'secondary' |
| } |
| |
| |
| # HTML Entity for a little emoji-icon associated with MessageType values. |
| MESSAGE_TYPE_ICONS = { |
| MessageType.ERROR: '⊗', # makeIcon('times-circle'), |
| MessageType.WARNING: '⚠', # makeIcon('exclamation-triangle'), |
| MessageType.NOTE: 'ℹ' # makeIcon('info-circle') |
| } |
| |
| LINK_ICON = '🔗' # link icon |
| |
| |
| class HTMLPrinter(BasePrinter): |
| """Implementation of BasePrinter for generating diagnostic reports in HTML format. |
| |
| Generates a single file containing neatly-formatted messages. |
| |
| The HTML file loads Bootstrap 4 as well as 'prism' syntax highlighting from CDN. |
| """ |
| |
| def __init__(self, filename): |
| """Construct by opening the file.""" |
| self.f = open(filename, 'w', encoding='utf-8') |
| self.f.write("""<!doctype html> |
| <html lang="en"><head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> |
| <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" /> |
| <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" /> |
| <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" /> |
| <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous"> |
| <style> |
| pre { |
| overflow-x: scroll; |
| white-space: nowrap; |
| } |
| </style> |
| <title>check_spec_links results</title> |
| </head> |
| <body> |
| <div class="container"> |
| <h1><code>check_spec_links.py</code> Scan Results</h1> |
| """) |
| # |
| self.filenameTransformer = re.compile(r'[^\w]+') |
| self.fileRange = {} |
| self.fileLines = {} |
| self.backLink = namedtuple( |
| 'BackLink', ['lineNum', 'col', 'end_col', 'target', 'tooltip', 'message_type']) |
| self.fileBackLinks = {} |
| |
| self.nextAnchor = 0 |
| super().__init__() |
| |
| def close(self): |
| """Write the tail end of the file and close it.""" |
| self.f.write(""" |
| </div> |
| <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> |
| <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> |
| <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> |
| <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> |
| <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> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.slim.min.js" integrity="sha256-3edrmyuQ0w65f8gfBsqowzjJe2iM6n0nKciPUp8y+7E=" crossorigin="anonymous"></script> |
| <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> |
| <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script> |
| <script> |
| $(function () { |
| $('[data-toggle="tooltip"]').tooltip(); |
| function autoExpand() { |
| var hash = window.location.hash; |
| if (!hash) { |
| return; |
| } |
| $(hash).parents().filter('.collapse').collapse('show'); |
| } |
| window.addEventListener('hashchange', autoExpand); |
| $(document).ready(autoExpand); |
| $('.accordion').on('shown.bs.collapse', function(e) { |
| e.target.parentNode.scrollIntoView(); |
| }) |
| }) |
| </script> |
| </body></html> |
| """) |
| self.f.close() |
| |
| ### |
| # Output methods: these all write to the HTML file. |
| def outputResults(self, checker, broken_links=True, |
| missing_includes=False): |
| """Output the full results of a checker run. |
| |
| Includes the diagnostics, broken links (if desired), |
| missing includes (if desired), and excerpts of all files with diagnostics. |
| """ |
| self.output(checker) |
| self.outputBrokenAndMissing( |
| checker, broken_links=broken_links, missing_includes=missing_includes) |
| |
| self.f.write(""" |
| <div class="container"> |
| <h2>Excerpts of referenced files</h2>""") |
| for fn in self.fileRange: |
| self.outputFileExcerpt(fn) |
| self.f.write('</div><!-- .container -->\n') |
| |
| def outputChecker(self, checker): |
| """Output the contents of a MacroChecker object. |
| |
| Starts and ends the accordion populated by outputCheckerFile(). |
| """ |
| self.f.write( |
| '<div class="container"><h2>Per-File Warnings and Errors</h2>\n') |
| self.f.write('<div class="accordion" id="fileAccordion">\n') |
| super(HTMLPrinter, self).outputChecker(checker) |
| self.f.write("""</div><!-- #fileAccordion --> |
| </div><!-- .container -->\n""") |
| |
| def outputCheckerFile(self, fileChecker): |
| """Output the contents of a MacroCheckerFile object. |
| |
| Stashes the lines of the file for later excerpts, |
| and outputs any diagnostics in an accordion card. |
| """ |
| # Save lines for later |
| self.fileLines[fileChecker.filename] = fileChecker.lines |
| |
| if not fileChecker.numDiagnostics(): |
| return |
| |
| self.f.write(""" |
| <div class="card"> |
| <div class="card-header" id="{id}-file-heading"> |
| <div class="row"> |
| <div class="col"> |
| <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}"> |
| {relativefn} |
| </button> |
| </div> |
| """.format(id=self.makeIdentifierFromFilename(fileChecker.filename), relativefn=html.escape(self.getRelativeFilename(fileChecker.filename)))) |
| self.f.write('<div class="col-1">') |
| warnings = fileChecker.numMessagesOfType(MessageType.WARNING) |
| if warnings > 0: |
| self.f.write("""<span class="badge badge-warning" data-toggle="tooltip" title="{num} warnings in this file"> |
| {icon} |
| {num}<span class="sr-only"> warnings</span></span>""".format(num=warnings, icon=MESSAGE_TYPE_ICONS[MessageType.WARNING])) |
| self.f.write('</div>\n<div class="col-1">') |
| errors = fileChecker.numMessagesOfType(MessageType.ERROR) |
| if errors > 0: |
| self.f.write("""<span class="badge badge-danger" data-toggle="tooltip" title="{num} errors in this file"> |
| {icon} |
| {num}<span class="sr-only"> errors</span></span>""".format(num=errors, icon=MESSAGE_TYPE_ICONS[MessageType.ERROR])) |
| self.f.write(""" |
| </div><!-- .col-1 --> |
| </div><!-- .row --> |
| </div><!-- .card-header --> |
| <div id="collapse-{id}" class="collapse" aria-labelledby="{id}-file-heading" data-parent="#fileAccordion"> |
| <div class="card-body"> |
| """.format(id=self.makeIdentifierFromFilename(fileChecker.filename))) |
| super(HTMLPrinter, self).outputCheckerFile(fileChecker) |
| |
| self.f.write(""" |
| </div><!-- .card-body --> |
| </div><!-- .collapse --> |
| </div><!-- .card --> |
| <!-- ..................................... --> |
| """.format(id=self.makeIdentifierFromFilename(fileChecker.filename))) |
| |
| def outputMessage(self, msg): |
| """Output a Message.""" |
| anchor = self.getUniqueAnchor() |
| |
| self.recordUsage(msg.context, |
| linkBackTarget=anchor, |
| linkBackTooltip='{}: {} [...]'.format( |
| msg.message_type, msg.message[0]), |
| linkBackType=msg.message_type) |
| |
| self.f.write(""" |
| <div class="card"> |
| <div class="card-body"> |
| <h5 class="card-header bg bg-{style}" id="{anchor}">{icon} {t} Line {lineNum}, Column {col} (-{arg})</h5> |
| <p class="card-text"> |
| """.format( |
| anchor=anchor, |
| icon=MESSAGE_TYPE_ICONS[msg.message_type], |
| style=MESSAGE_TYPE_STYLES[msg.message_type], |
| t=self.formatBrief(msg.message_type), |
| lineNum=msg.context.lineNum, |
| col=getColumn(msg.context), |
| arg=msg.message_id.enable_arg())) |
| self.f.write(self.formatContext(msg.context)) |
| self.f.write('<br/>') |
| for line in msg.message: |
| self.f.write(html.escape(line)) |
| self.f.write('<br />\n') |
| self.f.write('</p>\n') |
| if msg.see_also: |
| self.f.write('<p>See also:</p><ul>\n') |
| for see in msg.see_also: |
| if isinstance(see, MessageContext): |
| self.f.write( |
| '<li>{}</li>\n'.format(self.formatContext(see))) |
| self.recordUsage(see, |
| linkBackTarget=anchor, |
| linkBackType=MessageType.NOTE, |
| linkBackTooltip='see-also associated with {} at {}'.format(msg.message_type, self.formatContextBrief(see))) |
| else: |
| self.f.write('<li>{}</li>\n'.format(self.formatBrief(see))) |
| self.f.write('</ul>') |
| if msg.replacement is not None: |
| self.f.write( |
| '<div class="alert alert-primary">Hover the highlight text to view suggested replacement.</div>') |
| if msg.fix is not None: |
| self.f.write( |
| '<div class="alert alert-info">Note: Auto-fix available.</div>') |
| if msg.script_location: |
| self.f.write( |
| '<p>Message originated at <code>{}</code></p>'.format(msg.script_location)) |
| self.f.write('<pre class="line-numbers language-asciidoc" data-start="{}"><code>'.format( |
| msg.context.lineNum)) |
| highlightStart, highlightEnd = getHighlightedRange(msg.context) |
| self.f.write(html.escape(msg.context.line[:highlightStart])) |
| self.f.write( |
| '<span class="border border-{}"'.format(MESSAGE_TYPE_STYLES[msg.message_type])) |
| if msg.replacement is not None: |
| self.f.write( |
| ' data-toggle="tooltip" title="{}"'.format(msg.replacement)) |
| self.f.write('>') |
| self.f.write(html.escape( |
| msg.context.line[highlightStart:highlightEnd])) |
| self.f.write('</span>') |
| self.f.write(html.escape(msg.context.line[highlightEnd:])) |
| self.f.write('</code></pre></div></div>') |
| |
| def outputBrokenLinks(self, checker, broken): |
| """Output a table of broken links. |
| |
| Called by self.outputBrokenAndMissing() if requested. |
| """ |
| self.f.write(""" |
| <div class="container"> |
| <h2>Missing Referenced API Includes</h2> |
| <p>Items here have been referenced by a linking macro, so these are all broken links in the spec!</p> |
| <table class="table table-striped"> |
| <thead> |
| <th scope="col">Add line to include this file</th> |
| <th scope="col">or add this macro instead</th> |
| <th scope="col">Links to this entity</th></thead> |
| """) |
| |
| for entity_name, uses in sorted(broken.items()): |
| category = checker.findEntity(entity_name).category |
| anchor = self.getUniqueAnchor() |
| asciidocAnchor = '[[{}]]'.format(entity_name) |
| include = generateInclude(dir_traverse='../../generated/', |
| generated_type='api', |
| category=category, |
| entity=entity_name) |
| self.f.write(""" |
| <tr id={}> |
| <td><code class="text-dark language-asciidoc">{}</code></td> |
| <td><code class="text-dark">{}</code></td> |
| <td><ul class="list-inline"> |
| """.format(anchor, include, asciidocAnchor)) |
| for context in uses: |
| self.f.write( |
| '<li class="list-inline-item">{}</li>'.format(self.formatContext(context, MessageType.NOTE))) |
| self.recordUsage( |
| context, |
| linkBackTooltip='Link broken in spec: {} not seen'.format( |
| include), |
| linkBackTarget=anchor, |
| linkBackType=MessageType.NOTE) |
| self.f.write("""</ul></td></tr>""") |
| self.f.write("""</table></div>""") |
| |
| def outputMissingIncludes(self, checker, missing): |
| """Output a table of missing includes. |
| |
| Called by self.outputBrokenAndMissing() if requested. |
| """ |
| self.f.write(""" |
| <div class="container"> |
| <h2>Missing Unreferenced API Includes</h2> |
| <p>These items are expected to be generated in the spec build process, but aren't included. |
| However, as they also are not referenced by any linking macros, they aren't broken links - at worst they are undocumented entities, |
| at best they are errors in <code>check_spec_links.py</code> logic computing which entities get generated files.</p> |
| <table class="table table-striped"> |
| <thead> |
| <th scope="col">Add line to include this file</th> |
| <th scope="col">or add this macro instead</th> |
| """) |
| |
| for entity in sorted(missing): |
| fn = checker.findEntity(entity).filename |
| anchor = '[[{}]]'.format(entity) |
| self.f.write(""" |
| <tr> |
| <td><code class="text-dark">{filename}</code></td> |
| <td><code class="text-dark">{anchor}</code></td> |
| """.format(filename=fn, anchor=anchor)) |
| self.f.write("""</table></div>""") |
| |
| def outputFileExcerpt(self, filename): |
| """Output a card containing an excerpt of a file, sufficient to show locations of all diagnostics plus some context. |
| |
| Called by self.outputResults(). |
| """ |
| self.f.write("""<div class="card"> |
| <div class="card-header" id="heading-{id}"><h5 class="mb-0"> |
| <button class="btn btn-link" type="button"> |
| {fn} |
| </button></h5></div><!-- #heading-{id} --> |
| <div class="card-body"> |
| """.format(id=self.makeIdentifierFromFilename(filename), fn=self.getRelativeFilename(filename))) |
| lines = self.fileLines[filename] |
| r = self.fileRange[filename] |
| self.f.write("""<pre class="line-numbers language-asciidoc line-highlight" id="excerpt-{id}" data-start="{start}"><code>""".format( |
| id=self.makeIdentifierFromFilename(filename), |
| start=r.start)) |
| for lineNum, line in enumerate( |
| lines[(r.start - 1):(r.stop - 1)], r.start): |
| # self.f.write(line) |
| lineLinks = [x for x in self.fileBackLinks[filename] |
| if x.lineNum == lineNum] |
| for col, char in enumerate(line): |
| colLinks = (x for x in lineLinks if x.col == col) |
| for link in colLinks: |
| # TODO right now the syntax highlighting is interfering with the link! so the link-generation is commented out, |
| # only generating the emoji icon. |
| |
| # self.f.write('<a href="#{target}" title="{title}" data-toggle="tooltip" data-container="body">{icon}'.format( |
| # target=link.target, title=html.escape(link.tooltip), |
| # icon=MESSAGE_TYPE_ICONS[link.message_type])) |
| self.f.write(MESSAGE_TYPE_ICONS[link.message_type]) |
| self.f.write('<span class="sr-only">Cross reference: {t} {title}</span>'.format( |
| title=html.escape(link.tooltip, False), t=link.message_type)) |
| |
| # self.f.write('</a>') |
| |
| # Write the actual character |
| self.f.write(html.escape(char)) |
| self.f.write('\n') |
| |
| self.f.write('</code></pre>') |
| self.f.write('</div><!-- .card-body -->\n') |
| self.f.write('</div><!-- .card -->\n') |
| |
| def outputFallback(self, obj): |
| """Output some text in a general way.""" |
| self.f.write(obj) |
| |
| ### |
| # Format method: return a string. |
| def formatContext(self, context, message_type=None): |
| """Format a message context in a verbose way.""" |
| if message_type is None: |
| icon = LINK_ICON |
| else: |
| icon = MESSAGE_TYPE_ICONS[message_type] |
| return 'In context: <a href="{href}">{icon}{relative}:{lineNum}:{col}</a>'.format( |
| href=self.getAnchorLinkForContext(context), |
| icon=icon, |
| # id=self.makeIdentifierFromFilename(context.filename), |
| relative=self.getRelativeFilename(context.filename), |
| lineNum=context.lineNum, |
| col=getColumn(context)) |
| |
| ### |
| # Internal methods: not mandated by parent class. |
| def recordUsage(self, context, linkBackTooltip=None, |
| linkBackTarget=None, linkBackType=MessageType.NOTE): |
| """Internally record a 'usage' of something. |
| |
| Increases the range of lines that are included in the excerpts, |
| and records back-links if appropriate. |
| """ |
| BEFORE_CONTEXT = 6 |
| AFTER_CONTEXT = 3 |
| # Clamp because we need accurate start line number to make line number |
| # display right |
| start = max(1, context.lineNum - BEFORE_CONTEXT) |
| stop = context.lineNum + AFTER_CONTEXT + 1 |
| if context.filename not in self.fileRange: |
| self.fileRange[context.filename] = range(start, stop) |
| self.fileBackLinks[context.filename] = [] |
| else: |
| oldRange = self.fileRange[context.filename] |
| self.fileRange[context.filename] = range( |
| min(start, oldRange.start), max(stop, oldRange.stop)) |
| |
| if linkBackTarget is not None: |
| start_col, end_col = getHighlightedRange(context) |
| self.fileBackLinks[context.filename].append(self.backLink( |
| lineNum=context.lineNum, col=start_col, end_col=end_col, |
| target=linkBackTarget, tooltip=linkBackTooltip, |
| message_type=linkBackType)) |
| |
| def makeIdentifierFromFilename(self, fn): |
| """Compute an acceptable HTML anchor name from a filename.""" |
| return self.filenameTransformer.sub('_', self.getRelativeFilename(fn)) |
| |
| def getAnchorLinkForContext(self, context): |
| """Compute the anchor link to the excerpt for a MessageContext.""" |
| return '#excerpt-{}.{}'.format( |
| self.makeIdentifierFromFilename(context.filename), context.lineNum) |
| |
| def getUniqueAnchor(self): |
| """Create and return a new unique string usable as a link anchor.""" |
| anchor = 'anchor-{}'.format(self.nextAnchor) |
| self.nextAnchor += 1 |
| return anchor |