From 5ca95271f4d6c17f370e31d758bfd50af9a1fab8 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Fri, 26 Jan 2024 16:28:10 -0500 Subject: [PATCH 01/20] Warn at compile time on arity mismatch for function invocation --- src/basilisp/lang/compiler/analyzer.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/basilisp/lang/compiler/analyzer.py b/src/basilisp/lang/compiler/analyzer.py index 6c5526cf9..917d0672d 100644 --- a/src/basilisp/lang/compiler/analyzer.py +++ b/src/basilisp/lang/compiler/analyzer.py @@ -2506,6 +2506,26 @@ def _invoke_ast(form: Union[llist.PersistentList, ISeq], ctx: AnalyzerContext) - fn = _analyze_form(form.first, ctx) if fn.op == NodeOp.VAR and isinstance(fn, VarRef): + if ctx.warn_on_arity_mismatch and getattr(fn.var.value, "_basilisp_fn", False): + arities: Optional[Tuple[Union[int, kw.Keyword]]] = getattr( + fn.var.value, "arities", None + ) + if arities is not None: + has_variadic = kw.keyword("rest") in arities + fixed_arities = frozenset( + filter(lambda v: v is not kw.keyword("rest"), arities) + ) + # This count could be off by 1 for cases where kwargs are being passed, + # but only Basilisp functions intended to be called by Python code + # (e.g. with a :kwargs strategy) should ever be called with kwargs, + # so this seems unlikely enough. + num_args = runtime.count(form.rest) + if num_args not in fixed_arities and not has_variadic: + logger.warning( + f"calling function {fn.var} with {num_args} arguments; " + f"expected any of: {', '.join(map(str, arities))}" + ) + if _is_macro(fn.var) and ctx.should_macroexpand: try: macro_env = ctx.symbol_table.as_env_map() From 809dced8d85dd635c4ae270d30c942a46cf62cf0 Mon Sep 17 00:00:00 2001 From: Chris Rink Date: Mon, 29 Jan 2024 13:27:39 -0500 Subject: [PATCH 02/20] I guess I'm getting a bunch of random format shit thanks black --- src/basilisp/lang/compiler/analyzer.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/src/basilisp/lang/compiler/analyzer.py b/src/basilisp/lang/compiler/analyzer.py index 917d0672d..6c5526cf9 100644 --- a/src/basilisp/lang/compiler/analyzer.py +++ b/src/basilisp/lang/compiler/analyzer.py @@ -2506,26 +2506,6 @@ def _invoke_ast(form: Union[llist.PersistentList, ISeq], ctx: AnalyzerContext) - fn = _analyze_form(form.first, ctx) if fn.op == NodeOp.VAR and isinstance(fn, VarRef): - if ctx.warn_on_arity_mismatch and getattr(fn.var.value, "_basilisp_fn", False): - arities: Optional[Tuple[Union[int, kw.Keyword]]] = getattr( - fn.var.value, "arities", None - ) - if arities is not None: - has_variadic = kw.keyword("rest") in arities - fixed_arities = frozenset( - filter(lambda v: v is not kw.keyword("rest"), arities) - ) - # This count could be off by 1 for cases where kwargs are being passed, - # but only Basilisp functions intended to be called by Python code - # (e.g. with a :kwargs strategy) should ever be called with kwargs, - # so this seems unlikely enough. - num_args = runtime.count(form.rest) - if num_args not in fixed_arities and not has_variadic: - logger.warning( - f"calling function {fn.var} with {num_args} arguments; " - f"expected any of: {', '.join(map(str, arities))}" - ) - if _is_macro(fn.var) and ctx.should_macroexpand: try: macro_env = ctx.symbol_table.as_env_map() From 12e0a008e9fb0feec204b996c70df55b61164ce6 Mon Sep 17 00:00:00 2001 From: Chris Rink Date: Mon, 29 Jan 2024 13:32:14 -0500 Subject: [PATCH 03/20] More stuff --- src/basilisp/lang/seq.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/basilisp/lang/seq.py b/src/basilisp/lang/seq.py index c41364a3b..ef8aa7521 100644 --- a/src/basilisp/lang/seq.py +++ b/src/basilisp/lang/seq.py @@ -259,11 +259,13 @@ def sequence(s: Iterable[T]) -> ISeq[T]: @overload -def _seq_or_nil(s: None) -> None: ... +def _seq_or_nil(s: None) -> None: + ... @overload -def _seq_or_nil(s: ISeq) -> Optional[ISeq]: ... +def _seq_or_nil(s: ISeq) -> Optional[ISeq]: + ... def _seq_or_nil(s): From 3f354a1c46299b35112b7666076dec5eb3d70ae8 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Sun, 28 Jan 2024 09:46:28 -0500 Subject: [PATCH 04/20] Ok --- src/basilisp/lang/seq.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/basilisp/lang/seq.py b/src/basilisp/lang/seq.py index ef8aa7521..c41364a3b 100644 --- a/src/basilisp/lang/seq.py +++ b/src/basilisp/lang/seq.py @@ -259,13 +259,11 @@ def sequence(s: Iterable[T]) -> ISeq[T]: @overload -def _seq_or_nil(s: None) -> None: - ... +def _seq_or_nil(s: None) -> None: ... @overload -def _seq_or_nil(s: ISeq) -> Optional[ISeq]: - ... +def _seq_or_nil(s: ISeq) -> Optional[ISeq]: ... def _seq_or_nil(s): From be52fb564be9a6775624bf49df869113e2a03a5f Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Sun, 11 Feb 2024 10:06:27 -0500 Subject: [PATCH 05/20] Improve compiler exceptions --- CHANGELOG.md | 1 + src/basilisp/cli.py | 5 +- src/basilisp/lang/compiler/exception.py | 63 ++++++++++++++++- src/basilisp/lang/exception.py | 91 +++++++++++++++++++++++++ src/basilisp/lang/reader.py | 47 +++++++++++++ src/basilisp/prompt.py | 3 +- 6 files changed, 206 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ae3632dd..15c8872e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added support for `*flush-on-newline*` to flush the `prn` and `println` output stream after the last newline (#865) * Added support for binding destructuring in `for` bindings (#774) * Added `==` as an alias to `=` (#859) + * Added an exception hook and custom exception formatting for `basilisp.lang.compiler.exception.CompilerException` and `basilisp.lang.reader.SyntaxError` to show more useful details to users on errors (#???) ### Changed * Cause exceptions arising from compilation issues during macroexpansion will no longer be nested for each level of macroexpansion (#852) diff --git a/src/basilisp/cli.py b/src/basilisp/cli.py index d3da5a69d..d0171736b 100644 --- a/src/basilisp/cli.py +++ b/src/basilisp/cli.py @@ -15,6 +15,7 @@ from basilisp.lang import runtime as runtime from basilisp.lang import symbol as sym from basilisp.lang import vector as vec +from basilisp.lang.exception import print_exception from basilisp.prompt import get_prompter CLI_INPUT_FILE_PATH = "" @@ -449,11 +450,11 @@ def repl( prompter.print(runtime.lrepr(result)) repl_module.mark_repl_result(result) except reader.SyntaxError as e: - traceback.print_exception(reader.SyntaxError, e, e.__traceback__) + print_exception(reader.SyntaxError, e, e.__traceback__) repl_module.mark_exception(e) continue except compiler.CompilerException as e: - traceback.print_exception( + print_exception( compiler.CompilerException, e, e.__traceback__ ) repl_module.mark_exception(e) diff --git a/src/basilisp/lang/compiler/exception.py b/src/basilisp/lang/compiler/exception.py index 197af52ba..1f5e78b0c 100644 --- a/src/basilisp/lang/compiler/exception.py +++ b/src/basilisp/lang/compiler/exception.py @@ -1,9 +1,12 @@ import ast +import linecache from enum import Enum -from typing import Any, Dict, Optional, Union +from types import TracebackType +from typing import Any, Dict, Optional, Type, Union import attr +from basilisp.lang.exception import format_exception, format_source_context from basilisp.lang import keyword as kw from basilisp.lang import map as lmap from basilisp.lang.compiler.nodes import Node @@ -104,3 +107,61 @@ def data(self) -> IPersistentMap: def __str__(self): return f"{self.msg} {lrepr(self.data)}" + + +@format_exception.register(CompilerException) +def format_compiler_exception( # pylint: disable=unused-argument + e: CompilerException, tp: Type[Exception], tb: TracebackType +) -> list[str]: + """Return context notes for a Compiler Exception""" + context_exc: Optional[BaseException] = e.__cause__ + + lines = [] + lines.append("\n") + if context_exc is not None: + lines.append(f" exception: {type(context_exc)} from {type(e)}\n") + else: + lines.append(f" exception: {type(e)}\n") + lines.append(f" phase: {e.phase.value}\n") + if context_exc is None: + lines.append(f" message: {e.msg}\n") + elif e.phase in {CompilerPhase.MACROEXPANSION, CompilerPhase.INLINING}: + if isinstance(context_exc, CompilerException): + lines.append(f" message: {e.msg}: {context_exc.msg}\n") + else: + lines.append(f" message: {e.msg}: {context_exc}\n") + else: + lines.append(f" message: {e.msg}: {context_exc}\n") + if e.form is not None: + lines.append(f" form: {e.form!r}\n") + + d = e.data + line = d.val_at(_LINE) + end_line = d.val_at(_END_LINE) + if line is not None and end_line is not None and line != end_line: + line_nums = f"{line}-{end_line}" + elif line is not None: + line_nums = str(line) + else: + line_nums = "" + + if e.filename is not None: + lines.append(f" location: {e.filename}:{line_nums or 'NO_SOURCE_LINE'}\n") + elif line_nums: + lines.append(f" lines: {line_nums}\n") + + # Print context source lines around the error. Use the current exception to + # derive source lines, but use the inner cause exception to place a marker + # around the error. + if ( + e.filename is not None + and line is not None + and ( + context_lines := format_source_context(e.filename, line, end_line=end_line) + ) + ): + lines.append(" context:\n") + lines.append("\n") + lines.extend(context_lines) + + return lines diff --git a/src/basilisp/lang/exception.py b/src/basilisp/lang/exception.py index 294b5d6d7..e4c77c560 100644 --- a/src/basilisp/lang/exception.py +++ b/src/basilisp/lang/exception.py @@ -1,3 +1,10 @@ +import functools +import linecache +import os +import traceback +from types import TracebackType +from typing import Optional, Type + import attr from basilisp.lang.interfaces import IExceptionInfo, IPersistentMap @@ -16,3 +23,87 @@ def __repr__(self): def __str__(self): return f"{self.message} {lrepr(self.data)}" + + +try: + import pygments.lexers + import pygments.formatters + import pygments.styles +except ImportError: + + def _format_source(s: str) -> str: + return f"{s}\n" + +else: + + def _get_formatter_name() -> Optional[str]: + if os.environ.get("BASILISP_NO_COLOR", "false").lower() in {"1", "true"}: + return None + elif os.environ.get("COLORTERM", "") in ("truecolor", "24bit"): + return "terminal16m" + elif "256" in os.environ.get("TERM", ""): + return "terminal256" + else: + return "terminal" + + def _format_source(s: str) -> str: + if (formatter_name := _get_formatter_name()) is not None: + return s + return pygments.highlight( + s, + lexer=pygments.lexers.get_lexer_by_name("clojure"), + formatter=pygments.formatters.get_formatter_by_name( + formatter_name, style=pygments.styles.get_style_by_name("emacs") + ), + ) + + +def format_source_context( + filename: str, line: int, end_line: Optional[int] = None, num_context_lines: int = 5 +) -> list[str]: + """Format source code context with line numbers and identifiers for the affected + line(s).""" + assert num_context_lines >= 0 + + lines = [] + + if not filename.startswith("<") and not filename.endswith(">"): + if end_line is not None and end_line != line: + cause_range = range(line, end_line + 1) + else: + cause_range = range(line, line + 1) + + if source_lines := linecache.getlines(filename): + start = max(0, line - num_context_lines) + end = min((end_line or line) + num_context_lines, len(source_lines)) + num_justify = max(len(str(start)), len(str(end))) + 1 + for n, source_line in zip(range(start, end), source_lines[start:end]): + if n + 1 in cause_range: + line_marker = " > " + else: + line_marker = " " + + line_num = str(n + 1).rjust(num_justify) + lines.append( + f"{line_num}{line_marker}| {_format_source(source_line.rstrip())}" + ) + + return lines + + +@functools.singledispatch +def format_exception(e: Exception, tp: Type[Exception], tb: TracebackType) -> list[str]: + return traceback.format_exception(tp, e, tb) + + +def print_exception(tp: Type[Exception], e: Exception, tb: TracebackType) -> None: + """Print the given exception `e` using Basilisp's own exception formatting. + + For the majority of exception types, this should be identical to the base Python + traceback formatting. `basilisp.lang.compiler.CompilerException` and + `basilisp.lang.reader.SyntaxError` have special handling to print useful information + on exceptions.""" + print("".join(format_exception(e, tp, tb))) + + +basilisp_excepthook = print_exception diff --git a/src/basilisp/lang/reader.py b/src/basilisp/lang/reader.py index e1d27e5af..9f2612edc 100644 --- a/src/basilisp/lang/reader.py +++ b/src/basilisp/lang/reader.py @@ -10,6 +10,7 @@ from datetime import datetime from fractions import Fraction from itertools import chain +from types import TracebackType from typing import ( Any, Callable, @@ -24,6 +25,7 @@ Sequence, Set, Tuple, + Type, TypeVar, Union, cast, @@ -31,6 +33,7 @@ import attr +from basilisp.lang.exception import format_exception, format_source_context from basilisp.lang import keyword as kw from basilisp.lang import list as llist from basilisp.lang import map as lmap @@ -152,6 +155,50 @@ def __str__(self): return f"{self.message} ({details})" +@format_exception.register(SyntaxError) +def format_syntax_error( + e: SyntaxError, tp: Type[Exception], tb: TracebackType +) -> list[str]: + context_exc: Optional[BaseException] = e.__cause__ + + lines = [] + lines.append("\n") + if context_exc is not None: + lines.append(f" exception: {type(context_exc)} from {type(e)}\n") + else: + lines.append(f" exception: {type(e)}\n") + if context_exc is None: + lines.append(f" message: {e.message}\n") + else: + lines.append(f" message: {e.message}: {context_exc}\n") + + if e.line is not None and e.col: + line_num = f"{e.line}:{e.col}" + elif e.line is not None: + line_num = str(e.line) + else: + line_num = "" + + if e.filename is not None: + lines.append(f" location: {e.filename}:{line_num or 'NO_SOURCE_LINE'}\n") + elif line_num: + lines.append(f" lines: {line_num}\n") + + # Print context source lines around the error. Use the current exception to + # derive source lines, but use the inner cause exception to place a marker + # around the error. + if ( + e.filename is not None + and e.line is not None + and (context_lines := format_source_context(e.filename, e.line)) + ): + lines.append(" context:\n") + lines.append("\n") + lines.extend(context_lines) + + return lines + + class UnexpectedEOFError(SyntaxError): """Syntax Error type raised when the reader encounters an unexpected EOF reading a form. diff --git a/src/basilisp/prompt.py b/src/basilisp/prompt.py index 685f31806..3e09dd51c 100644 --- a/src/basilisp/prompt.py +++ b/src/basilisp/prompt.py @@ -18,6 +18,7 @@ from basilisp.lang import reader as reader from basilisp.lang import runtime as runtime +from basilisp.lang.exception import print_exception _USER_DATA_HOME = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share")) BASILISP_USER_DATA = os.path.abspath(os.path.join(_USER_DATA_HOME, "basilisp")) @@ -103,7 +104,7 @@ def _(event: KeyPressEvent) -> None: except reader.SyntaxError as e: run_in_terminal( partial( - traceback.print_exception, + print_exception, reader.SyntaxError, e, e.__traceback__, From 4fe7f49f708dbbab19a866a493154a5196a16f45 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Tue, 30 Jan 2024 19:31:07 -0500 Subject: [PATCH 06/20] Code formatting --- src/basilisp/cli.py | 4 +--- src/basilisp/lang/compiler/exception.py | 2 +- src/basilisp/lang/exception.py | 11 ++++++++--- src/basilisp/lang/reader.py | 2 +- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/basilisp/cli.py b/src/basilisp/cli.py index d0171736b..0f29a3bf9 100644 --- a/src/basilisp/cli.py +++ b/src/basilisp/cli.py @@ -454,9 +454,7 @@ def repl( repl_module.mark_exception(e) continue except compiler.CompilerException as e: - print_exception( - compiler.CompilerException, e, e.__traceback__ - ) + print_exception(compiler.CompilerException, e, e.__traceback__) repl_module.mark_exception(e) continue except Exception as e: # pylint: disable=broad-exception-caught diff --git a/src/basilisp/lang/compiler/exception.py b/src/basilisp/lang/compiler/exception.py index 1f5e78b0c..3027484e4 100644 --- a/src/basilisp/lang/compiler/exception.py +++ b/src/basilisp/lang/compiler/exception.py @@ -6,10 +6,10 @@ import attr -from basilisp.lang.exception import format_exception, format_source_context from basilisp.lang import keyword as kw from basilisp.lang import map as lmap from basilisp.lang.compiler.nodes import Node +from basilisp.lang.exception import format_exception, format_source_context from basilisp.lang.interfaces import IExceptionInfo, IMeta, IPersistentMap, ISeq from basilisp.lang.obj import lrepr from basilisp.lang.reader import ( diff --git a/src/basilisp/lang/exception.py b/src/basilisp/lang/exception.py index e4c77c560..a77bc2691 100644 --- a/src/basilisp/lang/exception.py +++ b/src/basilisp/lang/exception.py @@ -26,8 +26,8 @@ def __str__(self): try: - import pygments.lexers import pygments.formatters + import pygments.lexers import pygments.styles except ImportError: @@ -37,6 +37,10 @@ def _format_source(s: str) -> str: else: def _get_formatter_name() -> Optional[str]: + """Get the Pygments formatter name for formatting the source code by + inspecting various environment variables set by terminals. + + If `BASILISP_NO_COLOR` is set to a truthy value, use no formatting.""" if os.environ.get("BASILISP_NO_COLOR", "false").lower() in {"1", "true"}: return None elif os.environ.get("COLORTERM", "") in ("truecolor", "24bit"): @@ -47,8 +51,9 @@ def _get_formatter_name() -> Optional[str]: return "terminal" def _format_source(s: str) -> str: - if (formatter_name := _get_formatter_name()) is not None: - return s + """Format source code for terminal output.""" + if (formatter_name := _get_formatter_name()) is None: + return f"{s}\n" return pygments.highlight( s, lexer=pygments.lexers.get_lexer_by_name("clojure"), diff --git a/src/basilisp/lang/reader.py b/src/basilisp/lang/reader.py index 9f2612edc..9338da0cb 100644 --- a/src/basilisp/lang/reader.py +++ b/src/basilisp/lang/reader.py @@ -33,7 +33,6 @@ import attr -from basilisp.lang.exception import format_exception, format_source_context from basilisp.lang import keyword as kw from basilisp.lang import list as llist from basilisp.lang import map as lmap @@ -42,6 +41,7 @@ from basilisp.lang import symbol as sym from basilisp.lang import util as langutil from basilisp.lang import vector as vec +from basilisp.lang.exception import format_exception, format_source_context from basilisp.lang.interfaces import ( ILispObject, ILookup, From a794d2cca3134b980316d1c9c16096c80c8250be Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Tue, 30 Jan 2024 20:54:30 -0500 Subject: [PATCH 07/20] Split source formatting into a separate file --- src/basilisp/lang/compiler/exception.py | 3 +- src/basilisp/lang/exception.py | 85 ++++--------------------- src/basilisp/lang/reader.py | 3 +- src/basilisp/lang/source.py | 82 ++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 76 deletions(-) create mode 100644 src/basilisp/lang/source.py diff --git a/src/basilisp/lang/compiler/exception.py b/src/basilisp/lang/compiler/exception.py index 3027484e4..d55e53cb9 100644 --- a/src/basilisp/lang/compiler/exception.py +++ b/src/basilisp/lang/compiler/exception.py @@ -9,7 +9,7 @@ from basilisp.lang import keyword as kw from basilisp.lang import map as lmap from basilisp.lang.compiler.nodes import Node -from basilisp.lang.exception import format_exception, format_source_context +from basilisp.lang.exception import format_exception from basilisp.lang.interfaces import IExceptionInfo, IMeta, IPersistentMap, ISeq from basilisp.lang.obj import lrepr from basilisp.lang.reader import ( @@ -18,6 +18,7 @@ READER_END_LINE_KW, READER_LINE_KW, ) +from basilisp.lang.source import format_source_context from basilisp.lang.typing import LispForm _FILE = kw.keyword("file") diff --git a/src/basilisp/lang/exception.py b/src/basilisp/lang/exception.py index a77bc2691..6147b19a3 100644 --- a/src/basilisp/lang/exception.py +++ b/src/basilisp/lang/exception.py @@ -1,6 +1,4 @@ import functools -import linecache -import os import traceback from types import TracebackType from typing import Optional, Type @@ -25,83 +23,22 @@ def __str__(self): return f"{self.message} {lrepr(self.data)}" -try: - import pygments.formatters - import pygments.lexers - import pygments.styles -except ImportError: - - def _format_source(s: str) -> str: - return f"{s}\n" - -else: - - def _get_formatter_name() -> Optional[str]: - """Get the Pygments formatter name for formatting the source code by - inspecting various environment variables set by terminals. - - If `BASILISP_NO_COLOR` is set to a truthy value, use no formatting.""" - if os.environ.get("BASILISP_NO_COLOR", "false").lower() in {"1", "true"}: - return None - elif os.environ.get("COLORTERM", "") in ("truecolor", "24bit"): - return "terminal16m" - elif "256" in os.environ.get("TERM", ""): - return "terminal256" - else: - return "terminal" - - def _format_source(s: str) -> str: - """Format source code for terminal output.""" - if (formatter_name := _get_formatter_name()) is None: - return f"{s}\n" - return pygments.highlight( - s, - lexer=pygments.lexers.get_lexer_by_name("clojure"), - formatter=pygments.formatters.get_formatter_by_name( - formatter_name, style=pygments.styles.get_style_by_name("emacs") - ), - ) - - -def format_source_context( - filename: str, line: int, end_line: Optional[int] = None, num_context_lines: int = 5 +@functools.singledispatch +def format_exception( + e: Optional[Exception], tp: Optional[Type[Exception]], tb: Optional[TracebackType] ) -> list[str]: - """Format source code context with line numbers and identifiers for the affected - line(s).""" - assert num_context_lines >= 0 - - lines = [] - - if not filename.startswith("<") and not filename.endswith(">"): - if end_line is not None and end_line != line: - cause_range = range(line, end_line + 1) - else: - cause_range = range(line, line + 1) - - if source_lines := linecache.getlines(filename): - start = max(0, line - num_context_lines) - end = min((end_line or line) + num_context_lines, len(source_lines)) - num_justify = max(len(str(start)), len(str(end))) + 1 - for n, source_line in zip(range(start, end), source_lines[start:end]): - if n + 1 in cause_range: - line_marker = " > " - else: - line_marker = " " + """Format an exception into something readable, returning a list of newline + terminated strings. - line_num = str(n + 1).rjust(num_justify) - lines.append( - f"{line_num}{line_marker}| {_format_source(source_line.rstrip())}" - ) - - return lines - - -@functools.singledispatch -def format_exception(e: Exception, tp: Type[Exception], tb: TracebackType) -> list[str]: + For the majority of Python exceptions, this will just be the result from calling + `traceback.format_exception`. For Basilisp specific compilation errors, a custom + output will be returned.""" return traceback.format_exception(tp, e, tb) -def print_exception(tp: Type[Exception], e: Exception, tb: TracebackType) -> None: +def print_exception( + e: Optional[Exception], tp: Optional[Type[Exception]], tb: Optional[TracebackType] +) -> None: """Print the given exception `e` using Basilisp's own exception formatting. For the majority of exception types, this should be identical to the base Python diff --git a/src/basilisp/lang/reader.py b/src/basilisp/lang/reader.py index 9338da0cb..e27394f6d 100644 --- a/src/basilisp/lang/reader.py +++ b/src/basilisp/lang/reader.py @@ -41,7 +41,7 @@ from basilisp.lang import symbol as sym from basilisp.lang import util as langutil from basilisp.lang import vector as vec -from basilisp.lang.exception import format_exception, format_source_context +from basilisp.lang.exception import format_exception from basilisp.lang.interfaces import ( ILispObject, ILookup, @@ -63,6 +63,7 @@ get_current_ns, lrepr, ) +from basilisp.lang.source import format_source_context from basilisp.lang.typing import IterableLispForm, LispForm, ReaderForm from basilisp.lang.util import munge from basilisp.util import Maybe, partition diff --git a/src/basilisp/lang/source.py b/src/basilisp/lang/source.py new file mode 100644 index 000000000..c5f6bd857 --- /dev/null +++ b/src/basilisp/lang/source.py @@ -0,0 +1,82 @@ +import linecache +import os +from typing import Optional + +try: + import pygments.formatters + import pygments.lexers + import pygments.styles +except ImportError: + + def _format_source(s: str) -> str: + return f"{s}\n" + +else: + + def _get_formatter_name() -> Optional[str]: + """Get the Pygments formatter name for formatting the source code by + inspecting various environment variables set by terminals. + + If `BASILISP_NO_COLOR` is set to a truthy value, use no formatting.""" + if os.environ.get("BASILISP_NO_COLOR", "false").lower() in {"1", "true"}: + return None + elif os.environ.get("COLORTERM", "") in ("truecolor", "24bit"): + return "terminal16m" + elif "256" in os.environ.get("TERM", ""): + return "terminal256" + else: + return "terminal" + + def _format_source(s: str) -> str: + """Format source code for terminal output.""" + if (formatter_name := _get_formatter_name()) is None: + return f"{s}\n" + return pygments.highlight( + s, + lexer=pygments.lexers.get_lexer_by_name("clojure"), + formatter=pygments.formatters.get_formatter_by_name( + formatter_name, style=pygments.styles.get_style_by_name("emacs") + ), + ) + + +def format_source_context( + filename: str, + line: int, + end_line: Optional[int] = None, + num_context_lines: int = 5, + show_cause_marker: bool = True, +) -> list[str]: + """Format source code context with line numbers and identifiers for the affected + line(s).""" + assert num_context_lines >= 0 + + lines = [] + + if not filename.startswith("<") and not filename.endswith(">"): + cause_range: Optional[range] + if not show_cause_marker: + cause_range = None + elif end_line is not None and end_line != line: + cause_range = range(line, end_line) + else: + cause_range = range(line, line) + + if source_lines := linecache.getlines(filename): + start = max(0, line - num_context_lines) + end = min((end_line or line) + num_context_lines, len(source_lines)) + num_justify = max(len(str(start)), len(str(end))) + 1 + for n, source_line in zip(range(start, end), source_lines[start:end]): + if cause_range is None: + line_marker = " " + elif n + 1 in cause_range: + line_marker = " > " + else: + line_marker = " " + + line_num = str(n + 1).rjust(num_justify) + lines.append( + f"{line_num}{line_marker}| {_format_source(source_line.rstrip())}" + ) + + return lines From 48517052b651871bcd4779256551f6ce36428e09 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Wed, 31 Jan 2024 08:35:32 -0500 Subject: [PATCH 08/20] Hook it --- src/basilisp/cli.py | 6 ++- src/basilisp/lang/compiler/exception.py | 9 ++-- src/basilisp/lang/exception.py | 7 +-- src/basilisp/lang/reader.py | 7 ++- src/basilisp/lang/runtime.py | 57 ++++++++++++++++++++++++- src/basilisp/lang/source.py | 4 +- src/basilisp/main.py | 2 + src/basilisp/prompt.py | 4 +- 8 files changed, 74 insertions(+), 22 deletions(-) diff --git a/src/basilisp/cli.py b/src/basilisp/cli.py index 0f29a3bf9..cc95efb14 100644 --- a/src/basilisp/cli.py +++ b/src/basilisp/cli.py @@ -450,11 +450,13 @@ def repl( prompter.print(runtime.lrepr(result)) repl_module.mark_repl_result(result) except reader.SyntaxError as e: - print_exception(reader.SyntaxError, e, e.__traceback__) + runtime.basilisp_except_hook(reader.SyntaxError, e, e.__traceback__) repl_module.mark_exception(e) continue except compiler.CompilerException as e: - print_exception(compiler.CompilerException, e, e.__traceback__) + runtime.basilisp_except_hook( + compiler.CompilerException, e, e.__traceback__ + ) repl_module.mark_exception(e) continue except Exception as e: # pylint: disable=broad-exception-caught diff --git a/src/basilisp/lang/compiler/exception.py b/src/basilisp/lang/compiler/exception.py index d55e53cb9..7ccdafe5a 100644 --- a/src/basilisp/lang/compiler/exception.py +++ b/src/basilisp/lang/compiler/exception.py @@ -2,7 +2,7 @@ import linecache from enum import Enum from types import TracebackType -from typing import Any, Dict, Optional, Type, Union +from typing import Any, Dict, List, Optional, Type, Union import attr @@ -113,12 +113,11 @@ def __str__(self): @format_exception.register(CompilerException) def format_compiler_exception( # pylint: disable=unused-argument e: CompilerException, tp: Type[Exception], tb: TracebackType -) -> list[str]: - """Return context notes for a Compiler Exception""" +) -> List[str]: + """Format a compiler exception as a list of newline-terminated strings.""" context_exc: Optional[BaseException] = e.__cause__ - lines = [] - lines.append("\n") + lines = ["\n"] if context_exc is not None: lines.append(f" exception: {type(context_exc)} from {type(e)}\n") else: diff --git a/src/basilisp/lang/exception.py b/src/basilisp/lang/exception.py index 6147b19a3..e815c4ba7 100644 --- a/src/basilisp/lang/exception.py +++ b/src/basilisp/lang/exception.py @@ -1,7 +1,7 @@ import functools import traceback from types import TracebackType -from typing import Optional, Type +from typing import List, Optional, Type import attr @@ -26,7 +26,7 @@ def __str__(self): @functools.singledispatch def format_exception( e: Optional[Exception], tp: Optional[Type[Exception]], tb: Optional[TracebackType] -) -> list[str]: +) -> List[str]: """Format an exception into something readable, returning a list of newline terminated strings. @@ -46,6 +46,3 @@ def print_exception( `basilisp.lang.reader.SyntaxError` have special handling to print useful information on exceptions.""" print("".join(format_exception(e, tp, tb))) - - -basilisp_excepthook = print_exception diff --git a/src/basilisp/lang/reader.py b/src/basilisp/lang/reader.py index e27394f6d..b94e83d2f 100644 --- a/src/basilisp/lang/reader.py +++ b/src/basilisp/lang/reader.py @@ -157,13 +157,12 @@ def __str__(self): @format_exception.register(SyntaxError) -def format_syntax_error( +def format_syntax_error( # pylint: disable=unused-argumentma e: SyntaxError, tp: Type[Exception], tb: TracebackType -) -> list[str]: +) -> List[str]: context_exc: Optional[BaseException] = e.__cause__ - lines = [] - lines.append("\n") + lines = ["\n"] if context_exc is not None: lines.append(f" exception: {type(context_exc)} from {type(e)}\n") else: diff --git a/src/basilisp/lang/runtime.py b/src/basilisp/lang/runtime.py index 6ad35a377..bc35a6df6 100644 --- a/src/basilisp/lang/runtime.py +++ b/src/basilisp/lang/runtime.py @@ -46,6 +46,7 @@ from basilisp.lang import symbol as sym from basilisp.lang import vector as vec from basilisp.lang.atom import Atom +from basilisp.lang.exception import print_exception from basilisp.lang.interfaces import ( IAssociative, IBlockingDeref, @@ -84,6 +85,8 @@ COMPILER_OPTIONS_VAR_NAME = "*compiler-options*" COMMAND_LINE_ARGS_VAR_NAME = "*command-line-args*" DEFAULT_READER_FEATURES_VAR_NAME = "*default-reader-features*" +DEFAULT_EXCEPT_HOOK_VAR_NAME = "default-except-hook" +EXCEPT_HOOK_VAR_NAME = "*except-hook*" GENERATED_PYTHON_VAR_NAME = "*generated-python*" PRINT_GENERATED_PY_VAR_NAME = "*print-generated-python*" MAIN_NS_VAR_NAME = "*main-ns*" @@ -2030,6 +2033,26 @@ def print_generated_python() -> bool: ) +################### +# Traceback Hooks # +################### + + +def basilisp_except_hook( + tp: Type[BaseException], + e: BaseException, + tb: Optional[types.TracebackType], +) -> None: + """Basilisp except hook which is installed as `sys.excepthook` to print any + unhandled tracebacks.""" + ns_sym = sym.symbol(EXCEPT_HOOK_VAR_NAME, ns=CORE_NS) + return ( + Maybe(Var.find(ns_sym)) + .map(lambda hook: hook(e, tp, tb)) + .or_else_raise(lambda: RuntimeException(f"Dynamic Var {ns_sym} not bound!")) + ) + + ######################### # Bootstrap the Runtime # ######################### @@ -2127,7 +2150,7 @@ def in_ns(s: sym.Symbol): ), ) - # Dynamic Var containing command line arguments passed via `basilisp run` + # Dynamic Var containing the name of the main namespace (if one is given) Var.intern( CORE_NS_SYM, sym.symbol(MAIN_NS_VAR_NAME), @@ -2162,6 +2185,38 @@ def in_ns(s: sym.Symbol): ), ) + # Var containing the original traceback printer + Var.intern( + CORE_NS_SYM, + sym.symbol(DEFAULT_EXCEPT_HOOK_VAR_NAME), + print_exception, + meta=lmap.map( + { + _DOC_META_KEY: ( + f"The default value of :lpy:var:`{EXCEPT_HOOK_VAR_NAME}`." + ) + } + ), + ) + + # Dynamic Var containing the current traceback printer + Var.intern( + CORE_NS_SYM, + sym.symbol(EXCEPT_HOOK_VAR_NAME), + print_exception, + dynamic=True, + meta=lmap.map( + { + _DOC_META_KEY: ( + "A function which controls the printing of exceptions. " + "The default function has special handling for compiler errors " + "and reader syntax errors. All other exception types are printed " + "as by `traceback.print_exception `_." + ) + } + ), + ) + # Dynamic Vars examined by the compiler for generating Python code for debugging Var.intern( CORE_NS_SYM, diff --git a/src/basilisp/lang/source.py b/src/basilisp/lang/source.py index c5f6bd857..1ea950d1b 100644 --- a/src/basilisp/lang/source.py +++ b/src/basilisp/lang/source.py @@ -1,6 +1,6 @@ import linecache import os -from typing import Optional +from typing import List, Optional try: import pygments.formatters @@ -46,7 +46,7 @@ def format_source_context( end_line: Optional[int] = None, num_context_lines: int = 5, show_cause_marker: bool = True, -) -> list[str]: +) -> List[str]: """Format source code context with line numbers and identifiers for the affected line(s).""" assert num_context_lines >= 0 diff --git a/src/basilisp/main.py b/src/basilisp/main.py index 671efee85..69877f3b9 100644 --- a/src/basilisp/main.py +++ b/src/basilisp/main.py @@ -1,6 +1,7 @@ import importlib import logging import site +import sys from pathlib import Path from typing import List, Optional @@ -29,6 +30,7 @@ def init(opts: Optional[CompilerOpts] = None) -> None: """ runtime.init_ns_var() runtime.bootstrap_core(opts if opts is not None else compiler_opts()) + sys.excepthook = runtime.basilisp_except_hook importer.hook_imports() importlib.import_module("basilisp.core") diff --git a/src/basilisp/prompt.py b/src/basilisp/prompt.py index 3e09dd51c..5a1ae5819 100644 --- a/src/basilisp/prompt.py +++ b/src/basilisp/prompt.py @@ -2,7 +2,6 @@ import os import re -import traceback from functools import partial from types import MappingProxyType from typing import Any, Iterable, Mapping, Optional, Type @@ -103,8 +102,7 @@ def _(event: KeyPressEvent) -> None: event.current_buffer.insert_text("\n") except reader.SyntaxError as e: run_in_terminal( - partial( - print_exception, + lambda: runtime.basilisp_except_hook( reader.SyntaxError, e, e.__traceback__, From 623cdbf8330e72ef5a3d7dbc482a1251de0a752f Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Wed, 31 Jan 2024 08:39:06 -0500 Subject: [PATCH 09/20] yeah --- src/basilisp/prompt.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/basilisp/prompt.py b/src/basilisp/prompt.py index 5a1ae5819..112205ef5 100644 --- a/src/basilisp/prompt.py +++ b/src/basilisp/prompt.py @@ -2,7 +2,6 @@ import os import re -from functools import partial from types import MappingProxyType from typing import Any, Iterable, Mapping, Optional, Type @@ -17,7 +16,6 @@ from basilisp.lang import reader as reader from basilisp.lang import runtime as runtime -from basilisp.lang.exception import print_exception _USER_DATA_HOME = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share")) BASILISP_USER_DATA = os.path.abspath(os.path.join(_USER_DATA_HOME, "basilisp")) From d7fe072e4515797529e6deb0647dacf56ac2750f Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Wed, 31 Jan 2024 09:00:21 -0500 Subject: [PATCH 10/20] Fixes on fixes --- src/basilisp/cli.py | 1 - src/basilisp/lang/compiler/exception.py | 3 +-- src/basilisp/lang/reader.py | 2 +- src/basilisp/lang/runtime.py | 12 +++++++----- src/basilisp/lang/source.py | 2 +- src/basilisp/prompt.py | 4 +++- 6 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/basilisp/cli.py b/src/basilisp/cli.py index cc95efb14..ffb5b9eeb 100644 --- a/src/basilisp/cli.py +++ b/src/basilisp/cli.py @@ -15,7 +15,6 @@ from basilisp.lang import runtime as runtime from basilisp.lang import symbol as sym from basilisp.lang import vector as vec -from basilisp.lang.exception import print_exception from basilisp.prompt import get_prompter CLI_INPUT_FILE_PATH = "" diff --git a/src/basilisp/lang/compiler/exception.py b/src/basilisp/lang/compiler/exception.py index 7ccdafe5a..c05b1f9ed 100644 --- a/src/basilisp/lang/compiler/exception.py +++ b/src/basilisp/lang/compiler/exception.py @@ -1,5 +1,4 @@ import ast -import linecache from enum import Enum from types import TracebackType from typing import Any, Dict, List, Optional, Type, Union @@ -111,7 +110,7 @@ def __str__(self): @format_exception.register(CompilerException) -def format_compiler_exception( # pylint: disable=unused-argument +def format_compiler_exception( # pylint: disable=too-many-branches,unused-argument e: CompilerException, tp: Type[Exception], tb: TracebackType ) -> List[str]: """Format a compiler exception as a list of newline-terminated strings.""" diff --git a/src/basilisp/lang/reader.py b/src/basilisp/lang/reader.py index b94e83d2f..ef4430f5d 100644 --- a/src/basilisp/lang/reader.py +++ b/src/basilisp/lang/reader.py @@ -157,7 +157,7 @@ def __str__(self): @format_exception.register(SyntaxError) -def format_syntax_error( # pylint: disable=unused-argumentma +def format_syntax_error( # pylint: disable=unused-argument e: SyntaxError, tp: Type[Exception], tb: TracebackType ) -> List[str]: context_exc: Optional[BaseException] = e.__cause__ diff --git a/src/basilisp/lang/runtime.py b/src/basilisp/lang/runtime.py index bc35a6df6..ea643ff64 100644 --- a/src/basilisp/lang/runtime.py +++ b/src/basilisp/lang/runtime.py @@ -2046,11 +2046,13 @@ def basilisp_except_hook( """Basilisp except hook which is installed as `sys.excepthook` to print any unhandled tracebacks.""" ns_sym = sym.symbol(EXCEPT_HOOK_VAR_NAME, ns=CORE_NS) - return ( - Maybe(Var.find(ns_sym)) - .map(lambda hook: hook(e, tp, tb)) - .or_else_raise(lambda: RuntimeException(f"Dynamic Var {ns_sym} not bound!")) - ) + if (hook := Var.find(ns_sym)) is not None: + hook(e, tp, tb) + else: + # Emit an error to stderr and fall back to the default Python except hook + # if no hook is currently defined in Basilisp. + sys.stderr.write(f"Dynamic Var {ns_sym} not bound!") + sys.__excepthook__(tp, e, tb) ######################### diff --git a/src/basilisp/lang/source.py b/src/basilisp/lang/source.py index 1ea950d1b..bd59735f9 100644 --- a/src/basilisp/lang/source.py +++ b/src/basilisp/lang/source.py @@ -20,7 +20,7 @@ def _get_formatter_name() -> Optional[str]: If `BASILISP_NO_COLOR` is set to a truthy value, use no formatting.""" if os.environ.get("BASILISP_NO_COLOR", "false").lower() in {"1", "true"}: return None - elif os.environ.get("COLORTERM", "") in ("truecolor", "24bit"): + elif os.environ.get("COLORTERM", "") in {"truecolor", "24bit"}: return "terminal16m" elif "256" in os.environ.get("TERM", ""): return "terminal256" diff --git a/src/basilisp/prompt.py b/src/basilisp/prompt.py index 112205ef5..81672f250 100644 --- a/src/basilisp/prompt.py +++ b/src/basilisp/prompt.py @@ -2,6 +2,7 @@ import os import re +from functools import partial from types import MappingProxyType from typing import Any, Iterable, Mapping, Optional, Type @@ -100,7 +101,8 @@ def _(event: KeyPressEvent) -> None: event.current_buffer.insert_text("\n") except reader.SyntaxError as e: run_in_terminal( - lambda: runtime.basilisp_except_hook( + partial( + runtime.basilisp_except_hook, reader.SyntaxError, e, e.__traceback__, From aecfce87a68b5f8ece2f9f43170c6f72ce4d0c6d Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Wed, 31 Jan 2024 09:05:56 -0500 Subject: [PATCH 11/20] And another one --- src/basilisp/cli.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/basilisp/cli.py b/src/basilisp/cli.py index ffb5b9eeb..fcf3e41fe 100644 --- a/src/basilisp/cli.py +++ b/src/basilisp/cli.py @@ -4,7 +4,6 @@ import os import sys import textwrap -import traceback import types from pathlib import Path from typing import Any, Callable, Optional, Sequence, Type @@ -459,7 +458,7 @@ def repl( repl_module.mark_exception(e) continue except Exception as e: # pylint: disable=broad-exception-caught - traceback.print_exception(Exception, e, e.__traceback__) + runtime.basilisp_except_hook(type(e), e, e.__traceback__) repl_module.mark_exception(e) continue From 81d5508ff1d5b8f86dc7cef60a4e0d157c6a3de4 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Thu, 8 Feb 2024 11:10:30 -0500 Subject: [PATCH 12/20] More stuff --- src/basilisp/cli.py | 8 +++---- src/basilisp/lang/runtime.py | 43 ++++++++++++++++++++++++------------ src/basilisp/main.py | 2 +- src/basilisp/prompt.py | 3 ++- 4 files changed, 35 insertions(+), 21 deletions(-) diff --git a/src/basilisp/cli.py b/src/basilisp/cli.py index fcf3e41fe..cd3c5848c 100644 --- a/src/basilisp/cli.py +++ b/src/basilisp/cli.py @@ -448,17 +448,15 @@ def repl( prompter.print(runtime.lrepr(result)) repl_module.mark_repl_result(result) except reader.SyntaxError as e: - runtime.basilisp_except_hook(reader.SyntaxError, e, e.__traceback__) + sys.excepthook(reader.SyntaxError, e, e.__traceback__) repl_module.mark_exception(e) continue except compiler.CompilerException as e: - runtime.basilisp_except_hook( - compiler.CompilerException, e, e.__traceback__ - ) + sys.excepthook(compiler.CompilerException, e, e.__traceback__) repl_module.mark_exception(e) continue except Exception as e: # pylint: disable=broad-exception-caught - runtime.basilisp_except_hook(type(e), e, e.__traceback__) + sys.excepthook(type(e), e, e.__traceback__) repl_module.mark_exception(e) continue diff --git a/src/basilisp/lang/runtime.py b/src/basilisp/lang/runtime.py index ea643ff64..a23130d1a 100644 --- a/src/basilisp/lang/runtime.py +++ b/src/basilisp/lang/runtime.py @@ -9,6 +9,7 @@ import logging import math import numbers +import os import platform import re import sys @@ -2038,21 +2039,35 @@ def print_generated_python() -> bool: ################### -def basilisp_except_hook( - tp: Type[BaseException], - e: BaseException, - tb: Optional[types.TracebackType], -) -> None: - """Basilisp except hook which is installed as `sys.excepthook` to print any - unhandled tracebacks.""" +def create_basilisp_except_hook(): + """Create an except hook which is suitable to being installed as `sys.excepthook` + to print any unhandled tracebacks. + + This function must be called after bootstrapping `basilisp.core`.""" ns_sym = sym.symbol(EXCEPT_HOOK_VAR_NAME, ns=CORE_NS) - if (hook := Var.find(ns_sym)) is not None: - hook(e, tp, tb) - else: - # Emit an error to stderr and fall back to the default Python except hook - # if no hook is currently defined in Basilisp. - sys.stderr.write(f"Dynamic Var {ns_sym} not bound!") - sys.__excepthook__(tp, e, tb) + hook_var = Var.find(ns_sym) + assert hook_var is not None + + def basilisp_except_hook( + tp: Type[BaseException], + e: BaseException, + tb: Optional[types.TracebackType], + ) -> None: + if (hook := hook_var.value) is not None: + try: + hook(e, tp, tb) + except Exception: + sys.__stderr__.write( + f"Exception occurred in Basilisp except hook {ns_sym}{os.linesep}" + ) + sys.__excepthook__(tp, e, tb) + else: + # Emit an error to stderr and fall back to the default Python except hook + # if no hook is currently defined in Basilisp. + sys.__stderr__.write(f"Dynamic Var {ns_sym} not bound!{os.linesep}") + sys.__excepthook__(tp, e, tb) + + return basilisp_except_hook ######################### diff --git a/src/basilisp/main.py b/src/basilisp/main.py index 69877f3b9..6b0fe7bef 100644 --- a/src/basilisp/main.py +++ b/src/basilisp/main.py @@ -30,7 +30,7 @@ def init(opts: Optional[CompilerOpts] = None) -> None: """ runtime.init_ns_var() runtime.bootstrap_core(opts if opts is not None else compiler_opts()) - sys.excepthook = runtime.basilisp_except_hook + sys.excepthook = runtime.create_basilisp_except_hook() importer.hook_imports() importlib.import_module("basilisp.core") diff --git a/src/basilisp/prompt.py b/src/basilisp/prompt.py index 81672f250..8add926b2 100644 --- a/src/basilisp/prompt.py +++ b/src/basilisp/prompt.py @@ -2,6 +2,7 @@ import os import re +import sys from functools import partial from types import MappingProxyType from typing import Any, Iterable, Mapping, Optional, Type @@ -102,7 +103,7 @@ def _(event: KeyPressEvent) -> None: except reader.SyntaxError as e: run_in_terminal( partial( - runtime.basilisp_except_hook, + sys.excepthook, reader.SyntaxError, e, e.__traceback__, From 71b0c7de0035e6d30fc54430d0be492648b98a93 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Mon, 12 Feb 2024 21:33:08 -0500 Subject: [PATCH 13/20] Several small things --- src/basilisp/core.lpy | 7 ++++--- src/basilisp/lang/runtime.py | 15 --------------- src/basilisp/stacktrace.lpy | 6 +++++- 3 files changed, 9 insertions(+), 19 deletions(-) diff --git a/src/basilisp/core.lpy b/src/basilisp/core.lpy index 9054717ba..04b656871 100644 --- a/src/basilisp/core.lpy +++ b/src/basilisp/core.lpy @@ -4171,7 +4171,7 @@ ([] (.write *out* os/linesep) (when *flush-on-newline* - (.flush *out*)) + (.flush *out*)) nil) ([x] (let [stdout *out*] @@ -4402,11 +4402,12 @@ (with-open [f (python/open path ** :mode "r")] (load-reader f))) -(defn- resolve-load-path [path] +(defn- resolve-load-path "Resolve load ``path`` relative to the current namespace, or, if it begins with \"/\", relative to the syspath, and return it. - Throw a python/FileNotFoundError if the ``path`` cannot be resolved." + Throw a ``python/FileNotFoundError`` if the ``path`` cannot be resolved." + [path] (let [path-rel (if (.startswith path "/") path (let [path-parent (-> (name *ns*) diff --git a/src/basilisp/lang/runtime.py b/src/basilisp/lang/runtime.py index a23130d1a..b62bdce1b 100644 --- a/src/basilisp/lang/runtime.py +++ b/src/basilisp/lang/runtime.py @@ -86,7 +86,6 @@ COMPILER_OPTIONS_VAR_NAME = "*compiler-options*" COMMAND_LINE_ARGS_VAR_NAME = "*command-line-args*" DEFAULT_READER_FEATURES_VAR_NAME = "*default-reader-features*" -DEFAULT_EXCEPT_HOOK_VAR_NAME = "default-except-hook" EXCEPT_HOOK_VAR_NAME = "*except-hook*" GENERATED_PYTHON_VAR_NAME = "*generated-python*" PRINT_GENERATED_PY_VAR_NAME = "*print-generated-python*" @@ -2202,20 +2201,6 @@ def in_ns(s: sym.Symbol): ), ) - # Var containing the original traceback printer - Var.intern( - CORE_NS_SYM, - sym.symbol(DEFAULT_EXCEPT_HOOK_VAR_NAME), - print_exception, - meta=lmap.map( - { - _DOC_META_KEY: ( - f"The default value of :lpy:var:`{EXCEPT_HOOK_VAR_NAME}`." - ) - } - ), - ) - # Dynamic Var containing the current traceback printer Var.intern( CORE_NS_SYM, diff --git a/src/basilisp/stacktrace.lpy b/src/basilisp/stacktrace.lpy index da0c5c86e..c53308621 100644 --- a/src/basilisp/stacktrace.lpy +++ b/src/basilisp/stacktrace.lpy @@ -1,5 +1,9 @@ (ns basilisp.stacktrace - "Utility functions for printing stack traces." + "Utility functions for printing stack traces. + + Stack traces printed by the functions in this namespace do not respect the function + bound to :lpy:var:`*except-hook*`. There is no special handling for reader syntax + errors or compiler exceptions." (:require [basilisp.string :as str]) (:import [traceback :as tb])) From c5034bc6901f7fc36f8a371a8663d1d2592693aa Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Wed, 14 Feb 2024 16:10:56 -0500 Subject: [PATCH 14/20] Quiet now --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 6a8050590..69776f15d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,7 @@ branch = true omit = [ "*/__version__.py", "*/basilisp/contrib/sphinx/*", + "*/basilisp/sitecustomize.py", ] [tool.coverage.paths] From 1207b58653ab865ff72fdc60cda80583d44fa5a8 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Wed, 14 Feb 2024 17:14:56 -0500 Subject: [PATCH 15/20] More --- src/basilisp/cli.py | 10 +-- src/basilisp/lang/exception.py | 12 +++- src/basilisp/lang/runtime.py | 112 +++++++++++++++++++++++++++++---- src/basilisp/prompt.py | 31 ++++++--- tests/basilisp/prompt_test.py | 2 +- 5 files changed, 136 insertions(+), 31 deletions(-) diff --git a/src/basilisp/cli.py b/src/basilisp/cli.py index b7e7daf17..421dcdff2 100644 --- a/src/basilisp/cli.py +++ b/src/basilisp/cli.py @@ -14,7 +14,6 @@ from basilisp.lang import runtime as runtime from basilisp.lang import symbol as sym from basilisp.lang import vector as vec -from basilisp.lang.exception import print_exception from basilisp.prompt import get_prompter CLI_INPUT_FILE_PATH = "" @@ -396,8 +395,9 @@ def repl( warn_on_var_indirection=args.warn_on_var_indirection, ) basilisp.init(opts) + print_exception = runtime.get_basilisp_repl_exception_hook() ctx = compiler.CompilerContext(filename=REPL_INPUT_FILE_PATH, opts=opts) - prompter = get_prompter() + prompter = get_prompter(print_exception=print_exception) eof = object() # Bind user-settable dynamic Vars to their existing value to allow users to @@ -449,15 +449,15 @@ def repl( prompter.print(runtime.lrepr(result)) repl_module.mark_repl_result(result) except reader.SyntaxError as e: - print_exception(e, reader.SyntaxError, e.__traceback__) + print_exception(reader.SyntaxError, e, e.__traceback__) repl_module.mark_exception(e) continue except compiler.CompilerException as e: - print_exception(e, compiler.CompilerException, e.__traceback__) + print_exception(compiler.CompilerException, e, e.__traceback__) repl_module.mark_exception(e) continue except Exception as e: # pylint: disable=broad-exception-caught - print_exception(e, Exception, e.__traceback__) + print_exception(type(e), e, e.__traceback__) repl_module.mark_exception(e) continue diff --git a/src/basilisp/lang/exception.py b/src/basilisp/lang/exception.py index 157d4f64a..4a499662f 100644 --- a/src/basilisp/lang/exception.py +++ b/src/basilisp/lang/exception.py @@ -1,8 +1,9 @@ import functools import sys import traceback +import types from types import TracebackType -from typing import List, Optional, Type +from typing import Callable, List, Optional, Type import attr @@ -24,6 +25,11 @@ def __str__(self): return f"{self.message} {lrepr(self.data)}" +ExceptionPrinter = Callable[ + [Type[BaseException], BaseException, Optional[types.TracebackType]], None +] + + @functools.singledispatch def format_exception( e: Optional[BaseException], @@ -45,9 +51,9 @@ def format_exception( def print_exception( + tp: Optional[Type[BaseException]], e: Optional[BaseException], - tp: Optional[Type[BaseException]] = None, - tb: Optional[TracebackType] = None, + tb: Optional[TracebackType], ) -> None: """Print the given exception `e` using Basilisp's own exception formatting. diff --git a/src/basilisp/lang/runtime.py b/src/basilisp/lang/runtime.py index b62bdce1b..af4fb8718 100644 --- a/src/basilisp/lang/runtime.py +++ b/src/basilisp/lang/runtime.py @@ -9,10 +9,10 @@ import logging import math import numbers -import os import platform import re import sys +import textwrap import threading import types from collections.abc import Sequence, Sized @@ -47,7 +47,7 @@ from basilisp.lang import symbol as sym from basilisp.lang import vector as vec from basilisp.lang.atom import Atom -from basilisp.lang.exception import print_exception +from basilisp.lang.exception import ExceptionPrinter, print_exception from basilisp.lang.interfaces import ( IAssociative, IBlockingDeref, @@ -87,6 +87,7 @@ COMMAND_LINE_ARGS_VAR_NAME = "*command-line-args*" DEFAULT_READER_FEATURES_VAR_NAME = "*default-reader-features*" EXCEPT_HOOK_VAR_NAME = "*except-hook*" +REPL_EXCEPT_HOOK_VAR_NAME = "*repl-except-hook*" GENERATED_PYTHON_VAR_NAME = "*generated-python*" PRINT_GENERATED_PY_VAR_NAME = "*print-generated-python*" MAIN_NS_VAR_NAME = "*main-ns*" @@ -2038,7 +2039,7 @@ def print_generated_python() -> bool: ################### -def create_basilisp_except_hook(): +def create_basilisp_except_hook() -> ExceptionPrinter: """Create an except hook which is suitable to being installed as `sys.excepthook` to print any unhandled tracebacks. @@ -2054,16 +2055,49 @@ def basilisp_except_hook( ) -> None: if (hook := hook_var.value) is not None: try: - hook(e, tp, tb) + hook(tp, e, tb) except Exception: - sys.__stderr__.write( - f"Exception occurred in Basilisp except hook {ns_sym}{os.linesep}" + print( + f"Exception occurred in Basilisp except hook {ns_sym}", + file=sys.__stderr__, ) sys.__excepthook__(tp, e, tb) else: # Emit an error to stderr and fall back to the default Python except hook # if no hook is currently defined in Basilisp. - sys.__stderr__.write(f"Dynamic Var {ns_sym} not bound!{os.linesep}") + print(f"Dynamic Var {ns_sym} not bound!", file=sys.__stderr__) + sys.__excepthook__(tp, e, tb) + + return basilisp_except_hook + + +def get_basilisp_repl_exception_hook() -> ExceptionPrinter: + """Create an exception hook which can be used to print exception tracebacks for + interactive REPL sessions. + + This function must be called after bootstrapping `basilisp.core`.""" + ns_sym = sym.symbol(REPL_EXCEPT_HOOK_VAR_NAME, ns=CORE_NS) + hook_var = Var.find(ns_sym) + assert hook_var is not None + + def basilisp_except_hook( + tp: Type[BaseException], + e: BaseException, + tb: Optional[types.TracebackType], + ) -> None: + if (hook := hook_var.value) is not None: + try: + hook(tp, e, tb) + except Exception: + print( + f"Exception occurred in Basilisp REPL except hook {ns_sym}", + file=sys.__stderr__, + ) + sys.__excepthook__(tp, e, tb) + else: + # Emit an error to stderr and fall back to the default Python except hook + # if no hook is currently defined in Basilisp. + print(f"Dynamic Var {ns_sym} not bound!", file=sys.__stderr__) sys.__excepthook__(tp, e, tb) return basilisp_except_hook @@ -2201,7 +2235,7 @@ def in_ns(s: sym.Symbol): ), ) - # Dynamic Var containing the current traceback printer + # Dynamic Vars containing the current traceback printers Var.intern( CORE_NS_SYM, sym.symbol(EXCEPT_HOOK_VAR_NAME), @@ -2209,11 +2243,63 @@ def in_ns(s: sym.Symbol): dynamic=True, meta=lmap.map( { - _DOC_META_KEY: ( - "A function which controls the printing of exceptions. " - "The default function has special handling for compiler errors " - "and reader syntax errors. All other exception types are printed " - "as by `traceback.print_exception `_." + _DOC_META_KEY: textwrap.indent( + textwrap.dedent( + f""" + A function of 3 arguments which controls the printing of unhandled + exceptions. + + The three arguments should be in order: an exception *type*, an + exception *instance*, and an exception *traceback* (e.g. the value + of the ``__traceback__`` attribute). In the majority of cases, only + the second argument is needed but nevertheless ``sys.excepthook`` + is called with all 3 in that order. + + The default function has special handling for compiler errors + and reader syntax errors. All other exception types are printed + as by `traceback.print_exception `_. + + After bootstrapping the runtime, Python's `sys.excepthook `_ + will use the function bound to this Var for printing any unhandled + exceptions, including those not originating from Basilisp code. + + For controlling the display of exceptions occurring during REPL + sessions, see :lpy:var:`{REPL_EXCEPT_HOOK_VAR_NAME}`. + """ + ).strip(), + " ", + ) + } + ), + ) + Var.intern( + CORE_NS_SYM, + sym.symbol(REPL_EXCEPT_HOOK_VAR_NAME), + print_exception, + dynamic=True, + meta=lmap.map( + { + _DOC_META_KEY: textwrap.indent( + textwrap.dedent( + f""" + A function of 3 arguments which controls the printing of exceptions + thrown during interactive REPL sessions. + + The three arguments should be in order: an exception *type*, an + exception *instance*, and an exception *traceback* (e.g. the value + of the ``__traceback__`` attribute). In the majority of cases, only + the second argument is needed but nevertheless ``sys.excepthook`` + is called with all 3 in that order. + + The default function has special handling for compiler errors + and reader syntax errors. All other exception types are printed + as by `traceback.print_exception `_. + + This Var is distinct from :lpy:var:`{EXCEPT_HOOK_VAR_NAME}`, which controls + printing unhandled exceptions occurring outside the REPL context. + """ + ).strip(), + " ", ) } ), diff --git a/src/basilisp/prompt.py b/src/basilisp/prompt.py index ca6ad3ee9..4eb601bb5 100644 --- a/src/basilisp/prompt.py +++ b/src/basilisp/prompt.py @@ -2,9 +2,11 @@ import os import re +import traceback +import types from functools import partial from types import MappingProxyType -from typing import Any, Iterable, Mapping, Optional, Type +from typing import Any, Callable, Iterable, Mapping, Optional, Type from prompt_toolkit import PromptSession from prompt_toolkit.application import run_in_terminal @@ -17,7 +19,7 @@ from basilisp.lang import reader as reader from basilisp.lang import runtime as runtime -from basilisp.lang.exception import print_exception +from basilisp.lang.exception import ExceptionPrinter _USER_DATA_HOME = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share")) BASILISP_USER_DATA = os.path.abspath(os.path.join(_USER_DATA_HOME, "basilisp")) @@ -30,7 +32,13 @@ class Prompter: - __slots__ = () + __slots__ = ("_print_exception",) + + def __init__( + self, + print_exception: ExceptionPrinter = traceback.print_exception, + ): + self._print_exception = print_exception def prompt(self, msg: str) -> str: """Prompt the user for input with the input string `msg`.""" @@ -65,7 +73,11 @@ class PromptToolkitPrompter(Prompter): __slots__ = ("_session",) - def __init__(self): + def __init__( + self, + print_exception: ExceptionPrinter = traceback.print_exception, + ): + super().__init__(print_exception) self._session: PromptSession = PromptSession( auto_suggest=AutoSuggestFromHistory(), completer=REPLCompleter(), @@ -76,8 +88,7 @@ def __init__(self): **self._style_settings, ) - @staticmethod - def _get_key_bindings() -> KeyBindings: + def _get_key_bindings(self) -> KeyBindings: """Return `KeyBindings` which override the builtin `enter` handler to allow multi-line input. @@ -103,7 +114,7 @@ def _(event: KeyPressEvent) -> None: except reader.SyntaxError as e: run_in_terminal( partial( - print_exception, + self._print_exception, reader.SyntaxError, e, e.__traceback__, @@ -162,12 +173,14 @@ def print(self, msg: str) -> None: _DEFAULT_PROMPTER = StyledPromptToolkitPrompter -def get_prompter() -> Prompter: +def get_prompter( + print_exception: ExceptionPrinter = traceback.print_exception, +) -> Prompter: """Return a Prompter instance for reading user input from the REPL. Prompter instances may be stateful, so the Prompter instance returned by this function can be reused within a single REPL session.""" - return _DEFAULT_PROMPTER() + return _DEFAULT_PROMPTER(print_exception) __all__ = ["Prompter", "get_prompter"] diff --git a/tests/basilisp/prompt_test.py b/tests/basilisp/prompt_test.py index 2b06e1893..d4c87393d 100644 --- a/tests/basilisp/prompt_test.py +++ b/tests/basilisp/prompt_test.py @@ -176,7 +176,7 @@ def make_key_press_event(self, text: str): @pytest.fixture(scope="class") def handler(self) -> Binding: - kb = PromptToolkitPrompter._get_key_bindings() + kb = PromptToolkitPrompter()._get_key_bindings() handler, *_ = kb.get_bindings_for_keys((Keys.ControlM,)) return handler From fd7dfaea65a7b251d8fae0caaf4f596ceb5543f2 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Wed, 14 Feb 2024 18:29:09 -0500 Subject: [PATCH 16/20] Test --- src/basilisp/lang/runtime.py | 53 ++++++++++++---------------------- src/basilisp/prompt.py | 3 +- tests/basilisp/runtime_test.py | 50 ++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 36 deletions(-) diff --git a/src/basilisp/lang/runtime.py b/src/basilisp/lang/runtime.py index af4fb8718..6760672fc 100644 --- a/src/basilisp/lang/runtime.py +++ b/src/basilisp/lang/runtime.py @@ -2039,14 +2039,9 @@ def print_generated_python() -> bool: ################### -def create_basilisp_except_hook() -> ExceptionPrinter: - """Create an except hook which is suitable to being installed as `sys.excepthook` - to print any unhandled tracebacks. - - This function must be called after bootstrapping `basilisp.core`.""" - ns_sym = sym.symbol(EXCEPT_HOOK_VAR_NAME, ns=CORE_NS) - hook_var = Var.find(ns_sym) - assert hook_var is not None +def _create_except_hook_fn(hook_var: Var) -> ExceptionPrinter: + """Create an except hook function which is mostly compatible with + `traceback.print_exception` which uses the function from the Var `hook_var`.""" def basilisp_except_hook( tp: Type[BaseException], @@ -2056,21 +2051,32 @@ def basilisp_except_hook( if (hook := hook_var.value) is not None: try: hook(tp, e, tb) - except Exception: + except Exception: # pylint: disable=broad-exception-caught print( - f"Exception occurred in Basilisp except hook {ns_sym}", - file=sys.__stderr__, + f"Exception occurred in Basilisp except hook {hook_var}", + file=sys.stderr, ) sys.__excepthook__(tp, e, tb) else: # Emit an error to stderr and fall back to the default Python except hook # if no hook is currently defined in Basilisp. - print(f"Dynamic Var {ns_sym} not bound!", file=sys.__stderr__) + print(f"Dynamic Var {hook_var} not bound!", file=sys.stderr) sys.__excepthook__(tp, e, tb) return basilisp_except_hook +def create_basilisp_except_hook() -> ExceptionPrinter: + """Create an except hook which is suitable to being installed as `sys.excepthook` + to print any unhandled tracebacks. + + This function must be called after bootstrapping `basilisp.core`.""" + ns_sym = sym.symbol(EXCEPT_HOOK_VAR_NAME, ns=CORE_NS) + hook_var = Var.find(ns_sym) + assert hook_var is not None + return _create_except_hook_fn(hook_var) + + def get_basilisp_repl_exception_hook() -> ExceptionPrinter: """Create an exception hook which can be used to print exception tracebacks for interactive REPL sessions. @@ -2079,28 +2085,7 @@ def get_basilisp_repl_exception_hook() -> ExceptionPrinter: ns_sym = sym.symbol(REPL_EXCEPT_HOOK_VAR_NAME, ns=CORE_NS) hook_var = Var.find(ns_sym) assert hook_var is not None - - def basilisp_except_hook( - tp: Type[BaseException], - e: BaseException, - tb: Optional[types.TracebackType], - ) -> None: - if (hook := hook_var.value) is not None: - try: - hook(tp, e, tb) - except Exception: - print( - f"Exception occurred in Basilisp REPL except hook {ns_sym}", - file=sys.__stderr__, - ) - sys.__excepthook__(tp, e, tb) - else: - # Emit an error to stderr and fall back to the default Python except hook - # if no hook is currently defined in Basilisp. - print(f"Dynamic Var {ns_sym} not bound!", file=sys.__stderr__) - sys.__excepthook__(tp, e, tb) - - return basilisp_except_hook + return _create_except_hook_fn(hook_var) ######################### diff --git a/src/basilisp/prompt.py b/src/basilisp/prompt.py index 4eb601bb5..f972beed1 100644 --- a/src/basilisp/prompt.py +++ b/src/basilisp/prompt.py @@ -3,10 +3,9 @@ import os import re import traceback -import types from functools import partial from types import MappingProxyType -from typing import Any, Callable, Iterable, Mapping, Optional, Type +from typing import Any, Iterable, Mapping, Optional, Type from prompt_toolkit import PromptSession from prompt_toolkit.application import run_in_terminal diff --git a/tests/basilisp/runtime_test.py b/tests/basilisp/runtime_test.py index df460a1e0..d95b4e578 100644 --- a/tests/basilisp/runtime_test.py +++ b/tests/basilisp/runtime_test.py @@ -1,5 +1,6 @@ import platform import sys +import traceback from decimal import Decimal from fractions import Fraction @@ -741,3 +742,52 @@ def test_resolve_alias(self, core_ns): ) == runtime.resolve_alias( sym.symbol("non-existent-alias-var", ns="wee.woo"), ns=ns ) + + +class TestExceptHook: + @pytest.fixture + def hook_var(self) -> runtime.Var: + ns_name = "basilisp.except-hook-test" + ns_sym = sym.symbol(ns_name) + yield runtime.Var.intern(ns_sym, sym.symbol("*except-hook*"), None) + + def test_hook_value_is_nil(self, hook_var: runtime.Var, capsys): + hook = runtime._create_except_hook_fn(hook_var) + + try: + raise ValueError("Woo!") + except ValueError as e: + hook(ValueError, e, e.__traceback__) + + res = capsys.readouterr() + assert f"Dynamic Var {hook_var} not bound!" in res.err + + def test_hook_throws_exception(self, hook_var: runtime.Var, capsys): + def _bad_except_hook(tp, e, tb): + raise TypeError("I'm bad at my job!") + + hook_var.bind_root(_bad_except_hook) + hook = runtime._create_except_hook_fn(hook_var) + + try: + raise ValueError("Woo!") + except ValueError as e: + hook(ValueError, e, e.__traceback__) + + res = capsys.readouterr() + assert f"Exception occurred in Basilisp except hook {hook_var}" in res.err + + def test_hook_success(self, hook_var: runtime.Var, capsys): + hook_var.bind_root(traceback.print_exception) + hook = runtime._create_except_hook_fn(hook_var) + + try: + raise ValueError("Woo!") + except ValueError as e: + hook(ValueError, e, e.__traceback__) + + res = capsys.readouterr() + assert ( + "".join(traceback.format_exception(ValueError, e, e.__traceback__)) + == res.err + ) From e098351f44baeaf4617459ca65d8745720e8cc01 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Wed, 14 Feb 2024 18:32:00 -0500 Subject: [PATCH 17/20] Yeah --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0b241d30..03f11da2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added support for binding destructuring in `for` bindings (#774) * Added `==` as an alias to `=` (#859) * 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) - * Added `basilisp.core/*except-hook*` to allow users the ability to customize behaviors for printing unhandled exceptions (#???) + * Added `basilisp.core/*except-hook*` to allow users the ability to customize behaviors for printing unhandled exceptions (#873) ### Changed * Cause exceptions arising from compilation issues during macroexpansion will no longer be nested for each level of macroexpansion (#852) From cd5004395d09224ed3f9380680a51f3ed9b89396 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Wed, 14 Feb 2024 18:32:09 -0500 Subject: [PATCH 18/20] Seriously --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03f11da2e..b0384a62a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added support for binding destructuring in `for` bindings (#774) * Added `==` as an alias to `=` (#859) * 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) - * Added `basilisp.core/*except-hook*` to allow users the ability to customize behaviors for printing unhandled exceptions (#873) + * Added `basilisp.core/*except-hook*` and `basilisp.core/*repl-except-hook*` to allow users the ability to customize behaviors for printing unhandled exceptions (#873) ### Changed * Cause exceptions arising from compilation issues during macroexpansion will no longer be nested for each level of macroexpansion (#852) From 574dbaafb4a89514ae66d419eeb5dd3fa610db61 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Thu, 15 Feb 2024 08:52:23 -0500 Subject: [PATCH 19/20] Create keybindings differently --- src/basilisp/prompt.py | 98 +++++++++++++++++------------------ tests/basilisp/prompt_test.py | 9 +++- 2 files changed, 55 insertions(+), 52 deletions(-) diff --git a/src/basilisp/prompt.py b/src/basilisp/prompt.py index f972beed1..1d8404043 100644 --- a/src/basilisp/prompt.py +++ b/src/basilisp/prompt.py @@ -31,13 +31,10 @@ class Prompter: - __slots__ = ("_print_exception",) + __slots__ = ("_key_bindings",) - def __init__( - self, - print_exception: ExceptionPrinter = traceback.print_exception, - ): - self._print_exception = print_exception + def __init__(self, key_bindings: KeyBindings): + self._key_bindings = key_bindings def prompt(self, msg: str) -> str: """Prompt the user for input with the input string `msg`.""" @@ -48,6 +45,46 @@ def print(self, msg: str) -> None: print(msg) +def create_key_bindings( + print_exception: ExceptionPrinter = traceback.print_exception, +) -> KeyBindings: + """Return `KeyBindings` which override the builtin `enter` handler to + allow multi-line input. + + Inputs are read by the reader to determine if they represent valid + Basilisp syntax. If an `UnexpectedEOFError` is raised, then allow multiline + input. If a more general `SyntaxError` is raised, then the exception will + be printed to the terminal. In all other cases, handle the input normally.""" + kb = KeyBindings() + _eof = object() + + @kb.add("enter") + def _(event: KeyPressEvent) -> None: + try: + list( + reader.read_str( + event.current_buffer.text, + resolver=runtime.resolve_alias, + eof=_eof, + ) + ) + except reader.UnexpectedEOFError: + event.current_buffer.insert_text("\n") + except reader.SyntaxError as e: + run_in_terminal( + partial( + print_exception, + reader.SyntaxError, + e, + e.__traceback__, + ) + ) + else: + event.current_buffer.validate_and_handle() + + return kb + + _DELIMITED_WORD_PATTERN = re.compile(r"([^\[\](){\}\s]+)") @@ -72,58 +109,18 @@ class PromptToolkitPrompter(Prompter): __slots__ = ("_session",) - def __init__( - self, - print_exception: ExceptionPrinter = traceback.print_exception, - ): - super().__init__(print_exception) + def __init__(self, key_bindings: KeyBindings): + super().__init__(key_bindings) self._session: PromptSession = PromptSession( auto_suggest=AutoSuggestFromHistory(), completer=REPLCompleter(), history=FileHistory(BASILISP_REPL_HISTORY_FILE_PATH), - key_bindings=self._get_key_bindings(), + key_bindings=self._key_bindings, lexer=self._prompt_toolkit_lexer, multiline=True, **self._style_settings, ) - def _get_key_bindings(self) -> KeyBindings: - """Return `KeyBindings` which override the builtin `enter` handler to - allow multi-line input. - - Inputs are read by the reader to determine if they represent valid - Basilisp syntax. If an `UnexpectedEOFError` is raised, then allow multiline - input. If a more general `SyntaxError` is raised, then the exception will - be printed to the terminal. In all other cases, handle the input normally.""" - kb = KeyBindings() - _eof = object() - - @kb.add("enter") - def _(event: KeyPressEvent) -> None: - try: - list( - reader.read_str( - event.current_buffer.text, - resolver=runtime.resolve_alias, - eof=_eof, - ) - ) - except reader.UnexpectedEOFError: - event.current_buffer.insert_text("\n") - except reader.SyntaxError as e: - run_in_terminal( - partial( - self._print_exception, - reader.SyntaxError, - e, - e.__traceback__, - ) - ) - else: - event.current_buffer.validate_and_handle() - - return kb - _prompt_toolkit_lexer: Optional["PygmentsLexer"] = None _style_settings: Mapping[str, Any] = MappingProxyType({}) @@ -179,7 +176,8 @@ def get_prompter( Prompter instances may be stateful, so the Prompter instance returned by this function can be reused within a single REPL session.""" - return _DEFAULT_PROMPTER(print_exception) + key_bindings = create_key_bindings(print_exception) + return _DEFAULT_PROMPTER(key_bindings) __all__ = ["Prompter", "get_prompter"] diff --git a/tests/basilisp/prompt_test.py b/tests/basilisp/prompt_test.py index d4c87393d..0de95f894 100644 --- a/tests/basilisp/prompt_test.py +++ b/tests/basilisp/prompt_test.py @@ -8,7 +8,12 @@ from prompt_toolkit.keys import Keys from basilisp.lang.runtime import Namespace -from basilisp.prompt import PromptToolkitPrompter, REPLCompleter, get_prompter +from basilisp.prompt import ( + PromptToolkitPrompter, + REPLCompleter, + create_key_bindings, + get_prompter, +) try: import pygments @@ -176,7 +181,7 @@ def make_key_press_event(self, text: str): @pytest.fixture(scope="class") def handler(self) -> Binding: - kb = PromptToolkitPrompter()._get_key_bindings() + kb = create_key_bindings() handler, *_ = kb.get_bindings_for_keys((Keys.ControlM,)) return handler From 363247ce8bc914473e34d92fc18a5e9bd2e4b5a6 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Thu, 15 Feb 2024 09:11:34 -0500 Subject: [PATCH 20/20] Missed this one --- src/basilisp/contrib/nrepl_server.lpy | 3 ++- tests/basilisp/cli_test.py | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/basilisp/contrib/nrepl_server.lpy b/src/basilisp/contrib/nrepl_server.lpy index 4255b1819..b46f3e26d 100644 --- a/src/basilisp/contrib/nrepl_server.lpy +++ b/src/basilisp/contrib/nrepl_server.lpy @@ -355,13 +355,14 @@ log-response-mw response-for-mw)) -(defn- on-connect [tcp-req-handler opts] +(defn- on-connect "Serve a new nREPL connection as found in ``tcp-req-handler`` according to ``opts``. ``opts`` is a map of options with the following optional keys. :recv-buffer-size The buffer size to using for incoming nREPL messages." + [tcp-req-handler opts] (let [{:keys [recv-buffer-size] :or {recv-buffer-size 1024}} opts socket (.-request tcp-req-handler) diff --git a/tests/basilisp/cli_test.py b/tests/basilisp/cli_test.py index 801597dc4..58aa346f8 100644 --- a/tests/basilisp/cli_test.py +++ b/tests/basilisp/cli_test.py @@ -14,8 +14,8 @@ import attr import pytest +from basilisp import prompt from basilisp.cli import BOOL_FALSE, BOOL_TRUE, invoke_cli -from basilisp.prompt import Prompter @pytest.fixture(autouse=True) @@ -172,7 +172,10 @@ def test_run_nrepl(self, run_cli): class TestREPL: @pytest.fixture(scope="class", autouse=True) def prompter(self): - with patch("basilisp.cli.get_prompter", return_value=Prompter()): + key_bindings = prompt.create_key_bindings() + with patch( + "basilisp.cli.get_prompter", return_value=prompt.Prompter(key_bindings) + ): yield def test_no_input(self, run_cli):