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)