1# -*- encoding: utf-8 -*-
2# Copyright 2020 the authors.
3# This file is part of Hy, which is free software licensed under the Expat
4# license. See the LICENSE.
5import os
6import re
7import sys
8import traceback
9import pkgutil
10
11from functools import reduce
12from colorama import Fore
13from contextlib import contextmanager
14from hy import _initialize_env_var
15
16_hy_filter_internal_errors = _initialize_env_var('HY_FILTER_INTERNAL_ERRORS',
17                                                 True)
18COLORED = _initialize_env_var('HY_COLORED_ERRORS', False)
19
20
21class HyError(Exception):
22    pass
23
24
25class HyInternalError(HyError):
26    """Unexpected errors occurring during compilation or parsing of Hy code.
27
28    Errors sub-classing this are not intended to be user-facing, and will,
29    hopefully, never be seen by users!
30    """
31
32
33class HyLanguageError(HyError):
34    """Errors caused by invalid use of the Hy language.
35
36    This, and any errors inheriting from this, are user-facing.
37    """
38
39    def __init__(self, message, expression=None, filename=None, source=None,
40                 lineno=1, colno=1):
41        """
42        Parameters
43        ----------
44        message: str
45            The message to display for this error.
46        expression: HyObject, optional
47            The Hy expression generating this error.
48        filename: str, optional
49            The filename for the source code generating this error.
50            Expression-provided information will take precedence of this value.
51        source: str, optional
52            The actual source code generating this error.  Expression-provided
53            information will take precedence of this value.
54        lineno: int, optional
55            The line number of the error.  Expression-provided information will
56            take precedence of this value.
57        colno: int, optional
58            The column number of the error.  Expression-provided information
59            will take precedence of this value.
60        """
61        self.msg = message
62        self.compute_lineinfo(expression, filename, source, lineno, colno)
63
64        if isinstance(self, SyntaxError):
65            syntax_error_args = (self.filename, self.lineno, self.offset,
66                                 self.text)
67            super(HyLanguageError, self).__init__(message, syntax_error_args)
68        else:
69            super(HyLanguageError, self).__init__(message)
70
71    def compute_lineinfo(self, expression, filename, source, lineno, colno):
72
73        # NOTE: We use `SyntaxError`'s field names (i.e. `text`, `offset`,
74        # `msg`) for compatibility and print-outs.
75        self.text = getattr(expression, 'source', source)
76        self.filename = getattr(expression, 'filename', filename)
77
78        if self.text:
79            lines = self.text.splitlines()
80
81            self.lineno = getattr(expression, 'start_line', lineno)
82            self.offset = getattr(expression, 'start_column', colno)
83            end_column = getattr(expression, 'end_column',
84                                 len(lines[self.lineno-1]))
85            end_line = getattr(expression, 'end_line', self.lineno)
86
87            # Trim the source down to the essentials.
88            self.text = '\n'.join(lines[self.lineno-1:end_line])
89
90            if end_column:
91                if self.lineno == end_line:
92                    self.arrow_offset = end_column
93                else:
94                    self.arrow_offset = len(self.text[0])
95
96                self.arrow_offset -= self.offset
97            else:
98                self.arrow_offset = None
99        else:
100            # We could attempt to extract the source given a filename, but we
101            # don't.
102            self.lineno = lineno
103            self.offset = colno
104            self.arrow_offset = None
105
106    def __str__(self):
107        """Provide an exception message that includes SyntaxError-like source
108        line information when available.
109        """
110        # Syntax errors are special and annotate the traceback (instead of what
111        # we would do in the message that follows the traceback).
112        if isinstance(self, SyntaxError):
113            return super(HyLanguageError, self).__str__()
114        # When there isn't extra source information, use the normal message.
115        elif not self.text:
116            return super(HyLanguageError, self).__str__()
117
118        # Re-purpose Python's builtin syntax error formatting.
119        output = traceback.format_exception_only(
120            SyntaxError,
121            SyntaxError(self.msg, (self.filename, self.lineno, self.offset,
122                                   self.text)))
123
124        arrow_idx, _ = next(((i, x) for i, x in enumerate(output)
125                             if x.strip() == '^'),
126                            (None, None))
127        if arrow_idx:
128            msg_idx = arrow_idx + 1
129        else:
130            msg_idx, _ = next((i, x) for i, x in enumerate(output)
131                              if x.startswith('SyntaxError: '))
132
133        # Get rid of erroneous error-type label.
134        output[msg_idx] = re.sub('^SyntaxError: ', '', output[msg_idx])
135
136        # Extend the text arrow, when given enough source info.
137        if arrow_idx and self.arrow_offset:
138            output[arrow_idx] = '{}{}^\n'.format(output[arrow_idx].rstrip('\n'),
139                                                 '-' * (self.arrow_offset - 1))
140
141        if COLORED:
142            output[msg_idx:] = [Fore.YELLOW + o + Fore.RESET for o in output[msg_idx:]]
143            if arrow_idx:
144                output[arrow_idx] = Fore.GREEN + output[arrow_idx] + Fore.RESET
145            for idx, line in enumerate(output[::msg_idx]):
146                if line.strip().startswith(
147                        'File "{}", line'.format(self.filename)):
148                    output[idx] = Fore.RED + line + Fore.RESET
149
150        # This resulting string will come after a "<class-name>:" prompt, so
151        # put it down a line.
152        output.insert(0, '\n')
153
154        # Avoid "...expected str instance, ColoredString found"
155        return reduce(lambda x, y: x + y, output)
156
157
158class HyCompileError(HyInternalError):
159    """Unexpected errors occurring within the compiler."""
160
161
162class HyTypeError(HyLanguageError, TypeError):
163    """TypeError occurring during the normal use of Hy."""
164
165
166class HyNameError(HyLanguageError, NameError):
167    """NameError occurring during the normal use of Hy."""
168
169
170class HyRequireError(HyLanguageError):
171    """Errors arising during the use of `require`
172
173    This, and any errors inheriting from this, are user-facing.
174    """
175
176
177class HyMacroExpansionError(HyLanguageError):
178    """Errors caused by invalid use of Hy macros.
179
180    This, and any errors inheriting from this, are user-facing.
181    """
182
183
184class HyEvalError(HyLanguageError):
185    """Errors occurring during code evaluation at compile-time.
186
187    These errors distinguish unexpected errors within the compilation process
188    (i.e. `HyInternalError`s) from unrelated errors in user code evaluated by
189    the compiler (e.g. in `eval-and-compile`).
190
191    This, and any errors inheriting from this, are user-facing.
192    """
193
194
195class HyIOError(HyInternalError, IOError):
196    """ Subclass used to distinguish between IOErrors raised by Hy itself as
197    opposed to Hy programs.
198    """
199
200
201class HySyntaxError(HyLanguageError, SyntaxError):
202    """Error during the Lexing of a Hython expression."""
203
204
205class HyWrapperError(HyError, TypeError):
206    """Errors caused by language model object wrapping.
207
208    These can be caused by improper user-level use of a macro, so they're
209    not really "internal".  If they arise due to anything else, they're an
210    internal/compiler problem, though.
211    """
212
213
214def _module_filter_name(module_name):
215    try:
216        compiler_loader = pkgutil.get_loader(module_name)
217        if not compiler_loader:
218            return None
219
220        filename = compiler_loader.get_filename(module_name)
221        if not filename:
222            return None
223
224        if compiler_loader.is_package(module_name):
225            # Use the package directory (e.g. instead of `.../__init__.py`) so
226            # that we can filter all modules in a package.
227            return os.path.dirname(filename)
228        else:
229            # Normalize filename endings, because tracebacks will use `pyc` when
230            # the loader says `py`.
231            return filename.replace('.pyc', '.py')
232    except Exception:
233        return None
234
235
236_tb_hidden_modules = {m for m in map(_module_filter_name,
237                                     ['hy.compiler', 'hy.lex',
238                                      'hy.cmdline', 'hy.lex.parser',
239                                      'hy.importer', 'hy._compat',
240                                      'hy.macros', 'hy.models',
241                                      'rply'])
242                      if m is not None}
243
244
245def hy_exc_filter(exc_type, exc_value, exc_traceback):
246    """Produce exceptions print-outs with all frames originating from the
247    modules in `_tb_hidden_modules` filtered out.
248
249    The frames are actually filtered by each module's filename and only when a
250    subclass of `HyLanguageError` is emitted.
251
252    This does not remove the frames from the actual tracebacks, so debugging
253    will show everything.
254    """
255    # frame = (filename, line number, function name*, text)
256    new_tb = []
257    for frame in traceback.extract_tb(exc_traceback):
258        if not (frame[0].replace('.pyc', '.py') in _tb_hidden_modules or
259                os.path.dirname(frame[0]) in _tb_hidden_modules):
260            new_tb += [frame]
261
262    lines = traceback.format_list(new_tb)
263
264    lines.insert(0, "Traceback (most recent call last):\n")
265
266    lines.extend(traceback.format_exception_only(exc_type, exc_value))
267    output = ''.join(lines)
268
269    return output
270
271
272def hy_exc_handler(exc_type, exc_value, exc_traceback):
273    """A `sys.excepthook` handler that uses `hy_exc_filter` to
274    remove internal Hy frames from a traceback print-out.
275    """
276    if os.environ.get('HY_DEBUG', False):
277        return sys.__excepthook__(exc_type, exc_value, exc_traceback)
278
279    try:
280        output = hy_exc_filter(exc_type, exc_value, exc_traceback)
281        sys.stderr.write(output)
282        sys.stderr.flush()
283    except Exception:
284        sys.__excepthook__(exc_type, exc_value, exc_traceback)
285
286
287@contextmanager
288def filtered_hy_exceptions():
289    """Temporarily apply a `sys.excepthook` that filters Hy internal frames
290    from tracebacks.
291
292    Filtering can be controlled by the variable
293    `hy.errors._hy_filter_internal_errors` and environment variable
294    `HY_FILTER_INTERNAL_ERRORS`.
295    """
296    global _hy_filter_internal_errors
297    if _hy_filter_internal_errors:
298        current_hook = sys.excepthook
299        sys.excepthook = hy_exc_handler
300        yield
301        sys.excepthook = current_hook
302    else:
303        yield
304