/ scripts / spec_tools / html_printer.py
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: '&#x2297;',  # makeIcon('times-circle'),
 38      MessageType.WARNING: '&#9888;',  # makeIcon('exclamation-triangle'),
 39      MessageType.NOTE: '&#x2139;'  # makeIcon('info-circle')
 40  }
 41  
 42  LINK_ICON = '&#128279;'  # 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