Skip to content

Commit 85035c1

Browse files
chrisrink10Christopher Rink
andauthored
Improve compiler exception and syntax error formatting at the REPL (#869)
Fixes #870 --------- Co-authored-by: Christopher Rink <[email protected]>
1 parent b77703d commit 85035c1

File tree

11 files changed

+442
-21
lines changed

11 files changed

+442
-21
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
* Added support for `*flush-on-newline*` to flush the `prn` and `println` output stream after the last newline (#865)
1414
* Added support for binding destructuring in `for` bindings (#774)
1515
* Added `==` as an alias to `=` (#859)
16+
* Added custom exception formatting for `basilisp.lang.compiler.exception.CompilerException` and `basilisp.lang.reader.SyntaxError` to show more useful details to users on errors (#870)
1617

1718
### Changed
1819
* Cause exceptions arising from compilation issues during macroexpansion will no longer be nested for each level of macroexpansion (#852)

src/basilisp/cli.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import os
55
import sys
66
import textwrap
7-
import traceback
87
import types
98
from pathlib import Path
109
from typing import Any, Callable, Optional, Sequence, Type
@@ -15,6 +14,7 @@
1514
from basilisp.lang import runtime as runtime
1615
from basilisp.lang import symbol as sym
1716
from basilisp.lang import vector as vec
17+
from basilisp.lang.exception import print_exception
1818
from basilisp.prompt import get_prompter
1919

2020
CLI_INPUT_FILE_PATH = "<CLI Input>"
@@ -449,17 +449,15 @@ def repl(
449449
prompter.print(runtime.lrepr(result))
450450
repl_module.mark_repl_result(result)
451451
except reader.SyntaxError as e:
452-
traceback.print_exception(reader.SyntaxError, e, e.__traceback__)
452+
print_exception(e, reader.SyntaxError, e.__traceback__)
453453
repl_module.mark_exception(e)
454454
continue
455455
except compiler.CompilerException as e:
456-
traceback.print_exception(
457-
compiler.CompilerException, e, e.__traceback__
458-
)
456+
print_exception(e, compiler.CompilerException, e.__traceback__)
459457
repl_module.mark_exception(e)
460458
continue
461459
except Exception as e: # pylint: disable=broad-exception-caught
462-
traceback.print_exception(Exception, e, e.__traceback__)
460+
print_exception(e, Exception, e.__traceback__)
463461
repl_module.mark_exception(e)
464462
continue
465463

src/basilisp/core.lpy

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4171,7 +4171,7 @@
41714171
([]
41724172
(.write *out* os/linesep)
41734173
(when *flush-on-newline*
4174-
(.flush *out*))
4174+
(.flush *out*))
41754175
nil)
41764176
([x]
41774177
(let [stdout *out*]
@@ -4402,11 +4402,12 @@
44024402
(with-open [f (python/open path ** :mode "r")]
44034403
(load-reader f)))
44044404

4405-
(defn- resolve-load-path [path]
4405+
(defn- resolve-load-path
44064406
"Resolve load ``path`` relative to the current namespace, or, if it
44074407
begins with \"/\", relative to the syspath, and return it.
44084408

4409-
Throw a python/FileNotFoundError if the ``path`` cannot be resolved."
4409+
Throw a ``python/FileNotFoundError`` if the ``path`` cannot be resolved."
4410+
[path]
44104411
(let [path-rel (if (.startswith path "/")
44114412
path
44124413
(let [path-parent (-> (name *ns*)

src/basilisp/lang/compiler/exception.py

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import ast
2+
import os
23
from enum import Enum
3-
from typing import Any, Dict, Optional, Union
4+
from types import TracebackType
5+
from typing import Any, Dict, List, Optional, Type, Union
46

57
import attr
68

79
from basilisp.lang import keyword as kw
810
from basilisp.lang import map as lmap
911
from basilisp.lang.compiler.nodes import Node
12+
from basilisp.lang.exception import format_exception
1013
from basilisp.lang.interfaces import IExceptionInfo, IMeta, IPersistentMap, ISeq
1114
from basilisp.lang.obj import lrepr
1215
from basilisp.lang.reader import (
@@ -15,6 +18,7 @@
1518
READER_END_LINE_KW,
1619
READER_LINE_KW,
1720
)
21+
from basilisp.lang.source import format_source_context
1822
from basilisp.lang.typing import LispForm
1923

2024
_FILE = kw.keyword("file")
@@ -56,16 +60,15 @@ def __bool__(self):
5660
class CompilerException(IExceptionInfo):
5761
msg: str
5862
phase: CompilerPhase
59-
filename: Optional[str] = None
63+
filename: str
6064
form: Union[LispForm, None, ISeq] = None
6165
lisp_ast: Optional[Node] = None
6266
py_ast: Optional[ast.AST] = None
6367

6468
@property
6569
def data(self) -> IPersistentMap:
6670
d: Dict[kw.Keyword, Any] = {_PHASE: self.phase.value}
67-
if self.filename is not None:
68-
d[_FILE] = self.filename
71+
d[_FILE] = self.filename
6972
loc = None
7073
if self.form is not None:
7174
d[_FORM] = self.form
@@ -104,3 +107,57 @@ def data(self) -> IPersistentMap:
104107

105108
def __str__(self):
106109
return f"{self.msg} {lrepr(self.data)}"
110+
111+
112+
@format_exception.register(CompilerException)
113+
def format_compiler_exception( # pylint: disable=too-many-branches,unused-argument
114+
e: CompilerException,
115+
tp: Optional[Type[Exception]] = None,
116+
tb: Optional[TracebackType] = None,
117+
) -> List[str]:
118+
"""Format a compiler exception as a list of newline-terminated strings."""
119+
context_exc: Optional[BaseException] = e.__cause__
120+
121+
lines = [os.linesep]
122+
if context_exc is not None:
123+
lines.append(f" exception: {type(context_exc)} from {type(e)}{os.linesep}")
124+
else:
125+
lines.append(f" exception: {type(e)}{os.linesep}")
126+
lines.append(f" phase: {e.phase.value}{os.linesep}")
127+
if context_exc is None:
128+
lines.append(f" message: {e.msg}{os.linesep}")
129+
elif e.phase in {CompilerPhase.MACROEXPANSION, CompilerPhase.INLINING}:
130+
if isinstance(context_exc, CompilerException):
131+
lines.append(f" message: {e.msg}: {context_exc.msg}{os.linesep}")
132+
else:
133+
lines.append(f" message: {e.msg}: {context_exc}{os.linesep}")
134+
else:
135+
lines.append(f" message: {e.msg}: {context_exc}{os.linesep}")
136+
if e.form is not None:
137+
lines.append(f" form: {e.form!r}{os.linesep}")
138+
139+
d = e.data
140+
line = d.val_at(_LINE)
141+
end_line = d.val_at(_END_LINE)
142+
if line is not None and end_line is not None and line != end_line:
143+
line_nums = f"{line}-{end_line}"
144+
elif line is not None:
145+
line_nums = str(line)
146+
else:
147+
line_nums = ""
148+
149+
lines.append(
150+
f" location: {e.filename}:{line_nums or 'NO_SOURCE_LINE'}{os.linesep}"
151+
)
152+
153+
# Print context source lines around the error. Use the current exception to
154+
# derive source lines, but use the inner cause exception to place a marker
155+
# around the error.
156+
if line is not None and (
157+
context_lines := format_source_context(e.filename, line, end_line=end_line)
158+
):
159+
lines.append(f" context:{os.linesep}")
160+
lines.append(os.linesep)
161+
lines.extend(context_lines)
162+
163+
return lines

src/basilisp/lang/exception.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
import functools
2+
import sys
3+
import traceback
4+
from types import TracebackType
5+
from typing import List, Optional, Type
6+
17
import attr
28

39
from basilisp.lang.interfaces import IExceptionInfo, IPersistentMap
@@ -16,3 +22,37 @@ def __repr__(self):
1622

1723
def __str__(self):
1824
return f"{self.message} {lrepr(self.data)}"
25+
26+
27+
@functools.singledispatch
28+
def format_exception(
29+
e: Optional[BaseException],
30+
tp: Optional[Type[BaseException]] = None,
31+
tb: Optional[TracebackType] = None,
32+
) -> List[str]:
33+
"""Format an exception into something readable, returning a list of newline
34+
terminated strings.
35+
36+
For the majority of Python exceptions, this will just be the result from calling
37+
`traceback.format_exception`. For Basilisp specific compilation errors, a custom
38+
output will be returned."""
39+
if isinstance(e, BaseException):
40+
if tp is None:
41+
tp = type(e)
42+
if tb is None:
43+
tb = e.__traceback__
44+
return traceback.format_exception(tp, e, tb)
45+
46+
47+
def print_exception(
48+
e: Optional[BaseException],
49+
tp: Optional[Type[BaseException]] = None,
50+
tb: Optional[TracebackType] = None,
51+
) -> None:
52+
"""Print the given exception `e` using Basilisp's own exception formatting.
53+
54+
For the majority of exception types, this should be identical to the base Python
55+
traceback formatting. `basilisp.lang.compiler.CompilerException` and
56+
`basilisp.lang.reader.SyntaxError` have special handling to print useful information
57+
on exceptions."""
58+
print("".join(format_exception(e, tp, tb)), file=sys.stderr)

src/basilisp/lang/reader.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
import decimal
66
import functools
77
import io
8+
import os
89
import re
910
import uuid
1011
from datetime import datetime
1112
from fractions import Fraction
1213
from itertools import chain
14+
from types import TracebackType
1315
from typing import (
1416
Any,
1517
Callable,
@@ -24,6 +26,7 @@
2426
Sequence,
2527
Set,
2628
Tuple,
29+
Type,
2730
TypeVar,
2831
Union,
2932
cast,
@@ -39,6 +42,7 @@
3942
from basilisp.lang import symbol as sym
4043
from basilisp.lang import util as langutil
4144
from basilisp.lang import vector as vec
45+
from basilisp.lang.exception import format_exception
4246
from basilisp.lang.interfaces import (
4347
ILispObject,
4448
ILookup,
@@ -60,6 +64,7 @@
6064
get_current_ns,
6165
lrepr,
6266
)
67+
from basilisp.lang.source import format_source_context
6368
from basilisp.lang.typing import IterableLispForm, LispForm, ReaderForm
6469
from basilisp.lang.util import munge
6570
from basilisp.util import Maybe, partition
@@ -152,6 +157,53 @@ def __str__(self):
152157
return f"{self.message} ({details})"
153158

154159

160+
@format_exception.register(SyntaxError)
161+
def format_syntax_error( # pylint: disable=unused-argument
162+
e: SyntaxError,
163+
tp: Optional[Type[Exception]] = None,
164+
tb: Optional[TracebackType] = None,
165+
) -> List[str]:
166+
context_exc: Optional[BaseException] = e.__cause__
167+
168+
lines = [os.linesep]
169+
if context_exc is not None:
170+
lines.append(f" exception: {type(context_exc)} from {type(e)}{os.linesep}")
171+
else:
172+
lines.append(f" exception: {type(e)}{os.linesep}")
173+
if context_exc is None:
174+
lines.append(f" message: {e.message}{os.linesep}")
175+
else:
176+
lines.append(f" message: {e.message}: {context_exc}{os.linesep}")
177+
178+
if e.line is not None and e.col:
179+
line_num = f"{e.line}:{e.col}"
180+
elif e.line is not None:
181+
line_num = str(e.line)
182+
else:
183+
line_num = ""
184+
185+
if e.filename is not None:
186+
lines.append(
187+
f" location: {e.filename}:{line_num or 'NO_SOURCE_LINE'}{os.linesep}"
188+
)
189+
elif line_num:
190+
lines.append(f" line: {line_num}{os.linesep}")
191+
192+
# Print context source lines around the error. Use the current exception to
193+
# derive source lines, but use the inner cause exception to place a marker
194+
# around the error.
195+
if (
196+
e.filename is not None
197+
and e.line is not None
198+
and (context_lines := format_source_context(e.filename, e.line))
199+
):
200+
lines.append(f" context:{os.linesep}")
201+
lines.append(os.linesep)
202+
lines.extend(context_lines)
203+
204+
return lines
205+
206+
155207
class UnexpectedEOFError(SyntaxError):
156208
"""Syntax Error type raised when the reader encounters an unexpected EOF
157209
reading a form.

src/basilisp/lang/source.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import linecache
2+
import os
3+
from typing import List, Optional
4+
5+
try:
6+
import pygments.formatters
7+
import pygments.lexers
8+
import pygments.styles
9+
except ImportError: # pragma: no cover
10+
11+
def _format_source(s: str) -> str:
12+
return f"{s}{os.linesep}"
13+
14+
else:
15+
16+
def _get_formatter_name() -> Optional[str]: # pragma: no cover
17+
"""Get the Pygments formatter name for formatting the source code by
18+
inspecting various environment variables set by terminals.
19+
20+
If `BASILISP_NO_COLOR` is set to a truthy value, use no formatting."""
21+
if os.environ.get("BASILISP_NO_COLOR", "false").lower() in {"1", "true"}:
22+
return None
23+
elif os.environ.get("COLORTERM", "") in {"truecolor", "24bit"}:
24+
return "terminal16m"
25+
elif "256" in os.environ.get("TERM", ""):
26+
return "terminal256"
27+
else:
28+
return "terminal"
29+
30+
def _format_source(s: str) -> str: # pragma: no cover
31+
"""Format source code for terminal output."""
32+
if (formatter_name := _get_formatter_name()) is None:
33+
return f"{s}{os.linesep}"
34+
return pygments.highlight(
35+
s,
36+
lexer=pygments.lexers.get_lexer_by_name("clojure"),
37+
formatter=pygments.formatters.get_formatter_by_name(
38+
formatter_name, style=pygments.styles.get_style_by_name("emacs")
39+
),
40+
)
41+
42+
43+
def format_source_context(
44+
filename: str,
45+
line: int,
46+
end_line: Optional[int] = None,
47+
num_context_lines: int = 5,
48+
show_cause_marker: bool = True,
49+
) -> List[str]:
50+
"""Format source code context with line numbers and identifiers for the affected
51+
line(s)."""
52+
assert num_context_lines >= 0
53+
54+
lines = []
55+
56+
if not filename.startswith("<") and not filename.endswith(">"):
57+
cause_range: Optional[range]
58+
if not show_cause_marker:
59+
cause_range = None
60+
elif end_line is not None and end_line != line:
61+
cause_range = range(line, end_line)
62+
else:
63+
cause_range = range(line, line + 1)
64+
65+
if source_lines := linecache.getlines(filename):
66+
start = max(0, line - num_context_lines)
67+
end = min((end_line or line) + num_context_lines, len(source_lines))
68+
num_justify = max(len(str(start)), len(str(end))) + 1
69+
for n, source_line in zip(range(start, end), source_lines[start:end]):
70+
if cause_range is None:
71+
line_marker = " "
72+
elif n + 1 in cause_range:
73+
line_marker = " > "
74+
else:
75+
line_marker = " "
76+
77+
line_num = str(n + 1).rjust(num_justify)
78+
lines.append(
79+
f"{line_num}{line_marker}| {_format_source(source_line.rstrip())}"
80+
)
81+
82+
return lines

0 commit comments

Comments
 (0)