Skip to content

Commit 02e2c9a

Browse files
authored
Symbol and keyword readline completers (#340)
* Install a Readline completer function for symbols and keywords * Support readline history * Formatting * Test for keyword completions * Tests for Namespace completions
1 parent 811bfc5 commit 02e2c9a

File tree

5 files changed

+264
-9
lines changed

5 files changed

+264
-9
lines changed

src/basilisp/cli.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1+
import atexit
12
import importlib
2-
3-
# noinspection PyUnresolvedReferences
4-
import readline # noqa: F401
3+
import os.path
54
import traceback
65
import types
76
from typing import Any
@@ -16,9 +15,31 @@
1615
import basilisp.main as basilisp
1716

1817
CLI_INPUT_FILE_PATH = "<CLI Input>"
18+
BASILISP_REPL_HISTORY_FILE_PATH = os.path.join(
19+
os.path.expanduser("~"), ".basilisp_history"
20+
)
21+
BASILISP_REPL_HISTORY_LENGTH = 1000
1922
REPL_INPUT_FILE_PATH = "<REPL Input>"
2023

2124

25+
try:
26+
import readline
27+
except ImportError:
28+
pass
29+
else:
30+
readline.parse_and_bind("tab: complete")
31+
readline.set_completer_delims("()[]{} \n\t")
32+
readline.set_completer(runtime.repl_complete)
33+
34+
try:
35+
readline.read_history_file(BASILISP_REPL_HISTORY_FILE_PATH)
36+
readline.set_history_length(BASILISP_REPL_HISTORY_LENGTH)
37+
except FileNotFoundError:
38+
pass
39+
40+
atexit.register(readline.write_history_file, BASILISP_REPL_HISTORY_FILE_PATH)
41+
42+
2243
@click.group()
2344
def cli():
2445
"""Basilisp is a Lisp dialect inspired by Clojure targeting Python 3."""

src/basilisp/lang/keyword.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
from typing import Optional
1+
from typing import Optional, Iterable
22

33
from pyrsistent import pmap, PMap
44

55
import basilisp.lang.associative as lassoc
66
import basilisp.lang.atom as atom
77
from basilisp.lang.obj import LispObject
88

9-
__INTERN = atom.Atom(pmap())
9+
__INTERN: atom.Atom["PMap[int, Keyword]"] = atom.Atom(pmap())
1010

1111

1212
class Keyword(LispObject):
@@ -42,7 +42,34 @@ def __call__(self, m: lassoc.Associative, default=None):
4242
return None
4343

4444

45-
def __get_or_create(kw_cache: PMap, h: int, name: str, ns: Optional[str]) -> PMap:
45+
def complete(
46+
text: str, kw_cache: atom.Atom["PMap[int, Keyword]"] = __INTERN
47+
) -> Iterable[str]:
48+
"""Return an iterable of possible completions for the given text."""
49+
assert text.startswith(":")
50+
interns = kw_cache.deref()
51+
text = text[1:]
52+
53+
if "/" in text:
54+
prefix, suffix = text.split("/", maxsplit=1)
55+
results = filter(
56+
lambda kw: (kw.ns is not None and kw.ns == prefix)
57+
and kw.name.startswith(suffix),
58+
interns.itervalues(),
59+
)
60+
else:
61+
results = filter(
62+
lambda kw: kw.name.startswith(text)
63+
or (kw.ns is not None and kw.ns.startswith(text)),
64+
interns.itervalues(),
65+
)
66+
67+
return map(str, results)
68+
69+
70+
def __get_or_create(
71+
kw_cache: "PMap[int, Keyword]", h: int, name: str, ns: Optional[str]
72+
) -> PMap:
4673
"""Private swap function used to either get the interned keyword
4774
instance from the input string."""
4875
if h in kw_cache:
@@ -52,7 +79,9 @@ def __get_or_create(kw_cache: PMap, h: int, name: str, ns: Optional[str]) -> PMa
5279

5380

5481
def keyword(
55-
name: str, ns: Optional[str] = None, kw_cache: atom.Atom = __INTERN
82+
name: str,
83+
ns: Optional[str] = None,
84+
kw_cache: atom.Atom["PMap[int, Keyword]"] = __INTERN,
5685
) -> Keyword:
5786
"""Create a new keyword."""
5887
h = hash((name, ns))

src/basilisp/lang/runtime.py

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44
import itertools
55
import logging
66
import math
7+
import re
78
import threading
89
import types
910
from fractions import Fraction
10-
from typing import Optional, Dict, Tuple, Union, Any, Callable
11+
from typing import Optional, Dict, Tuple, Union, Any, Callable, Iterable
1112

1213
import basilisp.lang.associative as lassoc
1314
import basilisp.lang.collection as lcoll
@@ -80,6 +81,9 @@
8081
_VAR,
8182
)
8283

84+
CompletionMatcher = Callable[[Tuple[sym.Symbol, Any]], bool]
85+
CompletionTrimmer = Callable[[Tuple[sym.Symbol, Any]], str]
86+
8387

8488
def _new_module(name: str, doc=None) -> types.ModuleType:
8589
"""Create a new empty Basilisp Python module.
@@ -541,6 +545,108 @@ def remove(cls, name: sym.Symbol) -> Optional["Namespace"]:
541545
if cls._NAMESPACES.compare_and_set(oldval, newval):
542546
return ns
543547

548+
# REPL Completion support
549+
550+
@staticmethod
551+
def __completion_matcher(text: str) -> CompletionMatcher:
552+
"""Return a function which matches any symbol keys from map entries
553+
against the given text."""
554+
555+
def is_match(entry: Tuple[sym.Symbol, Any]) -> bool:
556+
return entry[0].name.startswith(text)
557+
558+
return is_match
559+
560+
def __complete_alias(
561+
self, prefix: str, name_in_ns: Optional[str] = None
562+
) -> Iterable[str]:
563+
"""Return an iterable of possible completions matching the given
564+
prefix from the list of aliased namespaces. If name_in_ns is given,
565+
further attempt to refine the list to matching names in that namespace."""
566+
candidates: Iterable[Tuple[sym.Symbol, Namespace]] = filter(
567+
Namespace.__completion_matcher(prefix), self.aliases
568+
)
569+
if name_in_ns is not None:
570+
for _, candidate_ns in candidates:
571+
for match in candidate_ns.__complete_interns(
572+
name_in_ns, include_private_vars=False
573+
):
574+
yield f"{prefix}/{match}"
575+
else:
576+
for alias, _ in candidates:
577+
yield f"{alias}/"
578+
579+
def __complete_imports_and_aliases(
580+
self, prefix: str, name_in_module: Optional[str] = None
581+
) -> Iterable[str]:
582+
"""Return an iterable of possible completions matching the given
583+
prefix from the list of imports and aliased imports. If name_in_module
584+
is given, further attempt to refine the list to matching names in that
585+
namespace."""
586+
imports = self.imports
587+
aliases = lmap.map(
588+
{
589+
alias: imports.entry(import_name)
590+
for alias, import_name in self.import_aliases
591+
}
592+
)
593+
594+
candidates: Iterable[Tuple[sym.Symbol, types.ModuleType]] = filter(
595+
Namespace.__completion_matcher(prefix), itertools.chain(aliases, imports)
596+
)
597+
if name_in_module is not None:
598+
for _, module in candidates:
599+
for name in module.__dict__:
600+
if name.startswith(name_in_module):
601+
yield f"{prefix}/{name}"
602+
else:
603+
for candidate_name, _ in candidates:
604+
yield f"{candidate_name}/"
605+
606+
def __complete_interns(
607+
self, value: str, include_private_vars: bool = True
608+
) -> Iterable[str]:
609+
"""Return an iterable of possible completions matching the given
610+
prefix from the list of interned Vars."""
611+
if include_private_vars:
612+
is_match = Namespace.__completion_matcher(value)
613+
else:
614+
_is_match = Namespace.__completion_matcher(value)
615+
616+
def is_match(entry: Tuple[sym.Symbol, Var]) -> bool:
617+
return _is_match(entry) and not entry[1].is_private
618+
619+
return map(lambda entry: f"{entry[0].name}", filter(is_match, self.interns))
620+
621+
def __complete_refers(self, value: str) -> Iterable[str]:
622+
"""Return an iterable of possible completions matching the given
623+
prefix from the list of referred Vars."""
624+
return map(
625+
lambda entry: f"{entry[0].name}",
626+
filter(Namespace.__completion_matcher(value), self.refers),
627+
)
628+
629+
def complete(self, text: str) -> Iterable[str]:
630+
"""Return an iterable of possible completions for the given text in
631+
this namespace."""
632+
assert not text.startswith(":")
633+
634+
if "/" in text:
635+
prefix, suffix = text.split("/", maxsplit=1)
636+
results = itertools.chain(
637+
self.__complete_alias(prefix, name_in_ns=suffix),
638+
self.__complete_imports_and_aliases(prefix, name_in_module=suffix),
639+
)
640+
else:
641+
results = itertools.chain(
642+
self.__complete_alias(text),
643+
self.__complete_imports_and_aliases(text),
644+
self.__complete_interns(text),
645+
self.__complete_refers(text),
646+
)
647+
648+
return results
649+
544650

545651
###################
546652
# Runtime Support #
@@ -944,6 +1050,23 @@ def lstr(o) -> str:
9441050
return lrepr(o, human_readable=True)
9451051

9461052

1053+
__NOT_COMPLETEABLE = re.compile(r"^[0-9].*")
1054+
1055+
1056+
def repl_complete(text: str, state: int) -> Optional[str]:
1057+
"""Completer function for Python's readline/libedit implementation."""
1058+
# Can't complete Keywords, Numerals
1059+
if __NOT_COMPLETEABLE.match(text):
1060+
return None
1061+
elif text.startswith(":"):
1062+
completions = kw.complete(text)
1063+
else:
1064+
ns = get_current_ns()
1065+
completions = ns.complete(text)
1066+
1067+
return list(completions)[state] if completions is not None else None
1068+
1069+
9471070
def _collect_args(args) -> lseq.Seq:
9481071
"""Collect Python starred arguments into a Basilisp list."""
9491072
if isinstance(args, tuple):

tests/basilisp/keyword_test.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import pytest
2+
from pyrsistent import PMap, pmap
3+
14
import basilisp.lang.map as lmap
2-
from basilisp.lang.keyword import keyword
5+
from basilisp.lang.atom import Atom
6+
from basilisp.lang.keyword import Keyword, keyword, complete
37

48

59
def test_keyword_identity_equals():
@@ -41,3 +45,32 @@ def test_keyword_as_function():
4145
assert 1 == kw(lmap.map({kw: 1}))
4246
assert "hi" == kw(lmap.map({kw: "hi"}))
4347
assert None is kw(lmap.map({"hi": kw}))
48+
49+
50+
class TestKeywordCompletion:
51+
@pytest.fixture
52+
def empty_cache(self) -> Atom["PMap[int, Keyword]"]:
53+
return Atom(pmap())
54+
55+
def test_empty_cache_no_completion(self, empty_cache: Atom["PMap[int, Keyword]"]):
56+
assert [] == list(complete(":", kw_cache=empty_cache))
57+
58+
@pytest.fixture
59+
def cache(self) -> Atom["PMap[int, Keyword]"]:
60+
values = [Keyword("kw"), Keyword("ns"), Keyword("kw", ns="ns")]
61+
return Atom(pmap({hash(v): v for v in values}))
62+
63+
def test_no_ns_completion(self, cache: Atom["PMap[int, Keyword]"]):
64+
assert [] == list(complete(":v", kw_cache=cache))
65+
assert {":kw", ":ns/kw"} == set(complete(":k", kw_cache=cache))
66+
assert {":kw", ":ns/kw"} == set(complete(":kw", kw_cache=cache))
67+
assert {":ns", ":ns/kw"} == set(complete(":n", kw_cache=cache))
68+
assert {":ns", ":ns/kw"} == set(complete(":ns", kw_cache=cache))
69+
70+
def test_ns_completion(self, cache: Atom["PMap[int, Keyword]"]):
71+
assert [] == list(complete(":v/", kw_cache=cache))
72+
assert [] == list(complete(":k/", kw_cache=cache))
73+
assert [] == list(complete(":kw/", kw_cache=cache))
74+
assert [] == list(complete(":n/", kw_cache=cache))
75+
assert [":ns/kw"] == list(complete(":ns/", kw_cache=cache))
76+
assert [":ns/kw"] == list(complete(":ns/k", kw_cache=cache))

tests/basilisp/namespace_test.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,3 +293,52 @@ def test_alias(ns_cache: patch):
293293

294294
assert None is ns1.get_alias(sym.symbol("ns2"))
295295
assert ns2 is ns1.get_alias(sym.symbol("n2"))
296+
297+
298+
class TestCompletion:
299+
@pytest.fixture
300+
def ns(self) -> Namespace:
301+
ns_sym = sym.symbol("test")
302+
ns = Namespace(ns_sym)
303+
304+
str_ns_alias = sym.symbol("basilisp.string")
305+
join_sym = sym.symbol("join")
306+
chars_sym = sym.symbol("chars")
307+
str_ns = Namespace(str_ns_alias)
308+
str_ns.intern(join_sym, Var(ns, join_sym))
309+
str_ns.intern(
310+
chars_sym, Var(ns, chars_sym, meta=lmap.map({kw.keyword("private"): True}))
311+
)
312+
ns.add_alias(str_ns_alias, str_ns)
313+
314+
str_alias = sym.symbol("str")
315+
ns.add_alias(str_alias, Namespace(str_alias))
316+
317+
str_sym = sym.symbol("str")
318+
ns.intern(str_sym, Var(ns, str_sym))
319+
320+
is_string_sym = sym.symbol("string?")
321+
ns.intern(is_string_sym, Var(ns, is_string_sym))
322+
323+
time_sym = sym.symbol("time")
324+
time_alias = sym.symbol("py-time")
325+
ns.add_import(time_sym, __import__("time"), time_alias)
326+
327+
core_ns = Namespace(sym.symbol("basilisp.core"))
328+
map_alias = sym.symbol("map")
329+
ns.add_refer(map_alias, Var(core_ns, map_alias))
330+
331+
return ns
332+
333+
def test_ns_completion(self, ns: Namespace):
334+
assert {"basilisp.string/"} == set(ns.complete("basilisp.st"))
335+
assert {"basilisp.string/join"} == set(ns.complete("basilisp.string/j"))
336+
assert {"str/", "string?", "str"} == set(ns.complete("st"))
337+
assert {"map"} == set(ns.complete("m"))
338+
assert {"map"} == set(ns.complete("ma"))
339+
340+
def test_import_and_alias(self, ns: Namespace):
341+
assert {"time/"} == set(ns.complete("ti"))
342+
assert {"time/asctime"} == set(ns.complete("time/as"))
343+
assert {"py-time/"} == set(ns.complete("py-t"))
344+
assert {"py-time/asctime"} == set(ns.complete("py-time/as"))

0 commit comments

Comments
 (0)