html_printer.py
1 """Defines HTMLPrinter, a BasePrinter subclass for a single-page HTML results file.""" 2 3 # Copyright (c) 2018-2019 Collabora, Ltd. 4 # 5 # Licensed under the Apache License, Version 2.0 (the "License"); 6 # you may not use this file except in compliance with the License. 7 # You may obtain a copy of the License at 8 # 9 # http://www.apache.org/licenses/LICENSE-2.0 10 # 11 # Unless required by applicable law or agreed to in writing, software 12 # distributed under the License is distributed on an "AS IS" BASIS, 13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 # See the License for the specific language governing permissions and 15 # limitations under the License. 16 # 17 # Author(s): Ryan Pavlik <ryan.pavlik@collabora.com> 18 19 import html 20 import re 21 from collections import namedtuple 22 23 from .base_printer import BasePrinter, getColumn 24 from .shared import (MessageContext, MessageType, generateInclude, 25 getHighlightedRange) 26 27 # Bootstrap styles (for constructing CSS class names) associated with MessageType values. 28 MESSAGE_TYPE_STYLES = { 29 MessageType.ERROR: 'danger', 30 MessageType.WARNING: 'warning', 31 MessageType.NOTE: 'secondary' 32 } 33 34 35 # HTML Entity for a little emoji-icon associated with MessageType values. 36 MESSAGE_TYPE_ICONS = { 37 MessageType.ERROR: '⊗', # makeIcon('times-circle'), 38 MessageType.WARNING: '⚠', # makeIcon('exclamation-triangle'), 39 MessageType.NOTE: 'ℹ' # makeIcon('info-circle') 40 } 41 42 LINK_ICON = '🔗' # link icon 43 44 45 class HTMLPrinter(BasePrinter): 46 """Implementation of BasePrinter for generating diagnostic reports in HTML format. 47 48 Generates a single file containing neatly-formatted messages. 49 50 The HTML file loads Bootstrap 4 as well as 'prism' syntax highlighting from CDN. 51 """ 52 53 def __init__(self, filename): 54 """Construct by opening the file.""" 55 self.f = open(filename, 'w', encoding='utf-8') 56 self.f.write("""<!doctype html> 57 <html lang="en"><head> 58 <meta charset="utf-8"> 59 <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> 60 <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" /> 61 <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" /> 62 <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" /> 63 <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous"> 64 <style> 65 pre { 66 overflow-x: scroll; 67 white-space: nowrap; 68 } 69 </style> 70 <title>check_spec_links results</title> 71 </head> 72 <body> 73 <div class="container"> 74 <h1><code>check_spec_links.py</code> Scan Results</h1> 75 """) 76 # 77 self.filenameTransformer = re.compile(r'[^\w]+') 78 self.fileRange = {} 79 self.fileLines = {} 80 self.backLink = namedtuple( 81 'BackLink', ['lineNum', 'col', 'end_col', 'target', 'tooltip', 'message_type']) 82 self.fileBackLinks = {} 83 84 self.nextAnchor = 0 85 super().__init__() 86 87 def close(self): 88 """Write the tail end of the file and close it.""" 89 self.f.write(""" 90 </div> 91 <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> 92 <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> 93 <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> 94 <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> 95 <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> 96 <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.slim.min.js" integrity="sha256-3edrmyuQ0w65f8gfBsqowzjJe2iM6n0nKciPUp8y+7E=" crossorigin="anonymous"></script> 97 <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> 98 <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script> 99 <script> 100 $(function () { 101 $('[data-toggle="tooltip"]').tooltip(); 102 function autoExpand() { 103 var hash = window.location.hash; 104 if (!hash) { 105 return; 106 } 107 $(hash).parents().filter('.collapse').collapse('show'); 108 } 109 window.addEventListener('hashchange', autoExpand); 110 $(document).ready(autoExpand); 111 $('.accordion').on('shown.bs.collapse', function(e) { 112 e.target.parentNode.scrollIntoView(); 113 }) 114 }) 115 </script> 116 </body></html> 117 """) 118 self.f.close() 119 120 ### 121 # Output methods: these all write to the HTML file. 122 def outputResults(self, checker, broken_links=True, 123 missing_includes=False): 124 """Output the full results of a checker run. 125 126 Includes the diagnostics, broken links (if desired), 127 missing includes (if desired), and excerpts of all files with diagnostics. 128 """ 129 self.output(checker) 130 self.outputBrokenAndMissing( 131 checker, broken_links=broken_links, missing_includes=missing_includes) 132 133 self.f.write(""" 134 <div class="container"> 135 <h2>Excerpts of referenced files</h2>""") 136 for fn in self.fileRange: 137 self.outputFileExcerpt(fn) 138 self.f.write('</div><!-- .container -->\n') 139 140 def outputChecker(self, checker): 141 """Output the contents of a MacroChecker object. 142 143 Starts and ends the accordion populated by outputCheckerFile(). 144 """ 145 self.f.write( 146 '<div class="container"><h2>Per-File Warnings and Errors</h2>\n') 147 self.f.write('<div class="accordion" id="fileAccordion">\n') 148 super(HTMLPrinter, self).outputChecker(checker) 149 self.f.write("""</div><!-- #fileAccordion --> 150 </div><!-- .container -->\n""") 151 152 def outputCheckerFile(self, fileChecker): 153 """Output the contents of a MacroCheckerFile object. 154 155 Stashes the lines of the file for later excerpts, 156 and outputs any diagnostics in an accordion card. 157 """ 158 # Save lines for later 159 self.fileLines[fileChecker.filename] = fileChecker.lines 160 161 if not fileChecker.numDiagnostics(): 162 return 163 164 self.f.write(""" 165 <div class="card"> 166 <div class="card-header" id="{id}-file-heading"> 167 <div class="row"> 168 <div class="col"> 169 <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}"> 170 {relativefn} 171 </button> 172 </div> 173 """.format(id=self.makeIdentifierFromFilename(fileChecker.filename), relativefn=html.escape(self.getRelativeFilename(fileChecker.filename)))) 174 self.f.write('<div class="col-1">') 175 warnings = fileChecker.numMessagesOfType(MessageType.WARNING) 176 if warnings > 0: 177 self.f.write("""<span class="badge badge-warning" data-toggle="tooltip" title="{num} warnings in this file"> 178 {icon} 179 {num}<span class="sr-only"> warnings</span></span>""".format(num=warnings, icon=MESSAGE_TYPE_ICONS[MessageType.WARNING])) 180 self.f.write('</div>\n<div class="col-1">') 181 errors = fileChecker.numMessagesOfType(MessageType.ERROR) 182 if errors > 0: 183 self.f.write("""<span class="badge badge-danger" data-toggle="tooltip" title="{num} errors in this file"> 184 {icon} 185 {num}<span class="sr-only"> errors</span></span>""".format(num=errors, icon=MESSAGE_TYPE_ICONS[MessageType.ERROR])) 186 self.f.write(""" 187 </div><!-- .col-1 --> 188 </div><!-- .row --> 189 </div><!-- .card-header --> 190 <div id="collapse-{id}" class="collapse" aria-labelledby="{id}-file-heading" data-parent="#fileAccordion"> 191 <div class="card-body"> 192 """.format(id=self.makeIdentifierFromFilename(fileChecker.filename))) 193 super(HTMLPrinter, self).outputCheckerFile(fileChecker) 194 195 self.f.write(""" 196 </div><!-- .card-body --> 197 </div><!-- .collapse --> 198 </div><!-- .card --> 199 <!-- ..................................... --> 200 """.format(id=self.makeIdentifierFromFilename(fileChecker.filename))) 201 202 def outputMessage(self, msg): 203 """Output a Message.""" 204 anchor = self.getUniqueAnchor() 205 206 self.recordUsage(msg.context, 207 linkBackTarget=anchor, 208 linkBackTooltip='{}: {} [...]'.format( 209 msg.message_type, msg.message[0]), 210 linkBackType=msg.message_type) 211 212 self.f.write(""" 213 <div class="card"> 214 <div class="card-body"> 215 <h5 class="card-header bg bg-{style}" id="{anchor}">{icon} {t} Line {lineNum}, Column {col} (-{arg})</h5> 216 <p class="card-text"> 217 """.format( 218 anchor=anchor, 219 icon=MESSAGE_TYPE_ICONS[msg.message_type], 220 style=MESSAGE_TYPE_STYLES[msg.message_type], 221 t=self.formatBrief(msg.message_type), 222 lineNum=msg.context.lineNum, 223 col=getColumn(msg.context), 224 arg=msg.message_id.enable_arg())) 225 self.f.write(self.formatContext(msg.context)) 226 self.f.write('<br/>') 227 for line in msg.message: 228 self.f.write(html.escape(line)) 229 self.f.write('<br />\n') 230 self.f.write('</p>\n') 231 if msg.see_also: 232 self.f.write('<p>See also:</p><ul>\n') 233 for see in msg.see_also: 234 if isinstance(see, MessageContext): 235 self.f.write( 236 '<li>{}</li>\n'.format(self.formatContext(see))) 237 self.recordUsage(see, 238 linkBackTarget=anchor, 239 linkBackType=MessageType.NOTE, 240 linkBackTooltip='see-also associated with {} at {}'.format(msg.message_type, self.formatContextBrief(see))) 241 else: 242 self.f.write('<li>{}</li>\n'.format(self.formatBrief(see))) 243 self.f.write('</ul>') 244 if msg.replacement is not None: 245 self.f.write( 246 '<div class="alert alert-primary">Hover the highlight text to view suggested replacement.</div>') 247 if msg.fix is not None: 248 self.f.write( 249 '<div class="alert alert-info">Note: Auto-fix available.</div>') 250 if msg.script_location: 251 self.f.write( 252 '<p>Message originated at <code>{}</code></p>'.format(msg.script_location)) 253 self.f.write('<pre class="line-numbers language-asciidoc" data-start="{}"><code>'.format( 254 msg.context.lineNum)) 255 highlightStart, highlightEnd = getHighlightedRange(msg.context) 256 self.f.write(html.escape(msg.context.line[:highlightStart])) 257 self.f.write( 258 '<span class="border border-{}"'.format(MESSAGE_TYPE_STYLES[msg.message_type])) 259 if msg.replacement is not None: 260 self.f.write( 261 ' data-toggle="tooltip" title="{}"'.format(msg.replacement)) 262 self.f.write('>') 263 self.f.write(html.escape( 264 msg.context.line[highlightStart:highlightEnd])) 265 self.f.write('</span>') 266 self.f.write(html.escape(msg.context.line[highlightEnd:])) 267 self.f.write('</code></pre></div></div>') 268 269 def outputBrokenLinks(self, checker, broken): 270 """Output a table of broken links. 271 272 Called by self.outputBrokenAndMissing() if requested. 273 """ 274 self.f.write(""" 275 <div class="container"> 276 <h2>Missing Referenced API Includes</h2> 277 <p>Items here have been referenced by a linking macro, so these are all broken links in the spec!</p> 278 <table class="table table-striped"> 279 <thead> 280 <th scope="col">Add line to include this file</th> 281 <th scope="col">or add this macro instead</th> 282 <th scope="col">Links to this entity</th></thead> 283 """) 284 285 for entity_name, uses in sorted(broken.items()): 286 category = checker.findEntity(entity_name).category 287 anchor = self.getUniqueAnchor() 288 asciidocAnchor = '[[{}]]'.format(entity_name) 289 include = generateInclude(dir_traverse='../../generated/', 290 generated_type='api', 291 category=category, 292 entity=entity_name) 293 self.f.write(""" 294 <tr id={}> 295 <td><code class="text-dark language-asciidoc">{}</code></td> 296 <td><code class="text-dark">{}</code></td> 297 <td><ul class="list-inline"> 298 """.format(anchor, include, asciidocAnchor)) 299 for context in uses: 300 self.f.write( 301 '<li class="list-inline-item">{}</li>'.format(self.formatContext(context, MessageType.NOTE))) 302 self.recordUsage( 303 context, 304 linkBackTooltip='Link broken in spec: {} not seen'.format( 305 include), 306 linkBackTarget=anchor, 307 linkBackType=MessageType.NOTE) 308 self.f.write("""</ul></td></tr>""") 309 self.f.write("""</table></div>""") 310 311 def outputMissingIncludes(self, checker, missing): 312 """Output a table of missing includes. 313 314 Called by self.outputBrokenAndMissing() if requested. 315 """ 316 self.f.write(""" 317 <div class="container"> 318 <h2>Missing Unreferenced API Includes</h2> 319 <p>These items are expected to be generated in the spec build process, but aren't included. 320 However, as they also are not referenced by any linking macros, they aren't broken links - at worst they are undocumented entities, 321 at best they are errors in <code>check_spec_links.py</code> logic computing which entities get generated files.</p> 322 <table class="table table-striped"> 323 <thead> 324 <th scope="col">Add line to include this file</th> 325 <th scope="col">or add this macro instead</th> 326 """) 327 328 for entity in sorted(missing): 329 fn = checker.findEntity(entity).filename 330 anchor = '[[{}]]'.format(entity) 331 self.f.write(""" 332 <tr> 333 <td><code class="text-dark">{filename}</code></td> 334 <td><code class="text-dark">{anchor}</code></td> 335 """.format(filename=fn, anchor=anchor)) 336 self.f.write("""</table></div>""") 337 338 def outputFileExcerpt(self, filename): 339 """Output a card containing an excerpt of a file, sufficient to show locations of all diagnostics plus some context. 340 341 Called by self.outputResults(). 342 """ 343 self.f.write("""<div class="card"> 344 <div class="card-header" id="heading-{id}"><h5 class="mb-0"> 345 <button class="btn btn-link" type="button"> 346 {fn} 347 </button></h5></div><!-- #heading-{id} --> 348 <div class="card-body"> 349 """.format(id=self.makeIdentifierFromFilename(filename), fn=self.getRelativeFilename(filename))) 350 lines = self.fileLines[filename] 351 r = self.fileRange[filename] 352 self.f.write("""<pre class="line-numbers language-asciidoc line-highlight" id="excerpt-{id}" data-start="{start}"><code>""".format( 353 id=self.makeIdentifierFromFilename(filename), 354 start=r.start)) 355 for lineNum, line in enumerate( 356 lines[(r.start - 1):(r.stop - 1)], r.start): 357 # self.f.write(line) 358 lineLinks = [x for x in self.fileBackLinks[filename] 359 if x.lineNum == lineNum] 360 for col, char in enumerate(line): 361 colLinks = (x for x in lineLinks if x.col == col) 362 for link in colLinks: 363 # TODO right now the syntax highlighting is interfering with the link! so the link-generation is commented out, 364 # only generating the emoji icon. 365 366 # self.f.write('<a href="#{target}" title="{title}" data-toggle="tooltip" data-container="body">{icon}'.format( 367 # target=link.target, title=html.escape(link.tooltip), 368 # icon=MESSAGE_TYPE_ICONS[link.message_type])) 369 self.f.write(MESSAGE_TYPE_ICONS[link.message_type]) 370 self.f.write('<span class="sr-only">Cross reference: {t} {title}</span>'.format( 371 title=html.escape(link.tooltip, False), t=link.message_type)) 372 373 # self.f.write('</a>') 374 375 # Write the actual character 376 self.f.write(html.escape(char)) 377 self.f.write('\n') 378 379 self.f.write('</code></pre>') 380 self.f.write('</div><!-- .card-body -->\n') 381 self.f.write('</div><!-- .card -->\n') 382 383 def outputFallback(self, obj): 384 """Output some text in a general way.""" 385 self.f.write(obj) 386 387 ### 388 # Format method: return a string. 389 def formatContext(self, context, message_type=None): 390 """Format a message context in a verbose way.""" 391 if message_type is None: 392 icon = LINK_ICON 393 else: 394 icon = MESSAGE_TYPE_ICONS[message_type] 395 return 'In context: <a href="{href}">{icon}{relative}:{lineNum}:{col}</a>'.format( 396 href=self.getAnchorLinkForContext(context), 397 icon=icon, 398 # id=self.makeIdentifierFromFilename(context.filename), 399 relative=self.getRelativeFilename(context.filename), 400 lineNum=context.lineNum, 401 col=getColumn(context)) 402 403 ### 404 # Internal methods: not mandated by parent class. 405 def recordUsage(self, context, linkBackTooltip=None, 406 linkBackTarget=None, linkBackType=MessageType.NOTE): 407 """Internally record a 'usage' of something. 408 409 Increases the range of lines that are included in the excerpts, 410 and records back-links if appropriate. 411 """ 412 BEFORE_CONTEXT = 6 413 AFTER_CONTEXT = 3 414 # Clamp because we need accurate start line number to make line number 415 # display right 416 start = max(1, context.lineNum - BEFORE_CONTEXT) 417 stop = context.lineNum + AFTER_CONTEXT + 1 418 if context.filename not in self.fileRange: 419 self.fileRange[context.filename] = range(start, stop) 420 self.fileBackLinks[context.filename] = [] 421 else: 422 oldRange = self.fileRange[context.filename] 423 self.fileRange[context.filename] = range( 424 min(start, oldRange.start), max(stop, oldRange.stop)) 425 426 if linkBackTarget is not None: 427 start_col, end_col = getHighlightedRange(context) 428 self.fileBackLinks[context.filename].append(self.backLink( 429 lineNum=context.lineNum, col=start_col, end_col=end_col, 430 target=linkBackTarget, tooltip=linkBackTooltip, 431 message_type=linkBackType)) 432 433 def makeIdentifierFromFilename(self, fn): 434 """Compute an acceptable HTML anchor name from a filename.""" 435 return self.filenameTransformer.sub('_', self.getRelativeFilename(fn)) 436 437 def getAnchorLinkForContext(self, context): 438 """Compute the anchor link to the excerpt for a MessageContext.""" 439 return '#excerpt-{}.{}'.format( 440 self.makeIdentifierFromFilename(context.filename), context.lineNum) 441 442 def getUniqueAnchor(self): 443 """Create and return a new unique string usable as a link anchor.""" 444 anchor = 'anchor-{}'.format(self.nextAnchor) 445 self.nextAnchor += 1 446 return anchor