From ed412776147fe72e031d64557dad19833118cbf6 Mon Sep 17 00:00:00 2001
From: Reid 'arrdem' McKenzie <me@arrdem.com>
Date: Sat, 21 Aug 2021 14:07:57 -0600
Subject: [PATCH] Hello, world!

---
 projects/lilith/BUILD                         | 12 +++
 .../lilith/src/python/lilith/interpreter.py   | 96 +++++++++++++++++++
 projects/lilith/src/python/lilith/parser.py   | 12 ++-
 projects/lilith/src/python/lilith/reader.py   |  4 +-
 projects/lilith/src/python/lilith/repl.py     | 60 ++++++++++++
 .../lilith/test/python/test_interpreter.py    | 34 +++++++
 projects/lilith/test/python/test_reader.py    |  1 -
 7 files changed, 213 insertions(+), 6 deletions(-)
 create mode 100644 projects/lilith/src/python/lilith/interpreter.py
 create mode 100644 projects/lilith/src/python/lilith/repl.py
 create mode 100644 projects/lilith/test/python/test_interpreter.py

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 <expr> <args> <kwargs>).
+        #   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