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