/ scripts / spec_tools / console_printer.py
console_printer.py
  1  """Defines ConsolePrinter, a BasePrinter subclass for appealing console output."""
  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  from sys import stdout
 20  
 21  from .base_printer import BasePrinter
 22  from .shared import (colored, getHighlightedRange, getInterestedRange,
 23                       toNameAndLine)
 24  
 25  try:
 26      from tabulate import tabulate_impl
 27      HAVE_TABULATE = True
 28  except ImportError:
 29      HAVE_TABULATE = False
 30  
 31  
 32  def colWidth(collection, columnNum):
 33      """Compute the required width of a column in a collection of row-tuples."""
 34      MIN_PADDING = 5
 35      return MIN_PADDING + max((len(row[columnNum]) for row in collection))
 36  
 37  
 38  def alternateTabulate(collection, headers=None):
 39      """Minimal re-implementation of the tabulate module."""
 40      # We need a list, not a generator or anything else.
 41      if not isinstance(collection, list):
 42          collection = list(collection)
 43  
 44      # Empty collection means no table
 45      if not collection:
 46          return None
 47  
 48      if headers is None:
 49          fullTable = collection
 50      else:
 51          underline = ['-' * len(header) for header in headers]
 52          fullTable = [headers, underline] + collection
 53      widths = [colWidth(collection, colNum)
 54                for colNum in range(len(fullTable[0]))]
 55      widths[-1] = None
 56  
 57      lines = []
 58      for row in fullTable:
 59          fields = []
 60          for data, width in zip(row, widths):
 61              if width:
 62                  spaces = ' ' * (width - len(data))
 63                  fields.append(data + spaces)
 64              else:
 65                  fields.append(data)
 66          lines.append(''.join(fields))
 67      return '\n'.join(lines)
 68  
 69  
 70  def printTabulated(collection, headers=None):
 71      """Call either tabulate.tabulate(), or our internal alternateTabulate()."""
 72      if HAVE_TABULATE:
 73          tabulated = tabulate_impl(collection, headers=headers)
 74      else:
 75          tabulated = alternateTabulate(collection, headers=headers)
 76      if tabulated:
 77          print(tabulated)
 78  
 79  
 80  def printLineSubsetWithHighlighting(
 81          line, start, end, highlightStart=None, highlightEnd=None, maxLen=120, replacement=None):
 82      """Print a (potential subset of a) line, with highlighting/underline and optional replacement.
 83  
 84      Will print at least the characters line[start:end], and potentially more if possible
 85      to do so without making the output too wide.
 86      Will highlight (underline) line[highlightStart:highlightEnd], where the default
 87      value for highlightStart is simply start, and the default value for highlightEnd is simply end.
 88      Replacment, if supplied, will be aligned with the highlighted range.
 89  
 90      Output is intended to look like part of a Clang compile error/warning message.
 91      """
 92      # Fill in missing start/end with start/end of range.
 93      if highlightStart is None:
 94          highlightStart = start
 95      if highlightEnd is None:
 96          highlightEnd = end
 97  
 98      # Expand interested range start/end.
 99      start = min(start, highlightStart)
100      end = max(end, highlightEnd)
101  
102      tildeLength = highlightEnd - highlightStart - 1
103      caretLoc = highlightStart
104      continuation = '[...]'
105  
106      if len(line) > maxLen:
107          # Too long
108  
109          # the max is to handle -1 from .find() (which indicates "not found")
110          followingSpaceIndex = max(end, line.find(' ', min(len(line), end + 1)))
111  
112          # Maximum length has decreased by at least
113          # the length of a single continuation we absolutely need.
114          maxLen -= len(continuation)
115  
116          if followingSpaceIndex <= maxLen:
117              # We can grab the whole beginning of the line,
118              # and not adjust caretLoc
119              line = line[:maxLen] + continuation
120  
121          elif (len(line) - followingSpaceIndex) < 5:
122              # We need to truncate the beginning,
123              # but we're close to the end of line.
124              newBeginning = len(line) - maxLen
125  
126              caretLoc += len(continuation)
127              caretLoc -= newBeginning
128              line = continuation + line[newBeginning:]
129          else:
130              # Need to truncate the beginning of the string too.
131              newEnd = followingSpaceIndex
132  
133              # Now we need two continuations
134              # (and to adjust caret to the right accordingly)
135              maxLen -= len(continuation)
136              caretLoc += len(continuation)
137  
138              newBeginning = newEnd - maxLen
139              caretLoc -= newBeginning
140  
141              line = continuation + line[newBeginning:newEnd] + continuation
142  
143      stdout.buffer.write(line.encode('utf-8'))
144      print()
145  
146      spaces = ' ' * caretLoc
147      tildes = '~' * tildeLength
148      print(spaces + colored('^' + tildes, 'green'))
149      if replacement is not None:
150          print(spaces + colored(replacement, 'green'))
151  
152  
153  class ConsolePrinter(BasePrinter):
154      """Implementation of BasePrinter for generating diagnostic reports in colored, helpful console output."""
155  
156      def __init__(self):
157          self.show_script_location = False
158          super().__init__()
159  
160      ###
161      # Output methods: these all print directly.
162      def outputResults(self, checker, broken_links=True,
163                        missing_includes=False):
164          """Output the full results of a checker run.
165  
166          Includes the diagnostics, broken links (if desired),
167          and missing includes (if desired).
168          """
169          self.output(checker)
170          if broken_links:
171              broken = checker.getBrokenLinks()
172              if broken:
173                  self.outputBrokenLinks(checker, broken)
174          if missing_includes:
175              missing = checker.getMissingUnreferencedApiIncludes()
176              if missing:
177                  self.outputMissingIncludes(checker, missing)
178  
179      def outputBrokenLinks(self, checker, broken):
180          """Output a table of broken links.
181  
182          Called by self.outputBrokenAndMissing() if requested.
183          """
184          print('Missing API includes that are referenced by a linking macro: these result in broken links in the spec!')
185  
186          def makeRowOfBroken(entity, uses):
187              fn = checker.findEntity(entity).filename
188              anchor = '[[{}]]'.format(entity)
189              locations = ', '.join((toNameAndLine(context, root_path=checker.root_path)
190                                     for context in uses))
191              return (fn, anchor, locations)
192          printTabulated((makeRowOfBroken(entity, uses)
193                          for entity, uses in sorted(broken.items())),
194                         headers=['Include File', 'Anchor in lieu of include', 'Links to this entity'])
195  
196      def outputMissingIncludes(self, checker, missing):
197          """Output a table of missing includes.
198  
199          Called by self.outputBrokenAndMissing() if requested.
200          """
201          missing = list(sorted(missing))
202          if not missing:
203              # Exit if none
204              return
205          print(
206              'Missing, but unreferenced, API includes/anchors - potentially not-documented entities:')
207  
208          def makeRowOfMissing(entity):
209              fn = checker.findEntity(entity).filename
210              anchor = '[[{}]]'.format(entity)
211              return (fn, anchor)
212          printTabulated((makeRowOfMissing(entity) for entity in missing),
213                         headers=['Include File', 'Anchor in lieu of include'])
214  
215      def outputMessage(self, msg):
216          """Output a Message, with highlighted range and replacement, if appropriate."""
217          highlightStart, highlightEnd = getHighlightedRange(msg.context)
218  
219          if '\n' in msg.context.filename:
220              # This is a multi-line string "filename".
221              # Extra blank line and delimiter line for readability:
222              print()
223              print('--------------------------------------------------------------------')
224  
225          fileAndLine = colored('{}:'.format(
226              self.formatBrief(msg.context)), attrs=['bold'])
227  
228          headingSize = len('{context}: {mtype}: '.format(
229              context=self.formatBrief(msg.context),
230              mtype=self.formatBrief(msg.message_type, False)))
231          indent = ' ' * headingSize
232          printedHeading = False
233  
234          lines = msg.message[:]
235          if msg.see_also:
236              lines.append('See also:')
237              lines.extend(('  {}'.format(self.formatBrief(see))
238                            for see in msg.see_also))
239  
240          if msg.fix:
241              lines.append('Note: Auto-fix available')
242  
243          for line in msg.message:
244              if not printedHeading:
245                  scriptloc = ''
246                  if msg.script_location and self.show_script_location:
247                      scriptloc = ', ' + msg.script_location
248                  print('{fileLine} {mtype} {msg} (-{arg}{loc})'.format(
249                      fileLine=fileAndLine, mtype=msg.message_type.formattedWithColon(),
250                      msg=colored(line, attrs=['bold']), arg=msg.message_id.enable_arg(), loc=scriptloc))
251                  printedHeading = True
252              else:
253                  print(colored(indent + line, attrs=['bold']))
254  
255          if len(msg.message) > 1:
256              # extra blank line after multiline message
257              print('')
258  
259          start, end = getInterestedRange(msg.context)
260          printLineSubsetWithHighlighting(
261              msg.context.line,
262              start, end,
263              highlightStart, highlightEnd,
264              replacement=msg.replacement)
265  
266      def outputFallback(self, obj):
267          """Output by calling print."""
268          print(obj)
269  
270      ###
271      # Format methods: these all return a string.
272      def formatFilename(self, fn, _with_color=True):
273          """Format a local filename, as a relative path if possible."""
274          return self.getRelativeFilename(fn)
275  
276      def formatMessageTypeBrief(self, message_type, with_color=True):
277          """Format a message type briefly, applying color if desired and possible.
278  
279          Delegates to the superclass if not formatting with color.
280          """
281          if with_color:
282              return message_type.formattedWithColon()
283          return super(ConsolePrinter, self).formatMessageTypeBrief(
284              message_type, with_color)