447 lines
21 KiB
Python
447 lines
21 KiB
Python
"""Defines HTMLPrinter, a BasePrinter subclass for a single-page HTML results file."""
|
|
|
|
# Copyright (c) 2018-2019 Collabora, Ltd.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
#
|
|
# 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
|