github.com/google/grumpy@v0.0.0-20171122020858-3ec87959189c/third_party/pythonparser/diagnostic.py (about) 1 """ 2 The :mod:`Diagnostic` module concerns itself with processing 3 and presentation of diagnostic messages. 4 """ 5 6 from __future__ import absolute_import, division, print_function, unicode_literals 7 from functools import reduce 8 from contextlib import contextmanager 9 import sys, re 10 11 class Diagnostic: 12 """ 13 A diagnostic message highlighting one or more locations 14 in a single source buffer. 15 16 :ivar level: (one of ``LEVELS``) severity level 17 :ivar reason: (format string) diagnostic message 18 :ivar arguments: (dictionary) substitutions for ``reason`` 19 :ivar location: (:class:`pythonparser.source.Range`) most specific 20 location of the problem 21 :ivar highlights: (list of :class:`pythonparser.source.Range`) 22 secondary locations related to the problem that are 23 likely to be on the same line 24 :ivar notes: (list of :class:`Diagnostic`) 25 secondary diagnostics highlighting relevant source 26 locations that are unlikely to be on the same line 27 """ 28 29 LEVELS = ["note", "warning", "error", "fatal"] 30 """ 31 Available diagnostic levels: 32 * ``fatal`` indicates an unrecoverable error. 33 * ``error`` indicates an error that leaves a possibility of 34 processing more code, e.g. a recoverable parsing error. 35 * ``warning`` indicates a potential problem. 36 * ``note`` level diagnostics do not appear by itself, 37 but are attached to other diagnostics to refer to 38 and describe secondary source locations. 39 """ 40 41 def __init__(self, level, reason, arguments, location, 42 highlights=None, notes=None): 43 if level not in self.LEVELS: 44 raise ValueError("level must be one of Diagnostic.LEVELS") 45 46 if highlights is None: 47 highlights = [] 48 if notes is None: 49 notes = [] 50 51 if len(set(map(lambda x: x.source_buffer, 52 [location] + highlights))) > 1: 53 raise ValueError("location and highlights must refer to the same source buffer") 54 55 self.level, self.reason, self.arguments = \ 56 level, reason, arguments 57 self.location, self.highlights, self.notes = \ 58 location, highlights, notes 59 60 def message(self): 61 """ 62 Returns the formatted message. 63 """ 64 return self.reason.format(**self.arguments) 65 66 def render(self, only_line=False, colored=False): 67 """ 68 Returns the human-readable location of the diagnostic in the source, 69 the formatted message, the source line corresponding 70 to ``location`` and a line emphasizing the problematic 71 locations in the source line using ASCII art, as a list of lines. 72 Appends the result of calling :meth:`render` on ``notes``, if any. 73 74 For example: :: 75 76 <input>:1:8-9: error: cannot add integer and string 77 x + (1 + "a") 78 ~ ^ ~~~ 79 80 :param only_line: (bool) If true, only print line number, not line and column range 81 """ 82 source_line = self.location.source_line().rstrip("\n") 83 highlight_line = bytearray(re.sub(r"[^\t]", " ", source_line), "utf-8") 84 85 for hilight in self.highlights: 86 if hilight.line() == self.location.line(): 87 lft, rgt = hilight.column_range() 88 highlight_line[lft:rgt] = bytearray("~", "utf-8") * (rgt - lft) 89 90 lft, rgt = self.location.column_range() 91 if rgt == lft: # Expand zero-length ranges to one ^ 92 rgt = lft + 1 93 highlight_line[lft:rgt] = bytearray("^", "utf-8") * (rgt - lft) 94 95 if only_line: 96 location = "%s:%s" % (self.location.source_buffer.name, self.location.line()) 97 else: 98 location = str(self.location) 99 100 notes = list(self.notes) 101 if self.level != "note": 102 expanded_location = self.location.expanded_from 103 while expanded_location is not None: 104 notes.insert(0, Diagnostic("note", 105 "expanded from here", {}, 106 self.location.expanded_from)) 107 expanded_location = expanded_location.expanded_from 108 109 rendered_notes = reduce(list.__add__, [note.render(only_line, colored) 110 for note in notes], []) 111 if colored: 112 if self.level in ("error", "fatal"): 113 level_color = 31 # red 114 elif self.level == "warning": 115 level_color = 35 # magenta 116 else: # level == "note" 117 level_color = 30 # gray 118 return [ 119 "\x1b[1;37m{}: \x1b[{}m{}:\x1b[37m {}\x1b[0m". 120 format(location, level_color, self.level, self.message()), 121 source_line, 122 "\x1b[1;32m{}\x1b[0m".format(highlight_line.decode("utf-8")) 123 ] + rendered_notes 124 else: 125 return [ 126 "{}: {}: {}".format(location, self.level, self.message()), 127 source_line, 128 highlight_line.decode("utf-8") 129 ] + rendered_notes 130 131 132 class Error(Exception): 133 """ 134 :class:`Error` is an exception which carries a :class:`Diagnostic`. 135 136 :ivar diagnostic: (:class:`Diagnostic`) the diagnostic 137 """ 138 def __init__(self, diagnostic): 139 self.diagnostic = diagnostic 140 141 def __str__(self): 142 return "\n".join(self.diagnostic.render()) 143 144 class Engine: 145 """ 146 :class:`Engine` is a single point through which diagnostics from 147 lexer, parser and any AST consumer are dispatched. 148 149 :ivar all_errors_are_fatal: if true, an exception is raised not only 150 for ``fatal`` diagnostic level, but also ``error`` 151 """ 152 def __init__(self, all_errors_are_fatal=False): 153 self.all_errors_are_fatal = all_errors_are_fatal 154 self._appended_notes = [] 155 156 def process(self, diagnostic): 157 """ 158 The default implementation of :meth:`process` renders non-fatal 159 diagnostics to ``sys.stderr``, and raises fatal ones as a :class:`Error`. 160 """ 161 diagnostic.notes += self._appended_notes 162 self.render_diagnostic(diagnostic) 163 if diagnostic.level == "fatal" or \ 164 (self.all_errors_are_fatal and diagnostic.level == "error"): 165 raise Error(diagnostic) 166 167 @contextmanager 168 def context(self, *notes): 169 """ 170 A context manager that appends ``note`` to every diagnostic processed by 171 this engine. 172 """ 173 self._appended_notes += notes 174 yield 175 del self._appended_notes[-len(notes):] 176 177 def render_diagnostic(self, diagnostic): 178 sys.stderr.write("\n".join(diagnostic.render()) + "\n")