diff options
Diffstat (limited to 'codegen/vulkan/scripts/spec_tools/html_printer.py')
-rw-r--r-- | codegen/vulkan/scripts/spec_tools/html_printer.py | 436 |
1 files changed, 436 insertions, 0 deletions
diff --git a/codegen/vulkan/scripts/spec_tools/html_printer.py b/codegen/vulkan/scripts/spec_tools/html_printer.py new file mode 100644 index 00000000..eb1df406 --- /dev/null +++ b/codegen/vulkan/scripts/spec_tools/html_printer.py @@ -0,0 +1,436 @@ +"""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 |