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: '⊗', # makeIcon('times-circle'), 28 MessageType.WARNING: '⚠', # makeIcon('exclamation-triangle'), 29 MessageType.NOTE: 'ℹ' # makeIcon('info-circle') 30} 31 32LINK_ICON = '🔗' # 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