diff --git a/projects/lilith/BUILD b/projects/lilith/BUILD index c050566..ac706b4 100644 --- a/projects/lilith/BUILD +++ b/projects/lilith/BUILD @@ -7,3 +7,15 @@ py_project( py_requirement("hypothesis"), ] ) + +py_binary( + name = "repl", + main = "src/python/lilith/repl.py", + imports = [ + "src/python", + ], + deps = [ + ":lilith", + py_requirement("prompt_toolkit"), + ] +) diff --git a/projects/lilith/src/python/lilith/interpreter.py b/projects/lilith/src/python/lilith/interpreter.py new file mode 100644 index 0000000..50f4f6a --- /dev/null +++ b/projects/lilith/src/python/lilith/interpreter.py @@ -0,0 +1,96 @@ +""" + +""" + +import typing as t + +from .parser import Apply, Block +from .reader import Module + + +class Runtime(t.NamedTuple): + name: str + modules: t.Dict[str, Module] + + +class BindingNotFound(KeyError): + def __init__(self, msg, context): + super().__init__(msg) + self.context = context + + +class Bindings(object): + def __init__(self, name=None, parent=None): + self._name = None + self._parent = None + self._bindings = {} + + def get(self, key): + if key in self._bindings: + return self._bindings.get(key) + elif self._parent: + try: + return self._parent.get(key) + except BindingNotFound as e: + raise BindingNotFound(str(e), self) + else: + raise BindingNotFound(f"No binding key {key}", self) + + +def lookup(runtime, mod, locals, name): + """Implement resolving a name against multiple namespaces.""" + + err = None + try: + return locals.get(name) + except BindingNotFound as e: + err = e + + if name in mod.defs: + return mod.defs.get(name) + + else: + raise err + + # FIXME (arrdem 2021-08-21): + # How do we ever get references to stuff in other modules? + # !import / !require is probably The Way + + +def eval(ctx: Runtime, mod: Module, locals: Bindings, expr): + """Eval. + + In the context of a given runtime and module which must exist within the + given runtime, evaluate the given expression recursively. + + """ + + # Pointedly not assert that the module is ACTUALLY in the runtime, + # We're just going to assume this for convenience. + + if isinstance(expr, Apply): + # FIXME (arrdem 2021-08-21): + # Apply should be (apply ). + # Now no distinction is made between strings ("") and symbols/barewords + fun = lookup(ctx, mod, locals, expr.name) + # Evaluate the parameters + args = eval(ctx, mod, locals, expr.args.positionals) + kwargs = eval(ctx, mod, locals, expr.args.kwargs) + # Use Python's __call__ protocol + return fun(*args, **kwargs) + + elif isinstance(expr, (int, float, str)): + return expr + + elif isinstance(expr, list): + return [eval(ctx, mod, locals, i) for i in expr] + + elif isinstance(expr, tuple): + return tuple(eval(ctx, mod, locals, i) for i in expr) + + elif isinstance(expr, dict): + return {eval(ctx, mod, locals, k): eval(ctx, mod, locals, v) + for k, v in expr.items()} + + else: + raise RuntimeError(f"Can't eval {expr}") diff --git a/projects/lilith/src/python/lilith/parser.py b/projects/lilith/src/python/lilith/parser.py index 6a1d24a..0024435 100644 --- a/projects/lilith/src/python/lilith/parser.py +++ b/projects/lilith/src/python/lilith/parser.py @@ -22,7 +22,7 @@ class Args(t.NamedTuple): class Apply(t.NamedTuple): - tag: str + name: str args: Args @@ -44,6 +44,11 @@ class Block(t.NamedTuple): class TreeToTuples(lark.Transformer): + def string(self, args): + # FIXME (arrdem 2021-08-21): + # Gonna have to do escape sequences here + return args[0].value + def int(self, args): return int(args[0]) @@ -66,7 +71,6 @@ class TreeToTuples(lark.Transformer): def application(self, args): tag = args[0] args = args[1] if len(args) > 1 else Args() - print(args) return Apply(tag, args) def args(self, args): @@ -107,6 +111,10 @@ def parser_with_transformer(grammar, start="header"): +def parse_expr(buff: str): + return parser_with_transformer(GRAMMAR, "expr").parse(buff) + + def parse_buffer(buff: str, name: str = "&buff") -> t.List[object]: header_parser = parser_with_transformer(GRAMMAR, "header") diff --git a/projects/lilith/src/python/lilith/reader.py b/projects/lilith/src/python/lilith/reader.py index fc6a1d4..c3ffc5c 100644 --- a/projects/lilith/src/python/lilith/reader.py +++ b/projects/lilith/src/python/lilith/reader.py @@ -19,9 +19,7 @@ def read_buffer(buffer: str, name: str = "&buff") -> Module: m = Module(name, {}) for block in parse_buffer(buffer, name): - log.debug(f"{name}, Got a block", block) - - if block.tag == "def": + if block.app.name == "def": if len(block.args.positionals) == 2: def_name, expr = block.args.positionals m.defs[def_name] = Block(expr, block.body_lines) diff --git a/projects/lilith/src/python/lilith/repl.py b/projects/lilith/src/python/lilith/repl.py new file mode 100644 index 0000000..4c173b4 --- /dev/null +++ b/projects/lilith/src/python/lilith/repl.py @@ -0,0 +1,60 @@ +"""A simple Lilith shell.""" + +from lilith.interpreter import Bindings, eval, Runtime +from lilith.parser import Apply, Args, parse_expr +from lilith.reader import Module + +from prompt_toolkit import print_formatted_text, prompt, PromptSession +from prompt_toolkit.formatted_text import FormattedText +from prompt_toolkit.history import FileHistory +from prompt_toolkit.styles import Style + + +STYLE = Style.from_dict( + { + # User input (default text). + "": "", + "prompt": "ansigreen", + "time": "ansiyellow", + "result": "ansiblue", + } +) + +def print_(fmt, **kwargs): + print_formatted_text(FormattedText(fmt), **kwargs) + + +if __name__ == "__main__": + session = PromptSession(history=FileHistory(".lilith.history")) + runtime = Runtime("test", dict()) + module = Module("__repl__", { + "print": print, + "int": int, + "string": str, + "float": float, + }) + + while True: + try: + line = session.prompt([("class:prompt", ">>> ")], style=STYLE) + except (KeyboardInterrupt): + continue + except EOFError: + break + + try: + expr = parse_expr(line) + except Exception as e: + print(e) + continue + + try: + result = eval( + runtime, module, + Bindings("__root__", None), + expr + ) + print_([("class:result", f"⇒ {result!r}")], style=STYLE) + except Exception as e: + print(e) + continue diff --git a/projects/lilith/test/python/test_interpreter.py b/projects/lilith/test/python/test_interpreter.py new file mode 100644 index 0000000..3b89daf --- /dev/null +++ b/projects/lilith/test/python/test_interpreter.py @@ -0,0 +1,34 @@ +""" + +""" + +from lilith.interpreter import Bindings, Runtime, eval +from lilith.reader import Module +from lilith.parser import Args, Apply + +import pytest + + +@pytest.mark.parametrize('expr, expected', [ + (1, 1), + ([1, 2], [1, 2]), + ({"foo": "bar"}, {"foo": "bar"}), +]) +def test_eval(expr, expected): + assert eval( + Runtime("test", dict()), + Module("__repl__", dict()), + Bindings("__root__", None), + expr + ) == expected + + +def test_hello_world(capsys): + assert eval( + Runtime("test", {}), + Module("__repl__", {"print": print}), + Bindings("__root__", None), + Apply("print", Args(["hello, world"], {})) + ) is None + captured = capsys.readouterr() + assert captured.out == "hello, world\n" diff --git a/projects/lilith/test/python/test_reader.py b/projects/lilith/test/python/test_reader.py index bf62f29..efab4ea 100644 --- a/projects/lilith/test/python/test_reader.py +++ b/projects/lilith/test/python/test_reader.py @@ -12,5 +12,4 @@ import pytest ]) def test_read(example, expected): got = read_buffer(example) - print(got) assert got == expected