Skip to content

Commit 0784adb

Browse files
authored
Support Multiline input at the REPL prompt (#467)
* Support Multiline input at the REPL prompt * Remove that * Some changes * Fix CLI REPL tests * Refactor prompter class hierarchy * More refactoring * Partial Prompter tests * Test prompter with mocks * Ignore coverage on key binding event * Test prompt enter keybinding handler * Conditionalize Pygments styled print test
1 parent 0a350bc commit 0784adb

File tree

12 files changed

+461
-45
lines changed

12 files changed

+461
-45
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77
## [Unreleased]
88
### Added
99
* Added support for Shebang-style line comments (#469)
10+
* Added multiline REPL support using `prompt-toolkit` (#467)
1011

1112
### Changed
1213
* Change the default user namespace to `basilisp.user` (#466)

Pipfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ name = "pypi"
66
[dev-packages]
77
black = "*"
88
docutils = "==0.15"
9+
pygments = "*"
910
pytest-pycharm = "*"
1011
sphinx = "*"
1112
sphinx-rtd-theme = "*"
@@ -19,6 +20,7 @@ atomos = "*"
1920
attrs = "*"
2021
basilisp = {editable = true,path = "."}
2122
click = "*"
23+
prompt-toolkit = "~=3.0.0"
2224
pyfunctional = "*"
2325
pyrsistent = "*"
2426
python-dateutil = "*"

Pipfile.lock

Lines changed: 16 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mypy.ini

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,11 @@ ignore_missing_imports = True
1111
[mypy-functional.*]
1212
ignore_missing_imports = True
1313

14+
[mypy-prompt_toolkit.*]
15+
ignore_missing_imports = True
16+
17+
[mypy-pygments.*]
18+
ignore_missing_imports = True
19+
1420
[mypy-pytest.*]
1521
ignore_missing_imports = True

setup.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,14 @@
2525
"atomos",
2626
"attrs",
2727
"click",
28+
"prompt-toolkit>=3.0.0",
2829
"pyfunctional",
2930
"pyrsistent",
3031
"pytest",
3132
"python-dateutil",
3233
]
3334

34-
EXTRAS = {}
35+
EXTRAS = {"pygments": ["pygments"]}
3536

3637
# Copied from the excellent https://github.com/kennethreitz/setup.py
3738

src/basilisp/cli.py

Lines changed: 4 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
import atexit
21
import importlib
3-
import os.path
4-
import platform
52
import traceback
63
import types
74
from typing import Any
@@ -14,39 +11,14 @@
1411
import basilisp.lang.runtime as runtime
1512
import basilisp.lang.symbol as sym
1613
import basilisp.main as basilisp
14+
from basilisp.prompt import get_prompter
1715

1816
CLI_INPUT_FILE_PATH = "<CLI Input>"
19-
BASILISP_REPL_HISTORY_FILE_PATH = os.path.join(
20-
os.path.expanduser("~"), ".basilisp_history"
21-
)
22-
BASILISP_REPL_HISTORY_LENGTH = 1000
2317
REPL_INPUT_FILE_PATH = "<REPL Input>"
2418
STDIN_INPUT_FILE_PATH = "<stdin>"
2519
STDIN_FILE_NAME = "-"
2620

2721

28-
try:
29-
import readline
30-
except ImportError: # pragma: no cover
31-
pass
32-
else:
33-
readline.parse_and_bind("tab: complete")
34-
readline.set_completer_delims("()[]{} \n\t")
35-
readline.set_completer(runtime.repl_complete)
36-
37-
try:
38-
readline.read_history_file(BASILISP_REPL_HISTORY_FILE_PATH)
39-
readline.set_history_length(BASILISP_REPL_HISTORY_LENGTH)
40-
except FileNotFoundError: # pragma: no cover
41-
pass
42-
except Exception: # noqa # pragma: no cover
43-
# PyPy 3.6's ncurses implementation throws an error here
44-
if platform.python_implementation() != "PyPy":
45-
raise
46-
else:
47-
atexit.register(readline.write_history_file, BASILISP_REPL_HISTORY_FILE_PATH)
48-
49-
5022
@click.group()
5123
def cli():
5224
"""Basilisp is a Lisp dialect inspired by Clojure targeting Python 3."""
@@ -144,11 +116,12 @@ def repl(
144116
},
145117
)
146118
ns_var = runtime.set_current_ns(default_ns)
119+
prompter = get_prompter()
147120
eof = object()
148121
while True:
149122
ns: runtime.Namespace = ns_var.value
150123
try:
151-
lsrc = input(f"{ns.name}=> ")
124+
lsrc = prompter.prompt(f"{ns.name}=> ")
152125
except EOFError:
153126
break
154127
except KeyboardInterrupt: # pragma: no cover
@@ -162,7 +135,7 @@ def repl(
162135
result = eval_str(lsrc, ctx, ns.module, eof)
163136
if result is eof: # pragma: no cover
164137
continue
165-
print(runtime.lrepr(result))
138+
prompter.print(runtime.lrepr(result))
166139
repl_module.mark_repl_result(result) # type: ignore
167140
except reader.SyntaxError as e:
168141
traceback.print_exception(reader.SyntaxError, e, e.__traceback__)

src/basilisp/lang/reader.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,14 @@ class SyntaxError(Exception): # pylint:disable=redefined-builtin
111111
pass
112112

113113

114+
class UnexpectedEOFError(SyntaxError):
115+
"""Syntax Error type raised when the reader encounters an unexpected EOF
116+
reading a form.
117+
118+
Useful for cases such as the REPL reader, where unexpected EOF errors
119+
likely indicate the user is trying to enter a multiline form."""
120+
121+
114122
class StreamReader:
115123
"""A simple stream reader with n-character lookahead."""
116124

@@ -489,7 +497,7 @@ def _read_coll(
489497
while True:
490498
token = reader.peek()
491499
if token == "":
492-
raise SyntaxError(f"Unexpected EOF in {coll_name}")
500+
raise UnexpectedEOFError(f"Unexpected EOF in {coll_name}")
493501
if whitespace_chars.match(token):
494502
reader.advance()
495503
continue
@@ -567,7 +575,7 @@ def __read_map_elems(ctx: ReaderContext) -> Iterable[RawReaderForm]:
567575
if v is COMMENT or isinstance(v, Comment):
568576
continue
569577
elif v is ctx.eof:
570-
raise SyntaxError("Unexpected EOF in map")
578+
raise UnexpectedEOFError("Unexpected EOF in map")
571579
elif _should_splice_reader_conditional(ctx, v):
572580
assert isinstance(v, ReaderConditional)
573581
selected_feature = v.select_feature(ctx.reader_features)
@@ -726,7 +734,7 @@ def _read_str(ctx: ReaderContext, allow_arbitrary_escapes: bool = False) -> str:
726734
while True:
727735
token = reader.next_token()
728736
if token == "":
729-
raise SyntaxError("Unexpected EOF in string")
737+
raise UnexpectedEOFError("Unexpected EOF in string")
730738
if token == "\\":
731739
token = reader.next_token()
732740
escape_char = _STR_ESCAPE_CHARS.get(token, None)

src/basilisp/lang/runtime.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1257,18 +1257,16 @@ def lstr(o) -> str:
12571257
__NOT_COMPLETEABLE = re.compile(r"^[0-9].*")
12581258

12591259

1260-
def repl_complete(text: str, state: int) -> Optional[str]:
1261-
"""Completer function for Python's readline/libedit implementation."""
1260+
def repl_completions(text: str) -> Iterable[str]:
1261+
"""Return an optional iterable of REPL completions."""
12621262
# Can't complete Keywords, Numerals
12631263
if __NOT_COMPLETEABLE.match(text):
1264-
return None
1264+
return ()
12651265
elif text.startswith(":"):
1266-
completions = kw.complete(text)
1266+
return kw.complete(text)
12671267
else:
12681268
ns = get_current_ns()
1269-
completions = ns.complete(text)
1270-
1271-
return list(completions)[state] if completions is not None else None
1269+
return ns.complete(text)
12721270

12731271

12741272
####################

0 commit comments

Comments
 (0)