blob: eb1df406f2ece9399444c9cf83aef028d04976c3 [file] [log] [blame]
"""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: '&#x2297;', # makeIcon('times-circle'),
MessageType.WARNING: '&#9888;', # makeIcon('exclamation-triangle'),
MessageType.NOTE: '&#x2139;' # makeIcon('info-circle')
}
LINK_ICON = '&#128279;' # 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